先讲一个你可能已经隐约感觉到、但一直没被点破的道理:在 Linux 里,一个"正在运行的程序"和我们平时写的"C 程序"其实是两套完全不同的东西。我们敲进编辑器的,是写在磁盘上的代码和数据,它是一堆静态的文件字节;而当你双击或者敲回车把它跑起来的那一刻,操作系统会为它创建出一个"活物"——它有自己的身份证号(PID)、有自己的内存、有自己的运行快慢节奏。这个"活物",就是进程(Process)。
上一篇文章我们讲了 C/C++ 的语法、内存、栈帧,那都是"静态程序"内部的事。从这篇文章开始,我们要进入 Linux 系统编程中最核心、也最迷人的地带:进程控制。简单说就是回答这么几个问题:进程是怎么从无到有出生的(fork)?一个进程想去执行另一个不同的程序该怎么办(exec)?进程死了之后谁来收尸、它的"临终遗言"谁能听到(wait/exit)?以及系统如何给这些进程排班调度。如果你把这四个问题想通了,一个 minishell、一个守护进程、一个简单服务器,你都能亲手写出来——因为它们的骨架,就是这么点儿东西。
这篇文章没有字数上限,所以我会把每一个概念都掰开揉碎:先讲是什么、再讲为什么、再给一段能编译运行的代码亲手验证,最后把最容易踩的坑、最容易误解的边界全部亮出来。文中的思考题我会紧跟着给详细答案,你不需要去网上查。
你应该有的知识准备
在往下读之前,有几样基础知识你得先有概念。它们大多散落在 C 语言的系统编程角落里,这里我只做一句话速览:
- 进程(Process)与程序(Program)的区别:程序是磁盘上的静态文件(代码 + 数据 + 资源信息),进程是程序的一次"运行态实例",是操作系统进行资源分配和调度的基本单位。同一个程序可以被启动成多个互不干扰的进程。
- PID 与 PPID:PID(Process ID)是内核给每个进程分配的唯一整数标识;PPID(Parent PID)是父进程的 PID。
- 系统调用(syscall):用户态程序请求内核做事的那扇门。
fork、execve、wait、exit最终都会经陷入(trap)进入内核态执行。 - 调用栈与栈帧(stack frame):一个被调用的函数会在调用栈上获得一块叫"栈帧"的区域,存放局部变量、参数和返回地址。这决定了之后我们要讲的"
fork为什么能从同一个return语句返回两次"和"函数调用栈"的紧密关系。 printf的缓冲区(buffer):printf默认并不立刻把每个字符写到屏幕,而是攒在内存缓冲区里,遇到换行、缓冲区满、程序正常退出(exit/return)、或显式fflush时才真正落屏。这一条直接决定了后面_exit和exit的天壤之别,务必记牢。
如果你带着这些概念进来,接下来的路会非常顺;如果哪一条还很模糊,建议就地补一补,因为它几乎是我们这堂课所有结论的地基。
fork 函数初识
进程不是凭空冒出来的。在 Linux 里,任何一个进程,除了 0 号进程(内核启动的 idle/swapper)和它的直接子嗣(1 号进程 systemd/init 等早期内核进程),都是由一个已经存在的进程"复制"出来的。负责完成复制这个动作的,就是大名鼎鼎的 fork 函数。
fork 是 Dictionary 意义上"叉子、分叉",正好形象地描述了它的行为:从一个已经存在的进程里,分叉出一个新进程。调用 fork 之前的那个进程叫父进程(parent),被创建出来的新进程叫子进程(child)。听起来好像很简单——不就是把进程复制一份吗?但它真正的精妙和坑,都藏在两个地方:返回值的诡异,和写时拷贝的内存模型。我们一点点来。
先看它的签名:
#include <unistd.h> // fork、getpid、sleep 等系统调用函数都声明在这里
pid_t fork(void); // 参数为空,返回一个 pid_t 类型的值这里的 pid_t 全称是 process id type,是一个整数类型(通常是 int 的同义词,在 64 位 Linux 上就是 int)。fork 被调用后,内核会在进程表中新建一个条目,作为当前进程的"同卵双胞胎"。
为什么一次 fork 有两个返回值
这是初学者最容易懵的地方。一个普通的函数,调用一次、返回一次。可 fork 偏偏不这样:它调用一次,却"返回"两次(准确说,是一次调用触发了两次返回,父子各自返回一次)。而且这两次的返回值还不一样。规整如下:
- 在子进程里,
fork返回 0; - 在父进程里,
fork返回子进程的 PID; - 如果出错,两边都不会得到正常后续,
fork返回 -1,并设置errno说明错误原因。
这就像一个复印机,你按了一次"开始",机器吐出来两张互相独立的纸(父、子),并且悄悄在两张纸上分别盖上了不同的印记——一张写"我是老大,我印的号码是 10086(子进程 PID)",一张写"我是被印出来的那张,我的印记是 0"。因此父进程和子进程虽然执行的代码一字不差,却可以靠 fork 的返回值一眼分出谁是谁、从而走上两条完全不同的命运。这正是 fork 最了不起的地方:一次系统调用,把"我"裂变成了两个独立的执行流,并且让这两个执行流都从 fork 返回后的下一行继续往下走。
这里我要提前打一个重要的预防针,它会在后面反复出现:从 fork 返回的那一刻起,父进程和子进程就是两个完全独立的进程了,它们各自拥有自己的地址空间、自己的程序计数器(PC 下一条要执行的指令位置)、自己的变量副本。它们唯一的共同点,是"代码内容一样、执行起点一样(都在 fork 返回后的那个位置)"。至于谁先跑谁后跑、谁先打印谁后打印,完全交给内核的进程调度器决定,我们程序员左右不了。
看一段最经典的验证代码。编译运行它,亲眼看一下"一次 fork 两行输出、两行 yet 各行其是":
#include <stdio.h> // printf、perror
#include <stdlib.h> // exit
#include <unistd.h> // fork、getpid
int main(void)
{
pid_t pid; // 用来接收 fork 的返回值的变量
// fork 之前的这一句,只有父进程会执行(此时还没有子进程)
printf("Before: pid is %d\n", getpid());
// 调用 fork,从这里开始分裂。返回 -1 表示失败
if ((pid = fork()) == -1) // 失败时打印错误并退出
{
perror("fork"); // 把 errno 对应的描述打印到 stderr
exit(1); // 以退出码 1 结束进程
}
// fork 之后的这行,父子进程都会执行到。用 fork 返回值区分身份
// 在父进程里 pid 是子进程的 PID,在子进程里 pid 是 0
printf("After: pid is %d, fork return %d\n", getpid(), pid);
sleep(1); // 睡 1 秒,让输出缓冲尽量落盘、结果稳定
return 0; // 正常退出
}编译和运行方式如下(Linux 环境,任意目录都可):
# gcc -o fork_demo fork_demo.c # 编译生成可执行文件
# ./fork_demo # 运行
Before: pid is 43676 # 这一行只有一次(fork 之前的父进程打的)
After: pid is 43676, fork return 43677 # 父进程:getpid=43676, fork返回=子pid 43677
After: pid is 43677, fork return 0 # 子进程:getpid=43677, fork返回=0把输出和代码逐行对上,你会发现三件值得玩味的事:
Before那行只出现了一次,After那行出现了两次。因为Before打在fork之前,那时世界上只有一个进程;After打在fork之后,此时已经是父子两个进程,各自打印一遍。- 父进程打印的
fork return是43677,恰好等于子进程的getpid()(43677)——父进程拿到的就是子进程的 PID。 - 子进程打印的
fork return是0,而它自己的getpid()是43677。
这就是"一次调用、两次返回"活生生的证据。同时也解答了课件开头抛出的第二个问题:"为什么一个函数 fork 会有两个返回值?"——因为它根本不是"在同一个函数里返回两次",而是 fork 让执行控制流从内核返回了两次,一次回到父进程的上下文,一次回到子进程的上下文。这两次返回虽然代码位置相同,但它们处在两套不同的进程地址空间里。
而课件的前两个问题:"为什么要给子进程返回 0、父进程返回子进程 PID",以及"为什么一个 id 既等于 0、又大于 0",我们在下一小节彻底讲透。
为什么父子返回的值截然不同
把这两个问题放在一起看:
- 子进程返回 0,是为了让子进程能立刻"认出自己"。因为父子执行的代码完全相同,如果没有返回值这个"身份暗号",父进程和子进程根本不知道自己是哪一边,也就无法分道扬镳。约定"子进程得 0",子进程只要见到返回 0,就知道"我是儿子",于是往
if (pid == 0)这个分支里钻。0 是一个天然安全的标识:它不可能是任何进程的 PID(PID 从 1 开始,1 号进程是init,0 是内核自己,用户态进程永远不可能是 0),所以拿来当"我是子进程"的信号绝无歧义。 - 父进程返回子进程的 PID,一是为了"认出儿子",二是这个值天然是"独一无二的"。 父进程插手管理子进程时(比如后面要讲的
waitpid精确回收某个子进程),必须握着子进程的 PID 才能精确指定"我等的是你"。而且父进程可能 fork 很多个儿子,它必须能拿到每个儿子各自的 PID 来区分它们。反过来,如果父进程返回一个固定值(比如 1),那 fork 了多个儿子之后父进程就分不清谁是谁了,所以"返回真正的子 PID"是唯一合理的设计。 - 为什么一个 id 既等于 0、又大于 0? 因为 0 和大于 0 这两种取值发生在两个不同的进程里,它们根本不是一个变量的两次取值,而是"同一条
return指令"在两个独立进程的上下文里各执行了一次,各得各的值。父进程看到的永远是大于 0 的那个,子进程看到的永远是 0。不存在"同一个变量一会儿是 0 一会儿不是"的问题——它们早就不是同一个变量了。
一句话升华:fork 的返回值不是函数的计算结果,而是父子两个进程的"出生身份牌"。它是让"一份复制出来的代码"能够走上不同命运分支的唯一手段,所以它必须设计得"父得儿子 ID、子得 0"。
内核对一次 fork 到底做了什么
你可能好奇,那一次 fork 在内核里究竟发生了什么事?课件给了四步,我来逐条配上人话解释:
- 分配新的内存块和内核数据结构给子进程:内核在进程表(task_struct)里新建一个条目、分配 task 结构、内核栈等,给这个"新生命"置办一整套"户口"。
- 将父进程部分数据结构的内容拷贝至子进程:把父进程的进程描述符、打开的文件描述符表、信号处理方式、当前目录、环境变量等继承给子进程(要注意是"部分",不包含涉及进程身份和家族关系的信息)。
- 添加子进程到系统进程列表:把它挂到系统的进程链表里,从此它就在调度器的视线范围内了。
fork返回,开始调度器调度:从内核态返回前,调度器会决定下一个让哪个进程上 CPU 跑。通常父子两个都会被调度,谁先跑不一定。
如果你用过 Windows 的 CreateProcess,你会发现一个鲜明的对比:Windows 是"凭空创建新进程",从可执行文件新加载起一个全新的进程;而 Linux 的 fork 是"复制我",先复制一份一模一样的自我,再(视需求)用 exec 把复制品换成别的程序。这种"先克隆、再替换"的两步走,是 Unix 哲学里被反复设计验证过的经典方案。
三个思考题(先做,答案在本小节末尾)
我按课件把它们整理成三道自测,你先在心里作答,再对照下面的详解:
- 为什么不能反过来设计成"父进程返回 0、子进程返回父进程 PID"?
fork之后,子进程是从头(main的第一行)开始执行的吗?为什么示例里子进程没有打印Before?fork返回后,父子谁先运行?能不能保证子进程一定先打印?
详解答案 1:可以想象两种设计。方案 A 是"父得 0、子得父 PID"。它有一个致命毛病——一个父进程 fork 出多个子进程时,每个子进程拿到的"父 PID"是同一个值,子进程们互相之间依然无法区分、也无法拿到自己的 PID 去被父进程精确管理;更麻烦的是父进程拿 0,可父进程管理子女时恰恰最需要"知道是哪一个儿子返回了"的唯一标识,给父一个固定值 0 等于废掉了它的管理能力。而现设计"父得子 PID、子得 0"里,父对每个儿子都能拿到唯一的 PID(精确管理),子只用 0 表示"我是儿子"(0 绝不可能是真 PID,无歧义),两边各取所需、信息自洽,是最合理的方案。
详解答案 2:不是。子进程不是从 main 第一行重新跑的,而是从 fork 调用返回后的那一句继续往下执行。因为父进程在执行 fork 时,程序计数器(PC)已经指向 fork 返回语句之后的位置,而子进程无论如何复制,它的 PC 也只能从"父亲当时执行到的位置"继承——也就是说子进程是从"中间"继续运行的。示例里子进程没有打印 Before,正因为 Before 那行在 fork 之前就已经被父进程打印过了,子进程根本不会"回溯"到那行再执行一次。这也是"父插子进、各自为政"的证据:fork 之前的代码只有父进程跑,fork 之后的代码父子各跑各的。
详解答案 3:不能保证。fork 之后谁先上 CPU 完全由内核调度器决定,这是一个不承诺任何顺序的"竞态(race)"。你永远不能假设"子进程一定先跑"或"父进程一定先跑"。真实场景里我们看到的顺序只是某一次运行的偶然结果。如果你确实希望某个顺序(比如让父等着),就要用后面要学的 wait 同步机制来主动等待,而不是靠假设。这也是系统编程区别于普通编程的核心思维之一:别对执行顺序做任何没有显式同步的假设。
写时拷贝:父子的内存为什么能彻底分离
现在到了 fork 概念里最精彩、也最反直觉的一环——写时拷贝(Copy-On-Write,简称 COW)。
先说朴素直觉:fork 是把父进程"复制"一份给子进程。如果真把父进程的所有内存(代码段、数据段、堆、栈,可能几十上百 MB)老老实实地逐字节拷一份,那 fork 一个进程的性能开销和内存开销都大得吓人。绝大多数进程 fork 出来的子进程马上又要 exec 加载新程序(我们稍后讲),那些拷过来的数据根本用不上,等于白拷。
内核的聪明之处在于**"偷懒"**——它引入写时拷贝:
- 刚开始,父子共用同一份物理内存,只是各自的页表都映射到同一批物理页,并通过"只读权限"标记这些页。
- 只要大家都不去写这些内存(只读共享),就一直共用,谁也不去复制,内存零复制、速度飞快。
- 一旦任意一方试图写入某个页面,内核会立刻触发一次"缺页异常后的写保护陷阱(page fault)"检测到"有人要写一个只读页",于是:先复制这一份物理页给要写的那一方,再解除写保护,让人家在自己的副本上放心写。这就是"写时拷贝"四个字的由来——直到有人动手写的那一刻,才真的去拷贝。
严格说一句:写时拷贝是典型的"延时申请/懒加载"技术。它把"拷贝"这个昂贵的动作推迟到"非做不可"的最后一刻才做,从而极大地提高了整机内存的利用率和 fork 的性能。想想父进程有 1 GB 内存、fork 100 个只读不写的子进程——COW 让这 100 个子进程几乎不额外多占物理内存(共享同一批页),这是传统"整拷"想都不敢想的数字。
我们用一个程序把它验证到"眼见为实"。思路是:fork 前给一个全局变量一个初值,fork 后父子各自打印这个变量的地址和值。在还没写它之前,父子应该看到相同的地址(共享同一页,逻辑地址相同);然后父进程写入改变它,子进程写入改变它,再打印——因为已经各自拥有独立副本,父子的修改互不影响,且各自打印的地址值不变、内容却各是各的:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int global = 100; // 一个全局变量,在数据段里。fork 前被父进程独占地址
int main(void)
{
pid_t pid;
printf("fork 前: global = %d, 地址 = %p\n", global, (void*)&global);
// fork:在这之前是父进程独有的一份内存
pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid > 0)
{
// 父进程分支:先打印"还没写共享页"时的地址,
// 再写 global,看自己的值和地址
printf("[父] 写前 global = %d, 地址 = %p\n", global, (void*)&global);
global = 200; // 父进程写 global,此刻触发写时拷贝
printf("[父] 写后 global = %d, 地址 = %p\n", global, (void*)&global);
}
else
{
// 子进程分支:与父进程完全对等的逻辑
printf("[子] 写前 global = %d, 地址 = %p\n", global, (void*)&global);
global = 300; // 子进程也写 global,同样触发写时拷贝
printf("[子] 写后 global = %d, 地址 = %p\n", global, (void*)&global);
}
return 0;
}编译运行,典型输出像这样(地址数字每次运行不同,但规律一致):
# gcc -o cow_demo cow_demo.c && ./cow_demo
fork 前: global = 100, 地址 = 0x55787f3f5014
[父] 写前 global = 100, 地址 = 0x55787f3f5014
[子] 写前 global = 100, 地址 = 0x55787f3f5014
[父] 写后 global = 200, 地址 = 0x55787f3f5014
[子] 写后 global = 300, 地址 = 0x55787f3f5014请留意两个关键观察:
- 父子打印的地址一字不差(都是
0x55787f3f5014)。这乍看像"父子共享同一份变量",可我们要小心:这里打印的地址是逻辑/虚拟地址,不是物理地址。父子的虚拟地址空间是各自独立的,虽然数值相同,但它们各自映射到不同的物理页(在各自写入之后)。即便在 COW 共享阶段,它们虽然映射到同一物理页,但因为地址空间彼此隔离,也谈不上"变量是同一个"。所以地址相同≠内存共享,这是 COW 最经典的门槛坑之一。 - 写后值完全不同:父是 200、子是 300,互不影响。这说明一旦双方都写,各自都拿到了自己的物理副本,真正"夫妻双双把家还、各过各的日子"了。
顺带把这个印证到课件那句话上:正因为有写时拷贝,父子进程才得以在内存层面彻底分离,进程的独立性才有了技术保障。它告诉我们的实践结论是:不要在 fork 后指望靠"改共享全局变量"来给父子通信——那是不可能的(他们早就 COW 分开了)。进程间要交换数据,得靠别的机制(管道、文件、信号量等,那又是后话了)。
一道自测(含详解)
自测:如果父进程 fork 前后从不写某个全局变量,子进程也从不写它,那么这个全局变量在父子之间是"共享"的吗?如果父进程只读该变量 1 亿次、子进程也只读 1 亿次,会不会触发写时拷贝?
详解:只要两边都只读不改,这个变量在物理层确实是"共享同一页"的——不会各自复制,也不会触发 COW(因为 COW 只在"写"那一刻才发生)。所以它两边读到的值始终一致,但你不能把它当成"共享内存通信通道"用,因为你一旦想去写它(那才是通信),就必然触发 COW 把两边劈开,通信立刻失效。至于只读 1 亿次会不会触发 COW——不会,只读永远不会触发写复制,内核的写时拷贝判断的就是"是否发生写",读操作完全无害。结论:只读无限次都安全,一写就分家。
另一个大坑:fork 前 printf 没换行,输出会"诈尸"两份
这是 fork 与缓冲区结合产生的著名易错点,我必须在讲完 COW 之后立刻提醒你,因为后面讲 exit/_exit 时还会和它精确对账。
回想开头的知识准备:printf 默认不立刻落屏,而是攒在用户态 stdio 缓冲区里,直到遇到换行/满/退出/fflush 才真正写。而 fork 会把父进程当时的整个内存复制给子进程——自然包括这块还没写出去的缓冲区。于是:
- 父进程在 fork 前
printf打了句不带换行的话(还在缓冲区里); - fork 后,父子各有一份"未落屏的话"的拷贝;
- 之后子进程退出时刷新缓冲区、父进程退出时也刷新缓冲区 → 这句话被打印了两遍!
来看这个经典事故现场:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
// 不带换行的 printf,会留在 stdio 用户态缓冲区里
printf("留意!这行没有换行符。");
// 立刻 fork:把缓冲区的拷贝也一并继承给了子进程
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
// 即使父子什么都不干,istream 退出时都会刷新一次自己的那份缓冲区
return 0;
}编译运行你会看到:
# gcc -o buf_fork buf_fork.c && ./buf_fork
留意!这行没有换行符。留意!这行没有换行符。——同一句话打了两次。原因正如上面剖析:fork 复制了缓冲区。这就是一个融合了"缓冲区 " 与 "COW 思想"的绝佳实例:缓冲区内容是"数据",fork 把它复制给了子进程,于是两份独立的内容分别在父、子退出时各自落屏一次。
规避办法:fork 前主动 fflush(stdout) 清空缓冲区,或者让每个 printf 都带 \n(换行即刷新),或者干脆在 fork 前不用缓冲式输出。从这以后,你在系统编程里写 fork 相关代码时,请把"缓冲区拷贝"这五个字在心里焊死。这也会直接关系到后面 exit(刷新缓冲)与 _exit(不刷新)的天壤之别。
一道自测(含详解)
自测:如果上面例子里,把 printf 的原话补上 \n(写成 "留意!这行没有换行符。\n"),还会打印两遍吗?
详解:不会。因为带 \n 的 printf 在 fork 之前就把内容真正刷到了终端(行缓冲:遇到换行即刷新),到 fork 时缓冲区已经是空的,子进程复制到的是一块空缓冲区,自然只会打印一遍。这再次说明:问题根源不在 printf 本身,而在"fork 复制了尚未写出的缓冲区"。凡是 fork 前用了缓冲且有未落盘内容又没有 fflush,都可能出现"重复/遗漏输出"的诡异现象。这就叫"知其然、更能知其所以然"。
fork 的常规用法与常见误区
讲完了原理,我们把 fork 放到真实工程里看它到底派什么用场。课件归纳了两种相当时放矢的常规用法:
-
父进程希望复制自己,让父子同时执行不同的代码段。最经典的例子是网络服务器:父进程负责盯着 socket,等待客户端连进来(
accept),每来一个连接就fork一个子进程去专门伺候这个客户(收发数据),父进程继续回头等下一个连接。这样"一个父、N 个子",子进程们并行各干各的活,父进程只做分单。这是 Unix 网络上 fork 的"祖传用法"。 -
一个进程要执行一个完全不同的程序。子进程从 fork 返回后,调用
exec系列函数把自己替换成一个新程序(比如子进程 fork 后立刻execvp("ls", ...)去执行 ls)。这就是"先克隆、再变身"的两步走。下一大节我们专门讲它。
为了让你亲手体会"父子执行不同代码段",我给出一个典型结构——写一个"父进程提供菜单、子进程干比喻的活"的小程序,它几乎就是后面微 shell 的雏形:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
// 一个模拟"子进程处理请求"的函数,生产环境里这里往往是网络/IO 处理
static void handle_request(int seq)
{
printf("[子进程 %d] start 处理第 %d 号请求\n", getpid(), seq);
sleep(1); // 假装在处理一件耗时的工作
printf("[子进程 %d] done 处理第 %d 号请求\n", getpid(), seq);
exit(0); // 干完活立刻退出,回 0
}
int main(void)
{
pid_t pid;
// 父进程循环"接单",模拟服务器主循环
for (int i = 1; i <= 3; i++)
{
pid = fork(); // 每来一单,fork 一个子进程去处理
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
// 子进程:进入处理函数,干完就退,不再返回主循环
handle_request(i);
}
// 父进程:不进入 if,继续下一轮循环 fork 下一个子进程
printf("[父进程 %d] 已派出子进程 %d 去处理第 %d 单\n", getpid(), pid, i);
}
sleep(1); // 给子进程一些收尾时间,稍后我们学会 wait 后就会改成"主动等"
return 0;
}输出大致(顺序因调度而异):
# gcc -o fork_jobs fork_jobs.c && ./fork_jobs
[父进程 1000] 已派出子进程 1001 去处理第 1 单
[子进程 1001] start 处理第 1 号请求
[父进程 1000] 已派出子进程 1002 去处理第 2 单
[子进程 1001] done 处理第 1 号请求
[父进程 1000] 已派出子进程 1003 去处理第 3 单
[子进程 1002] start 处理第 2 号请求
[子进程 1002] done 处理第 2 号请求
[子进程 1003] start 处理第 3 号请求
[子进程 1003] done 处理第 3 号请求看到没有——父进程只负责 fork 和打"派单"日志,绝不进入 handle_request;子进程一出生就直奔 handle_request,干完 exit,绝不再回到主循环。这就是"父子执行不同代码段"最直白的演示。注意这里为了演示故意没做 wait(等孩子收尸),这在生产代码里是必须补的,否则会制造一堆僵尸进程——那正是我们下一大节"进程等待"要解决的命案现场。
一个必踩的大坑:在循环里 fork 会导致数量指数爆炸
很多新手以为 for (int i = 0; i < 2; i++) fork(); 会创建 2 个子进程。大错特错。 它实际会创建 3 个子进程、总共 4 个活动进程。让我把它拆给你看,这个坑你必须掰着指头数明白了:
- 第 0 个进程是原始父进程 P。第一次循环
i=0,P 执行fork(),于是分裂出长子 A。此时世界上有两个进程:P、A。注意,A 的 PC 也会从"循环体结束、跳到 i++ 继续 for"那个位置继续,所以 A 会接着进入下一轮循环! - 第二次循环
i=1时,P 和 A 各自执行一次fork():P 生出次子 B,A 也生出一个孩子 A1。于是进程总数从 2 翻到 4。 - 循环结束。最终进程数是 P、A、B、A1 共 4 个。
如果把循环次数放大到 N 次,进程总数会膨胀到 2 的 N 次方(2^N)个——这就是典型的 fork 指数爆炸。很多新手因为"觉得循环是顺序执行的",就忘了被 fork 出来的子进程会继续无视循环边界、接着往下跑同一段代码,从而把循环体里的 fork 又执行了一遍又一遍。
规避办法:凡是要在循环里 fork,务必让子进程 fork 完立刻走(比如 exit 或进入分支函数),或者用 if (pid == 0) { ...; exit(); } 结构保证子进程"只在本次分支里干活,做完就退出,绝不回头继续循环"。上面我们的 fork_jobs 例子正是这样:子进程进了 handle_request 就 exit(0),永不回循环,因此它不会再去 fork 下一个。这正是"安全 fork"的标准姿势。
两道自测(含详解)
自测 1:不写任何代码,推理 for (int i = 0; i < 3; i++) fork(); 之后共有多少个进程、多少个是子进程。
详解:每轮循环让当前所有活着的进程每人 fork 一次,进程数翻倍。初始 1 个 → 第 1 轮后 2 个 → 第 2 轮后 4 个 → 第 3 轮后 8 个。所以最终 8 个活动进程,其中 7 个是子进程、1 个是原始父进程。这就是 2 的 3 次方。直觉和公式对上了:N 轮 fork 全体,总进程数 = 2^N。
自测 2:我们把 fork 的常规用法"父子执行不同代码段"和"子进程 exec 新程序"分别对应到我们上面的两个示例程序中的哪些句子?
详解:fork_jobs.c 里 pid = fork(); if (pid == 0) { handle_request(i); } 一组,体现"父子执行不同代码段"——子进程进处理函数、父进程继续循环。而"子进程要执行不同程序"是下一节 exec 的用武之地:子进程 fork 返回后不是调普通函数,而是调 execvp("ls", args) 之类直接换掉自身进程映像。
fork 调用失败的原因
聊完了成功的果子,别忘了失败的可能。fork 返回 -1 就代表没 fork 出来。两种最典型的失败原因:
- 系统中已经有太多进程:操作系统维护的进程总数有上限(
RLIMIT_NPROC、系统级 PID 上限pid_max等)。进程表塞满了,再 fork 就失败,返回 -1。 - 实际用户的进程数超过了限制:每个真实用户/uid 能同时拥有的进程数是受资源限制约束的(见
ulimit -u)。如果你这个 uid 下已经跑满了配额,fork 也会失败。这在多进程服务、批量任务里尤其常见——用ulimit -u或/proc/sys/kernel/pid_max可以查看相关阈值。
一个顺手可查的小命令:
# ulimit -u # 当前用户允许的最大进程数
# cat /proc/sys/kernel/pid_max # 系统可分配的最大 PID 值自测(含详解):假如你写了一个死循环无限 fork 的程序,运行它,通常会发生什么?它是"立刻报错"还是"内存先被耗尽"?
详解:通常首先会撞上"进程数/用户数超限"或"内存耗尽"两者之一,具体取决于先触碰到哪个阈值。因为每个进程(含内核对 task_struct 的分配)都要占内存,疯狂 fork 会导致机器内存吃紧,最终抛 Cannot allocate memory,或者进程上限被封顶导致 fork: Resource temporarily unavailable(即返回 -1,errno 为 EAGAIN)。这也是为什么"无限 fork"是崩溃系统的经典手段之一——写完这个自测,你就该意识到生产里要严格防止无纪律 fork(比如漏了 wait、或者父进程把子进程遗弃成孤儿)。测试时请务必用小循环或加个计数上限,不要真的把系统跑炸。
进程终止:进程的"寿终正寝"与"暴毙"
进程有出生,就必有死亡。进程终止的本质,是释放系统资源——把它在生命周期里向内核申请的那一整套数据结构(task_struct、page table、占用物理页、打开的文件、信号等)还给内核。如果释放不干净,就会给系统留下"僵尸"(我们在下一节深挖)。我们先理清终止的完整图景。
进程退出的三种情景
一个进程不会无缘无故消失,它退出无外乎这三种情景:
- 代码运行完毕,结果正确——这是"正常退出的 happy ending",进程把活干完了,如实上报完成。
- 代码运行完毕,结果不正确——程序跑完了、没有崩溃,但逻辑上失败了(比如检测到输入不合法、约定返回 -1/1 等非 0 退出码)。它也是"正常终止",只是结果非真。大家常说的"错误退出码"就落在这里。
- 代码异常终止——被某个信号打断,没来得及"正常地"退出就被终结了。最常见的就是你在终端按
Ctrl+C发送SIGINT(2 号信号),或kill发送SIGKILL(9 号信号)把进程直接打死。这类退出的特征是"退出码"不是由进程自己 return 出来的,而是由信号拟定的(128+信号编号)。
正常终止的三条路
把"正常终止"再细分,进程有三条路可以体面退出:
- 从
main返回——return n;。C 运行时会在main返回后把这个返回值交给exit(),所以return n;本质上就等于exit(n);,退出码就是n。 - 调用
exit(status)——标准库函数,退出前会做一系列"善后"(见下节)。 - 调用
_exit(status)——直接陷入内核、粗暴退出,不做任何用户态善后,立刻释放资源。
异常退出则主要靠信号:比如 Ctrl+C 对应的 SIGINT、kill -9 对应的 SIGKILL、kill -15 对应的 SIGTERM 等,都是让进程"非正常结束"。
退出码:$? 到底在说啥
先搞懂一个几乎每天都要打交道的概念——退出码(exit code / exit status)。它是进程结束时间系统汇报的"最后一个数字",用来告诉别人"我这次跑得怎么样"。用户在 shell 里跑完一条命令后,立刻敲 echo $?,能得到这条命令的退出码。注意:$? 是一锤子买卖的变量——它保存的是上一次执行的那条命令的退出状态,你敲一次它、然后它就被下一条命令的退出码覆盖了,所以要查就得紧跟在命令后面立刻查。
退出码的基本约定:
0:执行成功,没有问题的理想状态;- 任何非 0:执行不成功或不理想。常见 1 表示"不被允许的操作/一般性错误",比如没有
sudo权限强行yum、或者let a=1/0这种除零错误,都会返回 1; 128 + n:被信号终止的专用编码。其中n是终止该进程的信号编号。所以 130 是SIGINT(Ctrl+C,2 号+128),143 是SIGTERM(kill 默认发的终止信号,15 号+128),137 是SIGKILL(kill -9,9 号+128)。你以后看到 130、137、143 这类退出码,第一反应应该是"这个进程是被信号干掉的,不是自己 return 出来的"。
此外,你可以用 strerror(errno) 配合 errno 拿到某个错误码对应的文本描述,这在排查 fork、wait 等返回 -1 的调用时很常用。
让退出码浮出水面最简单的方式:写一个自定义退出码的小程序,再在 shell 里查:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
printf("我要以退出码 5 结束我自己。\n");
return 5; // return 5 等价于 exit(5),退出码即 5
}# gcc -o myexit myexit.c && ./myexit
我要以退出码 5 结束我自己。
# echo $? # 立刻查询上一条命令的退出码
5再对比一个被信号杀的:
# sleep 100 # 一个会一直睡下去的进程
# ## 在另一个终端:kill -9 <sleep 的 pid>
# ## 回到原终端,执行 sleep 的这个进程假设被 kill -9 打死(9 号信号)
# echo $? # 上一条"被 kill -9"的进程退出码 = 128 + 9 = 137
137这就把"128+n 是被信号杀的"落到实处了:kill -9 是 9 号 SIGKILL,所以计数是 137。
三道自测(含详解)
自测 1:echo $? 到底什么时候该看、什么时候会"失效"?
详解:$? 保存的是"上一条命令的退出码",所以必须紧跟在你想查的那条命令之后立刻查看。你一旦又执行了任意一条命令(包括 echo 自身、cd、甚至空命令),$? 就被覆盖了。例如你 ./myexit; echo $?; echo $? 会看到第一个 echo $? 打印 5,第二个再打印 0(因为 echo 自己要成功,退出码 0)。所以叫它"一锤子买卖"。
自测 2:为什么 Ctrl+C 之后退出码往往是 130 而非"-2"?
详解:因为 shell(以及 wait 等机制)采用的约定是"被信号终止 → 退出码 = 128 + 信号编号"。Ctrl+C 发 SIGINT,编号 2,于是 128 + 2 = 130。它本质上是一个"把"被信号杀"这个事实也编码进退出码"的约定,方便父进程/脚本一眼看出:哦,130=被 Ctrl+C 打断。这个 128+信号 的编码习惯和我们后面讲的 wait 状态位(status 低 7 位存信号号)是配套的,两边要一起看才会通。
自测 3:strerror 一句话是干什么用的?在什么场景下你会掏出来用?
详解:strerror(errno) 输入一个 errno 错误码,返回对应的可读英文描述字符串。当 fork、exec、open 等返回 -1 时,内核会设置全局 errno,你可以拿 strerror(errno) 打印出它到底念什么错("Cannot allocate memory"、"Resource temporarily unavailable" 等)。perror() 就是 fprintf(stderr, "%s: %s\n", msg, strerror(errno)) 的便捷封装。排查 -1 返回时必用。
_exit:粗暴的"关门就走"
先看第一个"体面度最低"、也最底层的退出方式 _exit。
#include <unistd.h> // _exit 的头文件
void _exit(int status); // 直接让当前进程结束,status 是终止状态_exit 是真正意义上的系统调用包装——它当场陷入内核,直接释放当前进程的所有内核资源并把进程置为终止,不做任何用户态的"善后"。这个"不做善后"有非常具体的后果,最典型的就是:不刷新 stdio 缓冲区。
还记得前面 printf 攒缓冲的事吧?如果你的程序用 printf 打了一段不带换行的内容、还留在缓冲区里没写出去,这时候你用 _exit(0) 结束进程,那些还困在缓冲区里的字符就永远不会被写出来了。因为 _exit 根本不管 stdio 那套缓存,它是直接从内核层面把这个进程干掉的。
看课件给的那个"灵异"现象,先用 exit:
#include <stdio.h>
#include <stdlib.h> // exit
int main(void)
{
printf("hello"); // 注意没有 \n,内容留在缓冲区
exit(0); // exit 会刷新缓冲,hello 会被写出来
}# gcc -o t_exit t_exit.c && ./t_exit
hello[root@localhost linux]# # hello 出现了(没有换行,所以紧跟提示符)再用 _exit:
#include <stdio.h>
#include <unistd.h> // _exit
int main(void)
{
printf("hello"); // 依旧不带换行,留在缓冲区
_exit(0); // _exit 不刷新缓冲区,hello 永不落屏
}# gcc -o t_uexit t_uexit.c && ./t_uexit
[root@localhost linux]# # 什么都没有!hello 丢在了内核外的缓冲区里同一个"hello",exit 打出来了,_exit 丢了——这正是"有没有冲洗缓冲区"最直观的对账。cron 定期任务、守护进程里如果误用 _exit,可能会丢掉大量本该落盘的日志,这是真实的踩坑现场。
还有一个更隐蔽的细节,课件特意强调:status 虽是 int,但父进程通过 wait 只能取到它的低 8 位。所以 _exit(-1) 在终端里看到的是 255(-1 的补码在低 8 位上是 0xFF=255),而不是 -1。因为退出状态本质上就是个 0-255 的字节。这里先记下结论,到 wait 的 status 位图那一节我们会彻底揭开 8 位的来龙去脉。
一道自测(含详解)
自测:写一个程序 printf("A"); _exit(0); 和一个 printf("A\n"); _exit(0);,分别运行,哪个能看到 A?为什么?
详解:第一个看不到 A(printf("A") 没换行、留在缓冲、_exit 不刷即丢);第二个能看到 A(\n 触发行缓冲自动刷新,字符在 _exit 前已经落到终端)。核心规律:_exit 只认"已经离开 stdio 缓冲区的内容",凡是还赖在缓冲区的都会被直接扔掉。所以凡要确保某些输出在 _exit 前务必存在,要么靠换行/fflush,要么干脆改用 exit。
exit:带善后的告别
exit 是标准库函数,它比 _exit 多了一层"善后交底",最终它仍会把 _exit 那条路走完(有些实现里 _exit 就是退到最底层的那个系统调用)。具体来说,调用 exit 会依次做三件事:
- 执行用户通过
atexit或on_exit注册的清理函数。比如"退出前关掉数据库连接、写日志、发心跳"这类收尾逻辑,都可以通过atexit挂到进程退出时自动触发,且按注册的逆序执行。 - 关闭所有已打开的流,并把所有缓存数据冲刷写盘——这就是它跟
_exit最大的分水岭。 - 调用
_exit,真正退出内核。
在我们上面两个例子里,exit(0) 之所以能打出 hello,正是因为第 2 步"冲刷缓冲";_exit(0) 少了这一步,说明它跳过了第 1、2 步直接到第 3 步。
看得更清楚一点,用 atexit 观察注册函数的触发:
#include <stdio.h>
#include <stdlib.h>
// 两个要被 atexit 注册的清理函数
static void bye1(void) { printf("bye1 清理成功\n"); }
static void bye2(void) { printf("bye2 清理成功\n"); }
int main(void)
{
// 按注册顺序(逆序执行):先注册 bye1,再注册 bye2
atexit(bye1);
atexit(bye2);
printf("main 快结束了\n");
return 0; // return 0 会去调用 exit(0),触发 atexit 清理
}# gcc -o t_atexit t_atexit.c && ./t_atexit
main 快结束了
bye2 清理成功 # 注意:逆序!后注册的先执行
bye1 清理成功这验证了 exit 的善后行为,并展示了 atexit 注册函数逆序执行(LIFO)这个特征。
两道自测(含详解)
自测 1:exit 里已经调了 _exit,那为什么我们要在代码里区分 exit 和 _exit?什么时候该用 _exit?
详解:因为两者在"要不要做用户态善后"上南辕北辙。永远优先用 exit(绝大多数业务场景),它替你冲刷缓冲、跑 atexit 清理。但当你确实不想做这些善后时(比如出于性能或安全,想极其干脆地终止;或者你刚在信号处理的极端上下文中不想触发除此外的任何代码)才考虑 _exit。二进制/管道子进程、fork 后子进程 exec 失败、或信号上下文中退出,常常用 _exit 更稳妥——因为避免在失控/半初始化状态下再执行复杂的清理代码可能引入二次故障。教训:先问"我要不要善后",再选函数。
自测 2:为什么 atexit 清理函数要按"后注册先执行"的逆序跑?
详解:这是经典的"资源后进先出"(stack 语义):你先打开文件 A、再打开文件 B,结束时应按相反顺序先关 B 再关 A(因为 B 往往依赖 A、或者 A 的关闭会影响 B 还握着的东西)。atexit 采用 LIFO,正是为了让"最后一个打开/注册、最早需要善后"的资源先被处理,避免释放顺序错乱,像栈一样天然满足依赖关系的逆序。这是 C 运行时和很多框架"cleanup 逆序"设计的通用道理。
return 退出:最自然的告别
最后一种正常退出方式——return,它其实是前面两种的"语法糖"。前面已经点破:return n; 等价于 exit(n);。原因在于:main 只是一个被 C 运行时(CRT,C Runtime)的启动代码调用的普通函数罢了。启动代码会把 main 的返回值当作 exit 的参数去调用 exit。所以:
return 0;在你main里说"成功" → 等价exit(0);return 非0;等价exit(非0);- 你甚至可以
return一个表达式的值,它原样变成退出码。
并且,因为 return 走的是 exit 那条"带善后"的路,所以 main 里 return 0 和 main 里 exit(0) 在"冲刷缓冲 + 跑 atexit"上表现一致。两者几乎可以互换,工程习惯上 return 多见于 main 里表达正常流程的分支结束,exit 则出现在任何深层的函数里想要立刻"终局"的场景(因为你在子函数里没法 return 掉整个进程,只能 exit)。
一道自测(含详解)
自测:为什么"在子函数里想立刻结束整个进程时,不能用 return 而必须 exit"?
详解:return 只会结束当前所在的那个函数,它把控制器还给调用者;一个嵌套多层的函数里 return 根本无法结束整个进程,得一层层传。而 exit(状态码) 无论在哪个函数里调用,都会直接终结整个进程并通知父进程。这就是子函数里"终极退出"只能靠 exit 的原因。反过来 main 里两个都行,因为 main 的 return 本来就被运行时转成了 exit。
进程等待:给子进程"收尸"
现在进入本文感情色彩最重、也最生产相关的一节——进程等待(process waiting)。它要解决的,是"子进程死后怎么办"这个既关乎资源、又关乎江湖道义的问题。
为什么必须等:僵尸进程与孤儿进程
先建立一个全图景概念。进程有三种死亡状态:
- 正常退出(既有父进程等着回收):子进程正常结束,父进程随后
wait回收,一切干净。 - 僵尸进程(Zombie):子进程退出了,可它的父进程迟迟不来
wait回收。此时子进程的内核 PCB(进程控制块的雏形)还在系统里占着一个位置、只是不再运行任何用户代码——它"死了,但没人收尸",被称为僵尸。它会占着一个 PID、占着少量内核内存,直到父进程wait掉它,或者父进程自己也死掉、由祖先进程(或被托孤的进程)来收。所以僵尸是可以被"消灭"的——谁杀死的?是它亲爹(或亲爹死后代偿的继承者)的wait。 - 孤儿进程(Orphan):父进程先死了、子进程还活着。内核会把子进程的"干爹"改成 1 号进程
init/systemd,由它来收养并负责后续回收子进程。孤儿本身不是坏事,靠 init 托管能避免"无主"子进程漂成僵尸没人收。
核心一句:僵尸进程是"子进程已死、父进程未 wait"的中间态。它拖得太久就是内存/ PID 泄漏;大量僵尸积压后,系统可能 fork 失败(碰到进程数上限)。
这里强调一个"教科书级冷知识":僵尸进程连 kill -9 都杀不掉。对它发 kill -9 毫无用处,进程已经死透了、只是没人来 wait 收尸,你没法再"杀死一具尸体"。真正消灭僵尸的途径只有两条:让父进程去 wait 它,或者让父进程自杀(这样它会被祖先进程托管回收)。这就是课件那声感叹"刀枪不入、谁都杀不死一个已经死去的进程"的由来。
明细必要性可以收拢成三条:
- 子进程退出后如不
wait,就会长期滞留成僵尸进程,造成内存与 PID 泄漏; - 僵尸
kill -9也无效,你拿它没办法; - 你派给子进程的活干得怎么样,你需要知道——它是正常退出还是被信号杀了,退出码是多少,都得靠
wait拿回来的status来解码。
亲手制造并观察一个僵尸进程
眼见为实。写一小段程序:子进程 exit(0) 死掉,父进程 sleep(20) 故意不去 wait,你就有 20 秒时间用 ps 看到子进程处于 Z(Zombie)状态:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
// 子进程:立刻死去
printf("子进程 %d 即将死亡\n", getpid());
exit(0);
}
else
{
// 父进程:故意不 wait,睡 20 秒方便你观察僵尸
printf("父进程 %d 没有 wait,子进程会变成僵尸 20 秒\n", getpid());
sleep(20);
// 20 秒后父进程退出,僵尸会被 1 号进程收养回收
}
return 0;
}# gcc -o zombie zombie.c
# ./zombie &
# ps -o pid,ppid,stat,cmd # 或者 ps aux | grep zombie
PID PPID STAT CMD
1237 1236 Z [zombie] <defunct> # 注意 STAT 那个 Z,就是僵尸STAT 里的 Z(Zombie)就是僵尸的铁证。20 秒后父进程退出,系统里靠 init 把它搜尸回收。你可以把 sleep 改大点反复观察。
孤儿进程观察
再补一个孤儿观察:让子进程打印自己的 PPID,父进程先死,子进程再等会儿打印父 PID,会发现它"干爹"变成了 1:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
// 子进程:先打印现在的父 PID,睡一会儿,再打印父 PID
printf("孤儿前: 我的 pid=%d, 父 pid=%d\n", getpid(), getppid());
sleep(3); // 这 3 秒里父进程会先退出
printf("孤儿后: 我的 pid=%d, 父 pid=%d (变成 1 说明被 init 收养)\n",
getpid(), getppid());
return 0;
}
// 父进程:打印完自己就退出,让子进程成为孤儿
printf("父进程 %d 马上退出\n", getpid());
return 0;
}# gcc -o orphan orphan.c && ./orphan
父进程 1000 马上退出
孤儿前: 我的 pid=1001, 父 pid=1000
孤儿后: 我的 pid=1001, 父 pid=1 # 干爹变成 1 号进程(init/systemd)这两段合在一起,就把"僵尸"和"孤儿"两个相对的概念涂清楚了:僵尸 = 子死了爹不管;孤儿 = 爹死了子被托管。治理坏习惯的通用思想都是"让活着的一方主动 Wait 回收"。
一个经典问题(含详解)
自测/思考:一个持续运行(不退出)的服务器父进程,如果不停 fork 子进程却一直不 wait,会发生什么?怎么解决?
详解:父进程循环 fork,子进程用完不 wait,子进程会成堆地变成僵尸,僵尸占满进程表/ PID 上限后,fork 会失败(返回 -1、errno=EAGAIN),服务逐渐瘫痪。解决办法就是每次 fork 完都用 wait/waitpid 回收;如果想"非阻塞地回收那些已经退出去的子进程"而不卡住主循环,就用 waitpid(-1, &st, WNOHANG) 去轮询,或者注册 SIGCHLD 信号处理函数来异步回收。这两招我们在下两节彻底解锁。
wait:等任意一个孩子回来
介绍第一个回收工具——wait。
#include <sys/types.h> // 定义 pid_t 等类型
#include <sys/wait.h> // 声明 wait、waitpid 以及 WIFEXITED 等宏
pid_t wait(int* status); // 阻塞地等待一个子进程结束;status 是输出参数要点拆开讲:
- 返回值:成功返回被回收的那个子进程的 PID;失败返回 -1(通常是没有可等的子进程了)。
- 参数
status:一个"输出型参数"(output parameter)——不是给它传值,而是让内核往里面写"被等待子进程退出的信息"。如果调用方不关心子进程的退出状态,就传NULL,表示"我不想知道它怎么死的,只求你回收它"。 - 行为:
wait是阻塞式的。调用它时,如果当前还没有任何子进程退出,它就一直卡在那里等,直到有一个子进程退出才带着那个子进程的 PID 返回。
一句话记住:wait(&st) 就像一个站在站台等第一班车的人——不挑车次(任意一个子进程),车不来就不走(阻塞),等到了就把它接走并告诉你接到的是哪一班(返回那个子 PID)。
最粗糙但常见的用法:不关心子进程怎么死、只求别留僵尸 → 直接 wait(NULL)。
注意:wait 一次只能回收一个子进程。如果你 fork 了三个子进程,就得 wait 三次(循环着调)才能把它们都回收。
我给你写一个"最长最小"验证
下面的程序 fork 一个子进程(睡 3 秒后 exit(7)),父进程调用 wait 阻塞等它,然后把"回收到的子 PID"和"子进程退出码"都解码出来:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
// 子进程:睡 3 秒假装干活,然后以退出码 7 结束
printf("子进程 %d: 开始干活,睡 3 秒…\n", getpid());
sleep(3);
printf("子进程 %d: 干完了,exit(7)\n", getpid());
exit(7);
}
// 父进程:阻塞调用 wait,一直等到该子进程结束
int status = 0;
pid_t ret = wait(&status);
if (ret < 0) { perror("wait"); exit(1); }
printf("父进程 %d: wait 等到子进程 %d 结束\n", getpid(), ret);
// 解码 status:判断子进程是否正常退出,以及它的退出码(低 8 位截取)
if (WIFEXITED(status)) // 若子进程是正常退出则为真
printf(" 子进程正常退出,退出码 = %d\n", WEXITSTATUS(status));
else
printf(" 子进程非正常退出\n");
return 0;
}# gcc -o wait_demo wait_demo.c && ./wait_demo
子进程 1001: 开始干活,睡 3 秒…
子进程 1001: 干完了,exit(7)
父进程 1000: wait 等到子进程 1001 结束
子进程正常退出,退出码 = 7你还会观察到:父进程在 wait 之前,printf 的输出要等子进程睡完 3 秒才出现——这就直观证明了 wait 的阻塞性:父进程卡在等上,没等到子进程,后面的打印一句都不发生。
两道自测(含详解)
自测 1:wait 是"等任意一个孩子"还是"等特定的那一个"?如果父进程 fork 了三个孩子,只调一次 wait,够不够?
详解:wait 等的是任意一个(不限定的)子进程,谁先死先收谁,并不指定 PID。它一次只回收一个孩子。所以 fork 了三个、只调一次 wait,另外两个孩子依然可能变僵尸——必须循环调用三次(或三次 wait)才能全收。要"精确等某个特定的孩子",得用下一节 waitpid 的 pid 参数。
自测 2:wait 的 status 传 NULL 是什么业务含义?
详解:就是调用方明确表示"我不关心孩子怎么退出的,只求你别让它憋成僵尸"。此时内核照常回收资源、但不再往 status 里写退出信息,也不会因此节省任何返回交换(返回值仍是孩子的 PID)。适合"任务派发、不关心结果"的场景。但如果你需要知道"任务成败",就必须传一个 int* 并用宏解码。
waitpid:精确点名的等待
wait 虽好,但只能"等任意一个",操控力不够。于是有 waitpid 这款"点名单":
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);pid:决定"等谁"。两个取值最重要:pid = -1:等待任意一个子进程,与wait等效;pid > 0:等待进程 ID 恰好等于 pid 的那个子进程(精确点名,只认它)。
status:同wait,输出型参数,用于收取退出信息。options:控制等待的方式,核心取值 0 和WNOHANG:0:阻塞等待,没有指定孩子退出就一直等着;WNOHANG:非阻塞轮询(Non-Blocking Hang),若没有子进程退出,waitpid不等待、立刻返回 0;若子进程已退出则返回其 PID。
返回值三态(务必背下来):
| waitpid 返回值 | 含义 |
|---|---|
> 0 | 成功,返回被回收的子进程 PID |
0 | 仅当设了 WNOHANG、而无子进程退出时返回(表示"现在还没结束") |
-1 | 出错(常见于等待不存在的子进程),errno 会指示错误 |
再记两个边界行为:
- 若子进程已经退出,无论什么时候调
wait/waitpid,都会立即返回并回收资源、给出退出信息——孩子死透了,回收是一锤现结,不需要再等。 - 若子进程还活着且正常运行,在任意时刻调用阻塞
wait/waitpid,进程就会阻塞在那里,直到该子进程退出。 - 若根本不存在这个子进程(PID 不对、或早已被收回),
waitpid会立刻出错返回 -1,不会傻等。
waitpid 的分类威力,可以在一个"精确回收指定 PID"的程序里看到。下面这个,同时观察阻塞式 waitpid(-1, ...) 的回收:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
// 连生两个子进程,各自睡不同秒数后带不同退出码回归
pid_t a = fork();
if (a < 0) { perror("fork"); exit(1); }
if (a == 0) { sleep(2); printf("子A(%d) 退出,码 11\n", getpid()); exit(11); }
pid_t b = fork();
if (b < 0) { perror("fork"); exit(1); }
if (b == 0) { sleep(4); printf("子B(%d) 退出,码 22\n", getpid()); exit(22); }
// 父进程分别精确回收 A 和 B:用阻塞的 waitpid 精确点名 pid
int status = 0;
pid_t ra = waitpid(a, &status, 0); // 等 PID 为 a 的这个孩子
if (WIFEXITED(status))
printf("父:精确回收了子 %d,退出码 %d\n", ra, WEXITSTATUS(status));
// 用 waitpid(-1) 回收"剩下的任意一个"(这里就是 B)
status = 0;
pid_t rb = waitpid(-1, &status, 0);
if (WIFEXITED(status))
printf("父:回收了剩下的子 %d,退出码 %d\n", rb, WEXITSTATUS(status));
return 0;
}# gcc -o waitpid_demo waitpid_demo.c && ./waitpid_demo
子A(1001) 退出,码 11
父:精确回收了子 1001,退出码 11
子B(1002) 退出,码 22
父:回收了剩下的子 1002,退出码 22这个例子把 waitpid 的"精确点名 + waitpid(-1) 等任意"两种形态都展演了:因为 A 先睡完先死,所以第一行 waitpid(a,...) 精确等到 A;B 后死,父进程再 waitpid(-1,...) 收下它。
三道自测(含详解)
自测 1:waitpid(-1, &st, 0) 和 wait(&st) 是不是完全等价?
详解:行为完全等价——都表示"阻塞等待任意一个子进程结束并返回其 PID"。wait 就是"堵死在 waitpid(-1,...) 且 options=0"上面的语法糖/简写。唯一的差别是 wait 是历史遗留接口、更简陋,waitpid 更通用、可控。
自测 2:什么时候 waitpid 会返回 0?什么时候返回 -1?
详解:返回 0 有且仅有一个场景——你传了 options=WNOHANG,且此刻被发现"没有已退出的子进程可收集",于是不等待直接还 0。返回 -1 则通常是调用出错:例如你点的那个 PID 的子进程根本不存在、或者已经在前一次 wait 中被回收过、或权限/信号等原因;删除正确的检查姿势是先看返回值再看 errno。
自测 3:如果一个子进程已经僵尸了,此时父进程再调 waitpid,是"立即返回"还是"继续阻塞"?
详解:立即返回(带着该子进程的 PID 和退出状态),并顺手把僵尸的 PCB 一并回收。因为"已经退出的子进程"正是 wait/waitpid 的猎物,它能当场现结。所以收僵尸没有"等待成本",只要父进程肯调 wait 家族函数,僵尸立刻被清理——问题永远只出在"父进程从来没去缓缓 wait"。
获取子进程 status:把临终遗言解读成人类语言
wait 和 waitpid 都能通过 status 把子进程的退出信息传回来。但切忌把 status 当成一个普普通通的 int——它的含义是"位图",操作系统用不同的位段隐瞒了不同的信息。我们重点研究它的低 16 位。
一张非常重要的位图约定(以 16 位视角俯视):
15 8 7 0
┌──────────┬─┬───────┐
│ (高8位) │ │(低7位) │
└──────────┴─┴───────┘
退出码(高位) 终止信号(低位)
严格按 Linux 的实现来拆,status 低 16 位是这样分配的:
- bit 8~15(即高 8 位):在正常退出(return 或 exit)时存放退出码。所以要取退出码,用
(status >> 8) & 0xFF。 - bit 0~6(低 7 位):存放使进程终止的信号编号(如果没有被信号杀则为 0)。取它用
status & 0x7F。 - bit 7:是否产生过 core dump(core 文件),可作额外参考。
于是两个判别逻辑豁然开朗:
- 正常退出时:低 7 位信号码为 0,退出码在高 8 位。判定"是否正常退出"就看
(status & 0x7F) == 0。 - 被信号杀死时:低 7 位是信号编号(非 0),高 8 位无意义,"退出码"谈不上。
标准库给你准备好了四个威风凛凛的宏,直接用它们,少自己手算:
WIFEXITED(status):若子进程正常退出(通过 return 或 exit)则为真。WEXITSTATUS(status):若WIFEXITED为真,用它提取子进程退出码(即高 8 位)。WIFSIGNALED(status):若子进程被某信号终止则为真。WTERMSIG(status):若WIFSIGNALED为真,用它取终止它的信号编号。
下面我把"手动位运算"和"宏"两种姿势同时演示,你可以对照着看 8 位到底藏在哪里。这个程序也是课件测试代码的完整版:
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
int main(void)
{
pid_t pid;
if ((pid = fork()) == -1) { perror("fork"); exit(1); }
if (pid == 0)
{
// 子进程:睡 20 秒,理论上你有 20 秒决定要不要 kill 它
printf("子进程 %d 就位(pid=%d),20 秒后自己 exit(10)\n", getpid(), pid);
sleep(20);
exit(10); // 20 秒后正常退出,退出码 10
}
// 父进程
int st = 0;
int ret = wait(&st); // 阻塞回收该子进程
if (ret < 0) { perror("wait"); exit(1); }
// 手动位运算解读
if (ret > 0 && (st & 0x7F) == 0) // 低 7 位为 0 => 正常退出
printf("【手动】正常退出, 退出码 = %d\n", (st >> 8) & 0xFF);
else if (ret > 0) // 低 7 位非 0 => 被信号终止
printf("【手动】被信号终止, 信号号 = %d\n", st & 0x7F);
// 用标准宏解读(更推荐)
if (WIFEXITED(st))
printf("【宏】正常退出, 退出码 = %d\n", WEXITSTATUS(st));
else if (WIFSIGNALED(st))
printf("【宏】被信号终止, 信号号 = %d\n", WTERMSIG(st));
return 0;
}第一种验证路径(什么都不做,等 20 秒让它自己 exit(10)):
# gcc -o status_decode status_decode.c && ./status_decode
子进程 1001 就位(pid=1001),20 秒后自己 exit(10)
【手动】正常退出, 退出码 = 10
【宏】正常退出, 退出码 = 10第二种验证路径(开跑后立刻在另一个终端 kill -9 <子进程pid>):
# ./status_decode &
# kill -9 1001 # 假设这是子进程 pid,用 9 号 SIGKILL 干掉它
【手动】被信号终止, 信号号 = 9
【宏】被信号终止, 信号号 = 9这样,一套代码两种结局你都见到了——正常退出解码出高 8 位的退出码 10;被信号杀解码出低 7 位的信号号 9。位图的两个区域各管一段信息,这就是 status 不能被当普通 int 的道理。
一道自测(含详解)
自测:我们说"退出码只有低 8 位被采用",那 exit(300) 在父进程用 WEXITSTATUS 读出来是多少?为什么?
详解:300 的二进制是 0b1 0010 1100,取低 8 位是 0010 1100 = 44。所以父进程 WEXITSTATUS(status) 得到的退出码是 44,而不是 300。因为退出状态本质上只保留了 status 的低 8 位、其余位被截断。这也是前面 _exit(-1) 显示 255(-1 低 8 位全 1 = 0xFF = 255)的同一回事。退出码有效范围就是 0~255,写程序时别设计超过 255 的"业务错误码",拿来就丢信息。
阻塞 vs 非阻塞等待
前面反复提到"阻塞",是时候把它讲透,并配上非阻塞的完整案例。
-
阻塞式等待:
wait(&st)或waitpid(pid, &st, 0)。调用的进程在没有线程孩子退出时,会卡死在这个调用上——CPU 被让出,进程转入睡眠等待状态(S),直到那个孩子退出、回收完才醒过来继续。它的好处是代码简单直接("等我干完再回来"),坏处是母进程被绑死,子进程睡 5 秒,父进程就空等 5 秒,什么都干不了。 -
非阻塞式等待:
waitpid(pid, &st, WNOHANG)。它不"死等"——调用立刻返回;如果孩子还没结束,返回 0(告诉你"还没完,你干别的去");如果孩子结束了,返回孩子 PID(顺手回收)。通常配合一个do...while或while轮询循环使用:父进程一边干自己的活,一边时不时来探一眼孩子死没死。代价是代码稍复杂、要写循环;好处是把"等待的主动权"交还给了父进程,父进程不再干等。
课件给了两种典型代码。我把阻塞版和非阻塞版都整理成可直接编译运行的 C 版本,方便你对照。先看阻塞版(子睡 5 秒,父空等 5 秒):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) { printf("fork error\n"); return 1; }
if (pid == 0) // 子进程
{
printf("child is run, pid is : %d\n", getpid());
sleep(5); // 干 5 秒活
exit(257); // 故意用超过 255 的退出码,验证低 8 位截断
}
int status = 0;
pid_t ret = waitpid(-1, &status, 0); // 阻塞式等待,死等 5 秒
printf("this is test for wait\n");
if (WIFEXITED(status) && ret == pid)
printf("wait child 5s success, child return code is :%d.\n",
WEXITSTATUS(status)); // 257 的低 8 位 = 1
else
printf("wait child failed, return.\n");
return 0;
}# gcc -o fut_wait fut_wait.c && ./fut_wait
child is run, pid is : 45110
# (中间你会明显"卡"5 秒,父进程的下一句 printf 迟迟不出)#
this is test for wait
wait child 5s success, child return code is :1.注意到 exit(257) 被读成 1(257 低 8 位 = 1)——再一次印证退出码只保留低 8 位。
再看非阻塞版,父进程用 WNOHANG 循环轮询、同时还能干点"临时任务":
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
// 演示:父进程在"非阻塞等孩子"的同时,还能执行一些临时任务(如心跳、轮询日志)
static void do_possible_job(int seq)
{
printf(" [父] 在孩子还没结束时处理第 %d 个临时任务\n", seq);
}
int main(void)
{
pid_t pid = fork();
if (pid < 0) { printf("fork error\n"); return 1; }
if (pid == 0)
{
printf("child is run, pid is : %d\n", getpid());
sleep(2); // 子进程只干 2 秒,方便观察轮询
exit(1); // 正常退出,码 1
}
int status = 0;
pid_t ret = 0;
int job_seq = 0;
do
{
// 非阻塞探测:孩子没退出就返回 0,退出了返回其 PID
ret = waitpid(-1, &status, WNOHANG);
if (ret == 0) // 孩子还活着
{
printf("child is still running\n");
do_possible_job(++job_seq); // 顺带干别的活
}
} while (ret == 0); // 只要还没等到,就继续轮询
if (WIFEXITED(status) && ret == pid)
printf("wait child success, child return code is :%d.\n",
WEXITSTATUS(status));
else
printf("wait child failed, return.\n");
return 0;
}# gcc -o fut_nonblock fut_nonblock.c && ./fut_nonblock
child is run, pid is : 45110
child is still running
[父] 在孩子还没结束时处理第 1 个临时任务
child is still running
[父] 在孩子还没结束时处理第 2 个临时任务
...(如此直到 2 秒过去,子进程退出)...
wait child success, child return code is :1.注意:非阻塞版里父进程没有闲着等,它在一遍遍探测孩子状态的同时,把"临时任务"做了——这正是"阻塞 vs 非阻塞"最直观的差异回放。生产里父进程要持续服务(比如服务器主循环)时,就绝不能用阻塞 wait 把自己卡死,而要像这样跑 WNOHANG 轮询,或者引入 SIGCHLD 信号异步回收。
两道自测(含详解)
自测 1:一个长期运行的服务器主进程,为什么不能用"每 fork 一个孩子就 wait"这种阻塞方式收孩子?
详解:因为阻塞 wait 会把主进程卡住直到该孩子结束。服务器主循环本应在孩子干活期间继续 fork 新任务、继续 accept 新连接——一旦阻塞等某个孩子,主进程就瘫痪了。正确做法:要么 waitpid(..., WNOHANG) 每轮循环去探测回收(不卡主循环),要么用信号 SIGCHLD 触发回收。核心思想是**"回收孩子不能阻塞服务主循环"**。
自测 2:WNOHANG 模式下 waitpid 返回 0 意味着什么?你是不是有可能"一次轮询就死循环"?
详解:返回 0 表示"此刻没有已退出的子进程可供回收、我没有等待就回来了"。在一个子进程干活较久的 do...while (ret == 0) 循环里,它会在子进程活着期间反复返回 0,直到子进程死掉才返回 PID 跳出循环——这本来就是轮询语义,不是死循环(只要孩子最终会退出)。真正要小心的倒是:如果孩子永不退出、又没设任何终止条件,while(ret==0) 会一直空转高 CPU。所以非阻塞轮询通常要配"条件:"(比如最多探测 N 次或设超时),或让孩子有确定寿命。
进程程序替换:让进程"改头换面"
前面几节,我们造的父子进程都在执行同一个程序(同一个二进制)。可现实里最常见的诉求是:我想让子进程去跑一个截然不同的程序(比如子进程 fork 后变成 ls、ps、或者一个 Java 程序)。这就要靠进程的程序替换(program replacement) 来完成。
一句话原理:程序替换通过一组 exec 接口,把磁盘上的一份全新程序(代码 + 数据)加载进调用进程自己的地址空间,替换掉调用进程原来的那份影像,然后从新程序的启动例程(startup routine,main 之前的运行时启动代码)开始执行。
最核心、也最反直觉的一条事实,务必刻进脑子:调用 exec 并不会创建新进程。替换前、替换后,这个进程的 PID 从头到尾没变 —— 变的是"这个 PID 所代表的那个进程映像"。这跟 Windows 那种"每跑一个程序就新建一个进程"的模型完全不同:Linux 里,"换程序"和"建进程"这两件事是解耦的——fork 只负责"造人",exec 只负责"换皮"。
六位 exec 家族成员
以 exec 开头的相关函数一共有六个,统称 exec 函数族:
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);它们俩俩之间的差异,藏在前缀字母 l / v(用什么形式传参数)、p(搜不搜 PATH)、e(带不带自定义环境)里。我们把每个字母对应的意思记成一张对照表,这是整个 exec 全家桶的记忆锚:
| 字母 | 含义(英文) | 作用 |
|---|---|---|
l | list(列表) | 参数逐个罗列在函数签名后面,用 ..., NULL 结尾 |
v | vector(数组) | 参数放在一个 char *argv[] 数组里,传数组名 |
p | PATH(路径) | 通过 PATH 环境变量自动搜索程序;file 可只写程序名,不写完整路径 |
e | environment(环境) | 需要自己额外提供一个 char *envp[] 自定义环境变量数组 |
串起来的规律非常顺:
- 以
l结尾:参数是列表(每种情况显式罗列),execl、execlp、execle; - 以
v结尾:参数是数组,execv、execvp、execve; - 带
p的(execlp、execvp):会自动搜索PATH,写程序名即可; - 带
e的(execle、execve):多了一个envp[]参数,用来指定子进程的环境变量。
关键行为(生死攸关):这些函数一旦成功就不会返回——成功意味着当前进程已经被新程序全面接管、执行起点跳到新程序去了,哪里还有可能"返回到 exec 调用处"?所以 exec 唯一会"返回"的情况是失败(返回 -1 并设置 errno)。这就是课件那句"exec 只有出错的返回值、没有成功的返回值"的由来。于是写 exec 代码有一个铁律:exec 之后的代码,几乎都是"exec 失败才可能走到"的错误处理代码(除非你是想让它作为不执行新程序时的 fallback)。
只有 execve 是真正的系统调用
还有一个对"面试官最爱问"的技术事实:这六个函数里,只有 execve 是真正的系统调用(man 手册第 2 节),其余五个(execl、execlp、execle、execv、execvp)都是对 execve 的不同包装(man 手册第 3 节,libc 库函数)。它们的内部实现殊途同归——最终都把调用参数整理成"路径 + argv[] + envp[]"三元组,然后去调用 execve。
这个"一实五虚、众生归一"的设计,让你记起一个了不起的规律:库函数负责"好用的接口",系统调用负责"朴素的地基"。用户态库帮你把"列表参数转数组""自动搜 PATH""补全环境变量"这些麻烦事都做掉了,真正落在内核地板上的基本功,始终是那一个 execve。
一个完整的 exec 验证:变换六种姿势
下面这段把六种姿势各调一遍。注意:exec 成功即"永不返回",真正跑到 exit(0) 那行的,只会是"前面的 exec 全部失败"的情况。为了让演示能逐个看到生效,我刻意让每段之间用"注释 + 绝不连续"的方式排布——实际工程里你只挑一种用即可。先把这个"会多次 exec"文件的原理看明白:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
char *const argv[] = {"ps", "-ef", NULL}; // v 系列用的参数数组
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL}; // e 系列用
// l 系列:参数以列表给出。; 分隔,若 exec 成功则永不执行后面的代码
execl("/bin/ps", "ps", "-ef", NULL);
// 若 exec 失败,代码才会走到这 -> 继续尝试下一种。失败会打印也不返回
perror("execl 失败,尝试 execlp");
// 带 p:只需写程序名,自动沿 PATH 搜索
execlp("ps", "ps", "-ef", NULL);
perror("execlp 失败,尝试 execle");
// 带 e:自己组装环境变量传给新程序
execle("ps", "ps", "-ef", NULL, envp);
perror("execle 失败,尝试 execv");
// v 系列:参数放数组里
execv("/bin/ps", argv);
perror("execv 失败,尝试 execvp");
execvp("ps", argv);
perror("execvp 失败,尝试 execve");
execve("/bin/ps", argv, envp);
// 到这一步说明上面全部失败
perror("execve 失败");
exit(1);
}你会看到:只要第一个 execl("/bin/ps", ...) 成功,进程就变成 ps -ef 并开始打印进程表,后面那堆 exec 和 perror 全都不执行——这正体现了"exec 成功即不返回"。只有当某个 exec 因找不到路径等原因失败时,才会 perror 出错误继续尝试下一种。
为让你更干净地各自验证,我再给一个纯粹的、一次只跑一种 exec 的最小例子(只用 execlp,演示 PATH 搜索 + 成功不返回):
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
printf("替换前:我的 PID 是 %d\n", getpid());
// 用带 p 的版本,只写程序名 ls,自动沿 PATH 找到 /bin/ls
execlp("ls", "ls", "-l", NULL);
// exec 成功永不返回;能走到这里说明 exec 失败了(比如 PATH 里没有 ls)
perror("exec 失败");
exit(1);
}# gcc -o myexec myexec.c && ./myexec
替换前:我的 PID 是 1000
-rw-r--r-- 1 root root 123 ... myexec
-rw-r--r-- 1 root root 456 ... myexec.c注意到没:PID 1000 从头到尾没变,可它已经从一个"打印自定义"的程序,变成了 ls -l 的样子——同一个 PID,映像被整体替换。这就是进程程序替换的直观展示:不新建进程、不改 PID,只是换掉这个进程正在执行的代码和数据。
两道自测(含详解)
自测 1:exec 成功"永不返回"这个特性,给代码编写带来了哪两个必须养成的习惯?
详解:第一,exec 成功后的代码永远不会执行,所以你不应该把"exec 之后"当成正常程序路径,而应视作"exec 失败的兜底",通常紧跟错误处理或直接退出。第二,如果你 fork 子进程后让子进程 exec,务必让 子进程 exec 失败时退出(如 exit(1)),否则子进程会退回并接着执行父进程剩下的代码,造成逻辑错乱。这是 fork+exec 经典 bug 的一大来源。
自测 2:同样是执行 ps -ef,execlp("ps", "ps", "-ef", NULL) 和 execl("/bin/ps", "ps", "-ef", NULL) 有何差别?
详解:execl 要求你写完整路径(/bin/ps),它不做 PATH 搜索;execlp 只要求写程序名(ps),会按 PATH 环境变量去挨个目录找,找到后执行。所以 execlp 更省事、更可移植(不需要知道程序装在哪),但代价是"依赖 PATH 对不对",万一 PATH 被改坏了就找不着。工程上判断:知道确切路径用 execl/execv 更可控,不确定路径用 execlp/execvp 更省心。
与 exec 配套的经典模式:fork + exec
讲了 exec,必须把它和 fork 焊在一起看——这才是 Linux 世界真正的主角组合。因为 exec 本身是"换皮不换人(PID 不变、进程还是原来那个)",所以如果你想"启动一个全新的程序、并且把它独立于当前进程"管理,就必须先 fork 出一个拷贝,再让拷贝去 exec。整体套路:
父进程 → fork() 出子进程
├─ 子进程:fork 返回 0 → 立即调 exec(...) 把自己换成新程序
└─ 父进程:fork 返回子 pid → waitpid() 等子进程干完并回收
这也就是几乎所有 Shell(bash)吃饭的看家本领——你在终端敲 ls 回车,bash 就是这样 fork 一个子进程把它 exec 成 ls,然后自己 wait 等它跑完,再把提示符还给你。
下面给一个完整可编译的 fork + exec + wait 三件套示例:父进程生一个子进程,子进程 exec 成 date(打印当前时间),父进程 wait 回收并报告:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); exit(1); }
if (pid == 0)
{
// 子进程:把自己替换成系统命令 date(打印当前日期时间)
printf("[子] 我在 exec 成 date 之前,PID 还是 %d\n", getpid());
execlp("date", "date", NULL);
// 走到这说明 exec 失败,必须退出,否则会继续跑父进程的代码!!
perror("exec date 失败");
exit(1);
}
// 父进程:阻塞等待子进程结束并回收
int status = 0;
pid_t ret = waitpid(pid, &status, 0);
if (ret == pid && WIFEXITED(status))
printf("[父] 子进程 %d 已结束,退出码 = %d\n", ret, WEXITSTATUS(status));
return 0;
}# gcc -o fork_exec fork_exec.c && ./fork_exec
[子] 我在 exec 成 date 之前,PID 还是 1001
2025-08-16 周六 10:30:22 CST # 这是 date 打印出来的
[父] 子进程 1001 已结束,退出码 = 0观察点:子进程 exec 前后 PID 都是 1001(没变),但它打印的内容从我们 printf 的话,变成了 date 的输出——这就是"fork 造人 + exec 换皮 + wait 收尸"三位一体最标准的一次演示。
一道自测(含详解)
自测:fork+exec 模式里,子进程 exec 失败后为什么必须 exit?如果不 exit 会怎样?
详解:因为子进程一旦 exec 失败返回,它手里拿到的还是父进程那份"复制来的代码",会继续往下执行父进程 fork 之后的剩余逻辑(比如父进程那段 waitpid 和打印)。这样你就得到"两个父进程都在跑父逻辑"的错乱,既浪费又难查。铁律:子进程里 exec 失败必须立刻 exit(通常非 0 退出码),把 exec 结果踹回父进程的 wait,由父进程统一判断。
自主 Shell 命令行解释器:把知识焊成一体
现在到了整个前半段的最强综合题——我们亲手写一个 微型 Shell。这一步不是为了做出来多有面子,而是要让你真切理解三件事的闭环:Shell 是怎么接收命令并派活的、内建命令(built-in command)和外部命令到底差在哪、以及"fork + exec + wait"三件套如何撑起一整台交互式命令解释器。学完这节你再看 bash 的每一次"你敲命令它跑完还给你提示符",会彻底看懂幕后机制。
Shell 的典型互动与时间轴
回顾一个最普通的 shell 会话:
$ ls
client.cpp readme.md server.cpp utility.h
$ ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps背后发生的事,用一条时间轴说清楚:
- shell 从终端读入用户敲的一行字符串
ls; - shell 解析出命令名和参数;
- shell 用 fork 新建一个子进程;
- 子进程 execvp 把自己替换成
ls; - shell(父进程)wait 等这个子进程结束,得到退出码;
- 回到第 1 步,shell 重新打印提示符、读下一行输入。
周而复始。所以写一个微 shell,本质上就是死循环跑上面六步。把这个循环想通,bash 在你眼里就只剩下"解析更细 + 功能更多",骨架并无二致。
完整可编译的微 Shell(C 语言实现)
为了让"内建命令 vs 外部命令"这个最重要的分岔讲清楚,我实现如下内建:cd(改变目录)、export(添加环境变量)、env(打印环境变量)、echo $?(打印最后一条命令的退出码)。其余一律走"fork+exec+wait"派给子进程跑。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define MAX_LINE 1024 // 单行命令的最大长度
#define MAX_ARG 64 // 一条命令最多支持多少个参数
// 全局:上一条命令的退出码,供 echo $? 使用
static int lastcode = 0;
// 步骤1:打印命令行提示符(用户名@主机名 目录# ),并冲刷缓冲
static void print_prompt(void)
{
char cwd[256];
getcwd(cwd, sizeof cwd); // 取当前工作目录
printf("[microsh %s]# ", cwd); // 简化的提示符
fflush(stdout); // 必须冲刷,让提示符马上显示
}
// 步骤2:从 stdin 读一整行,去掉末尾换行
static int get_cmd(char *buf, int size)
{
if (!fgets(buf, size, stdin)) return 0; // 读到 EOF(Ctrl+D)表示结束
buf[strcspn(buf, "\n")] = 0; // 把末尾换行换成字符串结束符
return 1;
}
// 步骤3:按空格拆分命令行,填进 argv[],返回 argc
static int parse_cmd(char *buf, char *argv[])
{
int argc = 0;
char *tok = strtok(buf, " "); // 第一个词
while (tok && argc < MAX_ARG - 1)
{
argv[argc++] = tok;
tok = strtok(NULL, " "); // 后续词依次取出
}
argv[argc] = NULL; // execvp 需要 NULL 结尾
return argc;
}
// 步骤0:环境变量处理——导入父 shell 的环境,供 export/env 使用
static void init_env(void)
{
extern char **environ; // 指向当前进程环境变量的全局
for (int i = 0; environ[i]; i++)
putenv(strdup(environ[i])); // 重新放一遍进 environ(可省)
}
// 内建命令分派:返回 1 表示"是内建、已处理";返回 0 表示"外部命令"
static int builtin(char *argv[], int argc)
{
if (strcmp(argv[0], "cd") == 0)
{
if (argc >= 2) chdir(argv[1]); // 切目录(成功后 PWD 也会更新)
else lastcode = 1;
return 1;
}
if (strcmp(argv[0], "export") == 0)
{
if (argc >= 2) { putenv(strdup(argv[1])); lastcode = 0; }
else lastcode = 2;
return 1;
}
if (strcmp(argv[0], "env") == 0) // 打印当前环境变量
{
extern char **environ;
for (int i = 0; environ[i]; i++) printf("%s\n", environ[i]);
lastcode = 0;
return 1;
}
if (strcmp(argv[0], "echo") == 0)
{
if (argc >= 2 && strcmp(argv[1], "$?") == 0) // echo $?
printf("%d\n", lastcode);
else if (argc >= 2) // echo 其他参数
printf("%s\n", argv[1]);
else
lastcode = 3;
return 1;
}
return 0; // 不是内建,交给外部命令流程
}
// 外部命令:fork + exec + wait 三件套
static void run_external(char *argv[])
{
pid_t pid = fork();
if (pid < 0) { lastcode = 127; return; }
if (pid == 0)
{
execvp(argv[0], argv); // 子进程把自己替换成命令
perror("execvp"); // exec 失败(如命令不存在)
exit(127); // 子进程立刻退出,勿回父逻辑
}
int status = 0;
waitpid(pid, &status, 0); // 父进程阻塞等孩子干完
if (WIFEXITED(status)) lastcode = WEXITSTATUS(status);
else lastcode = 128 + WTERMSIG(status); // 被信号杀:128+号
}
int main(void)
{
init_env();
char buf[MAX_LINE];
char *argv[MAX_ARG];
// 主循环:打印 -> 读 -> 解析 -> 分派(内建/外部)
while (1)
{
print_prompt();
if (!get_cmd(buf, MAX_LINE)) break; // Ctrl+D 退出 shell
int argc = parse_cmd(buf, argv);
if (argc == 0) continue; // 空行就重来
if (!builtin(argv, argc)) run_external(argv); // 内建?否则派子进程
}
printf("bye\n");
return 0;
}# gcc -o microsh microsh.c && ./microsh
[microsh /home/user]# ls
microsh microsh.c
[microsh /home/user]# cd /tmp
[microsh /tmp]# pwd
/tmp # 注意:这是外部命令 pwd 由子进程跑出来;cd 是内建、直接改目录
[microsh /tmp]# MYVAR=hello; echo $? # 演示退出码查询(echo 是内建)
0
[microsh /tmp]# env
PATH=... # export/env 都是内建,直接操作本进程的环境
[microsh /tmp]# ls no_such_file
ls: cannot access 'no_such_file': No such file or directory
[microsh /tmp]# echo $?
2 # 上一条外部命令 ls 的退出码被传了回来
[microsh /tmp]# exit这个微 shell 把前面所有知识点捏成了一个整体。请你对号入座:
- 内建命令为什么不能由子进程跑? 因为
cd要改的是 shell 自己的目录,export/echo $?要动 shell 自己进程里的状态。如果 fork 一个子进程去chdir,子进程改了它自己的目录、一会儿就死了,shell 自己的目录纹丝不动——这正是"必须由 shell 亲自执行(调自己的函数)"的内建本质。所以内建命令 = shell 调自己的函数,外部命令 = 派子进程去跑。 - 外部命令为什么必须 fork 再 exec? 因为 exec 会换掉当前进程映像;如果 shell 直接 exec
ls,shell 自己就变成 ls、再也回不到主循环了。必须 fork 出一个拷贝,让拷贝去 exec——正如我们 fork+exec 那节反复强调的。 - wait 在哪里体现? 父进程
waitpid(pid,&status,0)阻塞等子进程跑完,还顺手把退出码记进lastcode供echo $?用——这就是 shell$?的完整闭环。
两道自测(含详解)
自测 1:为什么 cd 必须是内建命令,换成"子进程去 chdir"会怎样?
详解:chdir 改变的是"调用进程自己的工作目录(CWD)"。如果 fork 一个子进程去 chdir /tmp,改变的是子进程的 CWD,子进程随即被 waitpid 回收消失,shell 自己的 CWD 纹丝不动,之后 shell 的 pwd/文件操作仍停留在原目录。所以 cd 只能由 shell 自己在自己的进程里 chdir——这是"内建命令必须由 shell 亲自执行"的最佳例证。
自测 2:export 为什么也必须是内建?如果它被派成子进程,环境变量会传给谁?
详解:export a=xx 的效果是把环境变量注入"当前 shell"这个进程的环境(放进它的 environ),从而让"之后 fork 出来的外部命令"都能继承它。若用子进程去 putenv,改变的是子进程自己的环境,子进程一死就没了,shell 后续 fork 的命令根本继承不到。所以环境变量的增改只能在 shell 自己的进程里做——这正解释了为什么 export、cd、env、echo $? 都得是内建(内建 = 由 shell 本尊执行)。
进程的优先级与调度
讲完进程的出生、等待、换皮,我们要在地基处再补一块最容易被忽略、却每天都被操作系统默默执行的拼图——进程优先级与调度。毕竟,进程不是排队等死,而是要被 CPU 有条不紊地切换执行,这是方向感的保障。
为什么需要优先级
一台单核 CPU 只有一个,却要跑几十上百个进程。操作系统必须用某种规则决定"下一个让谁上 CPU",这就是调度(scheduling)。而"谁更该先上"的量化指标,就是优先级(priority)。
Linux 采用 CFS(Completely Fair Scheduler,完全公平调度器)作为主流调度器:它追求"每个进程都得到应有的 CPU 时间"的公平,同时允许你用一个小窗口去影响某些进程获得更多/更少的份额。这个"影响窗口",就落在优先级上。
有两个相关但不同的优先级概念,必须分清楚:
-
nice 值(友好度):-20 ~ +19 的一个整数,是"调度优先级"的可调窗口。
nice越小,进程越"霸道"、获得 CPU 份额越多;越大越"谦让"、份额越少。默认 0。只有 root 能改成负数(提升优先级)。它叫 nice 名字也很形象——值越大,进程越"nice(对别人客气、自己让份额)"。
-
实时优先级(可选):
SCHED_FIFO、SCHED_RR等,用于硬实时任务,一般嵌入式/内核里用得多,用户态日常很少碰。我们聚焦 nice/CFS 这套。
一条命令看优先级:
# ps -o pid,nice,pri,comm # 打印 PID、nice 值、内核优先级、命令
PID NI PRI COMMAND
1000 0 20 bash
1001 -10 10 my_proc # 这个进程 nice=-10,pri 更高,更优先这里 NI 就是你设置的 nice 值,PRI 是内核内部使用的优先级数字(值越小优先级越高)。我们可以看到 nice 低(负数)的进程,其 PRI 明显更小(更优先)——两者呈反向关系。
用 nice / renice / top 观察和调整
- 启动时指定 nice:
nice -n -5 ./a.out(把它 nice 设成 -5,只有 root 能设负数); - 对一个正在运行的进程调 nice:
renice -n 10 -p <pid>(把 pid 这个进程 nice 升到 10,让它更谦让); - 用
top交互:按r再输入 PID 和 nice 值,就能调整;top里PR是优先级,NI是 nice。
用代码证明优先级影响"谁先跑"
排程器行为受很多因素影响,但我们可以做一个"半定量"实验:创建两个忙碌型子进程,一个 nice 设为 0、一个 nice 调低到 -10,让它们同时争抢 CPU 跑相同忙循环,测定各自"跑了多少次"。粗估公平性差异。为控制变量,父进程负责 fork 两个子,分别用 nice(0) 和 nice(-10) 设置自己的 nice,然后各跑一段忙循环并统计计数,最后父 wait 汇总。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
// 让当前进程跑一段定时的忙循环,返回自增次数(粗测 CPU 份额)
static long busy_run(void)
{
long cnt = 0;
for (double x = 0; x < 1.0; x += 0.0000001) // 非常粗略的一段时间窗
cnt++;
return cnt;
}
int main(void)
{
pid_t pa = fork();
if (pa < 0) { perror("fork a"); exit(1); }
if (pa == 0)
{
nice(0); // 子A:默认 nice 0
printf("子A: nice=0, 忙循环计数 = %ld\n", busy_run());
exit(0);
}
pid_t pb = fork();
if (pb < 0) { perror("fork b"); exit(1); }
if (pb == 0)
{
nice(-10); // 子B:提升优先级(需 root,若失败 sleep 演示)
printf("子B: nice=-10, 忙循环计数 = %ld\n", busy_run());
exit(0);
}
waitpid(pa, NULL, 0);
waitpid(pb, NULL, 0);
return 0;
}说明:
nice失败会返回 -1;提升优先级(负数)在非 root 下会失败。以 root 运行可让差异更明显;以普通用户跑,子B 的 nice(-10) 会失败维持 0,但代码仍能编译运行(此实验重在"演示如何用代码设 nice 并对比",绝对值请以实际调度结果为准)。
# sudo ./nice_demo # 以 root 跑,让子B 真的拿到 -10
子A: nice=0, 忙循环计数 = 40000
子B: nice=-10, 忙循环计数 = 95000 # 明显更多:优先级更高,抢到更多 CPU这种"nice 越低越吃 CPU"的现象,就是优先级在起作用。实际整机工况受干扰多,数值每次不同,但方向性结论稳定:低 nice 的进程会获得显著更多 CPU 时间。
三道自测(含详解)
自测 1:nice 的取值范围和"小而优先还是大而优先"分别是什么?为什么普通用户不能设负数?
详解:nice 范围 -20 ~ +19;越小越优先(获得更多 CPU),越大越谦让。负数赋予更高优先级,这会影响其他用户/系统的运行体验,属于特权操作,所以只有 root(有 CAP_SYS_NICE)能设负数;普通用户只能把 nice 上调到正值(让自己更谦让),不能抢占。这是"防止普通用户恶意霸占 CPU"的资源配额保护。
自测 2:ps -o pid,nice,pri,comm 里,nice 和 pri 是同一个概念吗?
详解:不是同一个概念。nice 是你(用户)可设置的、相对区间的"友好度";pri 是内核把 nice + 当前运行情况综合换算出来的最终调度优先级数值,值越小进程优先级越高。严格说:nice 低 → 内核换算出的 pri 相应地小(更优先)。两者方向相反、数值口径不同,一个是"用户可调的旋钮",一个是"内核定死的结果"。
自测 3:优先级是不是越高越好、能天天把自己全部进程设成 -20?为什么不能?
详解:不是。第一,普通用户无权设负数(受限);第二,即使 root,把大量进程全部调高默认会破坏"公平"和各进程应有的服务质量,重要进程也未必受益,反而加剧调度抖动;第三,pri 只是 CFS 里影响权重的一个因素,还有很多其他因素(等待时长、wakeup 历史等)参与公平调度,直接用 headroom 当"谁可爱就给谁"并不明智。合理用法是把确实时间敏感的任务(如音频、交互 UI)稍微调优,其余保持默认,让 CFS 的公平性自动平衡系统。
从 call/return 到 fork/exec:程序与程序之间的"函数调用"
课件在结尾抛出了一个漂亮的类比,我想把它升华成你理解整章的收尾。
考虑一个 C 程序内部:一个函数可以调用另一个函数、传参进去、被调函数执行操作后 return 一个值回来。这靠的是"调用栈 + 参数/返回值 + 局部变量"这些机制——这是结构化程序设计的基础。Linux 的哲学是:这种"函数调用"模式完全可以延伸到"程序与程序"之间。
对账起来出奇地整齐:
| C 程序内部的函数通信 | 进程之间的进程通信 |
|---|---|
call(调用、进入被调函数) | fork + exec(造一个子进程并让它跑另一个程序) |
| 传入参数 | exec 的 argv / 全局参数 |
return(被调函数返回值) | exit(n)(子进程回传退出码) |
| 调用者随后取回值 | 父进程 wait(&ret) 取到退出码 |
也就是说:exec/exit 之于进程,正如 call/return 之于函数。你在一个 C 程序里可以像"调用一具又一具函数"一样,fork+exec 出一个个程序,给它们传参,让它们干活,再用 wait 收它们 exit 回来的"返回值"。整个操作系统就像一个巨大的、可以跨程序的"函数式调用网络"。
想通这一层,你就掌握了 Unix 进程模型真正的灵魂:"一个程序去启动另一个程序、并通信其结果"不是魔法,而是操作系统的第一等公民能力,与函数调用用的是同一套心智模型。
一道综合思考(含详解)
自测:把上面类比用到"微 shell 执行 ls -l"这整件事上:分别对应 fork、exec、exit、wait 在类比中的角色是什么?微 shell 又是如何在"函数调用"心智下保持自己"屹立不倒、还能等下一个命令"的?
详解:ls -l 就像一次"跨程序的函数调用":shell(调用者)fork 出一个子进程作为"被调用的函数实体",子进程 exec 把"参数"(ls -l 这个 argv)装载进来、从 main 启动;子进程跑完 return/exit 把返回码带回;shell 用 wait 把这个"返回值"取回、并当作 $? 展示。而 shell 之所以能"屹立不倒、接下一命令",正因为它把"被调函数"的实体放在 fork 出来的子进程里、自己绝不 exec——shell 自己不 exec,就永不变成 ls,于是主循环可以继续等下一个命令。这与"main 调用一个函数、函数 return 后 main 继续跑下一句"是同一个道理,只不过"函数"被放大成了"进程"。这就是"从 call/return 到 fork/exec"的精髓。
走到这里,你已经把 Linux 进程控制的整块版图收进囊中:知道进程靠 fork 一分为二、靠写时拷贝在内存上彻底独立,理解了一次 fork 为何两处返回、父得子 PID 子得 0 的设计玄机;知道了进程终止的三条正道与退出码的低 8 位真相,看懂了 exit 与 _exit 在缓冲善后上的天壤之别;学会了用 wait/waitpid 给子进程收尸、用 status 的位图与 WIFEXITED/WEXITSTATUS 解码临终信息,分清了阻塞等待与非阻塞轮询的取舍;明白 exec 家族如何"换皮不换人"、只有 execve 是系统调用、exec 成功即永不返回;亲手搓出一个微 shell,把内建命令与外部命令的行车路线彻底贯通;最后还摸到了 nice 优先级这一小块调度地基,并用类比把"程序间通信"升维成了"函数调用"的模样。
如果上面每段代码你都亲手敲过、每个"为什么"都能自己讲给别人听,那么恭喜你,你已经具备了独立阅读任何 Linux 进程相关源码和文档的骨架——README 里的 fork、exec、wait 不再是字母,而是一整套有血有肉的时间线。下一篇文章,我们顺着这条主线继续往下走:进程间如何真正交换数据(IPC:管道、重定向与信号),那时你会发现,今天打下的地基,正是构建一整个服务器系统的承重墙。准备好了吗?
附录:本文全部思考题与答案速查
为方便你复习,我把全文所有"自测/思考题"连同答案集中列在这里(正文中已各带详解,这里一键回顾):
- 为什么不能反过来设计成"父返 0、子返父 PID"? 因为父需要唯一的子 PID 来精确管理多个孩子;给父固定 0 等于废掉它的管理能力;而子得 0(0 绝不可能是真 PID)无歧义。父得子 PID、子得 0 是唯一自洽设计。
- fork 后子进程从哪开始跑?为何示例没打印 Before? 从 fork 返回处继续(PC 继承自父当时的执行点),不重跑 main。Before 在 fork 前已被父打印,子不会回溯。
- fork 后父子谁先跑? 不能保证,完全由调度器决定;有顺序要求必须用 wait 显式同步。
- 只读不写的共享变量会不会 COW? 不会,只有"写"才触发 COW;但一旦用来通信(写)就触发 COW 分家,故不能当共享通道。
- for(i=0;i<3;i++) fork() 后多少进程? 8 个(2^3),其中 7 子 1 父。
- 无限 fork 的死循环程序开跑会发生什么? 内存耗尽或进程数超限报错,可能拖垮系统;控制变量要加计数上限。
- $? 何时看、何时失效? 必须紧跟在目标命令后立刻查;任何下一条命令都会覆盖它。
- 为什么 Ctrl+C 退出码是 130? 约定"被信号终止 = 128+信号号",Ctrl+C=SIGINT=2,128+2=130。
- strerror 干什么? 把 errno 数字码转成可读英文描述;排 -1 返回时必用。
- printf A; _exit(0) 能看见 A 吗? 不能;
\n触发刷缓冲才能。_exit 不冲 stdio 缓冲。 - exit 已调 _exit 为何还要区分? 因 exit 多做缓冲冲刷 + atexit 善后;要在极端上下文极干脆退出时用 _exit。
- atexit 为何逆序执行? 资源后进先出(LIFO)保证依赖关系逆序释放。
- 为何子函数里"结束整个进程"只能用 exit? return 只结束当前函数;exit 在任何位置都直接终局进程。
- 服务器不停 fork 不 wait 会怎样? 僵尸积压、PID/进程表占满、fork 失败;用 wait 或 WNOHANG 轮询/信号异步回收。
- wait 是等任意还是特定?fork 三个只调一次够吗? 等任意、一次只收一个;必须循环 wait 数次。
- wait 传 NULL 的含义? 不关心退出信息,只求回收,结果照样返回子 PID。
- waitpid(-1,...) 与 wait 等价吗? 行为完全等价。
- waitpid 何时返回 0、何时 -1? 只有配 WNOHANG 且无子可收时返回 0;出错(点名的进程不存在等)返回 -1。
- 僵尸子进程再 waitpid 会立即返回吗? 会;退出的孩子正是 wait 的猎物,现结现收。
- exit(300) 父读出来是? 44(低 8 位);退出码有效范围 0~255。
- 服务器为何不能阻塞 wait 收子? 会把主循环卡死;改用 WNOHANG 轮询或 SIGCHLD 信号。
- WNOHANG 返回 0 的含义 / 会不会死循环? 表示"片刻没有已退子进程、已返回";须配超时/终止条件防空转高 CPU。
- exec 成功不返回带来哪些习惯? exec 后代码视为失败兜底;fork+exec 中子 exec 失败必须 exit。
execlp("ps", "ps", "-ef", NULL)vsexecl("/bin/ps", "ps", "-ef", NULL)差别? 带 p 自动搜 PATH、可写程序名;不含 p 需写完整路径、不搜索,用 PATH 与完整路径各有利弊(可控性 vs 便利性)。- fork+exec 中子进程 exec 失败为何必须 exit? 否则子流程会退回跑父进程剩余逻辑,逻辑错乱。
- 为什么 cd 必须是内建? chdir 只改调用进程 CWD;子进程改自己 CWD 死了即失效,shell 目录不变。
- 为什么 export 必须是内建? 环境变量要注入 shell 进程以便后续子进程继承;子进程改自己环境一死即没了。
- nice 范围与"小优先大优先"?为何普通用户不能负数? -20~+19,越小越优先;负数为特权,防恶意抢占。
- nice 与 pri 是同一个吗? 不是;nice 是用户旋钮,pri 是内核换算出的最终优先级(小者优先),方向相反。
- 优先级越高越好吗? 不是;普通用户无权、全面调高破坏公平且未必受益,应只给时间敏感任务局部调优。
- 综合:微 shell 执行 ls -l 中各角色。 fork=造被调"函数实体"、exec=载入参数与程序、exit=返回值、wait=取回返回值;shell 自己不 exec 故能屹立不倒接下一令。
还没有评论 — 第一条由你来留。