恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Linux进程信号机制全解析:从原理到多线程实践
首页
资讯中心
/
Linux进程信号机制全解析:从原理到多线程实践
Linux进程信号机制全解析:从原理到多线程实践
发布时间:2026/10/13 2:40:44
先问个问题你在生产环境里有没有遇到过这种场景——程序运行得好好的突然就退了日志里什么都没留下或者某个后台进程莫名其妙没了但你觉得它“不应该会退出”。如果你第一反应是去看业务逻辑、查代码死循环那我建议你先查一下信号。为什么因为在 Linux 世界里进程的生死很多时候根本不是由代码逻辑决定的而是由信号决定的。Linux进程信号是整个系统通知进程“有事发生”的核心机制也是排查很多诡异问题的第一入口。不管你做后端、做嵌入式、还是做运维理解信号机制都绕不开而且这块东西如果不深入总会在某个凌晨两点的大规模故障里等着你。这篇文章我会从一个实际的排查过程切入把信号的产生、传递、处理、阻塞再到多线程环境下的特殊行为全部拆开讲一遍。重点不是背 API而是搞清楚信号这玩意在系统里到底是怎么流动的以及我们平时写的代码里哪些“习惯性写法”其实正在埋雷。适合所有在 Linux 下写 C/C、Go 或者做运维的朋友新手也能跟着走一遍。1. 信号是“软件的异常”但它比硬件异常更常见先想一个问题当一个进程访问了非法内存地址接下来发生的是什么你可能会说“段错误”但更底层的事实是——CPU 发现这个访问有问题操作系统内核收到这个异常之后向进程发送一个 SIGSEGV 信号。进程如果没有捕获这个信号默认动作就是终止并生成 core 文件。所以信号可以理解成是内核向进程发出的一种通知“你摊上事了自己看着办”。只不过这个“事”的来源特别多不只是硬件异常还有可能是另一个进程、一个终端按键、甚至是你自己。1.1 从一个“程序突然没了”的现场说起有一次我们有个数据处理模块运行在凌晨的定时任务里时不时就丢两个任务没有任何日志输出。因为任务是被一个守护进程统一拉起的我们第一反应是看守护进程的日志结果什么都没有。后来在系统日志里看到一个关键句子Out of memory: Kill process 12345 (data_worker) score 450 or sacrifice child瞬间明白了——这不是业务代码崩了是内核的 OOM Killer 直接把进程处决了而它处决的方式就是发一个 SIGKILL 信号。SIGKILL 是什么级别它不可捕获、不可阻塞、不可忽略一旦发给进程内核直接终结它。所以你那套优雅退出的逻辑、清理临时文件的钩子全都没机会执行。从这件事我学到的第一个教训是排查程序莫名退出第一步永远先看信号。看哪个信号、谁发的、为什么发比一头扎进代码里靠谱得多。怎么查最直接的方式就是用-1参数拉起进程比如./data_worker 然后随时看cat /proc/pid/status | grep -E Sig(Blk|Ign|Cgt|Pnd)这里面的信息量很大SigBlk 是当前阻塞的信号集合SigIgn 是被忽略的信号集合SigCgt 是已经注册了处理函数的信号集合SigPnd 是有待处理信号集合。你甚至不用写代码就能知道这个进程目前在信号方面是什么状态。1.2 信号是从哪来的不同来源决定你怎么排查信号可以产生自硬件也可以产生自软件还可以产生自终端。它们的来源决定了我们排查问题的方向。硬件来源最常见的就是非法内存访问、除零、非法指令。这类信号往往意味着代码里的逻辑 bug比如野指针、数组越界、未初始化变量参与运算。排查方向自然是内存检查和代码审查工具上可以用 AddressSanitizer 或者 Valgrind 一类的东西帮忙定位。软件来源就五花八门了。可能是其他进程用 kill 系统调用主动发过来的可能是你调用 raise 或 abort 自己给自己发的也可能是定时器到期之后内核帮你发一个 SIGALRM。这一类信号一般代表着某种“协作”比如主进程通知子进程重新加载配置、用户手动让某个服务优雅退出。终端来源就是我们常见的 CtrlC 发送 SIGINT、CtrlZ 发送 SIGTSTP、Ctrl\ 发送 SIGQUIT。注意这里有一个细节当你按下 CtrlC 的时候不是内核直接发给进程而是终端的驱动层收到这个按键之后把它转成信号发送给前台进程组。所以一个进程能不能收到这个信号取决于它是不是前台进程以及它所在的进程组是哪个。1.3 常见的信号表默认动作必须心里有数信号有一大堆没必要全背但常见的那十来个你最好烂熟于心。我列了一个整理过的表格加粗的是默认动作方便你对照信号编号默认动作常见触发场景SIGINT2终止进程终端 CtrlCSIGQUIT3终止并生成 core终端 Ctrl\SIGKILL9终止进程不可捕获kill -9SIGSEGV11终止并生成 core非法内存访问SIGPIPE13终止进程写管道但没有读者SIGALRM14终止进程alarm/setitimer 定时器到期SIGTERM15终止进程默认 kill 命令发送SIGCHLD17忽略子进程暂停或终止SIGCONT18继续执行继续被暂停的进程SIGSTOP19停止进程不可捕获kill -STOPSIGTSTP20停止进程CtrlZSIGUSR110终止进程自定义用途SIGUSR212终止进程自定义用途有几个容易搞混的地方值得单独说一下。SIGPIPE 这个很多人忽略但它其实非常阴险。你在一个网络服务进程里往一个已经关闭的 socket 写数据第一次写可能没事因为数据先进了缓冲区等内核真正尝试发送时发现对端已经关闭这个时候就会收到 SIGPIPE。如果你没处理它进程直接退出这就是很多 HTTP 服务莫名其妙“闪退”的原因之一。Nginx 这类高并发服务器在自己的事件循环里都是显式忽略或专门处理 SIGPIPE 的。SIGCHLD 的默认动作是忽略但请注意“忽略”不等于“不通知”。子进程退出时父进程会收到 SIGCHLD不需要主动去接受什么这个是自动的。只不过默认没人管它子进程就会变成僵尸进程直到父进程调用 wait/waitpid 回收。这块内容后面我会专门讲。还有 SIGCONT 和 SIGSTOP 这对互相配合的信号。SIGSTOP 可以把进程冻结SIGCONT 可以让它恢复。但有意思的是SIGSTOP 和 SIGKILL 一样都不能被捕获或阻塞——内核不让进程自己挡住“冻结”和“处决”这两个权力。这里给新手一个最简单的心法默认动作就是“不写任何代码时的行为结果”。你只需要记住哪些信号默认让你退出、哪些默认让你暂停、哪些默认当作没看见排查问题时就心里有谱了。2. 捕获信号不是“写个回调函数”那么简单sigaction 是唯一推荐入口如果你在进程里想对某个信号做自定义处理标准姿势是注册一个信号处理函数handler。C 语言里有老掉牙的 signal() 函数和现代的 sigaction() 函数国内很多教材还在讲 signal()但我在实际工程里几乎从来不用它。为什么因为 signal() 的行为在不同系统、不同版本上存在历史遗留差异特别是它处理“信号到达时当前正在执行另一个处理器”的情况时细节特别容易踩坑。2.1 signal() 与 sigaction() 的核心差异signal() 的经典问题之一是某些系统在处理器执行完毕后会把信号处理方式重置为默认行为。这意味着如果同一个信号连续来两次第二次可能直接触发默认动作——进程退出。更要命的是signal() 在处理信号期间并不会自动阻塞同类信号的再次到达。也就是说你的处理器刚执行到一半同一个信号又能打断你一次形成递归触发。这在真实项目里是极其危险的处理器的栈很容易被打爆或者逻辑出现不可预期的情况。而 sigaction() 通过一个结构体把什么都配置清楚了处理器函数、处理期间要额外阻塞哪些信号、是否需要 SA_RESTART 让被中断的系统调用自动重启以及一些更细的标志位。我把两者的核心行为差异做成一个对比行为维度signal()sigaction()处理器执行期间自动阻塞同类信号不能保证通过 sa_mask 明确控制处理器执行后是否重置为默认行为部分系统会重置不会重置被信号中断的系统调用是否自动重启不确定取决于系统通过 SA_RESTART 显式控制扩展信息如发送者 PID、用户态/内核态上下文拿不到可通过 SA_SIGINFO 获取线程安全性不好说线程安全POSIX 标准化所以结论就一个新代码一律使用 sigaction()。signal() 只适合在非常简单的脚本级程序里凑合一下工程代码用它会给自己埋坑。2.2 sigaction() 最小的完整用法我直接给一段可以照着用的代码风格上我习惯把这些信号处理的代码封装成一个独立模块。#include stdio.h #include signal.h #include string.h #include unistd.h static volatile sig_atomic_t g_flag 0; static void handle_signal(int sig) { // 在信号处理函数里保证只做异步信号安全的事 g_flag sig; } int main(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handle_signal; // 处理器执行期间阻塞 SIGINT 的再次到达 sigemptyset(sa.sa_mask); sigaddset(sa.sa_mask, SIGINT); // 让被信号中断的 read/accept 等系统调用自动重启 sa.sa_flags SA_RESTART; if (sigaction(SIGINT, sa, NULL) -1) { perror(sigaction); return 1; } printf(PID: %d, 按 CtrlC 试试\n, getpid()); while (1) { pause(); // 进程挂起直到收到信号 if (g_flag SIGINT) { printf(收到 SIGINT做清理工作...\n); break; } } return 0; }这段代码里有几个细节不是随便写的。volatile sig_atomic_t类型不是随便用的普通 int。它保证读写是原子操作并且告诉编译器不要在优化时对这块内存做不合理的假设——这在处理器和主循环之间共享标志位时是必须的。sigemptyset先把掩码集合清零再sigaddset往里面加信号这个顺序要养成习惯不要直接往一个未初始化的集合里加信号。SA_RESTART这个标志简单说就是让某个被信号打断的系统调用比如在read一个慢速设备时收到信号在信号处理完之后自动从头重试而不是返回一个 EINTR 错误。设不设它直接影响你程序里的 I/O 代码要不要额外判断errno EINTR。2.3 SA_RESTART 和 EINTR无数人栽在这里我见过一个实际案例某公司的网络代理模块在接到 SIGTERM 之后想优雅退出——先停止接收新连接再把已有的连接慢慢处理完。结果程序在退出过程中主线程明明阻塞在accept()上收到信号之后accept()却直接返回了 -1错误码是 EINTR。代码里没有对这个情况做处理于是程序就把“没有新连接”当成“连接出错”直接异常退出了。为什么会这样因为默认情况下某些慢速系统调用比如accept、read、write、wait在等待过程中如果被信号打断不会自动恢复执行而是返回 -1同时置errno为 EINTR。如果你的代码对这种情况没有像下面这样处理就以为系统出错而退出// 常见做法一遇到 EINTR 就重试 while (1) { n accept(fd, (struct sockaddr*)addr, len); if (n 0 errno EINTR) { continue; // 被信号打断重试 } if (n 0) { perror(accept); break; } break; }而设置SA_RESTART就是让内核帮你在多数情况下自动重试省去这层判断。但这里有一个非常关键的坑不是所有系统调用都支持 SA_RESTART 重启。像select、poll、epoll_wait、sigsuspend、nanosleep、clock_nanosleep、sem_wait这类函数即使设置了 SA_RESTART依然会在被信号打断时返回 EINTR。为什么因为它们设计之初就不确定“继续等待”和“从等待中恢复”哪个更合理所以把决定权交回给调用者。这也就是说你写 I/O 密集型的服务时任何阻塞系统调用最好都做好 errno EINTR 的处理不管你设不设 SA_RESTART。我一直要求自己代码里每一个涉及阻塞等待的地方都必须明确回答一个问题如果这里收到一个信号进程应该怎么办是重试、继续等待还是立刻退出想清楚再写代码。2.4 信号处理器里能不能用 printfasync-signal-safe 函数清单这是新手最常见的疑惑既然处理器函数是 C 语言写的里面总该可以随便调用库函数吧答案是大写的“不行”。信号处理函数可能在一个完全不安全的时机插入执行——比如正在调malloc正在操作某个全局链表正在执行 printf 内部的缓冲操作。如果处理器里也调用了这些函数轻则数据损坏重则死锁、崩溃。POSIX 标准给出了一个 async-signal-safe 函数清单意思是这些函数在信号处理函数中使用是安全的常见的包括write、read、open、close、osync族、sigaction、_exit等。要注意的是printf、malloc、free、pthread 系列函数都不在这个清单里。所以工程上的做法通常是处理器里只设置标志位volatile sig_atomic_t主循环发现标志位置位之后再去做实际的清理工作、打印日志、释放资源如果实在需要在处理器里做日志用write直接往文件描述符里写字符串不要用printf。我自己用过一个比较取巧的办法处理器里write往一个自建的管道写一个字节主循环在 epoll/select 里监听这个管道的读端。信号一来管道的读端变可读主循环自然被唤醒然后可以安全地做任何事。这就是经典的“信号自管道”self-pipe trick很多开源项目里都能见到兼顾了安全和简单。3. 阻塞、未决与排队标准信号和实时信号的本质差异很多人对信号的认知只停留在“捕获和忽略”这两个层面但真正让信号机制复杂的是“阻塞”和“未决”这两个状态。你哪怕不写任何处理代码内核也会为你维护两套信号状态集合理解它们的意义你才真正理解信号。3.1 阻塞集合与未决集合信号被“扣下”的过程每个进程都有两个关键的信号状态集合阻塞集合blocked set和未决集合pending set。阻塞集合也叫信号掩码signal mask它表示“哪些信号我不想现在处理”。注意阻塞和忽略完全是两回事。忽略是接收到了但当作没看见阻塞是接收到了但先扣在内核的队列里等到解除阻塞那一刻再补发给你。未决集合就是内核帮你“扣下”的信号集合。当你调用sigprocmask或pthread_sigmask把某个信号加入阻塞集合后面这个信号来了内核不会调用它的处理器而是先把信号挂到该进程的 pending 集合里。直到你解除阻塞或者显式地sigsuspend去等待信号才会被“补送”。一句话总结这个管线信号产生 →检查是否阻塞→ 未决集合 →解除阻塞或等待→ 递送到处理器你可以在/proc/pid/status文件里直接查看进程当前的这两个集合SigBlk: 0000000000000000 SigIgn: 0000000000000000 SigCgt: 0000000000000100 SigPnd: 0000000000000000右边的十六进制数按位展开每一位代表一个信号。这组数字对排查问题特别有用——如果一个进程卡住了而 SigPnd 里某一位不为零就说明有个信号已经送到了但被阻塞着始终没人处理。这通常是死锁的前兆。3.2 标准信号的丢失问题和实时信号的排队与数据携带这里有个非常非常重要的事实标准信号是“不排队”的。什么意思如果同一个信号在一个进程还没来得及处理的情况下被连续发送了十次内核的 pending 集合只是一个位图每个标准信号只占一位。所以十次发送在进程“看”来只相当于一次中间的九次全都合并、丢失了。这个问题在进程里有多个相同来源的信号源时会显得特别明显。比如你用setitimer定时器每隔 50ms 发一次 SIGALRM但你的处理器一次就要耗时 200ms 才能跑完那么在你处理的过程中新到的 SIGALRM 就会被合并成“还有一次”等当前这轮处理完再补一次再来的继续合并。Linux 解决这个问题的方法是引入了实时信号Realtime Signal。实时信号的编号范围是从 SIGRTMIN 到 SIGRTMAX在主流 Linux x86-64 上一般是 34 到 64。实时信号是可以排队的而且每个实时信号实例还支持携带一个整数或者指针值发送方用sigqueue系统调用把数据一块发过去接收方用 SA_SIGINFO 标志注册处理器从siginfo_t结构里把数据取出来。所以当你面临“需要可靠地发送频繁事件并携带数据”的场景时标准信号是力不从心的实时信号才是正解。不过实时信号数量有限从 34 到 64 一共 31 个某些架构可能略有差异用之前要规划好分配别贪心。3.3 阻塞和解除阻塞的实际代码千万别忘在 fork 之后重置掩码阻塞信号最常见的场景是程序启动早期很多资源还没初始化完成不希望某个信号打断初始化流程于是先把一组信号阻塞起来等初始化完成再解除。sigset_t newmask, oldmask; sigemptyset(newmask); sigaddset(newmask, SIGTERM); // 阻塞 SIGTERM sigprocmask(SIG_BLOCK, newmask, oldmask); // 记录旧掩码 // 这里做初始化工作即使有人发 SIGTERM 也不会立刻中断 sigprocmask(SIG_SETMASK, oldmask, NULL); // 恢复旧掩码这段代码本身不复杂真正复杂的是它的传播问题。如果这个进程里还调用了fork那么子进程会继承父进程当前的信号掩码包括你刚设的各种阻塞。如果你在子进程里没有恢复掩码而子进程恰好是个业务执行体它可能对其他进程发来的 SIGTERM 毫无反应甚至无法正常退出。正确做法是在fork之后的子进程中立刻判断是否要恢复默认掩码或者干脆用sigprocmask把掩码重置。这里我提供一个工程建议凡是会派生子进程的程序在子进程执行任何非 trivial 业务之前把信号掩码恢复为全空或明确设为已知状态否则一个“干净的”子进程可能带着一堆不合理的阻塞信号开始干活。3.4 sigsuspend原子地等待信号到达还有一个接口值得专门说——sigsuspend。它做的事情是用给定的掩码替代当前进程的阻塞集合然后挂起进程直到一个未被阻塞的信号到达并触发处理器之后恢复原来的掩码。为什么需要这个因为如果你按顺序分两步走先sigprocmask(SIG_SETMASK, mask)再pause()中间有一个时间窗口信号可能会在两者之间到达——你可能在恢复掩码之前就把这个信号错过了。而sigsuspend把“设置掩码并挂起等待”变成一个原子操作不会漏信号。我在写一个需要串行处理信号的应用时经常会用这样的模式把所有要处理的信号阻塞住然后在一个循环里调用sigsuspend等待信号到达后立即恢复阻塞再执行处理逻辑做完之后再次调用sigsuspend。这样能保证同一时刻最多只有一个信号处理器在执行规避了大量重入和并发问题。4. 多线程程序里的信号发的究竟是“进程”还是“线程”这部分是真正容易把很多人绕晕的地方也是面试里问到 Linux 进程信号时最常见的进阶题点。多线程环境下信号不再只是“发给进程”那么简单。你得搞清楚信号到底是发给了进程还是发给了线程谁会收到谁有资格处理4.1 进程定向信号与线程定向信号按 POSIX 的说法信号既可以是进程定向的也可以是线程定向的。最常见的kill(pid, sig)发送的就是进程定向信号。对内核来说进程其实是“线程组”它会把信号投递给该线程组中某个满足条件的线程。什么样的线程算满足条件首先该线程没有阻塞这个信号如果有多个线程都没阻塞内核一般选择其中一个——通常是主线程但这不是绝对的。而pthread_kill(pthread_t, sig)发送的是线程定向信号它明确地指定发给那个线程。注意线程定向信号也会触发默认动作比如你发给某个线程 SIGTERM如果它没处理那整个进程都会终止不要以为“只终止这个线程”。那内核如何决定信号发给谁判断优先级大致如下对于进程定向信号内核先检查所有线程的掩码去掉阻塞了这个信号的线程从剩余线程中选一个投递如果所有线程都阻塞了这个信号就放到进程的 pending 信号队列里然后哪个线程一旦解除阻塞信号就投递给谁。这个设计带来的实际问题是信号处理器的执行线程是不确定的。你无法保证它一定在主线程或其他特定线程里执行。如果你的处理器函数访问了某些“线程专属数据”比如 TLS、线程局部存储或者在一个持有锁的线程里操作了其他线程的资源就会出现极其隐蔽的并发问题。4.2 pthread_sigmask线程级信号掩码的隔离多线程程序里每个线程都有自己的信号掩码。这个掩码是用pthread_sigmask设置的不是sigprocmask。sigprocmask在多线程环境下的行为是未定义的POSIX 明确要求多线程程序使用pthread_sigmask。进程序号线程各自的掩码之间是什么关系当发送一个进程定向信号时内核检查所有线程的掩码找到一个不阻塞它的线程投递。但如果某个线程通过pthread_sigmask阻塞了这个信号而其他线程没有阻塞那么这个信号仍然会被其他线程接收——也就是说一个线程的阻塞并不会影响信号被整个进程“收到”。反过来如果你想确保某个信号只被特定线程处理一个通用做法是在进程启动早期、其他线程还没有创建之前就用pthread_sigmask把要处理的信号全部设置成阻塞。这样后来创建的每一个线程都会继承这个阻塞掩码。然后你单独创建一个“信号处理线程”在这个线程里先解除阻塞再通过sigsuspend或者sigwait专门等信号。这样信号只有这一个线程收得到处理逻辑天然地串行化。4.3 工程上推荐的“专职信号处理线程”模式我自己在写网络服务程序时很喜欢用这个模式把信号处理和主业务逻辑彻底隔离。基本步骤如下在main函数最开始任何线程创建之前用pthread_sigmask阻塞所有关心的信号比如 SIGTERM、SIGINT、SIGUSR1、SIGCHLD这样所有后续线程都会继承这个阻塞掩码创建一个线程专门调用sigwait或sigsuspend等待信号当信号到达时由这个专职线程收到并处理主线程和其他工作线程完全不受信号干扰如果需要优雅停机专门线程把“停止服务”的标志位置位或者通过管道/队列通知工作线程退出然后主线程等待所有子线程结束后再退出。sigwait接口的好处在于它不需要注册信号处理器直接以返回值形式给出收到的信号编号。整个程序里信号到了它这里被当成普通事件处理完全绕开了“处理器函数里不能随便调用非异步安全函数”的限制。我写过一个简化的示例示意这种模式的核心结构#include pthread.h #include signal.h static sigset_t g_wait_set; static void *signal_thread(void *arg) { int sig; while (1) { if (sigwait(g_wait_set, sig) ! 0) { continue; } if (sig SIGTERM) { // 收到终止信号通知业务线程退出 // 这里可以安全地调用各种库函数 } else if (sig SIGUSR1) { // 自定义重载配置 } } return NULL; } int main(void) { sigemptyset(g_wait_set); sigaddset(g_wait_set, SIGTERM); sigaddset(g_wait_set, SIGINT); sigaddset(g_wait_set, SIGUSR1); pthread_sigmask(SIG_BLOCK, g_wait_set, NULL); pthread_t tid; pthread_create(tid, NULL, signal_thread, NULL); // 启动业务线程... // 主线程可继续做别的事或直接 pthread_join 等待 return 0; }这个模式让我少踩了很多坑。信号处理不用再躲躲闪闪地避讳 malloc、printf 这些库函数因为sigwait是普通函数在信号线程的上下文里调用它们是安全的。代价就是你需要额外起一个线程并且必须在所有线程创建前就设好阻塞掩码——这个时机非常关键错过了某个早期线程可能已经创建并继承了默认掩码。4.4 信号对多线程同步原语的影响EINTR 的线程版在多线程环境里像pthread_mutex_lock这类锁操作被信号打断的情况比较少见因为 POSIX 规范不要求 pthread 锁在被信号打断时产生 EINTR。但pthread_cond_wait的情况不同。条件变量等待可能被信号打断并返回 EINTR虽然有些实现不会但你绝对不能依赖“不会”。我在一次项目里就遇到过某个工作线程在pthread_cond_wait上等待任务到来主线程收到 SIGINT 后希望所有线程退出但条件变量没有超时机制线程就一直卡在那里不做任何清理最终程序卡死在退出流程中。工程上的两个对策一是用pthread_cond_timedwait增加一个超时时间即使没有信号到达也能周期性醒来检查退出标志二是把所有信号接到专职线程里处理业务线程不直接面对信号。两个方案里我强烈推荐第二个因为方案一会给系统引入无谓的定时唤醒开销而且代码逻辑会变得越来越乱。5. 真实项目里最常见的几个信号“事故现场”最后这一部分我打算把实际工程中最常见的几个信号相关“事故现场”逐一还原每一个都是我亲眼见过、或者亲手排查过的问题。理解了这些案例你才算真正把信号机制用得很熟。5.1 SIGPIPE写管道写 socket 时的“静默杀手”你的程序往一个 socket 里写数据对端已经关闭内核在第二次或第三次写的时候发现了这个情况它会怎么做默认行为是向你的进程发送 SIGPIPE然后终止你的进程。问题就出在这个“默认行为”上。你的业务逻辑可能只是想让正常的网络错误通过返回值暴露给用户结果进程在你还来不及处理返回值之前就被信号干掉了。这个问题在管道场景里也常见。你用popen启动一个子进程然后往标准输入写数据结果子进程很快就退出了管道里没有读者你下一次写就会触发 SIGPIPE。处理方式很简单看你的场景如果你确实不关心“写管道但没人读”这件事可以直接忽略 SIGPIPE设置sa_handler为SIG_IGN如果你希望写失败通过返回值体现也是忽略 SIGPIPE然后把write/send的返回值检查好用errno EPIPE来判断对端关闭。几乎所有正式的网络服务程序都会在启动阶段把 SIGPIPE 显式忽略掉。这不是偷懒而是把“对端关闭”变成一个可处理的错误代码而不是一次无声的进程退出。5.2 SIGCHLD僵尸进程的元凶和正解父进程生了很多子进程子进程先退出父进程还没来得及wait此时子进程就变成僵尸进程。在子进程退出到父进程wait的这段时间里它的进程描述符还留在内核里占着 PID 和少量资源。如果父进程一直不wait僵尸进程就会一直积累最终可能导致 PID 耗尽。信号在这里的作用是子进程退出时内核自动向父进程发送 SIGCHLD。父进程如果不处理就是默认忽略——注意默认忽略只是父进程“不作为”僵尸进程依然存在内核不会自动帮你回收。正确的做法是在父进程里捕获 SIGCHLD并在处理器里调用waitpid(-1, status, WNOHANG)回收。但这里有个细节SIGCHLD 是标准信号不排队。如果你的程序同时有一大批子进程退出可能只会收到一次 SIGCHLD结果waitpid只回收了一个子进程其他子进程继续僵尸。所以正确的回收循环应该是收到一次 SIGCHLD就在处理器里不断用waitpid(-1, ..., WNOHANG)循环回收直到返回 0 或 -1把所有已退出的子进程全部收干净。static void handle_sigchld(int sig) { int saved_errno errno; while (1) { pid_t pid waitpid(-1, NULL, WNOHANG); if (pid 0) { continue; // 还有退出的子进程继续收 } break; // 没有更多退出的子进程退出循环 } errno saved_errno; }还有一个更省心的方案把 SIGCHLD 设置成SIG_IGN。在某些 Linux 系统上如果你显式忽略 SIGCHLD子进程退出时会立即被内核自动回收不会变成僵尸。不过这个行为在 POSIX 标准里属于可选的不一定所有平台都支持所以写跨平台代码时不要依赖这一招。5.3 SIGSEGV/SIGBUS 崩溃现场怎么收集可用的信息这两个信号是对开发者的老朋友了。SIGSEGV 是无效内存访问SIGBUS 是访问未对齐的内存地址或者访问一个被截断的映射文件区域。两者默认都会产生 core dump 文件用 gdb 或其他调试器去分析 core 文件是定位崩溃问题的有效方式。但有些生产环境为了安全会把 core 文件关闭。所以工程上更常见的做法是注册一个 SIGSEGV 的处理器在里面尽量保存崩溃时的上下文信息然后再恢复默认行为让进程退出以便生成 core。这个场景下要格外小心崩溃可能发生在任何地方你的处理器却不一定安全。处理器里只能做最简单的事——比如写一个固定的字符串到某个文件描述符或者调用sigaction恢复默认动作然后调用_exit不要调用任何可能分配内存的库函数。我见过有团队试图在 SIGSEGV 处理器里打印调用栈结果二次崩溃连原始信息都丢了。更稳妥的方案是直接用 gdb 的handle命令配置或者用系统工具如 systemd-coredump 来做收集而不是全靠自己写处理器。# 临时开启 core 文件 ulimit -c unlimited # 用 gdb 分析 core gdb ./program core5.4 SIGKILL 与 SIGSTOP永远不要试图“优雅地抗拒”最容易被一些人忽略的事实是SIGKILL 和 SIGSTOP 这两个信号没有办法捕获、没有办法阻塞、没有办法忽略。内核给出了这种“不可抗拒”的设计就是为了确保管理员在系统失控时仍然有最终的“强制停止”手段。所以当你的程序被kill -9干掉了不要抱怨“怎么没有执行清理逻辑”——SIGKILL 本身就是不给清理机会的内核直接用 force 的方式终止进程。这也解释了为什么老练的运维会先用默认的killSIGTERM给进程一个优雅退出的机会过几秒看进程还在再用kill -9强制处决。给业务程序做优雅退出时正确姿势是把资源清理逻辑写在 SIGTERM 的处理器或专用信号线程里利用系统给你的这最后几秒时间保存状态、关掉监听、通知子进程退出然后再退出主循环。我一直跟团队说你的程序应当把 SIGTERM 当作“上级找你谈话”把 SIGKILL 当作“保安直接把你架出去”。前者要好好应付后者你根本没机会挣扎。最后分享一个自用的调试技巧不知道你有没有遇到过这种情况程序在某个地方卡住了你想给它发个信号测试一下信号处理器到底对不对又不想把它干掉。这时候你可以先kill -STOP pid让进程暂停再用kill -USR1 pid发自定义信号观察进程的行为最后用kill -CONT pid恢复运行。整个过程就像是在给进程做“体外实验”不会干扰它的正常状态也不会有误杀风险。另一个我常用的排查小技巧是strace加信号跟踪参数。比如strace -e tracesignal -p pid这个命令会把进程接收到的每一个信号以及信号处理的系统调用实时打印出来配合日志一起看经常能直接定位到“信号来了但没人处理”的问题。我自己在实际项目中养成的习惯是在初始化代码里专门加几个调试用的自定义信号比如 SIGUSR1 打印线程数、SIGUSR2 打印连接池状态生产环境留门排查问题时随时能用信号“撬开”进程内部看看发生了什么。成本极低收益极高。如果你刚接触信号这块别急着背全部 API先用本文里的代码把“捕获一个信号并安全处理”跑通再去看阻塞和实时信号最后再啃多线程部分。这个顺序走下来Linux 进程信号这关基本就过了。