跳到内容

第 3 卷 / 第 8 章 / 第 1 课

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 独有;不存在一张稳定的教学结构体能复刻它。

可把模型画成:

text
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。

text
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 状态名不要混写 ​

教材常用四态模型:

text
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 时,它像从被暂停处继续。典型工作包括:

  1. 在 kernel entry/preemption point 保存必要 register context;
  2. 更新 A 的 scheduling/accounting state;
  3. 选择 B,并切换到 B 的 kernel stack 与保存上下文;
  4. 若 B 属于不同 address space,切换 page-table context 并处理 TLB tag/flush 规则;
  5. 恢复 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 编译并运行:

c
#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 中还可以检查:

bash
ps -o pid,ppid,stat,nlwp,comm -p "$$"
grep -E '^(Name|State|Pid|PPid|Threads|voluntary|nonvoluntary)' /proc/self/status

shell 的 /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 同样会被调度。

动手拆分上下文 ​

  1. 区分 executable、process、thread 和 Linux task,各写出一个只属于它的属性。
  2. 解释父子进程为什么可共享 open file description,却有独立 fd table。
  3. 画出同进程 thread switch 与跨进程 switch,标出哪一种通常需要换 mm。
  4. 用 getrusage 或 /proc/self/status 比较 sleep-heavy 与 CPU-heavy 程序的 voluntary/involuntary switch。
  5. 说明 PID reuse 如何让“先检查、后 signal”产生竞态,并查 pidfd 可解决的范围。
  6. 找出教材四态模型与 Linux TASK_RUNNING 的差异。

下一步:复制资源视图,再替换 program image ​

理解 context 后,fork 不再是“复制一个盒子”的机制:它创建新 task 与资源引用,建立新的地址空间语义;exec 则替换调用进程的 program image。下一篇把fork、exec、wait 与 zombie放在同一条可执行时间线上。

Built with VitePress | Software Systems Atlas