第 2 章 · 06 作业控制:后台、挂起与信号


文档摘要

第 2 章 · 06 作业控制:后台、挂起与信号 本节摘要:到目前为止,你的 Shell 每敲一条命令都要等它跑完才能回到提示符——但真实 Shell 不这样: 能让命令后台跑、Ctrl+Z 能把前台命令挂起、 / 能恢复它。这一节是本章的另一个难点,讲清作业控制(job control)的本质——Shell 用信号管理一组子进程。你要理解三个东西:进程组(把一条管道里的多个进程打包成一个调度单位)、终端的前台进程组(终端把 Ctrl+C/Ctrl+Z 发给谁)、以及 SIGTSTP/SIGCONT 这两个让进程「暂停/恢复」的信号。呼应《Linux 命令》第 6 章的「信号」——那章讲「信号怎么用」,这章讲「Shell 怎么靠信号管理作业」。

第 2 章 · 06 作业控制:后台、挂起与信号

本节摘要:到目前为止,你的 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」等)。

学习目标

阅读完本节,你应当能够:

  1. 解释「作业」(job)与「进程」的差别:一条管道 A | B | C 是三个进程,但构成一个作业——它们被统一调度。
  2. 说清进程组(process group)的作用:把一条作业里的多个进程归到同一个 PGID,便于 Shell 和终端统一发信号。
  3. 实现 & 后台运行:fork 后父进程不 wait,直接回到提示符,子进程在后台跑。
  4. 实现 Ctrl+Z 挂起:终端向前台进程组发 SIGTSTP,进程暂停,Shell 用 waitpidWUNTRACED 检测到「子进程被停住」。
  5. 实现 fg/bg 恢复:发 SIGCONT 让挂起的作业继续跑,fg 还要把它拉回前台进程组。
  6. 理解「前台进程组」概念:终端同一时刻只把输入给一个进程组——前台作业独占键盘,Ctrl+C/Ctrl+Z 也只发给它。
  7. 说清 Shell 自己必须屏蔽 SIGINT/SIGTSTP 的原因——否则一按 Ctrl+C/Ctrl+Z 就退出 Shell。

一、学习价值:从「一次一条」到「同时管多条」

到目前为止你的 Shell 是「阻塞式」的:敲一条命令,Shell fork 子进程、wait 它跑完、才回到提示符。这对绝大多数命令没问题——ls 一秒就跑完。但设想这些场景:

  • 你敲 make build,它要跑五分钟。你不想干等。
  • 你跑了个长时间的服务器 python -m http.server,你想一边用它一边干别的。
  • 你跑 find / 找文件,跑了十秒你发现参数写错了,想立刻打断它。
  • 你在编辑器里写代码,临时想切到终端跑个命令,但不想退出编辑器——于是 Ctrl+Z 挂起它,跑完终端命令再 fg 回去。

这四个场景分别对应作业控制的四大能力:

  • 后台运行 &:让命令在后台跑,Shell 立即回到提示符。
  • 挂起 Ctrl+Z:让正在前台跑的命令暂停(不是杀掉),稍后可以恢复。
  • 恢复 fg/bg:把挂起的作业拉回前台(fg,Shell 再次阻塞等它)或放后台继续(bg)。
  • 打断 Ctrl+C:直接终止前台命令(发 SIGINT)。

这些能力的底层是同一套机制:信号 + 进程组。Shell 通过向特定的进程组发信号,实现对一组子进程的统一控制。这套机制不只造 Shell 用得到——它是 Unix 「作业调度」的通用模型,任何需要管理多个并发子进程的工具(容器运行时、CI 系统、构建工具、进程管理器 supervisord)都在变相使用它的思想。

更重要的是,理解作业控制,你才真正理解《Linux 命令》第 6 章讲的「信号」是怎么在真实系统里工作的——信号不是孤立的系统调用,它是终端、Shell、进程三方协作的纽带。

关键概念:作业控制的本质,是把「Shell 和它启动的所有子进程」组织成多个「作业」,每个作业是一个进程组,Shell 通过向进程组发信号(暂停/恢复/终止)来统一管理整组进程。终端也参与这套协作——它把键盘信号(Ctrl+C/Ctrl+Z)只发给当前的前台进程组。

二、子系统拆解:进程组、信号、前台

核心概念:进程组

为什么需要进程组?考虑 find / | grep foo | wc -l &——这是一条后台管道,涉及三个进程。Shell 要把整条管道当作一个作业来管理:挂起它要同时挂三个、杀它要同时杀三个、恢复它要同时恢复三个。如果一个一个发信号,会很麻烦还可能漏——比如挂起了 findgrep 还在跑,管道就会卡死。

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)。它决定了:

  • 键盘输入给谁:终端只会把输入送给前台进程组,后台进程读 stdin 会收到 SIGTTIN 被停住。
  • Ctrl+C/Ctrl+Z 发给谁:终端检测到这些按键,会向前台进程组发 SIGINT/SIGTSTP。

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 怎么知道?靠 waitpidWNOHANG 标志——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 %1bg %1kill %1 都通过作业号查到 PGID,再对整组发信号。

挂起 Ctrl+Z

挂起的机制比后台微妙。当用户在前台命令运行时按 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+ZCtrl+C 「温和」——前者只是暂停(可以恢复),后者是终止(无法恢复)。

💡 学习建议:SIGTSTP 和 SIGSTOP 不是一回事。SIGSTOP 是不可捕获、不可忽略的强停信号(调试器 gdb attachkill -STOP 用);SIGTSTP 是可捕获的「软停」(进程可以选择忽略或自己处理)。Ctrl+Z 发的是 SIGTSTP——这就是为什么有些程序(比如 vim)能自己接管 Ctrl+Z 的行为(把它变成「存到后台并保留状态」)。如果你想让 Ctrl+Z 真正不可阻挡,用 kill -STOP pid

恢复 fg/bg

fgbg 的差别在于「恢复后让不让它占前台」:

  • 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+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(...)

后台作业表:Shell 的「任务清单」

完整的作业控制需要一个数据结构来记住所有后台/挂起的作业——这就是「后台作业表」(jobs table)。每个条目记录:

struct Job { int job_id; // 作业号: %1, %2, ... pid_t pgid; // 进程组 ID (发信号用) char *command; // 命令文本 (jobs 命令显示用) enum { RUNNING, STOPPED, DONE } state; // 可选: 启动时间、是否当前作业 (+/- 标记) };

这张表是 jobsfg %1bg %1kill %1 命令的统一数据源。维护它的几个要点:

  • 作业号分配:新作业取「当前最大作业号 + 1」;作业结束后,它的号会被复用(不会无限增长)。
  • 状态迁移:Running → Stopped(收到 SIGTSTP)、Stopped → Running(收到 SIGCONT)、任何状态 → Done(进程退出)。
  • Done 的清理:Done 状态的作业不能立即从表里删——要先打印 [1]+ Done command 让用户看到,然后在下次 Read 之前清理。
  • 当前作业标记:bash 用 +/- 标记「当前作业」和「上一个作业」,裸敲 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 都能从这两个字段看出来。

三、上手第一步:从「不 wait」到完整作业控制

作业控制复杂度较高,建议分四步递进,每步都是独立可用的能力:

第一步:实现 & 后台运行。最简单——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。

本节要点回顾

  1. 作业 = 进程组:一条管道里的多个进程归到同一个 PGID,Shell 给整个组发一次信号就能控制全部。
  2. & 后台运行的实现:fork 后不 wait,直接回到提示符;每轮 Read 前 waitpid(..., WNOHANG) 非阻塞收尾,防僵尸。
  3. Ctrl+Z 发 SIGTSTP:终端向前台进程组发信号,进程暂停(不死亡);Shell 用 waitpid(..., WUNTRACED) 检测到「被停住」。
  4. fg/bg 发 SIGCONT:唤醒挂起的作业;fg 还要 tcsetpgrp 切前台、Shell 阻塞等。
  5. 前台进程组:终端同一时刻只把输入和 Ctrl+C/Ctrl+Z 发给一个进程组;Shell fork 前台作业要让出前台、跑完要拿回。
  6. Shell 自己要忽略 SIGINT/SIGTSTP:否则一按 Ctrl+C/Ctrl+Z 就退出 Shell;但子进程要恢复默认处理。
  7. kill(-pgid, sig) 的负号语义:负 PGID 表示「发给整组」——这是进程组存在的全部价值。

推荐上手顺序

  1. 先实现 & 后台:fork 后不 wait,waitpid(..., WNOHANG) 非阻塞收尾。验证 sleep 5 & 立即回提示符。
  2. 让 Shell 忽略 SIGINT:启动时 signal(SIGINT, SIG_IGN),子进程恢复默认。验证 Ctrl+C 不杀 Shell。
  3. 加进程组与前台切换:setpgid(0, 0)tcsetpgrp 让出/拿回。验证后台读 stdin 会被停住。
  4. 实现 Ctrl+Z 与 fg/bg:waitpid(..., WUNTRACED) 检测挂起,kill(-pgid, SIGCONT) 恢复。
  5. 维护后台作业表:jobs 命令列出所有作业及其状态(Running/Stopped/Done),为 fg/bg 提供 %n 查询。

下一节(给 Shell 加上脚本能力)是本章的收尾:让你的 Shell 支持变量(x=5$x)、退出码($?)、条件(&&/||)。这一步把 Shell 从「交互式命令执行器」升级为「可编程脚本语言」,呼应《Linux 命令》第 9 章——那章教「用脚本」,这章教「造脚本能力」。


发布者: 作者: 灏天文库 转发
评论区 (0)
U