打开终端跑一条命令,看看操作系统眼里「正在运行的程序」长什么样:
$ 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 进程居然有俩,同一份代码跑成了两个进程。这章就顺着这三个线索展开:进程到底是什么(以及它和「程序」差在哪)、状态怎么流转、上下文切换贵在哪,然后聊那个经典之争:进程和线程,到底该用哪个。
程序是磁盘上一个静态文件,进程是它被装入内存、拥有资源、正在(或曾经)执行的那份活体。同一份程序可以同时是十个进程:你开十个终端跑同一个 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 整个进程不浪费吗),配合「写时复制」(父子先共享物理页,谁写谁复制)就合理了,还能近乎免费地做进程快照。
CPU 只有几颗,进程成百,调度器在它们之间快速换人,这就是并发。换人的动作叫上下文切换:把当前进程的寄存器现场存进它的 PCB,从下一个进程的 PCB 恢复现场,换页表、刷 TLB(计组第 8 章的 TLB 在这里开始收费)。
这个动作有多贵?现代 Linux 上一次进程切换微秒级,看着不大,算笔账就难受:每次切换浪费约 1~10 μs,其中还包括 Cache 和 TLB 的新进程「冷启动」。如果调度时间片切得太碎(比如每 1ms 切一次),切流失的 CPU 可能吃掉百分之几;这就是调度器(下一章)要在「响应快」和「切换少」之间找平衡的原因。切换本身对任何用户程序都没有生产价值,它是并发收的税,能省则省(比如少一些不必要的线程)。
进程有两个身份:既是资源容器(地址空间、文件、设备),也是执行流。这两个身份其实可以拆开,拆出来的执行流就是线程。同一个进程里的线程共享地址空间和资源,各自拥有一份栈、寄存器现场和程序计数器。
一张必须刻在脑子里的对照表:
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享(栈和寄存器除外) |
| 创建/切换开销 | 大(换页表、刷 TLB) | 小(只换栈和寄存器) |
| 崩溃影响 | 隔离,一家死不连累邻居 | 共享内存,一个越界全家遭殃 |
| 通信 | 要走 IPC(管道、共享内存、socket) | 直接读写变量(快,但同步是坑) |
| 语义 | 「谁拥有什么」 | 「谁在干活」 |
选型没有标准答案,看你要隔离还是要便宜。浏览器是教科书案例:每个标签页一个进程(Chrome 甚至每个站点一个),一个页面崩了或被恶意脚本拖死,其他标签页照常;而一个标签页内部,渲染、JS 执行、网络、GPU 各用线程分工。反过来,一个计算服务的十几个 worker 之间没什么可隔离的,用线程(或协程)省钱。经验法则:不可信的、容易崩的、需要故障隔离的,上进程;可信的、要共享数据高频协作的,上线程。
共享内存是一把双刃剑,线程间通信不用过内核,快是真快;但两个人同时改一个变量,数据就烂了,于是有了下一章的锁和同步,以及无数隐蔽的并发 bug。线程把「容易」从通信挪到了正确性,这笔账 Chapter 4 会算给你看。
线程由谁来管,决定了它的能力上限。
内核级线程:内核认识每个线程(有 TCB),能独立调度到不同核上真并行;一个线程阻塞(读磁盘),内核把同进程的其他线程调上 CPU。代价:创建、切换都要陷入内核,贵一点。
用户级线程:线程库在用户态自己管理,内核毫不知情,只看见一个进程。切换不用进内核,快到飞起(几十纳秒);致命伤在阻塞:一个线程发起读盘系统调用,内核把整个进程挂起,所有线程陪葬,多核也用不上。
映射关系三种:多对一(ULT 方案,整进程一条内核线)、一对一(Linux pthread,主流)、多对多(理论上最优,实现复杂,基本绝迹)。有趣的是近年来多对一以新马甲重生:协程(Go 的 goroutine、Python asyncio 的 task)。成千上万个用户级协程由运行时调度到少量内核线程上,只要协程「阻塞」的方式是运行时认识的(网络 IO),就不会真卡住内核线程。协程把 ULT 的老毛病(阻塞拖全家)修好了,顺便把线程切换成本再压一个数量级,这是第 10 章 OS 实例还会回来的话题。
pstree -p | head -30,画出你系统的前三层进程树;找出 systemd(或 init)的 PID,想想为什么所有进程都挂在它名下。/proc/self/status 里 Voluntary_ctxt_switches 和 Nonvoluntary_ctxt_switches 两个字段,分别跑一个纯计算程序和一个读大文件的程序,解释两个数字为什么长得不一样。