1. 从一个崩溃的程序说起
干Linux这行,谁没被信号折磨过。你写了个服务,跑得好好的,突然进程没了,dmesg里就一句话:segfault at 5f9e1e。或者你kill -9一个卡死的脚本,发现连kill -9都不一定立竿见影。再或者你用nohup启动一个后台任务,关掉终端后任务还在跑,但你以为它已经挂了。这些都和今天要聊的进程信号有关。
简单说,信号是Linux内核给进程发的一种异步通知,告诉它“你出事了”或者“有人要你办事”。它不像管道、消息队列那样传输数据,而是纯粹的控制消息——不会携带业务数据,只携带一个编号,外加可能的附加信息(比如siginfo)。你写服务要处理重启、优雅停机、处理子进程退出,绕不开信号。你排查线上故障,看到一个进程无端消失了,也要先怀疑是不是收到了某个信号。
这篇文章我会从信号的本质讲起,一直讲到信号的发送、捕捉、阻塞、多线程下的陷阱,还有几个真实踩过的坑。全程以C语言示例为主,穿插一些Shell层面的操作,让你既能看懂原理,也能直接用到运维和开发里。
2. 信号是什么:进程世界的“中断通知”
2.1 信号的本质是软件中断
很多人第一次接触信号,是在教科书里看到“软中断”三个字。这个类比其实很准确:硬件中断是CPU收到外设的通知,暂停当前指令流,跳到中断处理程序。信号则是内核替别的进程(或终端、或进程自己)给你发的一条消息,让目标进程在执行流的某个安全点停下来,转去处理这个消息。
关键点在于“异步”这两个字。比如你按下Ctrl+C,内核给前台进程组里每个进程发SIGINT。你的程序正在执行到哪条指令了?不知道。可能是main函数里,可能在一个系统调用里,可能在某个循环中间。内核不会等你把当前代码执行完,而是找一个相对安全的时机,把信号“递达”给进程。
那什么时候是安全时机?很粗糙地理解,进程从内核态返回用户态的瞬间,包括系统调用返回、中断返回、调度切换回来,以及陷入内核再出来的时候。在这之前,内核会检查进程的pending信号集合,如果有未被阻塞的信号要递达,就先把用户态的上下文保存好,在用户栈上构造一个信号处理帧,然后让CPU跳到信号处理函数地址去执行。处理函数跑完后,再恢复原来的上下文,继续执行之前被打断的代码。
2.2 信号的种类:从SIGINT到SIGRTMAX
Linux标准信号编号范围是1到31(部分架构有差异),实时信号是32到64。这些编号定义在<signal.h>里,典型几个:
| 信号 | 默认动作 | 典型触发场景 |
|---|---|---|
| SIGINT (2) | 终止进程 | Ctrl+C |
| SIGQUIT (3) | 终止进程并产生core | Ctrl+\ |
| SIGKILL (9) | 强制终止,不可捕捉/阻塞 | kill -9 |
| SIGSEGV (11) | 终止并产生core | 非法内存访问 |
| SIGPIPE (13) | 终止进程 | 写管道/断开的socket |
| SIGALRM (14) | 终止 | alarm()定时器到期 |
| SIGTERM (15) | 终止 | kill默认信号 |
| SIGCHLD (17) | 忽略 | 子进程退出/停止 |
| SIGSTOP (19) | 停止进程,不可捕捉/阻塞 | Ctrl+Z |
默认动作大致就五种:终止、终止并生成core文件、忽略、停止进程、继续运行。其中SIGKILL和SIGSTOP是两条“硬命令”,进程没有资格无视它们,这是内核兜底的手段。
有一点要特别注意:SIGCHLD的默认动作其实是“忽略”,但这里的“忽略”指的是进程不会因为这个信号被终止,不代表你不需要处理它。想回收子进程,你还是得用wait()或者waitpid(),信号只是提醒你有子进程状态变化了。
2.3 标准信号和实时信号的区别
标准信号有个致命短板:不排队。如果进程已经pending了一个SIGINT,在它被递达之前,你再给它发十个SIGINT,内核的pending集合里也只保留一个bit。信号丢失,处理方无从察觉。
实时信号(SIGRTMIN到SIGRTMAX)就是为了解决这件事引入的:每个实时信号使用独立的sigqueue结构,可以排队,先进先出,而且可以携带一个整型或指针形式的附加数据。你要是想在业务里自己定义“消息”,可以用实时信号,但附加数据不能传大对象,只能传个联合体或者指针,说白了就是个轻量通知。
3. 谁能收到信号:进程组、会话与前台进程
3.1 终端信号的分发逻辑
你在终端按下Ctrl+C,为什么整个前台进程组都收到SIGINT?这里要牵出三个概念:进程组、会话和控制终端。
每个进程属于一个进程组,组ID通常等于组长(第一个进程)的PID。一个会话包含一个或多个进程组,其中有一个前台进程组。终端驱动(TTY)知道当前哪个进程组是“前台”,Ctrl+C产生的信号不是发给某个特定PID,而是发给整个前台进程组。
这就是为什么你写个脚本,脚本里起了个管道cmd1 | cmd2,Ctrl+C一下,两个命令都会收到信号,而不是只发给shell。如果你在代码里用setpgid()把子进程放进别的进程组,再把新组设为前台(tcsetpgrp()),你就能实现“只有一部分进程响应Ctrl+C”的效果。我在写交互式终端程序时经常这么干,不然子进程乱响应终端信号,程序很快就失控了。
3.2 kill命令和信号发送的系统调用
kill这个名字起得很误导人。你以为kill就是杀进程,其实它只是“发送信号”的代名词。kill -0 PID甚至不发送任何有效信号,只用来检测PID是否存在、有没有权限,这在脚本里做进程探活非常方便。
用户态发信号有三个系统调用:
kill(pid_t pid, int sig):最常用。pid为正数时发给指定进程;pid为0时发给同进程组的所有进程;pid为-1时发给发送者有权限送的所有进程(排除系统进程和init);pid小于-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,因为可能多个子进程同时退出,信号只来一次。
孤儿进程是指父进程先退出、子进程被init(PID 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,但进程正在内核里做不可中断的磁盘IO(D状态),信号就会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 12345(SIGKILL),内核直接回收,无法捕捉,无法清理。 - 重新加载配置:很多服务约定用
kill -HUP 12345(SIGHUP)来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_SIGINFO:
static 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表示来自用户态kill,SI_KERNEL表示内核产生,SI_QUEUE表示来自sigqueue发送。排查时候,看到一个SIGSEGV的sender是自己,说明是自己把自己搞崩了,而不是隔壁进程误杀。
5.3 守护进程如何忽略终端信号
守护进程(daemon)的经典做法是daemon(0, 0),这个函数内部会做fork、setsid、chdir、重定向标准输入输出到/dev/null,从而摆脱控制终端。但即使不调用daemon函数,只要进程setsid成功,它就不再拥有控制终端,Ctrl+C、Ctrl+\ 都不会影响它。
但要小心,很多刚入行的同学写服务,自己手动daemon化时忘了一件事:现在终端上按Ctrl+C可能不再发信号给新会话里的进程了,但SIGHUP还是会发吗?答案是:不会,SIGHUP在会话leader退出时发给会话里的前台进程组。setsid之后新会话没有控制终端,自然没有这个问题。但如果你没完全脱离旧的进程组关系,SIGHUP仍然可能来。所以守护进程一般会显式忽略SIGHUP:
signal(SIGHUP, SIG_IGN);这个忽略同时照顾了另一个场景:父进程(通常是Shell)退出时,子进程可能收到SIGHUP。你要是没忽略,你的后台任务就会随着shell关闭而挂掉——这正是nohup命令的底层原理:它帮你调用了signal(SIGHUP, SIG_IGN)。
5.4 用strace和gdb观察信号
排查线上解决问题时,静态看代码不够,要动态观察信号发生过程。第一把刀是strace:
strace -e trace=signal -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()返回-1,errno设为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正常返回后,这些系统调用可能返回-1,errno=EINTR。你如果不处理,代码可能直接当成错误退出了。
最简单的规避方法就是注册时加SA_RESTART,内核会自动重新发起被中断的系统调用。但并非所有系统调用都能自动重启,比如sleep()、poll()、select()、epoll_wait()这些,即使设置了SA_RESTART,返回EINTR的几率依然存在,尤其是poll()系列。稳妥的写法是显式处理EINTR:
do { 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标准。把这篇文章里的几个关键点吃透,至少能少踩一半信号相关的坑。