8.1 进程、任务与上下文
调度器选择可运行线程,把它们安排到可用 CPU 上。单个逻辑 CPU 在不同执行流之间切换,多个 CPU 则可并行工作。线程何时让出 CPU 取决于调度策略、阻塞与抢占,不能一概归为固定时间片。
system call 让当前线程进入内核,但没有解释“当前”由哪些状态组成。本篇先分开程序、进程与线程;下一篇再沿 Unix 的
fork、exec与wait追踪进程生命周期。
可执行文件躺在磁盘上,进程活在内核里
磁盘上只有一个 worker 文件,进程列表里却可以出现十几个 PID。文件是一份静态程序映像,包含机器指令、只读数据、加载信息和符号等内容。进程是 OS 为一次执行维护的资源与保护域。
同一个 executable 可以同时装入多个进程;一个进程也可以通过 exec 在生命周期中换成另一个 program image。动态链接、JIT 和 plugin 还会让“进程只对应一个程序文件”显得过于简单。
进程通常拥有或关联:
- virtual address space 及其 mapping;
- file descriptor table、current working directory、umask;
- credentials、resource limits、namespace/cgroup membership;
- signal disposition、timer 和 IPC 状态;
- 一个或多个可调度 thread;
- PID、parent/child、process group 与 session 关系。
CPU 不会把这些对象一次全部“装进寄存器”。它们由 kernel data structure 相互引用,在系统调用、调度和资源回收时按需使用。
thread 才是通常的 CPU 调度单位
一个 thread 有自己的 instruction pointer、general-purpose register、stack、signal mask 和 scheduling state。栈是各线程的执行资源,但位于共享地址空间,并不是阻止其他线程访问的保护边界。同一 process 的 threads 通常共享 address space、file descriptor table 与 signal disposition。
Linux 内核以 task 为基本调度对象。每个 thread 有自己的 task ID(从用户态常称 TID);同一 thread group 的成员共享 thread-group ID,用户通常把这个 TGID 看作 process PID。主线程的 TID 与 TGID 相同。
“Linux 的 PCB 就是一个固定字段数的 task_struct”只适合作入门类比。task_struct 随 kernel version 与 config 改变,并通过指针连接 mm_struct、files_struct、credentials、signal state 等对象。有些资源在线程间共享,有些是每 task 独有;不存在一张稳定的教学结构体能复刻它。
可把模型画成:
process / thread group
├─ shared mm (virtual address space)
├─ shared files table
├─ shared signal dispositions
├─ thread A: registers + stack + scheduling state + mask
└─ thread B: registers + stack + scheduling state + mask这也是为什么进程和线程不能只用“重量不同”区分。两者首先是资源共享边界不同。
地址空间与 file descriptor 是两种不同引用
进程通过 page table 把 virtual address 映射到 memory object;通过 fd table 把小整数映射到 open file description。
fd 3 in process A --+
+--> open file description
fd 7 in process B --+ (offset, status flags)
|
+--> inode / pipe / socket ...fork 后父子 fd table 条目通常指向同一个 open file description,所以会共享 file offset 与某些 file status flag。两张 descriptor table 引用同一底层打开状态。关闭 child 的 fd 不会关闭 parent 的对应 fd;而 FD_CLOEXEC 等 descriptor flag 属于各自条目,与共享的 file status flag 不同。
dup 也会产生指向同一 open file description 的新 descriptor。单看整数 fd 相等与否,无法判断底层状态是否共享。
抽象状态机与 Linux 状态名不要混写
教材常用四态模型:
ready --dispatch--> running --block--> waiting
^ | |
+----preempt---------+------wake-------+
running --exit--> terminated进程创建后进入可运行集合;调度器选择某个 task 执行。它等待 I/O、mutex、timer 或 page 时进入 waiting,事件完成后重新 runnable。退出后不再参与调度。
这个模型有助于理解,但 Linux 的状态字段不是一一对应:
TASK_RUNNING同时覆盖“正在某 CPU 执行”和“在 run queue 等待”;- interruptible sleep 可被 signal 唤醒,uninterruptible sleep 通常等待特定内核条件;
- stopped/traced 用于 job control 与 debugger;
- zombie 已经退出,不是一个仍等 CPU 的 blocked task。
“terminated 状态等于 zombie”也不准确。进程退出后大部分运行资源已释放,内核保留退出 status 与少量身份/统计供 parent 回收;通常由 wait 收取后释放;显式忽略 SIGCHLD 或采用 SA_NOCLDWAIT 等配置还可能让 child 自动回收。
一次 context switch 保存的是可恢复执行状态
scheduler 从 task A 切到 task B,至少要保证下次恢复 A 时,它像从被暂停处继续。典型工作包括:
- 在 kernel entry/preemption point 保存必要 register context;
- 更新 A 的 scheduling/accounting state;
- 选择 B,并切换到 B 的 kernel stack 与保存上下文;
- 若 B 属于不同 address space,切换 page-table context 并处理 TLB tag/flush 规则;
- 恢复 B,最终返回 B 的 user 或 kernel execution point。
这不是每次把整份 address space、所有 cache line 或完整 PCB 复制一遍。page table 切换只是换当前 translation context,physical page 仍留在原处。ASID/PCID 可以让不同地址空间的 TLB entry 共存。
同一 process 的两个 thread 共享 mm,切换时通常不用换成另一套 page table,但仍要保存 register、stack 与 scheduler state。反过来,不同 process 的切换也可能因 PCID、warm cache 和工作集关系而比想象中便宜。成本不能只按“进程重、线程轻”给一个固定倍数。
context switch 的间接成本常比入口指令更难测
直接成本包括 scheduler code、register save/restore、clock accounting 与可能的 address-space switch。间接成本来自:
- 新 task 的 instruction/data working set 冲掉旧 cache 内容;
- branch predictor 与 prefetch state 不再适合;
- TLB reach、NUMA locality 或 CPU migration 改变;
- lock contention 与 run queue 延迟掺入测量。
perf stat -e context-switches 记录的是 OS 事件计数,不会直接告诉每次 switch 造成多少 cache miss。microbenchmark 若用 pipe ping-pong 测量,还包含 system call、wake-up、scheduler queue 与同步原语开销。
比较两个版本时,应同时观察 wall time、CPU time、voluntary/involuntary context switch、migration 和 cache/TLB counters,并固定 CPU affinity 与系统负载。只看到 context-switch 数下降,不能自动推导业务更快。
voluntary 与 involuntary 只是触发来源不同
task 主动进入等待,例如 blocking read、futex、nanosleep,通常计为 voluntary context switch。time slice、priority 或更高优先级 task 使它被 preempt,常计为 involuntary。
“主动”不表示应用源码显式调用了 sched_yield。page fault 等内核路径等待 I/O,也会让 task 睡眠。nonblocking I/O 能避免当前调用阻塞,却可能让 event loop 在 epoll_wait 等位置集中睡眠,context switch 并不会消失。
sched_yield 也不是通用的并发等待方案。它的效果受 scheduler policy 和其他 runnable task 影响;正确同步应使用 mutex/condition variable/futex-backed primitive 或事件机制。
CPU affinity 与 migration
scheduler 可把 runnable thread 从一个 CPU 迁到另一个 CPU 以平衡负载。迁移后,目标 CPU 的 private cache 可能没有该 working set,NUMA memory 也可能变远。
pinning 能减少实验噪声或满足实时/NUMA 设计,却会限制 scheduler 平衡并造成热点。生产服务常采用 topology-aware thread placement,而不是把所有 thread 永久钉死。
Linux 可用 taskset 或 sched_setaffinity 做实验,容器的 cpuset/cgroup 与 host scheduler 仍会影响可用 CPU。记录的是实际 allowed mask,不是机器标称 core 数。
用 /proc 观察当前 task,而不是臆测结构体
Linux 的 /proc/self/status 和 /proc/self/stat 暴露经过内核整理的状态。下面的 task_status.c 按完整字段名打印标识与 context-switch 计数。用 cc -std=c17 -Wall -Wextra -Werror task_status.c -o task_status 编译并运行:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
FILE *status = fopen("/proc/self/status", "r");
if (status == NULL) {
perror("fopen /proc/self/status");
return 1;
}
const char *keys[] = {
"Name:", "State:", "Tgid:", "Pid:", "PPid:", "Threads:",
"voluntary_ctxt_switches:", "nonvoluntary_ctxt_switches:"
};
int result = 0;
char *line = NULL;
size_t capacity = 0;
while (getline(&line, &capacity, status) >= 0) {
for (size_t index = 0; index < sizeof keys / sizeof keys[0]; index++) {
if (strncmp(line, keys[index], strlen(keys[index])) == 0) {
if (fputs(line, stdout) == EOF) {
perror("write status");
result = 1;
}
break;
}
}
if (result != 0) break;
}
if (result == 0 && !feof(status)) {
perror("getline");
result = 1;
}
free(line);
if (fclose(status) != 0) {
perror("fclose");
result = 1;
}
if (fflush(stdout) == EOF) {
perror("fflush");
result = 1;
}
return result;
}字段名带冒号参与匹配,因此 Pid: 不会误收 PidSomething:。procfs 是 Linux-specific、可能随版本扩展的接口;这里只读取指定字段,不依赖行顺序或“第几行”。
在 shell 中还可以检查:
ps -o pid,ppid,stat,nlwp,comm -p "$$"
grep -E '^(Name|State|Pid|PPid|Threads|voluntary|nonvoluntary)' /proc/self/statusshell 的 /proc/self 指向执行 grep 的进程,不一定是 shell 本身;这类细节正适合提醒我们:观察工具也在创建和调度 task。
process identity 不等于安全身份
PID 会复用。先读取一个 PID、稍后再向它发 signal 或检查 /proc/PID,目标可能已经退出并被新进程占用。现代 Linux 的 pidfd 为部分生命周期操作提供稳定引用,减少取得引用之后的 PID reuse race。若先用裸 PID 检查对象、很久后才调用 pidfd_open,取得引用前的复用窗口仍存在;应在创建时取得 pidfd 或核实引用与目标身份。
credentials 包括真实/有效 UID,也涉及 saved ID、filesystem ID、groups、capability、namespace 与 LSM policy。kill(pid, signal)、ptrace、open("/proc/...") 都要经过各自权限检查。
所以“PCB 是进程身份证”这个比喻到这里就该停下。identity、资源容器和调度实体在实现中交叉引用,但不是一个字段决定全部。
program counter 并不总指向用户代码
task 可能在 user mode 运行,也可能代表该 thread 正在 kernel mode 执行 system call、fault handler 或 interrupt-return path。被调度出去时保存的恢复点可以位于内核栈上的 kernel continuation,稍后再返回用户态。
硬件 interrupt 也可能暂时借用当前 thread 的 stack/context,softirq/workqueue 又可能由不同内核执行环境处理。把 CPU 世界画成“不是用户进程,就是异常 handler”仍然太粗;kernel thread 与 deferred work 同样会被调度。
动手拆分上下文
- 区分 executable、process、thread 和 Linux task,各写出一个只属于它的属性。
- 解释父子进程为什么可共享 open file description,却有独立 fd table。
- 画出同进程 thread switch 与跨进程 switch,标出哪一种通常需要换
mm。 - 用
getrusage或/proc/self/status比较 sleep-heavy 与 CPU-heavy 程序的 voluntary/involuntary switch。 - 说明 PID reuse 如何让“先检查、后 signal”产生竞态,并查 pidfd 可解决的范围。
- 找出教材四态模型与 Linux
TASK_RUNNING的差异。
下一步:复制资源视图,再替换 program image
理解 context 后,fork 不再是“复制一个盒子”的机制:它创建新 task 与资源引用,建立新的地址空间语义;exec 则替换调用进程的 program image。下一篇把fork、exec、wait 与 zombie放在同一条可执行时间线上。