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看。死循环进程的状态是 R,CPU占用飙高;sleep 进程的状态是 S,CPU占用基本是0。看到真实的状态变化,会更容易理解调度器的存在意义:它保证系统里所有进程都能分到CPU时间,而不是被某个进程独霸。
3.3 进程状态速查:ps 和 top 里的那些字母
Linux 下观测进程状态,最方便的是ps aux。STAT 列那些字母,背后全是教材里的状态概念。我整理了一张速查表:
| STAT | 含义 | 典型场景 |
|---|---|---|
| R | 运行或可运行 | 正在吃CPU或排队等CPU |
| S | 可中断睡眠 | sleep、等待IO、等待事件 |
| D | 不可中断睡眠 | 等待磁盘IO,信号难以打断 |
| Z | 僵尸进程 | 子进程已退出,父进程未回收 |
| T | 停止 | 被 Ctrl+Z 挂起 |
我个人的习惯是,每隔一段时间就执行一次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 进程进入临界区前 P,B 进程尝试 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,然后写管道,最后人为制造一批僵尸进程,看它们怎么出现、怎么被回收。当教材里的状态转移图变成你自己终端里的真实输出,进程这个概念才算真正内化了。这一章基础打牢,之后理解线程、信号量、调度器,都会顺利得多。