第 2 章 · 06 作业控制:后台、挂起与信号 本节摘要:到目前为止,你的 Shell 每敲一条命令都要等它跑完才能回到提示符——但真实 Shell 不这样: 能让命令后台跑、Ctrl+Z 能把前台命令挂起、 / 能恢复它。这一节是本章的另一个难点,讲清作业控制(job control)的本质——Shell 用信号管理一组子进程。你要理解三个东西:进程组(把一条管道里的多个进程打包成一个调度单位)、终端的前台进程组(终端把 Ctrl+C/Ctrl+Z 发给谁)、以及 SIGTSTP/SIGCONT 这两个让进程「暂停/恢复」的信号。呼应《Linux 命令》第 6 章的「信号」——那章讲「信号怎么用」,这章讲「Shell 怎么靠信号管理作业」。
本节摘要:到目前为止,你的 Shell 每敲一条命令都要等它跑完才能回到提示符——但真实 Shell 不这样:
&能让命令后台跑、Ctrl+Z 能把前台命令挂起、fg/bg能恢复它。这一节是本章的另一个难点,讲清作业控制(job control)的本质——Shell 用信号管理一组子进程。你要理解三个东西:进程组(把一条管道里的多个进程打包成一个调度单位)、终端的前台进程组(终端把 Ctrl+C/Ctrl+Z 发给谁)、以及 SIGTSTP/SIGCONT 这两个让进程「暂停/恢复」的信号。呼应《Linux 命令》第 6 章的「信号」——那章讲「信号怎么用」,这章讲「Shell 怎么靠信号管理作业」。理解这一节,你就理解了为什么Ctrl+C能杀掉跑飞的命令,以及为什么后台进程还能被你重新拉回前台。
内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源(如「Tutorial - Write a Shell in C」「Writing a UNIX Shell」等)。
阅读完本节,你应当能够:
A | B | C 是三个进程,但构成一个作业——它们被统一调度。PGID,便于 Shell 和终端统一发信号。& 后台运行:fork 后父进程不 wait,直接回到提示符,子进程在后台跑。waitpid 的 WUNTRACED 检测到「子进程被停住」。fg/bg 恢复:发 SIGCONT 让挂起的作业继续跑,fg 还要把它拉回前台进程组。到目前为止你的 Shell 是「阻塞式」的:敲一条命令,Shell fork 子进程、wait 它跑完、才回到提示符。这对绝大多数命令没问题——ls 一秒就跑完。但设想这些场景:
make build,它要跑五分钟。你不想干等。python -m http.server,你想一边用它一边干别的。find / 找文件,跑了十秒你发现参数写错了,想立刻打断它。fg 回去。这四个场景分别对应作业控制的四大能力:
&:让命令在后台跑,Shell 立即回到提示符。fg/bg:把挂起的作业拉回前台(fg,Shell 再次阻塞等它)或放后台继续(bg)。这些能力的底层是同一套机制:信号 + 进程组。Shell 通过向特定的进程组发信号,实现对一组子进程的统一控制。这套机制不只造 Shell 用得到——它是 Unix 「作业调度」的通用模型,任何需要管理多个并发子进程的工具(容器运行时、CI 系统、构建工具、进程管理器 supervisord)都在变相使用它的思想。
更重要的是,理解作业控制,你才真正理解《Linux 命令》第 6 章讲的「信号」是怎么在真实系统里工作的——信号不是孤立的系统调用,它是终端、Shell、进程三方协作的纽带。
关键概念:作业控制的本质,是把「Shell 和它启动的所有子进程」组织成多个「作业」,每个作业是一个进程组,Shell 通过向进程组发信号(暂停/恢复/终止)来统一管理整组进程。终端也参与这套协作——它把键盘信号(Ctrl+C/Ctrl+Z)只发给当前的前台进程组。
为什么需要进程组?考虑 find / | grep foo | wc -l &——这是一条后台管道,涉及三个进程。Shell 要把整条管道当作一个作业来管理:挂起它要同时挂三个、杀它要同时杀三个、恢复它要同时恢复三个。如果一个一个发信号,会很麻烦还可能漏——比如挂起了 find 但 grep 还在跑,管道就会卡死。
Unix 的解法是进程组:fork 出来的子进程,默认继承父进程的 PGID;但 Shell 可以用 setpgid(pid, pgid) 把每个子进程显式归到一个新的 PGID 里(通常用第一个子进程的 PID 作为整组的 PGID)。这样,一条管道里的所有进程共享同一个 PGID,Shell 给这个 PGID 发一次信号,整组都收到。
Shell (PID=100, PGID=100) │ ├── fork ──> find (PID=101) │ setpgid(101, 101) <- 把 find 的 PGID 设成它自己的 PID │ ├── fork ──> grep (PID=102) │ setpgid(102, 101) <- 归到 find 的组 │ └── fork ──> wc (PID=103) setpgid(103, 101) <- 归到 find 的组 现在 101/102/103 都在 PGID=101 这个组里 Shell 给 PGID=101 发信号, 三个进程同时收到
注意:setpgid(0, 0) 在子进程里调用时表示「把我的 PGID 设成我自己的 PID」——这就是「自成一组」的惯用写法。第一个子进程自成一组(成为组长),后续的子进程 setpgid(0, 第一个的 PID) 归到这一组。
关键概念:进程组是把「一条作业」打包成「一个调度单位」的机制。Shell 给整个组发一次信号,就能控制整条管道。作业 = 进程组,这是作业控制的基石。没有进程组,作业控制无从谈起。
终端(tty)有一个关键属性:前台进程组(tcgetpgrp/tcsetpgrp)。它决定了:
Shell 自己启动时是前台进程组。当它 fork 出前台作业时,要把终端的前台进程组让给作业(tcsetpgrp(STDIN, 作业的 PGID));作业跑完,Shell 再把前台拿回来(tcsetpgrp(STDIN, Shell 自己的 PGID))。
Shell 启动, 前台 = Shell 自己 (PGID=100) │ ▼ 用户敲 find / (前台作业) Shell: fork find, setpgid 让 find 自成一组 (PGID=101) Shell: tcsetpgrp(终端, 101) <- 把前台让给 find Shell: waitpid(find, ..., WUNTRACED) <- 阻塞等 find 结束或被停住 │ ▼ find 跑完 Shell: waitpid 返回 Shell: tcsetpgrp(终端, 100) <- 拿回前台 Shell: 回到提示符
后台作业则不拿前台——它在后台跑,终端的前台始终是 Shell。这就是为什么后台进程读 stdin 会失败(终端不给它输入,反而发 SIGTTIN 让它停住)、为什么 Ctrl+C 不会杀到后台进程(信号只发给前台)。
⚠️ 难点预警:前台进程组的切换是作业控制里最容易绕晕的部分。新手常忘掉「让出前台」这一步,结果后台进程读 stdin 把 Shell 自己搞死,或者前台作业结束后 Shell 拿不回终端、提示符按了没反应。记住口诀:fork 前台作业后让出前台、它跑完后拿回前台——这两步必须成对出现。
tcsetpgrp调用还可能触发 SIGTTOU(后台进程试图写终端),Shell 要处理这个信号,否则自己被停住。
&后台运行最简单。Shell fork 子进程后,不调用 wait,直接回到提示符。子进程在后台继续跑,跑完时 Shell 怎么知道?靠 waitpid 的 WNOHANG 标志——Shell 在每一轮 Read 之前,非阻塞地 waitpid(-1, &status, WNOHANG) 查一查有没有后台子进程跑完了,有就收掉(防止它们变僵尸)。
伪代码:后台运行 if 命令带 "&": pid = fork() if pid == 0: setpgid(0, 0) // 子进程自成一组 exec(命令) else: 把这个作业加入「后台作业表」(记录 pid, pgid, 命令, 状态=Running) // 不 wait, 直接回到提示符 printf("[1] %d\n", pid) // 像 bash 那样打印作业号
后台作业跑完时,Shell 在下次 Read 前 waitpid(..., WNOHANG) 收掉它,并打印 [1]+ Done command——这就是你日常在 bash 里看到的那行提示。
bash 还维护一个「后台作业表」(jobs table),记录每个后台作业的:作业号(%1、%2)、PID/PGID、命令文本、状态(Running/Stopped/Done)。jobs 命令就是查这张表。fg %1、bg %1、kill %1 都通过作业号查到 PGID,再对整组发信号。
挂起的机制比后台微妙。当用户在前台命令运行时按 Ctrl+Z,终端检测到这个按键(它是终端的「挂起字符」,由 termios 结构体里的 VSUSP 字段定义),向前台进程组发 SIGTSTP(终端停止信号)。这个信号的默认动作是把进程暂停(进入 T 状态,Stopped),不是杀掉——进程还在内存里,只是不跑了,等着被 SIGCONT 唤醒。
Shell 这边在做什么?它在 waitpid 前台作业,用的是带 WUNTRACED 标志的版本:waitpid(pid, &status, WUNTRACED)。这个标志让 waitpid 在子进程「被信号停住」时也返回(否则 waitpid 只在子进程退出时返回)。所以流程是:
用户按 Ctrl+Z │ ▼ 终端检测到 VSUSP 字符, 向前台进程组发 SIGTSTP │ ▼ 前台进程收到 SIGTSTP, 暂停(进入 T 状态) │ ▼ Shell 的 waitpid(..., WUNTRACED) 返回, status 显示「被停住」 │ ▼ Shell: 把这个作业的状态改为 Stopped, 存入「后台作业表」 Shell: tcsetpgrp(终端, Shell 自己) <- 拿回前台 Shell: 打印 [1]+ Stopped command, 回到提示符
注意:挂起的作业没有死,它还在「后台作业表」里,状态是 Stopped。用户随时可以用 fg/bg 唤醒它。这也是为什么 Ctrl+Z 比 Ctrl+C 「温和」——前者只是暂停(可以恢复),后者是终止(无法恢复)。
💡 学习建议:SIGTSTP 和 SIGSTOP 不是一回事。SIGSTOP 是不可捕获、不可忽略的强停信号(调试器
gdb attach、kill -STOP用);SIGTSTP 是可捕获的「软停」(进程可以选择忽略或自己处理)。Ctrl+Z 发的是 SIGTSTP——这就是为什么有些程序(比如 vim)能自己接管 Ctrl+Z 的行为(把它变成「存到后台并保留状态」)。如果你想让 Ctrl+Z 真正不可阻挡,用kill -STOP pid。
fg/bgfg 和 bg 的差别在于「恢复后让不让它占前台」:
bg %1:给挂起的作业发 SIGCONT(让它继续跑),但不切换前台进程组——它继续在后台跑,Shell 立即回到提示符。fg %1:给挂起的作业发 SIGCONT,并且切换前台进程组到这个作业——Shell 阻塞 waitpid 等它,就像它是新启动的前台命令一样。伪代码:fg %n job = 查后台作业表, 找到第 n 号作业 kill(-job.pgid, SIGCONT) // 给整个进程组发 SIGCONT (负号 PGID 表示「发给整组」) tcsetpgrp(终端, job.pgid) // 把前台让给这个作业 waitpid(job.pid, &status, WUNTRACED) // 阻塞等(可能再被 Ctrl+Z 挂起) tcsetpgrp(终端, Shell 自己) // 拿回前台
注意 kill(-pgid, SIGCONT) 的负号——这是 kill 系统调用的语法:第一个参数为负数时,表示「发给这个 PGID 的所有进程」。一次调用就能唤醒整条管道里的所有进程,这正是进程组存在的意义。
kill 系统调用的目标参数语义: kill(pid, sig) -> 发给 PID 为 pid 的单个进程 kill(0, sig) -> 发给调用进程所在组的所有进程 kill(-pid, sig) -> 发给 PGID 为 pid 的进程组的所有进程 (作业控制用这个) kill(-1, sig) -> 发给调用进程有权限发的所有进程
Ctrl+C 的机制和 Ctrl+Z 同构:终端检测到中断字符(VINTR 字段),向前台进程组发 SIGINT(默认动作是终止进程)。Shell 的 waitpid 也会返回(子进程被信号杀死),Shell 收尸、拿回前台、回到提示符。
一个细节:Shell 自己不该被 Ctrl+C 杀掉(否则你按一下 Ctrl+C 整个 Shell 就退出了)。所以 Shell 启动时要忽略/屏蔽 SIGINT,只让它的子进程收到。这就是为什么你在 bash 里按 Ctrl+C 只会打断当前命令,不会退出 bash。具体做法:Shell 主进程 signal(SIGINT, SIG_IGN);fork 出的子进程要恢复默认 signal(SIGINT, SIG_DFL)——否则 Ctrl+C 也杀不掉命令。
伪代码:Shell 启动时屏蔽信号 int main(): signal(SIGINT, SIG_IGN) // Shell 自己忽略 Ctrl+C signal(SIGTSTP, SIG_IGN) // Shell 自己忽略 Ctrl+Z signal(SIGQUIT, SIG_IGN) // Shell 自己忽略 Ctrl+\ ... while (true): ... fork 子进程 ... 在子进程: signal(SIGINT, SIG_DFL) // 子进程恢复默认(能被 Ctrl+C 杀) signal(SIGTSTP, SIG_DFL) exec(...)
完整的作业控制需要一个数据结构来记住所有后台/挂起的作业——这就是「后台作业表」(jobs table)。每个条目记录:
struct Job { int job_id; // 作业号: %1, %2, ... pid_t pgid; // 进程组 ID (发信号用) char *command; // 命令文本 (jobs 命令显示用) enum { RUNNING, STOPPED, DONE } state; // 可选: 启动时间、是否当前作业 (+/- 标记) };
这张表是 jobs、fg %1、bg %1、kill %1 命令的统一数据源。维护它的几个要点:
[1]+ Done command 让用户看到,然后在下次 Read 之前清理。+/- 标记「当前作业」和「上一个作业」,裸敲 fg(不带参数)默认作用于当前作业。💡 学习建议:实现作业表时,先实现最简形态——一个固定大小的数组,每条记录 PID 和状态。
jobs命令遍历数组打印即可。等基本功能跑通,再考虑动态扩容、作业号复用、当前作业标记这些细节。
作业控制出问题时,肉眼很难看出「哪个进程在哪个组、状态是什么」。两个命令是调试的利器:
ps -j:显示进程的 PGID、SID、TTY、STAT(状态)。能看出你的子进程是不是正确归到了同一个 PGID,以及它们是不是 Stopped(T 状态)。jobs -l:bash 的内建命令,显示作业号、PID、状态、命令文本。$ sleep 100 | grep foo & [1] 12345 12346 $ ps -j PID PGID TTY STAT COMMAND 12345 12345 pts/0 S sleep 12346 12345 pts/0 S grep <- 注意 PGID 和 sleep 相同, 说明同组 12400 12400 pts/0 S+ bash <- "+" 表示在前台进程组
如果你的 Shell 实现作业控制后行为异常(信号发不到、终端混乱、僵尸堆积),第一时间用 ps -j 查 PGID 和状态——90% 的 bug 都能从这两个字段看出来。
作业控制复杂度较高,建议分四步递进,每步都是独立可用的能力:
第一步:实现 & 后台运行。最简单——fork 后不 wait,直接回到提示符。每轮 Read 前用 waitpid(-1, &status, WNOHANG) 非阻塞收掉已结束的后台子进程。验证:sleep 5 & 立即回到提示符,五秒后看到 [1]+ Done。
第二步:实现 Ctrl+C 不杀 Shell。Shell 启动时 signal(SIGINT, SIG_IGN),让 Shell 自己忽略 Ctrl+C。但 fork 出的子进程要恢复默认处理(signal(SIGINT, SIG_DFL)),否则 Ctrl+C 也杀不掉命令。验证:敲 sleep 100 然后 Ctrl+C,命令被杀、Shell 还活着。
第三步:实现进程组与前台切换。fork 子进程时 setpgid(0, 0) 让它自成一组;前台作业切换 tcsetpgrp,跑完拿回。验证:前台命令运行时,后台命令读 stdin 会被 SIGTTIN 停住(而不是抢走键盘)。
第四步:实现 Ctrl+Z 挂起与 fg/bg。用 waitpid(..., WUNTRACED) 检测挂起,kill(-pgid, SIGCONT) 恢复。维护一张「后台作业表」记录每个作业的 PGID、命令、状态。验证:跑 sleep 100,Ctrl+Z 挂起,bg 让它后台继续,fg 拉回前台。
⚠️ 难点预警:作业控制是本章最容易踩坑的部分。常见 bug 有三类:(1) 忘记
setpgid导致多个子进程不在同一组,信号发不全;(2) 忘记tcsetpgrp让出/拿回前台,导致终端混乱;(3) 忘记在 Shell 启动时忽略 SIGINT/SIGTSTP,结果 Shell 自己被信号杀掉。遇到问题时,用ps -j查看进程的 PGID 和状态,这是调试作业控制最有效的手段。
💡 学习建议:如果作业控制把你绕晕了,可以先跳过这一节,去做第 07 节(脚本能力)——脚本能力比作业控制简单得多,且更常用。等你对 Shell 的整体结构更熟,再回来啃作业控制。这一节是「锦上添花」,不是「非做不可」。一个没有作业控制但其他功能完整的 Shell,依然是个能用的 Shell。
& 后台运行的实现:fork 后不 wait,直接回到提示符;每轮 Read 前 waitpid(..., WNOHANG) 非阻塞收尾,防僵尸。waitpid(..., WUNTRACED) 检测到「被停住」。fg/bg 发 SIGCONT:唤醒挂起的作业;fg 还要 tcsetpgrp 切前台、Shell 阻塞等。kill(-pgid, sig) 的负号语义:负 PGID 表示「发给整组」——这是进程组存在的全部价值。& 后台:fork 后不 wait,waitpid(..., WNOHANG) 非阻塞收尾。验证 sleep 5 & 立即回提示符。signal(SIGINT, SIG_IGN),子进程恢复默认。验证 Ctrl+C 不杀 Shell。setpgid(0, 0)、tcsetpgrp 让出/拿回。验证后台读 stdin 会被停住。fg/bg:waitpid(..., WUNTRACED) 检测挂起,kill(-pgid, SIGCONT) 恢复。jobs 命令列出所有作业及其状态(Running/Stopped/Done),为 fg/bg 提供 %n 查询。下一节(给 Shell 加上脚本能力)是本章的收尾:让你的 Shell 支持变量(x=5、$x)、退出码($?)、条件(&&/||)。这一步把 Shell 从「交互式命令执行器」升级为「可编程脚本语言」,呼应《Linux 命令》第 9 章——那章教「用脚本」,这章教「造脚本能力」。