排查线上服务卡死的时候,我习惯先敲一条ps -eo pid,ppid,stat,wchan:24,cmd。有一次看到一个进程 STAT 是D,kill -9打上去跟打在棉花上一样,纹丝不动。旁边的同事说"重启吧",但重启之前你得先知道它为什么杀不掉——这就牵扯到进程控制最底层的几个动作:进程是怎么被创建出来的、怎么被终止的、怎么进入阻塞、谁负责把它唤醒、CPU 又是在什么时刻从它身上切走的。这些动作在操作系统里有一个统称,叫进程控制原语。
这篇东西我打算从实操角度把这几件事串起来讲:fork/clone这类创建原语到底在内核里干了什么,exit/wait这条终止与回收链路上最容易踩的僵尸进程坑,阻塞与唤醒这一对状态机的扳机是怎么扣下去的,以及上下文切换的成本到底怎么量化。顺带说一句,"原语"这个词在两个圈子里完全不是一回事——搜"原语"的时候经常会撞出一堆IBUFG、OBUF、ICAPE2的 FPGA 内容,那是硬件描述层面的原语,我会专门拿一节把这两者的边界讲清楚,免得初学者把它们混成同一个概念。
不管你是刚开始学操作系统、被实验课里的fork绕晕,还是已经在写服务端代码、需要解释清楚为什么某个线程卡在D状态,这篇内容里的观测手段和排查路径都可以直接抄。
1. 进程控制原语的内核视角:为什么要"原子"地做完一整件事
1.1 从一个杀不掉的进程说起
用户态看到的进程操作,其实只是一层薄薄的壳。你调fork(),它不是一个普通的库函数在里面算了个数返回给你,而是一次陷入内核的请求;内核替你构造出一整套新的执行上下文,再把控制权还给你,只不过这次返回是两个进程各拿到一份返回值。同理,exit()也不是简单地把内存还掉就完事,它要按顺序拆掉地址空间、关闭文件描述符、通知父进程、把自己的任务结构挂到一个特定的队列上等待回收。
这就是"原语"两个字的含义:要么完整做完,要么完全没发生,中间不允许被别的控制流插进来看到半成品状态。你想想如果fork做到一半被调度器切走,新进程的 PCB 已经存在但地址空间还没建好,这时候另一个进程如果去遍历进程表,就会看到一个半死不活的实体。这种不一致是内核绝对不能容忍的,所以这些操作在内核里通常是用"关中断"或者"持锁 + 不允许睡眠"的方式保证原子性的。
1.2 原语的原子性,到底保护了什么
很多人把原子性理解成"快",这是误解。原子性保护的是状态的可见性边界。进程控制涉及至少四类共享结构:进程表(或者说任务链表)、调度器的运行队列、父子关系链、以及各类资源引用计数。创建、终止、阻塞、唤醒、切换这五类操作,本质上都是在改这些结构,而且往往一次要改好几处。
拿"阻塞"举例。一个进程要进入阻塞,必须同时完成三件事:把自己在运行队列里摘掉、把状态位从TASK_RUNNING改成TASK_INTERRUPTIBLE、再把自己挂到某个等待队列的链表上。这三步如果说做了一半被切走,就会出现"任务不在运行队列里、也不在任何等待队列里"的幽灵状态——它永远不会被调度,也永远不会被唤醒。
注意:判断一个进程"卡住"的时候,别只看它的状态码。真正有价值的信息是它挂在哪个
wchan上。S状态 +wchan是某个明确的等待点,说明它在等一个预期中的事件;S状态 +wchan是0,那才需要警惕,可能是状态机出了问题。
1.3 为什么这些动作必须下沉到内核
因为在用户态你根本没有权限改这些结构。进程的地址空间映射、页表基址寄存器、内核栈指针、调度优先级这些都属于特权状态,只有 CPU 在特权级运行的时候才能碰。用户态代码想改,必须通过系统调用这条唯一通道进去。
这带来一个很实际的性能含义:进程控制的每一次调用都比普通函数调用贵得多。一次fork涉及系统调用陷入、页表复制、若干结构体分配;一次上下文切换涉及寄存器保存恢复、地址空间切换和 TLB 失效。当你的程序在压测里出现莫名的吞吐上不去,很可能就是这些原语被调用得太频繁了,后面第五节我会讲怎么量化。
2. fork、vfork 与 clone:创建进程时内核替你复制了什么
2.1 一次 fork 背后的完整清单
直觉上你会觉得fork就是把父进程的内存全抄一份,实际上现代内核做的事精细得多。当fork一路走到内核里的复制流程时,大致做了这些事:
- 分配一个新的任务结构体,作为新进程的"身份证",里面装着 PID、状态、优先级、调度统计等。
- 分配一个内核栈,因为每个任务在内核态必须有自己的栈,否则系统调用嵌套起来就乱了。
- 复制父进程的页表结构,但只复制页表项,不复制物理页,把父子双方对这些物理页的映射都标记为只读。
- 复制或共享一批资源引用:打开的文件描述符表通常是复制的,但底层的
file对象是引用计数共享的,所以父子共享文件偏移量。 - 继承信号处理配置、进程组、会话、工作目录、资源限制等。
- 给新任务分配一个 PID,插入进程表,最后把它放到运行队列里等待被调度。
返回值的设计是这套机制里最巧妙的一笔:父进程拿到子进程 PID,子进程拿到 0,出错拿到 -1。为什么子进程返回 0?因为子进程只需要知道"我是子进程",它想知道父进程是谁直接调getppid()就行;而父进程必须要拿到 PID,否则它没法管理这个孩子。
2.2 写时复制省的不是内存,是复制时间
写时复制(Copy-On-Write)经常被解释成"为了省内存",这个说法只对了一半。它真正的价值在于把复制成本推迟到真正发生写入的那一刻。创建进程这个动作本身变得非常轻,只需要复制页表,物理页一个都不动。
代价是页表本身还是要复制的。一个占用 4GB 虚拟地址空间的进程,页表可能有几 MB 到几十 MB,这部分复制是实打实的开销。这也是为什么高并发场景下大家更愿意用线程而不是进程:线程共享同一套页表,创建时完全不需要复制这一块。
触发写时复制的时刻是页面错误处理:父子任一方往那个只读页写数据,CPU 抛出页错误,内核判断这是个 COW 页,就分配一个新物理页、把内容拷过去、改掉写方的页表项并恢复可写。所以有个反直觉的现象:你fork之后如果父子双方都不写内存,这个操作几乎没有内存成本;一旦双方都开始写,成本反而比直接复制更高,因为多了页错误处理和页分配两条路径。
2.3 vfork 和 clone 是同一套机制的不同开关
早期系统里fork要完整复制地址空间,而紧接着往往就是exec把地址空间全换掉,这个复制纯属浪费,于是有了vfork。它的语义是:子进程借用父进程的地址空间,父进程会被挂起,直到子进程调用exec或者退出为止。这解决了复制浪费的问题,但引入了一个巨大的陷阱——子进程在exec之前如果修改了任何变量,父进程看到的也会被改掉,而且从vfork返回后直接在父进程栈上调用函数,栈帧会被破坏。
在 Linux 上比较特殊的是,vfork的实现其实是通过clone加上CLONE_VM | CLONE_VFORK两个标志拼出来的。这就引出了真正的主角:clone才是 Linux 里唯一的创建原语,fork和vfork都是它的封装。
clone的精华在于那一组标志位,它们决定子进程和父进程共享哪些东西:
| 标志 | 共享的内容 | 效果 |
|---|---|---|
CLONE_VM | 地址空间 | 不复制页表,双方看同一份内存 |
CLONE_FILES | 文件描述符表 | 一方close,另一方立即受影响 |
CLONE_FS | 文件系统信息 | 共享工作目录和根目录 |
CLONE_SIGHAND | 信号处理表 | 前提是必须同时用CLONE_VM |
CLONE_THREAD | 线程组 | 同一个线程组,共享 PID,各有自己的 TID |
CLONE_VFORK | 父进程挂起 | 等待子进程exec或退出 |
把CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD这一整套打开,得到的就"差不多"是一个线程。说"差不多",是因为严格来说线程和进程在 Linux 里没有本质区别,区别只在这组标志位。你在strace里看一个多线程程序的系统调用,看到的就是一堆clone而不是pthread_create。
2.4 exec 不是创建,是替换
这一点初学者最容易搞混:fork之后调exec系列函数,并不是"启动了一个新进程",而是当前进程把自己换了个灵魂。PID 不变,父子关系不变,但地址空间被整个换掉,代码段数据段全部重新加载。
exec之后有几件事会被重置,值得单独记一下:信号处理函数会恢复成默认行为(被忽略的信号仍然被忽略),但被设置为捕获的信号会全部失效;内存映射除了显式标记保留的之外全部被丢弃;而那些设置了FD_CLOEXEC标志的文件描述符会被自动关闭。最后这条特别重要,服务端代码里经常出现"子进程莫名其妙继承了父进程的监听套接字",根因就是忘了设CLOEXEC。
一个标准的安全写法是fork之后在子进程里立刻关闭所有不需要的 fd,而不是依赖外部约定。我给个最简的模板:
pid_t pid = fork(); if (pid == 0) { /* 子进程:先把不需要的 fd 关掉,再 exec */ for (int fd = 3; fd < 256; fd++) close(fd); execl("/usr/bin/some-tool", "some-tool", NULL); _exit(127); /* exec 失败必须用 _exit,不能 return */ }注意这里用的是_exit而不是exit。这个区别在下一节会展开,它关系到缓冲区里的数据会不会被重复刷出去。
3. 进程终止:回收链路上那两个最容易翻车的角色
3.1 exit 与 _exit 之间隔着一层用户态缓冲区
exit()是标准库函数,_exit()是系统调用封装。前者的执行流程里多了几步用户态的收尾工作:
- 按注册的反向顺序调用
atexit注册的函数和标准库的清理函数; - 把标准 IO 流里还没刷出去的缓冲区数据刷到文件描述符;
- 关闭所有打开的流。
而_exit()直接进内核,什么都不刷。这个差异在fork场景下会造成非常经典的 bug:父进程往stdout写了一些内容但还没换行、缓冲区没满,这时候fork,父子双方的用户态缓冲区里都有同一份内容,子进程如果调exit(),缓冲区被刷一次;父进程最后再exit(),又刷一次。结果就是同一行输出出现了两遍。
这个坑我自己在写日志采集工具的时候踩过,当时排查了半天以为是并发写导致的重复上报,最后发现是缓冲区被复制了两份。结论很简单:fork出来的子进程,退出时一律用_exit()或_Exit()。
3.2 僵尸和孤儿,是两条完全不同的链路
这两个词经常被并列提起,但它们其实是两个独立的问题机制。
僵尸进程:子进程已经退出,内核里它的任务结构和退出状态还留着,因为父进程还没来取这个退出码。此时资源(内存、文件描述符)大部分已经释放,只剩一个空壳占着一个 PID。它的存在时间完全取决于父进程什么时候调wait系列函数。如果父进程一直不调,这些空壳会一直堆积,直到 PID 耗尽——这才是真正致命的。
孤儿进程:父进程先退出了,子进程还活着。这时候内核会把子进程的父指针改指向某个"收尸人",也就是init或者最近的 subreaper。所以孤儿进程本身不是问题,反而是一种保护机制——它保证了每个进程最终都有人负责回收。
两者结合起来还有一个组合形态:孤儿进程后来退出了,它的父进程(现在是 init)会立刻调wait把它回收掉,所以不会变成僵尸。真正会长期存在的僵尸,一定是父进程活着但从来不 wait。
3.3 waitpid 的正确写法,以及 SIGCHLD 的三个陷阱
回收僵尸的唯一办法就是父进程主动调wait或waitpid。最朴素的写法是:
int status; pid_t done = waitpid(-1, &status, 0); if (done > 0) { if (WIFEXITED(status)) printf("normal exit, code=%d\n", WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status)); }但生产代码里通常会配合SIGCHLD信号做异步回收,这里坑最多,我一个个说。
第一个坑是信号不排队。SIGCHLD是标准信号,不排队。如果你有 10 个子进程几乎同时退出,内核可能只给父进程投递一次SIGCHLD。所以处理函数里绝对不能只wait一次,必须循环waitpid(-1, &status, WNOHANG)直到返回 0 或者 -1,把已经退出的都收干净。
第二个坑是处理函数里能调什么。信号处理函数运行在异步上下文里,能安全调用的函数非常有限。waitpid本身是异步信号安全的,printf不是。所以在处理函数里只做最必要的回收动作,把状态记录到一个volatile或原子变量里,真正的日志打印放到主循环去做。
第三个坑是默认行为的干扰。SIGCHLD的默认行为是忽略,但"忽略"和"显式设置为SIG_IGN"效果不一样:显式设为SIG_IGN时,子进程退出会直接被丢弃不产生僵尸;而默认忽略时,子进程仍然会变成僵尸,只是你不收到通知。这个差异在很多老文档里都没有讲清楚。
回调循环的标准写法大致是这样:
static void on_sigchld(int sig) { (void)sig; int saved = errno; int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { /* 只记录,不做复杂操作 */ } errno = saved; }errno的保存和恢复是必须的。信号可能在任意时刻打断主流程,如果处理函数里改了errno不还原,主流程后续的错误判断就会拿到错误的信息,这种 bug 极难排查。
4. 阻塞与唤醒:进程状态机上的两个扳机
4.1 阻塞的本质是"主动交出 CPU"
阻塞不是"被暂停",而是进程主动放弃CPU 并声明自己在等某个事件。这个主动很重要,因为它是调度器做决策的前提:一个自愿让出 CPU 的任务,不会影响它的调度权重;而被强制剥夺 CPU 的任务,会被记录为一次非自愿切换。
在内核里,一次典型的阻塞流程是这样的:进程在某个资源上发现条件不满足(比如管道里没数据、信号量计数为 0、socket 接收缓冲区为空),于是把自己加入一个等待队列,把状态设为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE,然后调用调度器主动让出 CPU。等条件满足的时候,由另一方调用唤醒函数把等待队列上的任务状态改回TASK_RUNNING并放回运行队列。
用一个生活化的类比:这不是"你被保安拦在门口",而是"你去取号机拿了个号,然后坐在等候区等叫号"。窗口叫号的那一刻,就是唤醒。
4.2 可中断睡眠与不可中断睡眠的区别
这个区别直接决定了你能不能杀掉一个进程。
TASK_INTERRUPTIBLE叫可中断睡眠。它的特点是等待队列上挂了任务,但同时信号可以打断这个等待。进程收到信号后状态被改回TASK_RUNNING,系统调用返回-EINTR。这就是为什么很多系统调用需要写重试逻辑:被信号打断不是错误,只是需要重新发起。
TASK_UNINTERRUPTIBLE叫不可中断睡眠。信号会被挂起,但不会立刻投递,必须等它等的事件到来、状态切回之后才处理。这就是D状态的来源。为什么内核要设计这么一个状态?因为有些等待流程如果被中途打断,会导致数据结构处于不一致状态。典型场景是块设备 IO 和文件系统元数据操作,中途放弃可能让缓冲区状态混乱。
所以排查D状态进程的正确姿势不是反复kill -9,而是看它在等什么:
ps -eo pid,stat,wchan:32,cmd | awk '$2 ~ /^D/' cat /proc/<pid>/stack # 需要 root 权限,能看到内核栈 cat /proc/<pid>/wchan/proc/<pid>/stack是最有价值的,它直接告诉你进程卡在内核的哪个函数里。如果看到栈里是文件系统或者块层的函数,那基本可以判定是底层 IO 出问题了,比如网络存储挂载点失去响应。这种情况唯一的解法通常是恢复底层存储或者强制卸载。
4.3 唤醒路径与"惊群"
条件满足的一方调用唤醒函数把等待者叫醒。这里有一个经典的性能陷阱叫惊群:如果多个任务挂在同一个等待队列上,而唤醒函数把整个队列全叫醒了,但实际只有一个能拿到资源,剩下的醒来发现没戏还得重新睡回去。这一来一回白白消耗两次上下文切换。
现代内核里应对这个问题的手段有两类。一类是互斥等待,唤醒时只叫第一个,让被叫醒的那个负责继续唤醒下一个(所谓的"接力唤醒")。另一类是条件化唤醒,唤醒时判断一下资源够不够、够几个就叫几个,比如新版本里 socket 的等待队列就做了这种优化。
你自己写代码时也会遇到类似的问题。比如用条件变量的时候,如果用pthread_cond_broadcast唤醒所有等待者,而实际上资源只够一个线程用,那剩下那些线程醒来发现条件不满足又得睡回去。这时候应该用pthread_cond_signal只唤醒一个,并且唤醒动作要在持锁状态下做,避免出现"唤醒先于等待"的丢失唤醒问题。
4.4 用三个工具把阻塞与唤醒看穿
光看状态码不够,我习惯用这三个东西交叉验证。
第一个是ps的wchan列,它显示的是等待点在内核里的符号名。看到一个进程的wchan是某个具体的函数名,你就知道它在等什么类型的事件。
第二个是/proc/<pid>/status,重点看State和voluntary_ctxt_switches/nonvoluntary_ctxt_switches这两个计数器。前者是被中断打断的次数,后者是被调度器强制剥夺的次数,两个数字的变化趋势能反映这个进程的睡眠模式。
第三个是strace,重点看系统调用是"进去没出来"还是"反复进出"。一个阻塞在read上的进程,strace -p会看到它有read(开头但没有返回;而一个在内核里转圈刷EAGAIN的程序,会看到大量read(...)= -1 EAGAIN。
5. 进程切换:一次上下文切换到底花了多少钱
5.1 上下文里到底装了什么
上下文切换指的是 CPU 从执行任务 A 换到执行任务 B 的过程。要能换回来继续跑 A,必须把 A 的执行现场完整保存下来。这个现场包括:
- 通用寄存器的值,特别是那些调用约定要求由被调用方保存的寄存器,因为跨函数调用存活的值可能就放在这里面。
- 栈指针和指令指针,这是恢复执行位置的关键,进程切走时栈指针指向它自己的内核栈。
- 浮点和向量寄存器状态。这部分通常用惰性保存,只有真正用过才存,因为状态体积大,每次都存开销太高。
- 地址空间标识,也就是页表基址。如果切换的两个任务属于不同进程,页表基址要换,这会带来 TLB 失效的连带成本。
最后这条是进程切换和线程切换成本差异的来源。同一个进程内的两个线程共享页表,切换时不需要换页表基址,也就不需要刷 TLB;不同进程之间切换则要换,TLB 里缓存的映射可能大面积失效,后续访存要重新走页表遍历。
5.2 自愿切换与强制切换
调度器把切换分成两类,统计时分开算。
自愿切换是任务自己让出的,比如阻塞在 IO 上、调用sched_yield、或者等待锁。这类切换是"有意义的",因为任务确实没法继续推进。
强制切换是任务还想跑但被剥夺了。触发条件是时间片耗尽或者有更高优先级的任务被唤醒。这类切换反映的是 CPU 竞争程度,如果这个数字很高,说明系统里的任务在抢 CPU。
在 CFS 调度器下,"时间片"这个概念已经不太一样了。CFS 按虚拟运行时间排队,优先级高的任务虚拟时间涨得慢,所以能跑更久。它并不是给每个任务分配一个固定的时间片,而是在调度周期内按权重分配,同时有个最小粒度的下限,避免切换过于频繁。
5.3 亲手测一次切换开销
测开销最好的办法是自己构造负载,然后用系统工具观察。
# 观察系统整体切换频率 vmstat 1 5 # 按进程观察自愿/非自愿切换 pidstat -w -p <pid> 1 5 # 查看单个进程的调度统计 cat /proc/<pid>/schedvmstat输出里的cs列就是每秒上下文切换次数。pidstat -w会分出cswch/s(自愿)和nvcswch/s(非自愿)。
我做过一组对照:一个纯计算的多线程程序,nvcswch/s通常在几百的量级;一个频繁读写小包的网络程序,cswch/s能轻松上到几万。这两个数字的差异说明问题的性质完全不同:前者是抢 CPU,后者是 IO 太碎。
一个粗略的经验值:在常见的 x86 服务器上,一次上下文切换的直接开销大概在 1 到 3 微秒之间,如果涉及跨 NUMA 节点的调度或者 TLB 失效严重,能到 5 微秒以上。这意味着如果一个服务每秒做 10 万次切换,光切换本身就吃掉 10% 到 30% 的 CPU。所以优化思路很明确:减少切换次数,比优化单次切换更重要。具体手段包括把大批量小 IO 合并、用无锁队列代替锁等待、控制线程数量在合理范围。
6. 同名不同义:OS 原语与 FPGA 里的 IBUFG、OBUF、ICAPE2
6.1 硬件描述里的"原语"是另一种东西
搜"原语"的时候,除了操作系统内容,你一定会撞到大量 FPGA 相关的结果:IBUFG、OBUF、ICAPE2、IBUFGDS之类的名词。这些是器件原语,含义和进程控制原语完全不同。
在 FPGA 的世界里,原语指的是厂商在器件里预先做好的、有固定功能和固定位置的底层硬件单元。综合工具无法自己"推断"出一个原语,你必须显式地在代码里实例化它,工具才会把它映射到器件里那个具体的硬件块上。它的"不可分割",指的是这个功能单元在硬件层面就已经封装好了,你无法用查找表拼出同样的功能。
这跟操作系统原语的共同点在于:都是由底层平台保证语义、都不可再分、都不能用上层语言自然地表达出来。区别在于一个是软件行为,一个是硬件元件。
6.2 IBUFG 与 OBUF 的角色分工
IBUFG是带全局时钟能力的输入缓冲原语,负责把外部引脚上的时钟信号引进芯片内部的全局时钟网络。它有个硬约束:必须放在支持时钟输入的引脚上。如果你在综合后看到布局报错说找不到合法位置,八成是把IBUFG画到了普通 IO 引脚上。普通的信号输入用IBUF就够了,只有需要驱动时钟网络的时候才用IBUFG。
OBUF是输出缓冲,负责把芯片内部的逻辑信号驱动到外部引脚上。它有几个可以配置的属性需要和引脚约束保持一致:驱动能力、翻转速率。这两个参数配错了,表现出来就是信号完整性差、边沿过冲或者上升沿太慢。还有一点经常被忽略,同一 bank 内的电平标准必须和 VCCO 电压匹配,不同 bank 之间可以不同,但同一 bank 内混用会直接报错。
一个最小的用法大致是这样:
IBUFG u_clk_in ( .I(clk_pin), .O(clk_global) ); OBUF u_led_out ( .I(led_drive), .O(led_pin) );6.3 ICAPE2 把重配置能力做成了一个原语
ICAPE2是内部配置访问端口的原语,作用是让 FPGA 内部的逻辑能够自己去读或者写配置存储器,从而实现内部触发的重配置或者配置回读。它的意义在于,你不用外挂一颗控制器芯片,就能在运行时改自己的配置。
这个原语的端口不多:时钟、片选、读写控制和一对数据总线。看起来简单,但使用时限制不少。片选和读写线的时序必须严格遵守,一次事务没能完整走完就中断,后续状态会错乱。而且操作序列里必须先写入一段同步字,让配置引擎知道接下来是有效的配置数据流。
几个实操上的注意点,都是从调试中总结的:
- 事务顺序不能乱。配置数据流里命令和数据的先后顺序是有严格定义的,你按自己的方便去调换顺序,配置引擎不会给你任何反馈,只是静默失败。
- 时钟不能停。这个原语对时钟的连续性有要求,如果驱动它的时钟被门控掉了,中途停振会导致内部状态机挂死。
- 别和外部配置口抢。如果外部配置链路也在访问同一个配置资源,两条路径会冲突。这种情况下必须有明确的互斥机制,一般靠外部控制器和内部逻辑约定的握手信号来保证。
- 强烈建议先验证再落地。先用一个简单设计跑通回读,确认链路和时序都对,再去做真正的重配置逻辑。直接上复杂设计出了问题很难定位是时序问题还是配置流本身的内容问题。
第六节的内容看起来和进程控制离得远,但因为"原语"这个词的搜索热度把它们混在一起了,我索性把这层区分讲清楚。你只要记住一句话:操作系统原语是软件层面不可分割的动作,FPGA 原语是硬件层面不可替代的元件,它们共享的只是"底层保证、不可再分"这个抽象含义。
7. 一个最小可观测实验:把创建到切换的全链路跑一遍
7.1 实验设计
纸上谈兵不如跑一遍。我设计了一个能同时覆盖创建、阻塞、唤醒、终止、回收的实验,代码不长,但每一步都能对应到前面讲的原语:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/wait.h> int main(void) { int pipefd[2]; if (pipe(pipefd) < 0) { perror("pipe"); return 1; } pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { /* 子进程:关闭写端,读端会进入阻塞 */ close(pipefd[1]); char buf[64]; ssize_t n = read(pipefd[0], buf, sizeof(buf)); if (n > 0) { buf[n] = '\0'; dprintf(2, "[child %d] woke up, got: %s\n", getpid(), buf); } close(pipefd[0]); _exit(0); } /* 父进程:先让子进程有时间阻塞 */ sleep(3); const char *msg = "hello-primitive\n"; write(pipefd[1], msg, strlen(msg)); /* 这一步触发唤醒 */ close(pipefd[1]); int status; waitpid(pid, &status, 0); /* 这一步完成回收 */ printf("[parent] reaped %d, exit=%d\n", pid, WEXITSTATUS(status)); return 0; }7.2 观测执行过程
编译运行起来之后,在另一个终端里用这几个命令交叉观察:
# 1. 看进程状态和等待点 ps -eo pid,ppid,stat,wchan:28,cmd | grep -E 'PID|a.out' # 2. 看调度统计的变化 cat /proc/<child_pid>/sched | head -20 # 3. 追踪系统调用序列 strace -f -e trace=clone,fork,vfork,execve,wait4,exit_group ./a.out7.3 数据怎么读
ps的输出在sleep(3)期间应该能看到子进程处于S状态,wchan指向管道读取相关的等待点。这个S就是可中断睡眠——它挂在等待队列上,等着管道里有数据。
strace的输出会清楚地展示调用顺序:父进程这边是一串clone(也就是fork的实现)、然后wait4阻塞;子进程这边是read阻塞了几秒、然后返回、最后exit_group。两条时间线并行展开,你能直观看到"阻塞"和"唤醒"在系统调用层面的样子。
/proc/<pid>/sched里那个se.statistics.nr_wakeups计数器,在write之后会加一——这就是唤醒发生的直接证据。
7.4 几个我在实验里反复遇到的误解
第一个误解是"阻塞的进程也在消耗 CPU"。不是的,一个真正处于阻塞状态的进程不在运行队列上,调度器根本不会考虑它。你在top里看到某个进程 CPU 占用高,它一定不在阻塞状态,要么在跑,要么在反复进出内核。
第二个误解是"fork之后父子是同时执行的"。它们的执行顺序完全取决于调度器,不要写任何依赖顺序的代码。父进程如果需要等子进程,必须用进程间同步手段,而不是靠"sleep 一下应该就跑完了"这种假设。
第三个误解是"waitpid只能回收直接子进程"。确实只能回收直接子进程,但如果你在父进程里又fork出了孙进程,那孙进程退出后由它的父进程负责回收,跟祖父进程没关系。这条链如果断了一环,僵尸就会出现在你没有预期的地方。
第四个误解是"状态码可以直接用"。waitpid返回的status是个打包过的值,必须用WIFEXITED、WEXITSTATUS、WIFSIGNALED、WTERMSIG这些宏去解,直接当整数用会得到完全错误的结果,而且不会报错。
我个人在实际操作中的体会是,进程控制这块知识的价值不在于能背出多少个系统调用,而在于排查问题时能不能建立一条从"现象"到"内核状态"的映射路径。看到一个进程卡住,脑子里要能立刻反应出去查它的wchan和内核栈;看到切换次数异常高,要知道区分自愿和非自愿;看到僵尸堆积,要能顺着父子链找到那个不负责的父进程。这几个动作熟练之后,绝大部分和进程相关的疑难问题都能在几分钟内定位到根因。
最后再分享一个小技巧:调试期可以在代码里加一个定时打印,每隔几秒把/proc/self/status里的状态和切换计数打一次。这个输出的信息量比任何日志都大,而且完全不需要改配置,运行起来就能拿到。