操作系统学习笔记 · 第 8 课 · 信号——进程之间的"通知"
你在终端跑着一个程序,眼看它卡住了,你下意识按下Ctrl+C——它停了。又或者,你发现某个进程怎么都杀不掉,于是掏出kill -9,它立刻毙命。这些"远程遥控"背后,靠的都是同一个机制:信号(Signal)。这一课,我们认识这个进程之间的"通知系统"。
从"按 Ctrl+C"说起
你正跑着一个死循环程序,屏幕上刷着一行行输出。你按 Ctrl+C,程序停了。
这一瞬间到底发生了什么?很简单:你的按键动作,让内核给前台进程发了一个"通知"——"请立刻中断"。 进程收到这个通知后,按约定好的方式响应(默认就是终止自己)。
这个"通知",就是信号。
8.1 信号是什么:异步的"一句话通知"
一句话理解:信号是内核(或别的进程)发给某个进程的"一句话通知",收到后进程按约定好的方式响应。它是异步的——随时可能来。
几个关键点,务必抓住:
- 信号不是函数调用。函数调用是"你主动去调、当场执行";信号是"别人突然塞给你、打断你当前正在做的事,然后才去处理"。这就是异步——它不打招呼,说来就来。
- 信号的发送方可以是内核(比如你
Ctrl+C,内核转发),也可以是别的进程(比如kill命令)。 - 信号本质是个整数编号。SIGINT 是 2,SIGKILL 是 9……内核记住"进程收到了几号信号"就行。
你可以这样类比:你正在写代码(进程在跑),突然手机响了(收到信号),你停下敲键盘去接电话(执行信号处理)。"手机响"这件事,和你写代码的节奏完全无关,随时可能打断你——这就是异步。
8.2 常用信号速查
信号有几十种,先记住这几个最常用的就够应付绝大多数场景:
| 信号 | 编号 | 含义 | 默认动作 |
|---|---|---|---|
SIGINT | 2 | Ctrl+C,中断当前程序 | 终止 |
SIGKILL | 9 | 强制杀死(不可捕获、不可忽略) | 终止 |
SIGTERM | 15 | 请求"请你自己退出" | 终止 |
SIGCHLD | 17 | 子进程状态变化(结束/停止) | 忽略 |
SIGSEGV | 11 | 段错误(非法内存访问) | 终止 |
几个值得特别点名的:
- SIGINT 就是
Ctrl+C背后的信号,你天天都在用; - SIGKILL 是"最后手段"——它不能被捕获、不能被忽略,说杀就杀;
- SIGTERM 是"礼貌的请求",给进程一个自我清理的机会;
- SIGSEGV 是你 C 语言写越界时见到的那个"段错误"(还记得第 3 课说异常和信号的关系吗?非法内存访问是异常,内核把它翻译成 SIGSEGV 信号发给进程);
- SIGCHLD 和上一课的"收尸"直接相关,下面单独讲。
8.3 三种处置方式
对每一个信号,进程可以选三种处置方式:
一句话理解:对每个信号,你可以「默认处置」「忽略(SIG_IGN)」「自定义 handler」。唯一例外是 SIGKILL 和 SIGSTOP——它们不能被捕获或忽略。
- 默认处置:不特别设置的话,按信号的"默认动作"来(多数是终止进程);
- 忽略(SIG_IGN):把这个信号当空气,收到也不理;
- 自定义 handler:自己写一个函数,收到信号时去执行它——就像设置"手机响了就执行
接电话()"。
在 C 里,用 signal 或更推荐的 sigaction 来设置:
signal(SIGINT, on_int); // 收到 SIGINT 时,执行我写的 on_int 函数注意一个铁律:
SIGKILL(9)和SIGSTOP(19)不能被捕获、也不能被忽略。
为什么?因为它们是系统最后的"保险丝"。如果连 SIGKILL 都能被进程忽略,那一个彻底死掉的进程,操作系统就没有任何办法终止它了——这会导致系统失控。所以内核强行规定:这两个信号,进程无权反抗。
这也解释了那句运维名言:能 kill 掉的就先 kill,kill -9 永远是最后手段。因为 kill(SIGTERM)是"礼貌请求",进程可以趁机清理资源、优雅退出;而 kill -9(SIGKILL)是"直接击毙",进程连擦屁股的机会都没有。
8.4 SIGCHLD 与"收尸"
还记得第 4、7 课反复提到的"僵尸进程"和 wait 吗?这里终于把最后一环补上了。
一句话理解:子进程结束时,内核会向父进程发一个 SIGCHLD 信号,通知"你的孩子走了"。父进程可以在 handler 里调 wait 收尸,避免僵尸进程堆积。回顾一下完整链路:
- 子进程结束,内核不马上彻底销毁它,而是保留它的"临终信息"(退出状态等);
- 内核同时给父进程发一个 SIGCHLD 信号——"醒醒,你孩子走了,来办后事";
- 父进程收到 SIGCHLD,在 handler 里调用
wait,把子进程的临终信息领走; - 子进程这才被彻底清理,不留僵尸。
如果父进程忽略了 SIGCHLD,又不去 wait,子进程就一直卡在僵尸(Z)状态。所以:
写高并发服务器时,正确处理 SIGCHLD 是关键——否则每个结束的请求子进程都变僵尸,PID 迟早被耗尽,服务器就再也开不出新进程了。
动手实验:C 版 + Python 对照
C 版(捕获 SIGINT,优雅退出):
// lesson08.c —— 捕获 SIGINT,优雅退出
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile int stop = 0;
void on_int(int sig) {
printf("\n收到 SIGINT(Ctrl+C),准备退出...\n");
stop = 1;
}
int main(void) {
signal(SIGINT, on_int); // 自定义处理 Ctrl+C
while (!stop) {
printf("工作中...(按 Ctrl+C 退出)\n");
sleep(1);
}
printf("已优雅退出\n");
return 0;
}编译运行:
gcc lesson08.c -o lesson08 && ./lesson08运行中按 Ctrl+C,注意观察:程序没有立刻被杀掉,而是打印"收到 SIGINT,准备退出...",然后优雅退出。这就是"自定义 handler"接管了默认的"终止"动作。
Python 对照版:
import signal, time
def on_int(sig, frame):
print("\n收到 SIGINT,准备退出...")
raise SystemExit(0)
signal.signal(signal.SIGINT, on_int)
while True:
print("工作中...(按 Ctrl+C 退出)")
time.sleep(1)Python 的 signal.signal 和 C 的 signal 一一对应——语言的壳在变,信号的底子不变。
深入点:两个进阶的"坑"
① 可重入(reentrant):handler 里别乱调函数
这是信号处理里最容易踩的雷。你的 handler 可能在任何时刻被调用——包括主程序正卡在 printf、malloc 这些函数执行到一半的时候。
如果 handler 里又去调 printf、malloc,就可能出现重入问题:主程序刚进 printf 拿了把内部锁,还没释放,信号来了,handler 里又调 printf 想去拿同一把锁——死锁。
一句话理解:handler 里只能安全调用「异步信号安全」的函数(如write),printf/malloc不安全——信号可能打断它们导致死锁。
所以严谨的信号处理代码里,handler 通常只做最简单的事:设一个标志位(比如我们的 stop = 1),把真正的处理留到主循环里去做。这也正是我们实验代码里 volatile int stop 的用意。
② 信号与系统调用中断(EINTR)
另一个坑:当进程正阻塞在一个"慢系统调用"(比如 read 等键盘输入、accept 等网络连接)上时,信号来了,这个系统调用可能被打断,返回一个 EINTR 错误。
这不是真的出错,而是"假失败"——它其实是在说:"你等的事还没来,但我被信号打断了,你先去处理信号吧。" 健壮的代码要识别 EINTR,选择重试或跳出,而不是把 EINTR 当致命错误直接崩溃。
这背后其实呼应了第 3 课的"异常"和第 4 课的"阻塞态":信号一来,进程从"阻塞"被拉回"就绪",被阻塞的系统调用自然就被打断了。
小结与思考题
这一课,我们认识了进程之间的"通知系统"——信号:
- 信号是异步的"一句话通知",随时可能打断进程,这和"函数调用"本质不同;
- 常用信号:SIGINT(2)、SIGKILL(9)、SIGTERM(15)、SIGCHLD(17)、SIGSEGV(11);
- 三种处置:默认 / 忽略 / 自定义 handler,但 SIGKILL 和 SIGSTOP 不可捕获、不可忽略;
- SIGCHLD + wait 是"收尸"的标准组合,是防止僵尸进程的关键。
留三个问题:
- 信号和"函数调用"最本质的区别是什么?(提示:异步)
- 为什么 SIGKILL 不能被捕获或忽略?
- 僵尸进程和哪个信号、哪个系统调用相关?
到这里,我们手里已经有了一大把"零件":进程、线程、fd、fork/exec、信号。下一课,我们要让这些"孤岛"彼此说话——进程之间怎么交换数据?答案是进程间通信(IPC),而其中最经典的,就是你天天用的那个 |——管道。