1. 为什么操作系统要费劲抽象出一个“进程”1.1 一台 CPU 怎么假装同时干很多事《操作系统导论》第四章的标题是“抽象进程”。这是我从零开始读这本书的第一期学习笔记也是全书里我最想写透的一章因为“进程”这个概念后面讲调度、内存、并发全都建立在它上面。很多初学者会把进程理解成“正在运行的程序”这个说法不算错但经不起追问操作系统到底是怎么让一堆程序“看起来”同时在跑的如果你只有一个CPU核心微观上任意时刻只能执行一条指令那为什么你能一边写文档、一边后台下载、一边播放音乐好像所有程序都在并行答案就是“虚拟化”。操作系统把物理CPU做成了时分复用的资源一个进程跑一小段时间切走换下一个进程跑一小段时间再切回来。这个切换速度通常是毫秒级人的感知完全跟不上。于是每个进程都产生了一种幻觉它独享了整颗CPU。这种“把一份物理资源变成很多份逻辑资源”的手段是整本教材反复强调的核心。我接触过不少人看到ps里一大堆进程就误以为这些进程“同时”在运行。这是一个非常典型的抽象谬误。实际上大量进程处于“排队等CPU”或者“等IO”的状态。真正在CPU上运行的同一时刻只有一个。没有进程这个抽象操作系统根本不敢随意打断一个正在执行的任务因为它不知道这个任务跑到哪一行、寄存器里存了什么。进程就是用来记录这份“机器状态”的包裹。1.2 进程不是“运行中的程序”那么简单教材里有一句话我反复读了好几遍进程是运行中的程序的抽象。这里的“抽象”不是玄学它是在说操作系统并不关心你的程序是Java写的还是C写的也不关心你的业务逻辑是什么。它只关心一组足够描述“当前执行到哪、状态是什么”的信息。一个进程至少包含这些信息进程标识符PID、程序计数器、寄存器上下文、地址空间代码段、数据段、堆、栈、打开的文件描述符、信号处理器、环境变量。这些信息统称为机器状态。进程切换的本质就是保存一份机器状态再加载另一份机器状态。我自己的理解是进程就是操作系统给“一段执行流”打的包裹。调度器只负责把包裹搬上CPU跑一会儿再搬下来换另一个包裹上去。没有这个包裹操作系统连“暂停”都做不到更不用说“恢复”了。菜谱和做菜的区别就是这个道理程序是磁盘上静态的菜谱进程是锅里正在炒的那个状态。菜谱可以复制给任何人但炒到一半的菜只有厨师自己知道火候。第一章里常有人说“进程是动态的程序是静态的”这句话现在应该有画面感了程序是文件进程是运行环境加运行状态的总和。二者不是一回事。2. 亲手创建进程fork、exec、wait 三步走2.1 fork 为什么设计得这么“奇怪”我第一次在 Linux 里写进程相关的代码最不适应的就是fork()。这个函数只接受无参数调用一次却返回两次。父进程里它返回子进程的 PID子进程里它返回 0失败返回 -1。也就是说从fork()之后的代码开始一份代码变成了两条独立的执行流。为什么不像 Windows 的CreateProcess那样直接传一个可执行文件路径因为 fork 的设计哲学是“复制当前进程”。新进程刚创建时它几乎就是父进程的等价复制品内存、文件描述符、环境变量全都有了不需要传什么复杂参数。如果新进程想运行别的程序再来一步 exec 替换即可。这种“先复制再变身”的组合把一个创建进程的问题拆成了两个更小的问题。我建议第一次实验时把下面这段代码原样敲一遍#include stdio.h #include unistd.h #include sys/types.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { printf(子进程PID%d我爹是%d\n, getpid(), getppid()); } else { printf(父进程PID%d我生了个娃%d\n, getpid(), pid); } return 0; }编译运行后两行输出都会出现但顺序不一定。你先看到哪一行都正常因为这取决于调度器先把CPU给了谁。这里最反直觉的地方在于fork 之后父进程和子进程各有一份完整的内存副本。子进程里修改一个局部变量不会影响父进程的同名变量。这个特性做题目时很常见连续 fork 两次最终进程数量是 2 的 N 次方前提是每个 fork 都成功。2.2 exec让子进程“变身”成别的程序只 fork 不 exec子进程用的还是父进程的代码。想真正启动一个外部程序必须调用 exec 系列函数execlp、execvp、execle等等。它们的共同点是成功后不会返回因为当前进程的代码段、数据段、堆栈已经被新程序整个替换掉了。一个标准的组合是父进程 fork 出子进程子进程立刻调用exec变成目标程序父进程则调用wait等待子进程结束。这个流程从 Unix 早期延续到今天几乎所有 Shell 在启动外部命令时都是这么干的。注意一个细节exec 失败的时候会返回 -1并且继续执行后面的代码。所以写代码时exec 后面不要立刻写正常的业务逻辑而是紧跟一个 perror 加 exit防止子进程带着错误的身份继续乱跑。我见过有人在这里踩坑exec 没找到文件程序不但没报错还接着执行父进程后面的逻辑结果两个进程一起打印同一段内容排查了半天。下面是我常用的一个最小 demo#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { execlp(echo, echo, hello from exec, (char *)0); perror(exec 失败); exit(EXIT_FAILURE); } int status; wait(status); if (WIFEXITED(status)) { printf(子进程已退出退出码%d\n, WEXITSTATUS(status)); } return 0; }这里能明显看出“父子分工”子进程负责变身父进程负责善后。wait(status)会阻塞父进程直到子进程退出。退出状态被编码在 int 里要用WIFEXITED和WEXITSTATUS解出来。2.3 用系统命令观察进程的一生光看代码输出还不够。我建议开两个终端一个跑上面的程序另一个不断执行ps -o pid,ppid,stat,cmd。你会发现父进程在 wait 阶段状态是 S睡眠子进程被 exec 成 echo 后会很快变成 Z僵尸然后消失。这个过程对“进程生命周期”的理解比任何图表都来得深刻。如果 fork 返回 -1最常见的原因是进程数撞到了上限或者是内存不足。Linux 下可以用ulimit -u查看当前用户的最大进程数。我写实验代码时从不设防结果不小心写了个死循环 fork眼睁睁看着系统开始拒绝创建进程。这也反过来证明进程表项是有限资源服务端程序如果频繁 fork 却不回收迟早会出事。3. 进程状态机运行、就绪、阻塞到底怎么切3.1 三态切换不是“进程自己乱跑”教科书里进程有三种基本状态运行、就绪、阻塞。运行态是指进程正在占用CPU执行指令就绪态是指进程可以马上运行但CPU此刻被别的进程占着只能在就绪队列里等阻塞态是指进程因为等待某个事件暂停了比如等待磁盘返回数据、等待用户输入、等待网络包到达。这三个状态之间的切换逻辑往往让初学者晕。最核心的一点是从阻塞态醒来的进程不能直接回到运行态而是回到就绪态排队。因为CPU可能正在忙于另一个进程你贸然插队整个调度就乱套了。同样从运行态切到就绪态通常有两种情况时间片用完了或者有更高优先级的进程抢占。我常用餐厅来类比厨师是CPU订单是进程。一份订单正在等食材阻塞另一份订单已经配好菜就绪厨师当然先做配好菜的。等食材的订单到了之后不是插进正在炒菜的锅里而是放进“待做”队列里重新排队。阻塞、就绪、运行就是这套从“等待资源”到“排队上CPU”再到“正在被处理”的流转。3.2 调度器谁来决定下一份订单进锅调度器是操作系统的“决策者”。它从就绪队列里选一个进程决定让它跑多久再决定下一个是谁。最简单的调度策略是时间片轮转每次给进程固定长度的时间片比如 50ms到期就切走让就绪队列里的下一个进程上CPU。但不代表所有调度器都只按时间片轮转。现代操作系统要考虑进程优先级、是否IO密集、最近是否被调度过等因素。一个明显规律是IO密集型进程通常更受“优待”因为它频繁在等待IO留给调度器的CPU窗口本来就不多CPU密集型进程则必须被时间片限制防止它把其他进程饿死。我自己验证过一个小实验写一个死循环让CPU跑到100%再跑一个 sleep然后开着top看。死循环进程的状态是 RCPU占用飙高sleep 进程的状态是 SCPU占用基本是0。看到真实的状态变化会更容易理解调度器的存在意义它保证系统里所有进程都能分到CPU时间而不是被某个进程独霸。3.3 进程状态速查ps 和 top 里的那些字母Linux 下观测进程状态最方便的是ps aux。STAT 列那些字母背后全是教材里的状态概念。我整理了一张速查表STAT含义典型场景R运行或可运行正在吃CPU或排队等CPUS可中断睡眠sleep、等待IO、等待事件D不可中断睡眠等待磁盘IO信号难以打断Z僵尸进程子进程已退出父进程未回收T停止被 CtrlZ 挂起我个人的习惯是每隔一段时间就执行一次ps -ef --forest把进程树打出来看父子关系。比如某个守护进程它下面挂着哪些服务父子关系一目了然。top里的 load average 也能说明问题这个数字代表最近一段时间内处于 R 态或不可中断睡眠态的进程平均数并不是“CPU利用率”理解了状态就不会误读这个指标。4. 进程地址空间隔离策略与上下文切换成本4.1 每个进程都以为自己独占整块内存进程抽象带来的另一个重要效果是地址空间虚拟化。每个进程看到的地址空间都是从 0 开始的连续虚拟地址。两个进程都可以访问 0x1000 这个地址但它们各自映射到完全不同的物理内存。这个映射关系由页表和MMU共同维护。这种设计最大的价值是隔离。我写过一段有数组越界 bug 的程序运行后只崩溃了它自己其他进程安然无恙。为什么因为操作系统并没有把其他进程的物理页映射到当前进程的虚拟地址空间里。你越界改到的只是自己虚拟地址空间里的某些位置一旦访问了非法区域硬件会触发异常进程被操作系统强杀而不是把整个系统拖下水。一个普通的进程地址空间长这样代码段在低地址区紧接着是数据段、堆堆向上增长栈从高地址往下增长栈顶以下是内核区域。C语言里 malloc 出来的内存来自堆局部变量在栈上。理解这个布局之后很多奇怪的现象就有了解释为什么递归调用太深会导致“栈溢出”因为栈向下增长最终会和堆区域撞上或者跳到不可访问的地址。4.2 隔离不是白来的上下文切换代价不小为了切到另一个进程操作系统必须保存当前进程的寄存器现场、程序计数器、栈指针同时还要切换页表等地址空间相关状态。这个过程叫上下文切换。它的代价不只是保存和恢复寄存器更隐蔽的是切换之后CPU缓存可能大面积失效。因为进程的地址空间变了缓存里的数据不再可靠。这也是为什么现代系统会选择线程而不是一味用进程。同一个进程内的多个线程共享地址空间线程切换不需要切换页表自然比进程切换轻量。打个比方进程切换像是在两间独立房间里来回搬东西线程切换只是在同一间房里换个人坐椅子。后者少了很多搬运成本但代价是没有隔离一个线程越界整个进程都跟着遭殃。我在写网络服务时体会更深。如果每个连接都 fork 一个进程进程数很快会变得不可控而且频繁 fork 的开销会影响吞吐量。工程上的解法是进程池提前 fork 出一批固定数量的进程任务来了随机分配一个空闲进程处理处理完不放飞而是收回池子里继续复用。这样既保留了多进程的隔离性又避免了反复创建销毁进程的高昂代价。5. 进程间通信从管道到进程池的实战选择5.1 管道最直接但坑也不少的 IPC进程之间有隔离默认情况下数据是互不可见的。想要交换数据必须通过内核提供通信机制。管道pipe是最简单的一种一个进程往里写另一个进程从里读数据走内核缓冲区。Shell 里的ls | grep txt就是管道。ls 进程把输出写入管道grep 进程从管道里读取内容匹配。管道有一个很重要的特性读写阻塞。写满时不读写进程就会阻塞读不到数据时如果没有写端关闭读进程也会一直阻塞。这恰好实现了生产者消费者的同步。我自己写 C 管道时踩过一个很经典的坑pipe(fds)返回两个描述符fds[0]是读端fds[1]是写端。fork 之后父子进程各自持有两端。必须把不需要的一端 close 掉。比如父进程要发给子进程数据父进程应该关掉读端子进程应该关掉写端否则子进程 read 时永远等不到 EOF因为它的某个写端还开着。这种 bug 不看教材根本想不到。5.2 共享内存、消息队列与信号量怎么选管道适合小数据流但数据要反复在内核和用户空间之间拷贝。如果两个进程需要高频交换大量数据共享内存是效率最高的方案多个进程映射同一块物理内存到自己的虚拟地址空间读写直接发生在物理内存上不需要系统调用传递。但共享内存有个代价进程间失去了天然的同步保护。两个进程同时写同一块内存结果不可预期。所以共享内存通常要配合信号量来用。信号量本质是一个计数器P 操作减一V 操作加一。如果把信号量初始化为 1就等价于互斥锁A 进程进入临界区前 PB 进程尝试 P 时发现为 0就阻塞等待直到 A 执行 V。消息队列则适合按“消息”维度传递数据每条消息有类型和内容。它比管道灵活比共享内存安全但速度不如共享内存。四种方式各有定位方式传输单位性能同步适用场景管道字节流中等读写阻塞保证同步父子进程小数据共享内存内存块高需信号量高频大流量交换消息队列消息中等天然队列实现解耦按类型分发消息信号量计数器高本身就是同步原语资源互斥与同步5.3 进程池和守护进程从理论到工程聊完 IPC 原语回到工程里最常见的一组概念进程池和守护进程。进程池的思路就像银行柜台提前开固定窗口客户来了分配一个空闲窗口而不是每来一个客户就临时建一个网点。在服务端程序中频繁 fork 进程的开销很大还会造成上下文切换过多所以很多高性能服务会预创建固定数量的 worker 进程。比如 nginx一个 master 进程管理多个 worker 进程每个 worker 处理连接。守护进程则体现的是“长期后台运行”的进程设计。它没有控制终端用户登出后依然在跑。创建守护进程的标准流程是fork 一次让父进程退出、调用setsid开启新会话、重定向标准输入输出到/dev/null。这套链路的目的很单纯让进程不再依赖任何终端环境免受终端信号干扰。现在很多桌面应用也会拆成多进程架构。你打开一个即时通讯软件系统里能看到多个进程分别负责网络、界面渲染、业务逻辑。这种做法牺牲了一部分内存换来了稳定性一个子进程崩溃了主进程还能继续运行或者重启那个子进程。这就是进程抽象在软件架构里的价值。6. 进程学习中的坑僵尸、孤儿与线程辨析6.1 fork 之后要小心“继承”来的东西fork 子进程复制的东西比你以为的要多。它不只是复制内存还会继承父进程的文件描述符表、环境变量、信号处理器甚至当前工作目录。其中文件描述符继承最坑。如果父子进程同时写同一个日志文件描述符它们各自的写入位置最初相同但谁先写、谁后写不可控日志内容就乱了。所以在多进程程序里子进程 exec 前通常要关闭不需要的 fd或者用 O_APPEND 强制追加写避免偏移量互相覆盖。另一个隐蔽的坑是标准 IO 缓冲区。printf 默认是行缓冲但遇到文件重定向就变成全缓冲。父进程 fork 前 printf 了一段没带换行的内容数据停留在用户态缓冲区里fork 之后父子进程各持一份缓冲区副本最终两边都刷新你会看到同一段内容输出了两次。我调试过一次折腾很久才发现不是 fork 执行了两次而是缓冲区被复制了。解决方法是 fork 之前fflush(NULL)清空所有缓冲区。6.2 僵尸进程和孤儿进程两边都要处理父进程没调用 wait子进程退出后会留下一个僵尸进程。僵尸进程不占CPU但会占一个进程表项。如果系统里堆积大量 Z 状态进程用户无法创建新进程。这不是理论问题我亲测过写了个 parent 不 wait 的程序批量启动子进程后终端开始报 “Resource temporarily unavailable”。最稳妥的处理是fork 后尽快 wait。如果父进程还有自己的业务要做不想被阻塞可以为 SIGCHLD 信号注册一个处理函数在里面调用waitpid(pid, status, WNOHANG)。这个调用会非阻塞地回收已经退出的子进程。如果反过来父进程先退出了子进程就成了孤儿进程。孤儿进程会被 PID 为 1 的 init 进程收养退出时由 init 负责回收。所以在某些工程套路里会用“两次 fork”来制造孤儿进程第一次 fork 出的子进程再 fork 一次然后立刻退出这样第二个子进程变成孤儿被 init 托管原父进程不用一直惦记着 wait也不用承担清理责任。这个技巧很老但理解它之后对进程的生命周期理解会更深。6.3 进程和线程到底差在哪进程和线程的区别是操作系统面试里的高频题。我的判断标准很朴素进程是资源分配的基本单位线程是CPU调度的基本单位。一个进程里面至少有一个线程同一个进程的多个线程共享地址空间、全局变量、文件描述符但各持有自己的栈和寄存器上下文。线程和进程相比优势在于轻量。线程切换不需要切页表创建成本也比进程低因此高并发服务尤其是 I/O 密集型的网络服务通常首选线程池。但线程没有隔离性一个线程写坏内存整个进程都可能崩掉。进程虽然重但隔离性好一个进程崩了不影响其他进程。浏览器、服务端主从架构很多都愿意用多进程换取稳定。我长期写服务端的体会是如果业务对稳定性极其敏感选多进程如果追求QPS、要求极低延迟选多线程如果两样都想要就用“多进程 进程内多线程”的混合结构。操作系统没有银弹所谓抽象不过是在隔离、性能、易用性和资源开销之间不停权衡。最后分享一个小经验学“抽象进程”这章读多少遍书都比不上亲手跑一遍实验。每学一个 API就写几行代码再用ps和/proc观察进程状态。先 fork再 exec再 wait然后写管道最后人为制造一批僵尸进程看它们怎么出现、怎么被回收。当教材里的状态转移图变成你自己终端里的真实输出进程这个概念才算真正内化了。这一章基础打牢之后理解线程、信号量、调度器都会顺利得多。