
1. 从一个崩溃的程序说起干Linux这行谁没被信号折磨过。你写了个服务跑得好好的突然进程没了dmesg里就一句话segfault at 5f9e1e。或者你kill -9一个卡死的脚本发现连kill -9都不一定立竿见影。再或者你用nohup启动一个后台任务关掉终端后任务还在跑但你以为它已经挂了。这些都和今天要聊的进程信号有关。简单说信号是Linux内核给进程发的一种异步通知告诉它“你出事了”或者“有人要你办事”。它不像管道、消息队列那样传输数据而是纯粹的控制消息——不会携带业务数据只携带一个编号外加可能的附加信息比如siginfo。你写服务要处理重启、优雅停机、处理子进程退出绕不开信号。你排查线上故障看到一个进程无端消失了也要先怀疑是不是收到了某个信号。这篇文章我会从信号的本质讲起一直讲到信号的发送、捕捉、阻塞、多线程下的陷阱还有几个真实踩过的坑。全程以C语言示例为主穿插一些Shell层面的操作让你既能看懂原理也能直接用到运维和开发里。2. 信号是什么进程世界的“中断通知”2.1 信号的本质是软件中断很多人第一次接触信号是在教科书里看到“软中断”三个字。这个类比其实很准确硬件中断是CPU收到外设的通知暂停当前指令流跳到中断处理程序。信号则是内核替别的进程或终端、或进程自己给你发的一条消息让目标进程在执行流的某个安全点停下来转去处理这个消息。关键点在于“异步”这两个字。比如你按下CtrlC内核给前台进程组里每个进程发SIGINT。你的程序正在执行到哪条指令了不知道。可能是main函数里可能在一个系统调用里可能在某个循环中间。内核不会等你把当前代码执行完而是找一个相对安全的时机把信号“递达”给进程。那什么时候是安全时机很粗糙地理解进程从内核态返回用户态的瞬间包括系统调用返回、中断返回、调度切换回来以及陷入内核再出来的时候。在这之前内核会检查进程的pending信号集合如果有未被阻塞的信号要递达就先把用户态的上下文保存好在用户栈上构造一个信号处理帧然后让CPU跳到信号处理函数地址去执行。处理函数跑完后再恢复原来的上下文继续执行之前被打断的代码。2.2 信号的种类从SIGINT到SIGRTMAXLinux标准信号编号范围是1到31部分架构有差异实时信号是32到64。这些编号定义在signal.h里典型几个信号默认动作典型触发场景SIGINT (2)终止进程CtrlCSIGQUIT (3)终止进程并产生coreCtrl\SIGKILL (9)强制终止不可捕捉/阻塞kill -9SIGSEGV (11)终止并产生core非法内存访问SIGPIPE (13)终止进程写管道/断开的socketSIGALRM (14)终止alarm()定时器到期SIGTERM (15)终止kill默认信号SIGCHLD (17)忽略子进程退出/停止SIGSTOP (19)停止进程不可捕捉/阻塞CtrlZ默认动作大致就五种终止、终止并生成core文件、忽略、停止进程、继续运行。其中SIGKILL和SIGSTOP是两条“硬命令”进程没有资格无视它们这是内核兜底的手段。有一点要特别注意SIGCHLD的默认动作其实是“忽略”但这里的“忽略”指的是进程不会因为这个信号被终止不代表你不需要处理它。想回收子进程你还是得用wait()或者waitpid()信号只是提醒你有子进程状态变化了。2.3 标准信号和实时信号的区别标准信号有个致命短板不排队。如果进程已经pending了一个SIGINT在它被递达之前你再给它发十个SIGINT内核的pending集合里也只保留一个bit。信号丢失处理方无从察觉。实时信号SIGRTMIN到SIGRTMAX就是为了解决这件事引入的每个实时信号使用独立的sigqueue结构可以排队先进先出而且可以携带一个整型或指针形式的附加数据。你要是想在业务里自己定义“消息”可以用实时信号但附加数据不能传大对象只能传个联合体或者指针说白了就是个轻量通知。3. 谁能收到信号进程组、会话与前台进程3.1 终端信号的分发逻辑你在终端按下CtrlC为什么整个前台进程组都收到SIGINT这里要牵出三个概念进程组、会话和控制终端。每个进程属于一个进程组组ID通常等于组长第一个进程的PID。一个会话包含一个或多个进程组其中有一个前台进程组。终端驱动TTY知道当前哪个进程组是“前台”CtrlC产生的信号不是发给某个特定PID而是发给整个前台进程组。这就是为什么你写个脚本脚本里起了个管道cmd1 | cmd2CtrlC一下两个命令都会收到信号而不是只发给shell。如果你在代码里用setpgid()把子进程放进别的进程组再把新组设为前台tcsetpgrp()你就能实现“只有一部分进程响应CtrlC”的效果。我在写交互式终端程序时经常这么干不然子进程乱响应终端信号程序很快就失控了。3.2 kill命令和信号发送的系统调用kill这个名字起得很误导人。你以为kill就是杀进程其实它只是“发送信号”的代名词。kill -0 PID甚至不发送任何有效信号只用来检测PID是否存在、有没有权限这在脚本里做进程探活非常方便。用户态发信号有三个系统调用kill(pid_t pid, int sig)最常用。pid为正数时发给指定进程pid为0时发给同进程组的所有进程pid为-1时发给发送者有权限送的所有进程排除系统进程和initpid小于-1时发给 |pid| 进程组的所有进程。raise(int sig)给自己发信号等价于kill(getpid(), sig)。常用于给当前进程发一个终止信号比exit()多留了些“现场”痕迹。sigqueue(pid, sig, union sigval value)配合实时信号使用能附带一个sigval数据。接收方用siginfo_t里的si_value才能取到。这里有个权限问题普通用户只能给“同属一个uid”的进程发信号。跨用户的kill操作会被拒绝返回EPERM。root不受限。3.3 经典的僵尸进程与孤儿进程问题一个进程退出后如果父进程没有调用wait回收它就变成僵尸进程。僵尸进程不占用内存和CPU只保留一个进程表项里面记录着退出状态。长期大量堆积会耗尽PID这是线上服务很常见的故障。有人问子进程收到信号退出后父进程怎么第一时间知道两个机制一是轮询但开销大二是SIGCHLD信号子进程状态变化时内核自动发给父进程。父进程在SIGCHLD处理函数里调用waitpid注意要循环调用直到返回-1且errno为ECHILD因为可能多个子进程同时退出信号只来一次。孤儿进程是指父进程先退出、子进程被initPID 1现代系统上是systemd收养的情况。被收养后这些子进程的退出由init负责回收一般不会导致僵尸残留。但要注意孤儿进程会变成“后台进程组”的一员不再拥有控制终端如果它继续读写终端标准行为是收到SIGTTOU或者SIGTTIN被停止。4. 信号处理的完整流程注册、发送、递达、捕捉4.1 注册处理函数signal()还是sigaction()程序员初学时都学过signal()这个接口几行就能挂一个处理函数。但凡是踩过坑的人都明白真实项目里该用sigaction()。signal()在不同Unix系统上行为差异很大在Linux上它等价于设置了SA_RESTART标志的sigaction()而且历史上有些实现里处理函数执行期间会先把该信号重置为默认行为再执行你的handler。这个重置非常危险——如果你的handler是慢速系统调用期间又收到同一个信号默认行为是终止进程推送服务当场就挂了。sigaction()的完整结构Linux:struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); };关键的sa_flags有这几个值得记住SA_RESTART被信号打断的慢速系统调用read、wait等自动重启而不是返回EINTR。这是signal()默认帮你做的事很多人不知道这个坑用了sigaction()没设这个标志发现read总是返回-1一脸懵。SA_SIGINFO让内核走sa_sigaction这个扩展版本处理函数可以拿到siginfo_t里面包含信号编号、发送者PID、用户态/内核态来源、附加数据等。排查问题时这个结构非常有用。SA_NOCLDWAIT专门给SIGCHLD用的设置后子进程退出时不产生僵尸进程直接自动回收。但代价是你调waitpid()也没法拿到子进程的退出状态了业务上需要知道退出码的场景慎用。SA_ONSTACK在sigaltstack()提供的备选栈上执行信号处理函数。默认情况下信号handler使用的是进程的普通用户栈栈溢出时你连handler都跑不了备选栈就是最后的救命稻草。我见过一个崩溃排查案例程序栈被递归烧穿了信号处理函数拿着backtrace()也跑不了加了备选栈后直接从崩溃现场捞出了调用链。实际注册核心代码struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_sigaction on_sigterm; sa.sa_flags SA_SIGINFO | SA_RESTART; sigemptyset(sa.sa_mask); if (sigaction(SIGTERM, sa, NULL) -1) { perror(sigaction); exit(1); }注意sa_mask。它定义的是“handler执行期间哪些信号会被临时阻塞”。比如你在SIGTERM的handler里做资源清理不希望此时又收到SIGINT来打断就把SIGINT加进sa_mask里。用sigemptyset初始化后手动sigaddset比直接memset为零更健壮因为不同系统的sigset_t内部布局不一样。4.2 信号递达的时机和多阶段处理信号从“产生”到“递达”中间可能隔着一个“挂起”状态。你给进程发了SIGTERM但进程正在内核里做不可中断的磁盘IOD状态信号就会pending在那里直到进程回到用户态内核才检查pending集合然后递达。在这个等待期间进程还能继续做别的事。这就像给一个人发微信他可以没看但不能阻止你发。他下次打开微信的时候就会看到。有一个著名的坑点你的信号处理函数里如果调用了非异步安全函数比如malloc()、printf()、pthread_mutex_lock()而主程序恰好也在执行这些函数就可能出现死锁或者堆损坏。原因很简单handler是在主程序的任意执行点插入的主程序刚从malloc()内部环境里拿到堆锁你handler立刻又调malloc()一锁锁自己直接死锁。所以信号handler里能干什么严格来讲只有write()、read()、open()、close()、waitpid()、sigaction()这些被POSIX标记为async-signal-safe的函数而且你写文件要小心缓冲问题不能用printf()它是用户态缓冲的既非线程安全也不保证原子性。稳妥的做法是handler只做两件事——设置一个volatile sig_atomic_t标志位或者向一个预先打开的pipe()写入一个字节让主循环通过poll()或者epoll()感知到信号事件。后者尤其适合写事件驱动服务主循环统一处理不打断业务状态机。我实际项目里的模式是static volatile sig_atomic_t g_shutdown_requested 0; static void handle_sigterm(int sig) { g_shutdown_requested 1; (void)sig; } // 主循环里 while (!g_shutdown_requested) { // 正常业务循环 }4.3 可重入与不可重入的边界要理解“重入”的概念可以类比电话响。你正在打电话又有新电话进来你可以在同一个电话机上接第二个电话这个电话机函数就是可重入的——不依赖全局状态不使用共享缓冲区不持有锁。但在C语言里大部分函数都不可重入因为它们要么操作全局errno要么使用静态缓冲区比如strtok()、getpwd()、localtime()。在信号handler里调这些函数等于在新电话里同时操作同一份文件数据必然被搅烂。所以有一个非常实用的排查法则如果你的服务在收到大量外部信号后出现诡异崩溃比如偶发段错误、死锁不要急着查业务代码先把handler里的非原子安全调用全部换成“置标志管道通知”这个故障十有八九就消失了。5. 发送信号的完整实操几种场景的命令和代码5.1 Shell下发送信号最基础的发送场景是运维操作。先查进程ps -ef | grep myservice得到PID后发信号优雅终止kill 12345默认SIGTERM程序可以捕捉后做清理。强制终止kill -9 12345SIGKILL内核直接回收无法捕捉无法清理。重新加载配置很多服务约定用kill -HUP 12345SIGHUP来reload比如nginx、sshd。你改了配置文件跑一下kill -HUP $(cat /var/run/nginx.pid)就好。查看进程是否存在kill -0 12345配合echo检查返回值。这里有一个运维习惯的问题线上操作尽量先用SIGTERM等几秒再考虑SIGKILL。原因是很多中间件MySQL、Redis、Kafka在SIGTERM时能落盘脏数据、关闭文件句柄、通知集群节点下线直接SIGKILL等于让人家猝死数据一致性受影响。还有些守护进程收到SIGTERM会主动把“本机正在服务”的标识从注册中心摘掉避免流量还在往你这里打。5.2 C代码发送信号写服务间通知时代码里发信号特别常见。比如一个监控进程要提醒业务进程做日志切割可以发SIGUSR1要通知重新读取配置发SIGUSR2。以下是三种发信号的写法#include signal.h #include sys/types.h #include unistd.h // 方式1发给指定Pid kill(12345, SIGUSR1); // 方式2发给同进程组的所有进程 kill(0, SIGUSR1); // 方式3给自己发 raise(SIGTERM); // 方式4带附加数据的实时信号 union sigval val; val.sival_int 42; sigqueue(12345, SIGRTMIN 1, val);接收方如果想取附加数据要注册handler时带上SA_SIGINFOstatic void handler(int sig, siginfo_t *si, void *ctx) { if (si-si_code SI_QUEUE) { printf(got value %d from pid %d\n, si-si_value.sival_int, si-si_pid); } }siginfo_t里的si_code字段很有用它告诉你信号来源SI_USER表示来自用户态killSI_KERNEL表示内核产生SI_QUEUE表示来自sigqueue发送。排查时候看到一个SIGSEGV的sender是自己说明是自己把自己搞崩了而不是隔壁进程误杀。5.3 守护进程如何忽略终端信号守护进程daemon的经典做法是daemon(0, 0)这个函数内部会做fork、setsid、chdir、重定向标准输入输出到/dev/null从而摆脱控制终端。但即使不调用daemon函数只要进程setsid成功它就不再拥有控制终端CtrlC、Ctrl\ 都不会影响它。但要小心很多刚入行的同学写服务自己手动daemon化时忘了一件事现在终端上按CtrlC可能不再发信号给新会话里的进程了但SIGHUP还是会发吗答案是不会SIGHUP在会话leader退出时发给会话里的前台进程组。setsid之后新会话没有控制终端自然没有这个问题。但如果你没完全脱离旧的进程组关系SIGHUP仍然可能来。所以守护进程一般会显式忽略SIGHUPsignal(SIGHUP, SIG_IGN);这个忽略同时照顾了另一个场景父进程通常是Shell退出时子进程可能收到SIGHUP。你要是没忽略你的后台任务就会随着shell关闭而挂掉——这正是nohup命令的底层原理它帮你调用了signal(SIGHUP, SIG_IGN)。5.4 用strace和gdb观察信号排查线上解决问题时静态看代码不够要动态观察信号发生过程。第一把刀是stracestrace -e tracesignal -p 12345这个命令会打印出进程收到的信号和信号处理函数的调用轨迹。如果你发现进程反复收到SIGCHLD却不处理马上就能看出是waitpid没有正确调用。第二把刀是gdb。你可以让gdb捕获特定信号然后暂停程序看每个信号到达时的调用栈gdb -p 12345 (gdb) handle SIGSEGV stop print (gdb) continue程序崩溃时gdb会停在信号发生的地方你直接bt看栈定位非法访问的代码行。这是处理SIGSEGV问题的标准姿势。比dmesg里看一个地址管用得多。6. 信号集与阻塞不要被“信号屏蔽”绕晕6.1 信号掩码怎么工作每个进程都维护着一张信号掩码signal mask它和CPU中断的开关很像。被掩码的信号可以产生可以pending但不会递达。等掩码解除后内核再把pending的信号递达给进程。操作掩码的系统调用是sigprocmask()多线程环境下请用pthread_sigmask()效果一样但标准保证线程级可用。常用操作就是三个SIG_BLOCK把集合里的信号加入掩码、SIG_UNBLOCK从掩码里移除、SIG_SETMASK全量覆盖当前掩码。代码示例屏蔽SIGINT再恢复sigset_t set, oldset; sigemptyset(set); sigaddset(set, SIGINT); sigprocmask(SIG_BLOCK, set, oldset); // 这里如果来了SIGINT只会pending不会终止进程 // 做临界区操作... sigprocmask(SIG_SETMASK, oldset, NULL); // 恢复之后pending的SIGINT立即递达注意这个特性常常被用来“收集”信号你把一个信号阻塞住业务代码在某个安全检查点再解除阻塞一次性处理所有pending的信号。但标准信号不排队所以如果期间来了两次你最多也只会递达一次。如果需要排队还是得用实时信号。6.2 接收信号时如何临时改变掩码信号handler执行期间内核会自动把当前处理的信号加入掩码中然后再调用handler。这会导致一个问题如果handler在处理过程中又收到同一个信号信号会pending但不会递归进入。这个设计其实是保护性的防止同信号递归爆炸。如果你希望handler执行期间还能再响应同一个信号就得在handler里显式解除阻塞。不过实际项目中我基本不这么做——允许同信号重入等于自己给自己挖坑因为你很难保证handler的每个分支都安全。6.3 等待信号sigwait和sigsuspend有些程序不想用异步handler更愿意用一个线程专门等着收信号收到后再走同步逻辑。PCIe驱动这类底层程序中可能直接用sigwait()它接受一个信号集阻塞直到集合中的某个信号递达然后返回该信号的编号sigset_t set; int sig; sigemptyset(set); sigaddset(set, SIGTERM); sigaddset(set, SIGINT); sigwait(set, sig);使用sigwait()时这些信号通常要被所有线程阻塞住在创建线程前用pthread_sigmask设置这样它们才会走同步分发路径而不是异步handler。sigsuspend()则是一个更底层的原语它临时把当前的信号掩码替换成新的值然后挂起进程等待信号递达信号处理完之后恢复原来的掩码。它在“你希望某个条件满足后再继续”的场景里非常有用比如等待子进程退出。7. 信号与多线程的恩恩怨怨7.1 信号到底发给哪个线程多线程程序收到信号内核会选择一个不阻塞该信号的线程去递达。这个“选谁”的控制权应用层基本抢不到默认是主线程或者其他任意可接收的线程。这里有很多易踩的坑。比如你主线程不处理信号signal handler里却使用了线程相关的函数比如pthread_self()但handler在哪个线程执行不确定而不同线程对同一资源的锁竞争状态完全不同很容易引入偶发死锁。规范做法是在main()里、创建线程前用pthread_sigmask()把所有业务信号都阻塞住保证没有线程能异步收到它们。创建专门的信号处理线程循环sigwait()接收信号。收到信号后以正常线程同步的方式分发到业务线程通过消息队列、条件变量等。这种方法看起来多了一道搬运但避免了所有“异步打断”的问题排错时只要追一条同步线程的消息流比分析信号打断点要容易得多。我写的服务里优雅停机就是这么做的信号线程收到SIGTERM向主消息队列发一个“shutdown”事件业务循环收到事件后停止接受新请求、排空存量请求、落盘状态然后退出。7.2 多线程下一个特别的死锁如果你非要在线程里用sigaction注册handler而且handler里调用了pthread_mutex_lock()那基本等于在雷区蹦迪。你无法预测主线程处于什么状态可能主线程正握着这把锁做临界区操作handler再试图拿同一把锁直接死锁。一旦死锁进程就卡住了信号处理线程卡死在锁上主线程也正常不起来了。这种故障排查很磨人因为不是每次都必现在高并发场景下才会触发。我的排查习惯先用gdb看所有线程的栈凡是看到pthread_mutex_lock里锁的owner是别的线程而那个线程又卡在某个系统调用里就要高度怀疑是不是信号handler里上了锁。然后回头检查handler代码把所有锁调用全部剔除。7.3 线程信号掩码的继承线程创建时新线程会继承创建它的线程的信号掩码。如果你希望全进程的线程对某些信号保持相同的阻塞状态就要在pthread_create之前设置好pthread_sigmask()这样所有子线程自动继承。反之如果想针对特定线程做信号屏蔽就在该线程入口处单独调用pthread_sigmask()修改自己的掩码。比如一个线程专管处理SIGINT其他线程把SIGINT全屏蔽这样的职责分离能避免同一个信号被多个线程同时处理不同步的问题。8. 几个高发故障案例与排查思路8.1 core文件生成与配置SIGSEGV、SIGABRT这类信号默认动作是生成core dump这是排查崩溃问题最重要的现场证据。但很多系统默认关闭core dump你需要手动打开ulimit -c unlimited echo /data/cores/core.%e.%p /proc/sys/kernel/core_pattern%e是进程名%p是PID也可以加%t时间戳。有了core文件gdb挂上去gdb /path/to/binary /data/cores/core.myservice.12345 (gdb) bt (gdb) info threads (gdb) frame 5我长期持有的一个习惯是线上服务崩溃后第一时间把core文件拷贝走然后立刻把原来的core文件删掉或者改名防止磁盘被写满。还有个坑systemd的CoreDumpStorage配置可能会接管core dump写到journal或者spool目录你以为没生成其实是被系统“保管”了得去/var/lib/systemd/coredump/下翻。8.2 SIGPIPE网络服务最容易忽略的杀手写过socket服务的人基本都遇到过对方断开连接你继续write()收到SIGPIPE进程瞬间没了。程序没有打印任何错误因为SIGPIPE的默认动作就是终止。处理办法有两条路。一是在代码里忽略它signal(SIGPIPE, SIG_IGN);忽略之后write()返回-1errno设为EPIPE。这时候你的代码才能真正感知到连接断开走业务清理逻辑。但忽略SIGPIPE有个副作用如果写入的是管道而不是socket忽略后管道写入也可能静默失败所以要么显式检查返回值要么只在socket相关代码里屏蔽。另一条路是给socket设置MSG_NOSIGNAL标志send(fd, buf, len, MSG_NOSIGNAL);这样即使不忽略信号这次send也不会触发SIGPIPE而是返回-1。在多线程程序里我更推荐MSG_NOSIGNAL因为它只影响这次调用不需要动全局信号处理配置避免影响其他线程。8.3 SIGCHLD处理不当的僵尸堆积线上服务如果频繁创建子进程去干活最常见的bug就是父进程不在SIGCHLD handler里回收或者回收写得不彻底。比如只调了一次waitpid()如果同时有三个子进程退出而内核只发了一次SIGCHLD那剩下两个子进程就滞留成僵尸。正确的handler长这样void handle_sigchld(int sig) { int saved_errno errno; while (waitpid(-1, NULL, WNOHANG) 0) { // 循环回收直到没有可回收的子进程 } errno saved_errno; }注意保存并恢复errno。handler是异步插入的你不恢复errno主程序的下一个errno检查就会读到被handler改掉的值排查起问题来像在看鬼故事。8.4 慢速系统调用被信号打断的EINTR问题当进程阻塞在read()、write()、accept()等慢速系统调用上收到一个信号且handler正常返回后这些系统调用可能返回-1errnoEINTR。你如果不处理代码可能直接当成错误退出了。最简单的规避方法就是注册时加SA_RESTART内核会自动重新发起被中断的系统调用。但并非所有系统调用都能自动重启比如sleep()、poll()、select()、epoll_wait()这些即使设置了SA_RESTART返回EINTR的几率依然存在尤其是poll()系列。稳妥的写法是显式处理EINTRdo { ret poll(fds, nfds, timeout); } while (ret -1 errno EINTR);我见过有人把这段循环封装成一个poll_wrapper()所有业务代码都走这一层天然把EINTR问题抹掉了。8.5 SIGALRM和alarm之间纠缠不清alarm(seconds)是Unix里最朴素的定时器到期后给进程发SIGALRM。很多人用过它做超时控制但它有天然的坑一个进程只能有一个alarm新的调用会覆盖旧的而且多个模块共用同一个alarm会互相干扰。在做多定时器业务时别用alarm改用POSIX定时器timer_create()SIGEV_SIGNAL每个定时器独立可以重复触发还能设置时钟源比如CLOCK_MONOTONIC。我写网络超时检测时会在一张时间轮上维护所有连接的超时点由一个定时器统一驱动比每个连接都开一个定时器稳得多。9. 几个工程上的进阶选型9.1 信号还是别的IPC初学阶段容易什么都用信号做进程间通信但信号有几个天生的短板不带数据、标准信号会丢、异步处理容易踩重入问题。几个常见场景的选型建议需求推荐手段优雅关机、配置reload信号SIGTERM、SIGHUP因为这是系统默认约定传输业务数据管道、Unix socket、消息队列通知事件但不带数据信号、eventfd多线程内部通知条件变量、eventfd跨主机通知TCP/UDP协议消息信号最好守在它的“本分岗位”上控制类通知、系统异常通知、进程协调。不要拿它传输业务内容。9.2 自研信号框架的通用套路真到了要在工程里大规模使用信号的阶段我建议按这个套路来搭建启动阶段主线程统一注册所有信号处理函数统一走sigaction()SA_SIGINFO | SA_RESTART。业务线程创建前设置线程掩码把信号全部阻塞避免异步打断业务逻辑。一个专门信号接收线程用sigwait()收到信号后翻译成业务事件结构体投递到事件队列。handler里不写任何业务逻辑最多设置标志位或者向eventfd写一个字节唤醒epoll线程。对外提供统一接口register_signal_handler(sig, callback)、send_signal(pid, sig)业务侧永远看不到原始信号细节。这个框架的代价是多了一点点延迟信号到事件队列的转换但换来了极强的稳定性和可测试性。信号处理逻辑可以单测不会因为异步随时爆炸。9.3 容器环境下的信号陷阱容器场景里信号问题更隐蔽。比如Docker容器的PID 1进程如果它没有正确处理SIGTERM而父进程容器管理程序发送SIGTERM想优雅停容器结果PID 1默认行为是终止但如果有其他子进程还在跑容器可能进入一个奇怪的停止状态。更常见的是很多业务镜像里的PID 1是shell脚本shell脚本的信号处理和二进制程序不同子进程收到的信号传播逻辑也不同。在容器里你还需要注意信号不会像传统init那样自动处理“孤儿进程收养”因为PID 1往往是业务进程自身它如果不主动reap子进程僵尸进程问题会比虚拟机里更严重。所以容器内业务的入口进程如果是你自己写的请务必显式处理SIGCHLD并循环waitpid如果是现成脚本可以引入一个真正的init系统如tini让它当PID 1来转发信号和回收子进程。10. 直接可用的调试模板下面这个模板是我常用的信号排查工具把它编译后attach到目标进程可以看到目标进程收到信号时的基本信息以及当前pending的信号集合#include stdio.h #include signal.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/wait.h static void dump_sigset(const char *name, sigset_t *set) { int i; printf(%s:, name); for (i 1; i 64; i) { if (sigismember(set, i)) printf( %d, i); } printf(\n); } static void handler(int sig, siginfo_t *si, void *ctx) { int saved errno; sigset_t pending; printf( %s \n, strsignal(sig)); printf(si_pid%d si_uid%d si_code%d\n, si-si_pid, si-si_uid, si-si_code); if (sigpending(pending) 0) dump_sigset(pending, pending); errno saved; } int main(void) { struct sigaction sa; int i; memset(sa, 0, sizeof(sa)); sa.sa_sigaction handler; sa.sa_flags SA_SIGINFO | SA_RESTART; sigemptyset(sa.sa_mask); for (i 1; i 31; i) { if (i SIGKILL || i SIGSTOP) continue; if (sigaction(i, sa, NULL) -1) perror(sigaction); } printf(pid%d\n, getpid()); pause(); return 0; }编译命令gcc -Wall -O0 -g -o sigdump sigdump.c -lrt跑起来之后你用kill -USR1 PID给它发信号它会打印出发送者PID、来源代码、当前pending信号集合。排查“谁在给我发信号”这类问题时这个工具比日志好使。11. 信号处理的一点个人体会使用信号多年我最深刻的体会是信号本身不复杂复杂的是它“异步”的天然属性。所有和异步相关的bug都有一个共性——难以稳定复现偶发、时好时坏、压测时才出现。所以我的原则很简单能不用异步handler就不用能用同步事件队列就尽量用同步事件队列。信号这个机制作为系统级的“最后通知”非常好用但你要真把它当成业务功能的一部分就得拿对待并发代码的态度去约束它。另外排查信号问题一定要善用系统工具strace看系统调用gdb看线程栈core文件看崩溃现场/proc/PID/status里的SigBlk、SigCgt、SigPnd字段能直接告诉你进程当前阻塞了哪些信号、捕捉了哪些信号、有哪些信号pending。比如SigCgt显示0000000000000000说明进程一个信号处理函数都没注册那kill -TERM肯定直接终止了它。这比瞎猜强太多。纸上谈兵容易实际遇到问题还是要多看内核文档和POSIX标准。把这篇文章里的几个关键点吃透至少能少踩一半信号相关的坑。