第 2 章 · 进程与线程

打开终端跑一条命令,看看操作系统眼里「正在运行的程序」长什么样:

$ ps -o pid,ppid,stat,cmd
  PID  PPID STAT CMD
 8341  8340 Ss   -bash
 9207  8341 S+   ps -o pid,ppid,stat,cmd
14052  8341 S    python3 analyze.py
14053 14052 S    python3 analyze.py

每一行是一个进程。我第一次认真看这张表时注意到三件事:每个进程有编号(PID)和父进程编号(PPID),整个系统是一棵进程树(bash 是 ps 的爹);STAT 那列藏着 S、R、D 这些状态;那个 python 进程居然有俩,同一份代码跑成了两个进程。这章就顺着这三个线索展开:进程到底是什么(以及它和「程序」差在哪)、状态怎么流转、上下文切换贵在哪,然后聊那个经典之争:进程和线程,到底该用哪个。

2.1 程序和进程,差的是「正在」

程序是磁盘上一个静态文件,进程是它被装入内存、拥有资源、正在(或曾经)执行的那份活体。同一份程序可以同时是十个进程:你开十个终端跑同一个 bash,进程表里就是十条记录。

教科书定义进程 = 程序 + 数据 + PCB,前两个好理解,PCB(进程控制块)是灵魂:操作系统给每个进程建的档案袋,记着它的 PID、寄存器现场、页表指针、打开的文件、信号处理方式。进程存在的唯一标志就是 PCB,Linux 里的 task_struct 就是它的实体,这个结构体有几百个字段,堪称内核里最复杂的物件之一。

五状态模型是这门课的第一个抽象,值得把转移条件背熟:

        创建
          │
          ▼
就绪 ──被调度──▶ 运行 ──完成──▶ 终止
 ▲  ◀──时间片用完──┘  │
 │                  │ 等待 I/O 或事件
 │                  ▼
 └────I/O 完成──── 等待

两个易混点,考试常埋坑。其一,「等待」和「就绪」的差别:等待中的进程即使给它 CPU 也动不了(数据没回来),就绪的进程万事俱备只欠 CPU。其二,状态转移里没有「等待 → 运行」这条线:I/O 完成的进程先回就绪队列排队,不能插队直接上 CPU。真实系统里还有挂起态(被换出到磁盘)、以及 Linux 独有的浅睡眠 S 和深睡眠 D(D 状态的进程连 kill -9 都不理,通常是卡在不可中断的 IO 上,运维看到一堆 D 状态进程就该去查存储了)。

Unix 创建进程的方式也很值得单独说:fork 复制自己,execve 换身 programme。看个真实行为:

printf("before\n");
pid_t pid = fork();          // 一分为二
if (pid == 0)
    printf("child, pid=%d\n", getpid());
else
    printf("parent, pid=%d, child=%d\n", getpid(), pid);
before
parent, pid=9207, child=9208
child, pid=9208

fork() 调用一次,返回两次:父进程拿到孩子的 PID,孩子拿到 0。同一个 printf("after") 在两个进程里各执行一遍。shell 启动命令、Nginx fork worker、Redis 持久化(fork 出副本去写盘),全是这个原语。这设计初看古怪(fork 整个进程不浪费吗),配合「写时复制」(父子先共享物理页,谁写谁复制)就合理了,还能近乎免费地做进程快照。

2.2 上下文切换:并发的不记账成本

CPU 只有几颗,进程成百,调度器在它们之间快速换人,这就是并发。换人的动作叫上下文切换:把当前进程的寄存器现场存进它的 PCB,从下一个进程的 PCB 恢复现场,换页表、刷 TLB(计组第 8 章的 TLB 在这里开始收费)。

这个动作有多贵?现代 Linux 上一次进程切换微秒级,看着不大,算笔账就难受:每次切换浪费约 1~10 μs,其中还包括 Cache 和 TLB 的新进程「冷启动」。如果调度时间片切得太碎(比如每 1ms 切一次),切流失的 CPU 可能吃掉百分之几;这就是调度器(下一章)要在「响应快」和「切换少」之间找平衡的原因。切换本身对任何用户程序都没有生产价值,它是并发收的税,能省则省(比如少一些不必要的线程)。

2.3 线程:把「执行流」从「资源包」里拆出来

进程有两个身份:既是资源容器(地址空间、文件、设备),也是执行流。这两个身份其实可以拆开,拆出来的执行流就是线程。同一个进程里的线程共享地址空间和资源,各自拥有一份栈、寄存器现场和程序计数器。

一张必须刻在脑子里的对照表:

维度进程线程
地址空间独立共享(栈和寄存器除外)
创建/切换开销大(换页表、刷 TLB)小(只换栈和寄存器)
崩溃影响隔离,一家死不连累邻居共享内存,一个越界全家遭殃
通信要走 IPC(管道、共享内存、socket)直接读写变量(快,但同步是坑)
语义「谁拥有什么」「谁在干活」

选型没有标准答案,看你要隔离还是要便宜。浏览器是教科书案例:每个标签页一个进程(Chrome 甚至每个站点一个),一个页面崩了或被恶意脚本拖死,其他标签页照常;而一个标签页内部,渲染、JS 执行、网络、GPU 各用线程分工。反过来,一个计算服务的十几个 worker 之间没什么可隔离的,用线程(或协程)省钱。经验法则:不可信的、容易崩的、需要故障隔离的,上进程;可信的、要共享数据高频协作的,上线程。

共享内存是一把双刃剑,线程间通信不用过内核,快是真快;但两个人同时改一个变量,数据就烂了,于是有了下一章的锁和同步,以及无数隐蔽的并发 bug。线程把「容易」从通信挪到了正确性,这笔账 Chapter 4 会算给你看。

2.4 用户级线程和内核级线程

线程由谁来管,决定了它的能力上限。

内核级线程:内核认识每个线程(有 TCB),能独立调度到不同核上真并行;一个线程阻塞(读磁盘),内核把同进程的其他线程调上 CPU。代价:创建、切换都要陷入内核,贵一点。

用户级线程:线程库在用户态自己管理,内核毫不知情,只看见一个进程。切换不用进内核,快到飞起(几十纳秒);致命伤在阻塞:一个线程发起读盘系统调用,内核把整个进程挂起,所有线程陪葬,多核也用不上。

映射关系三种:多对一(ULT 方案,整进程一条内核线)、一对一(Linux pthread,主流)、多对多(理论上最优,实现复杂,基本绝迹)。有趣的是近年来多对一以新马甲重生:协程(Go 的 goroutine、Python asyncio 的 task)。成千上万个用户级协程由运行时调度到少量内核线程上,只要协程「阻塞」的方式是运行时认识的(网络 IO),就不会真卡住内核线程。协程把 ULT 的老毛病(阻塞拖全家)修好了,顺便把线程切换成本再压一个数量级,这是第 10 章 OS 实例还会回来的话题。

常见错误

  • 在多线程程序里随手 fork。fork 只复制调用线程,其他线程凭空消失,它们持有的锁永远无人释放,子进程一碰就死锁。多线程程序想并发执行别的活,用线程或 posix_spawn。
  • 不管僵尸进程。子进程退出后父进程不 wait,它的 PCB 成了僵尸挂在进程表里。短期没事,长期跑的服务会攒满进程表。要么 wait,要么让父进程收到 SIGCHLD 时收割,要么 fork 两次托孤给 init。
  • 以为线程数越多越快。CPU 密集型任务的线程数超过核数,只多付上下文切换的税;IO 密集型才需要更多线程把等待时间填满。

练习

  1. 动手:跑 pstree -p | head -30,画出你系统的前三层进程树;找出 systemd(或 init)的 PID,想想为什么所有进程都挂在它名下。
  2. 动手:看 /proc/self/status 里 Voluntary_ctxt_switches 和 Nonvoluntary_ctxt_switches 两个字段,分别跑一个纯计算程序和一个读大文件的程序,解释两个数字为什么长得不一样。
  3. 手算:某系统线程切换 2μs、进程切换 8μs,时间片 10ms。切换开销占比多少?若把时间片降到 0.1ms 呢?(这道题是下一章调度器设计的引子。)
  4. 思考:Chrome 为什么坚持每个站点一个进程,而不是一个进程多条线程?从内存开销、安全(恶意网页)、崩溃恢复三个角度各想一条理由。