1. 先搞懂信号到底是什么:从生活场景到内核机制
1.1 信号不是“软件中断”这么简单
如果你刚接触Linux,大概率会看到一句话:“信号是软件中断”。这句话没错,但不够。我更喜欢把信号理解成内核给进程发的一条“微信消息”——消息内容只有几个预定义的编号,比如SIGINT就是“有人按了Ctrl+C”,SIGTERM就是“请你优雅地退出”,SIGKILL就是“立刻消失,不许反驳”。
为什么说信号很重要?因为它几乎是Linux系统里最底层的进程间通信(IPC)方式之一。你写脚本时在终端按Ctrl+C,内核会把SIGINT发给前台进程组;你在命令行执行kill -9 1234,实际上就是调用kill系统调用给PID 1234发送SIGKILL。哪怕是Java进程被OOM Killer干掉,本质上也是内核强制给它发了一个SIGKILL。可以说,只要你在Linux上干活,就绕不开信号。
信号从哪来?常见来源有三个:一是用户通过终端按键产生,比如Ctrl+C、Ctrl+\;二是通过系统调用或命令发送,比如kill()、raise()、kill命令;三是硬件异常或内核检测到某种条件触发,比如除零产生SIGFPE,非法内存访问产生SIGSEGV,子进程退出时内核自动给父进程发SIGCHLD。
那信号是怎么被进程“接收到”的?这里要分清“信号的产生”和“信号的处理”。产生只是内核在进程的task_struct里标记了一个信号;处理则是进程在合适的时机(比如从内核态返回用户态前)检查这个标记,然后执行对应的动作。默认动作可能是终止进程、暂停进程、忽略信号,也可能是产生核心转储文件(core dump)。如果一个进程自己注册了信号处理函数,那么内核就会把当前的执行上下文保存好,跳转到你的处理函数里跑一遍,再跳回来继续原来的代码。
1.2 信号的完整生命周期:产生、注册、注销、处理
一个信号从出生到消亡,经历四个阶段:产生、注册、注销、处理。
产生我们上面说了。重点说“注册”。对于标准信号(1~31号),内核在进程的pending位图里把对应位置置1,这个操作叫作“登记”。如果同一个信号在第一次登记后还没被处理,第二次、第三次再发过来,内核不会再次登记,这就是标准信号会丢信号的根本原因。而真实时信号(SIGRTMIN及以上)则不同,每个实时信号都对应一个队列,信号可以排队,并且每个信号可以携带一个整数或指针值,通过sigqueue系统调用发送。
然后“注销”:如果信号在pending位图里已经置1,再次收到同类型标准信号时不会重复置位,所以实际上不需要“多次注销”。只有当进程开始处理这个信号时,内核才会把对应位清零,并把队列里的该信号节点移除。换句话说,一个标准信号从产生到处理,整个过程只保留一个“名额”。
最后才是“处理”。处理时机很讲究:进程从内核态返回用户态之前,内核会检查当前进程有没有pending信号。如果有,就选择一个信号去执行处理函数或默认动作。注意,信号处理函数是在用户态运行的,但触发信号检测的时机是内核态到用户态的切换。这也是为什么信号处理函数里调用printf是危险的——因为它可能改变内核相关的内部状态,后面我们会细说。
1.3 那些你天天见却不一定认识的信号:常用信号速查表
我在带新人的时候,经常会问:“SIGKILL和SIGTERM有什么区别?”很多人答不上来。这两个简直是Linux运维面试的常客,也是Java、Go等语言里进程退出时最常见的两个信号。区别很简单:
| 信号 | 编号 | 默认动作 | 能否捕获/忽略 | 典型场景 |
|---|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 可以 | 终端挂断,或kill -HUP让守护进程重载配置 |
| SIGINT | 2 | 终止进程 | 可以 | 键盘Ctrl+C |
| SIGQUIT | 3 | 终止并生成core | 可以 | 键盘Ctrl+\ |
| SIGKILL | 9 | 终止进程 | 不可捕获、不可忽略 | 强制杀进程 |
| SIGSEGV | 11 | 终止并生成core | 可以 | 非法内存访问 |
| SIGPIPE | 13 | 终止进程 | 可以 | 向已关闭的管道写数据 |
| SIGTERM | 15 | 终止进程 | 可以 | kill命令默认信号,优雅终止 |
| SIGCHLD | 17 | 忽略 | 可以 | 子进程停止或退出时通知父进程 |
| SIGUSR1/2 | 10/12 | 终止进程 | 可以 | 用户自定义信号 |
| SIGRTMIN | 34 | 终止进程 | 可以 | 实时信号,可排队、带数据 |
这里特别提一下SIGHUP。很多运维同学知道重启nginx用kill -HUP $(cat /var/run/nginx.pid),这个命令真正的含义是给nginx主进程发送SIGHUP信号,nginx主进程收到后重新加载配置文件。为什么用HUP而不是USR1?因为SIGHUP的经典语义就是“终端断开”,守护进程通常会把它解释为“重新初始化”。这个约定俗成,很多服务都沿用。
还有SIGPIPE,这个坑很多人踩过。你写一个管道程序,前一个进程的输出管道关闭了,你还往里面写,内核会给你发SIGPIPE,默认动作是终止进程。所以很多服务端程序都会忽略SIGPIPE,避免因为客户端断开连接导致整个服务进程挂掉。
2. 信号的发送与捕获:从kill到sigaction
2.1 kill命令和raise函数:不只是杀进程那么简单
kill这个名字太有迷惑性了,好像专门用来“杀死”进程。实际上它只是“发送信号”,至于收到信号的进程是死是活,要看信号的类型和进程自己的处理方式。你可以试试:
kill -USR1 1234如果进程没有注册SIGUSR1的处理函数,默认动作是终止进程,的确会把它干掉。但如果进程里写了sigaction(SIGUSR1, handler, NULL),那么进程会执行handler里的逻辑,而且不会退出。
raise()和kill()有什么区别?简单说,raise()是给自己发信号,kill()是给指定进程或进程组发信号。在单线程程序里,raise(sig)等价于kill(getpid(), sig)。但在多线程程序里就不一样了,raise()会向当前线程发送信号,而不是整个进程。这一点后面我们讲线程信号时会再展开。
还有一个容易被忽略的函数:alarm()。它会在指定秒数后给当前进程发送SIGALRM信号。很多人写超时控制时喜欢用alarm,但要注意,SIGALRM默认动作是终止进程,你需要先设置好处理函数,再调用alarm,否则写个程序等两秒就自己退出,看起来像魔法。
2.2 signal函数的坑与sigaction的正确姿势
刚学信号的时候,最常接触的是sighandler_t signal(int signum, sighandler_t handler)。这个函数简单,但坑很多。最著名的坑是:在System V(包括Linux)上,signal注册的处理函数执行完毕后,该信号的处理方式会被重置为默认值。也就是说,如果你用signal注册一个处理函数处理SIGINT,第一次Ctrl+C会执行你的函数,但函数返回后,SIGINT的处理方式又变回终止进程,第二次再按Ctrl+C,程序直接退出。要避免这个问题,你只能在handler里再次调用signal来重新注册,但这里存在窗口期,一个信号可能在重新注册之前到达,导致程序退出。
所以我几乎从来不用signal,而是用sigaction。它的接口看起来啰嗦,但语义明确、可控制性强:
#include <signal.h> #include <stdio.h> #include <string.h> static void handler(int sig) { write(STDOUT_FILENO, "got signal\n", 11); } int main() { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handler; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; // 不设置SA_RESTART时,被信号打断的系统调用会返回EINTR sigaction(SIGINT, &sa, NULL); while (1) { pause(); // 挂起等待信号 } return 0; }这里有个非常实用的面试考点:sa_flags里设不设SA_RESTART。默认情况下,进程正在执行一个可中断的系统调用(比如read、wait、accept)时,如果信号到达,系统调用会返回-1,并设置errno为EINTR。很多新手写循环读文件时遇到EINTR报错就懵了,不知道怎么处理。解决办法有两个:要么在代码里检测errno == EINTR然后重新调用系统函数,要么在注册信号时加上SA_RESTART标志,让内核自动重启被中断的系统调用。但需要知道,SA_RESTART并不是对所有系统调用都生效,比如poll、epoll_wait、nanosleep、waitid,即使设置了SA_RESTART,这些调用依然会返回EINTR。所以真正靠谱的做法是:在业务代码里统一处理EINTR,不要过分依赖SA_RESTART。
还有一个细节是sa_mask。它表示在信号处理函数执行期间,需要额外阻塞哪些信号。比如你希望在处理SIGUSR1时,如果来了SIGUSR2就先别执行它,等SIGUSR1处理完再处理,那就应该在sa_mask里加上SIGUSR2。注意,当前正在被处理的信号自动会被阻塞,不需要你手动加。
2.3 捕捉SIGCHLD:正确处理子进程退出
父进程怎么知道子进程退出了?最简单的办法是wait()阻塞等待,但很多时候父进程还有别的事要做,不能一直等着。这时候SIGCHLD就派上用场了:子进程退出时,内核会给父进程发送SIGCHLD信号。父进程注册一个handler,在handler里调用waitpid(pid, &status, WNOHANG)来回收子进程,这样父进程不用阻塞,也能及时知道子进程的状态。
这里有一个经典坑:在SIGCHLD的handler里,需要循环调用waitpid,用WNOHANG非阻塞模式,直到返回0或-1。为什么?因为如果多个子进程同时退出,handler只执行一次,但pending里可能积压了多个SIGCHLD信号。由于标准信号不排队,不循环回收的话,就会产生僵尸进程。
下面这段代码是标准写法:
static void sigchld_handler(int sig) { pid_t pid; int status; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { printf("child %d exited\n", pid); } }注意handler里别用printf,因为printf不是异步信号安全的函数。我在示例里用了,只是为了演示方便,实际工程中请用write和sprintf到临时缓冲区再输出,或者用管道把信息传给主循环处理。这也是信号处理函数和普通函数最大的一点不同。
3. 信号阻塞与未决:进程的“信号屏蔽”机制
3.1 信号集操作与sigprocmask
进程可以“屏蔽”某些信号,也就是暂时不让它递达,但并不是丢弃,而是让信号保持“未决”(pending)状态,等解除屏蔽后再处理。这个机制很像你在微信里把某个群设成了免打扰:消息仍然会收到,但不会弹出来,等你关掉免打扰,消息你会看到(不过Linux标准信号只能看到最近一条)。
信号集是一个sigset_t类型的位图。常用操作有:
sigemptyset(&set); // 清空集合 sigfillset(&set); // 把所有信号加入集合 sigaddset(&set, SIGINT); // 添加一个信号 sigdelset(&set, SIGINT); // 删除一个信号 sigismember(&set, SIGINT); // 判断是否在集合中真正改变进程屏蔽字的是sigprocmask:
sigset_t block_set, old_set; sigemptyset(&block_set); sigaddset(&block_set, SIGINT); sigprocmask(SIG_BLOCK, &block_set, &old_set); // 加上SIGINT的屏蔽 // 临界区代码这里执行 sigprocmask(SIG_SETMASK, &old_set, NULL); // 恢复原来的屏蔽字sigprocmask有三个操作模式:SIG_BLOCK(将集合中的信号加入当前屏蔽字)、SIG_UNBLOCK(移除)、SIG_SETMASK(直接用新屏蔽字替换)。典型应用场景是:你有一段代码不允许被信号打断,比如正在更新一个连锁的数据结构,这时候先屏蔽所有危险信号,做完了再恢复。这也是多线程编程里pthread_sigmask的同款思路。
注意,SIGKILL和SIGSTOP这两个信号是不能被屏蔽的,也不能被捕获,更不能被忽略。这是内核的绝对底线,否则进程自己屏蔽了SIGKILL就没法杀了,会变成无法控制的野进程。
3.2 别忽略pending:检查未决信号
经常有同学问:我怎么知道当前进程有没有被屏蔽的信号等待处理?答案是sigpending。
sigset_t pending_set; sigpending(&pending_set); if (sigismember(&pending_set, SIGINT)) { printf("there is a pending SIGINT\n"); }我调试信号问题的时候,会在带-g编译的程序里临时加一段sigpending打印,配合sigprocmask排查是不是有人不小心屏蔽了某些信号。不过这个接口在生产代码里用得少,更多是调试用途。
还有一个和pending相关的细节:当进程收到一个信号,如果当前正在处理相同信号,那么该信号会被自动加入进程的屏蔽字,防止递归处理同一个信号。但如果你用的是sigaction并且设置了SA_NODEFER标志,就不会自动屏蔽了,意思是可以重入。这个标志很少用,因为重入信号处理函数非常危险。
3.3 可重入函数与异步信号安全
信号处理函数是一个异步执行的上下文,它可能在主程序的任何一条指令处被插入执行。这就带来一个严重问题:如果主程序正在调用一个函数,而这个函数里用了全局缓冲区,信号处理函数也调用了这个函数,就会破坏数据。
所以就有了“异步信号安全函数”这个概念。POSIX标准规定了一组可以安全地在信号处理函数中调用的函数,比如write、read、open、close、fork、exit、sigaction等。尤其注意,因为信号处理函数会打断主程序,任何使用线程不安全或不可重入机制的函数都不能在里面调用,包括malloc、free、printf、snprintf(有的实现线程安全,但不保证异步信号安全)、gettimeofday(部分平台安全但建议谨慎)、strtok等。
我在写信号处理函数时,最常用的模式是:在handler里只做一件事——把信号写到管道(pipe)的写端,然后主循环用epoll或select监听管道的读端,在普通上下文里处理实际业务。这样不仅避开了异步信号安全问题,还能把信号处理和业务逻辑解耦,代码可读性和可靠性都高不少。
如果你已经在handler里用了不安全的函数,程序可能不会立刻崩溃,而是在某个随机时刻莫名其妙地出问题,这种bug最难查。所以规则很简单:handler里能不做事就不做事,最多用write通知一下。
4. 可靠信号与实时信号:为什么老信号会丢
4.1 不可靠信号的丢信号问题
这里说的“不可靠”指的是标准信号(1~31号),也就是非实时信号。不可靠体现在两个层面:
第一,信号可能丢失。前面说过,标准信号在pending位图里只有一个比特位,如果同一个信号在未被处理前多次到达,内核只记录一次,后面的全部丢弃。可以这样验证:写一个程序,屏蔽SIGUSR1,然后循环调用kill(getpid(), SIGUSR1)发送100次,解除屏蔽后,你的handler只会被调用一次,甚至可能一次都不调用(如果解除屏蔽前信号已经被合并)。
第二,处理函数执行完之后,信号的处理行为可能被重置为默认行为。这个问题在signal()函数上表现得最明显,在Linux手册页里甚至直接建议不要使用signal,而改用sigaction。
要验证信号丢失,可以写一个简单C程序,用一个原子操作计数。因为handler里不能用++之类的非原子操作(严格来说int类型在x86上对单字节对齐的int自增不算原子),我用__atomic_add_fetch来看效果:
#include <signal.h> #include <stdio.h> #include <stdatomic.h> #include <unistd.h> static atomic_int cnt = 0; static void handler(int sig) { __atomic_add_fetch(&cnt, 1, __ATOMIC_SEQ_CST); } int main() { struct sigaction sa = {.sa_handler = handler}; sigemptyset(&sa.sa_mask); sigaction(SIGUSR1, &sa, NULL); sigset_t block; sigemptyset(&block); sigaddset(&block, SIGUSR1); sigprocmask(SIG_BLOCK, &block, NULL); for (int i = 0; i < 100; i++) { kill(getpid(), SIGUSR1); } sigprocmask(SIG_UNBLOCK, &block, NULL); printf("cnt=%d\n", atomic_load(&cnt)); return 0; }你会发现cnt几乎不可能是100,通常就是1或者很小的数字。这就会引出一个问题:如果进程同时收到很多相同信号,我是不是只能认为它来了一次?对标准信号来说,是的。
4.2 实时信号的排队与优先级
为了解决信号丢失的问题,Linux引入了POSIX实时信号。它们的编号从SIGRTMIN(Linux x86上通常是34)到SIGRTMAX(64)。实时信号的特征有三个:
- 信号会排队,最多可以排队的信号数量受
ulimit -i(pending signals)限制,默认通常是31920,可配置。 - 每个信号可以携带一个附加数据(整数或指针),通过
sigqueue()发送。 - 不同实时信号之间按编号从小到大排序,编号越小优先级越高。同编号的实时信号按发送顺序排队。
发送实时信号用sigqueue:
#include <signal.h> union sigval val; val.sival_int = 42; sigqueue(target_pid, SIGRTMIN, val);接收处理时,用sigaction注册handler,可以通过siginfo_t拿到附加值和发送方信息:
static void rt_handler(int sig, siginfo_t *info, void *ucontext) { printf("signal %d, value=%d, from pid=%d\n", sig, info->si_value.sival_int, info->si_pid); } struct sigaction sa; sa.sa_sigaction = rt_handler; sa.sa_flags = SA_SIGINFO; sigemptyset(&sa.sa_mask); sigaction(SIGRTMIN, &sa, NULL);注意,注册时sa_flags要包含SA_SIGINFO,处理函数才是siginfo_t *版本的三参数形式;如果不带这个标志,即使你写了个三参数函数,也被当成普通的单参数函数用,编译会报错或者行为诡异。
实时信号的用途很广,比如在用户态实现一个简单的消息队列,或者让某个业务线程之间传递优先级事件。不过实时信号虽然不丢,但排队的上限还是有限,不能当成无限容量的消息系统用。真正高吞吐的场景还是要用消息队列、共享内存、事件fd这些。
4.3 从SIGUSR1到SIGRTMIN:工作里的信号选型
在写业务程序时,我常用SIGTERM做优雅退出,用SIGHUP做配置重载,用SIGUSR1做自定义事件通知。这些都属于标准信号,简单,但存在丢信号的风险。如果业务上要求“每次通知都不能丢”,比如某个通知对应一次任务调度,那么标准信号就扛不住了,必须用实时信号或别的IPC机制。
在嵌入式Linux项目里,经常会有主控程序需要通知另一个进程去采集数据、保存参数、切换模式。我看到很多项目直接用SIGUSR1、SIGUSR2。一开始数据量小没事,但一旦频繁触发,就会莫名其妙丢事件。排查信号丢失很难,因为程序看起来没出错,就是功能随机性地不生效。后来我们统一把这类事件通知改成了实时信号+sigqueue,或者干脆用UDP socket/Unix domain socket,问题就彻底解决了。
选型建议很简单:如果你只是想让进程“做一件事”,不关心做了几次,用SIGUSR1没问题;如果每一次通知都必须被处理,不能合并,那就用实时信号或socketpair事件通知。这也是很多服务器框架在内部事件通知上不使用signal,而是用eventfd的原因。
5. 实战:用信号解决一个真实场景
5.1 场景描述:守护进程平滑重载配置
假设你写了一个守护进程,跑在服务器上,配置文件是/etc/myapp.conf。需求是:不重启进程,只发一个信号就让进程重新读取配置。这是非常典型的信号应用,服务端进程日常运维都用得上。
方案很简单:注册SIGHUP的处理函数,在handler里设置一个volatile sig_atomic_t标志位,主循环检测到标志位后调用reload_config()。为什么不在handler里直接调用reload_config()?因为reload_config里很可能要做malloc、解析文件、打开数据库连接等操作,这些都不是异步信号安全的。正确做法是“信号只用来提醒,具体工作在主循环里完成”。
一个简化版的主循环可以长这样:
static volatile sig_atomic_t g_reload_flag = 0; static void hup_handler(int sig) { g_reload_flag = 1; } int main() { struct sigaction sa = {.sa_handler = hup_handler}; sigemptyset(&sa.sa_mask); sigaction(SIGHUP, &sa, NULL); while (1) { if (g_reload_flag) { g_reload_flag = 0; reload_config(); // 主循环里做重活 } do_work(); } }这里要注意g_reload_flag的类型必须是volatile sig_atomic_t,它是一个在信号处理函数中访问和修改保证原子性的类型。在x86上,它通常就是int,加volatile是告诉编译器不要把它优化到寄存器里,每次都要从内存读取,避免主循环永远看不到更新后的值。
5.2 实现一个minishell的信号处理框架
接下来我们写一个小项目,既能练信号,又能复习进程管理。实现一个“半成品minishell”:支持执行普通命令,支持Ctrl+C不杀死整个shell而只终止当前子进程,支持在后台任务结束时打印一条通知。
关键点有三个:
第一,shell本身要忽略SIGINT,否则用户按Ctrl+C,shell进程自己也退出了。但shell的前台子进程要收到SIGINT。通常的fork模型是:shell调用fork()创建子进程,子进程里把信号处理方式重置为默认,然后exec执行命令;父进程(shell)在waitpid期间如果收到SIGINT,只把信号转发给子进程,自己继续等待。为了简化,我们可以让shell忽略SIGINT,子进程重置为默认。实现如下:
// 子进程里,exec之前 signal(SIGINT, SIG_DFL); signal(SIGQUIT, SIG_DFL); execvp(cmd, argv);第二,后台任务结束的通知用SIGCHLD实现。在父进程的SIGCHLD handler里,用waitpid(-1, &status, WNOHANG)回收,然后打印“child exited”。但是printf不安全,所以更规范的做法是使用write直接写固定字符串。如果你希望在回收后还能拿到退出码并格式化打印,就得用我之前说的pipe方案:handler把子进程PID写入pipe,主循环读取后格式化打印。
第三,父进程等待前台子进程时,用waitpid(pid, &status, 0)阻塞。但阻塞期间,如果信号到达,waitpid会返回EINTR。如果你不想让Ctrl+C影响shell,就要循环处理EINTR:
while (waitpid(pid, &status, 0) < 0) { if (errno == EINTR) continue; perror("waitpid"); break; }这样shell就不会莫名暂停。
完整代码我放在一个文件里,大家可以编译跑一下,反复按Ctrl+C观察shell是否存活、子进程是否被终止。
5.3 调试信号问题的三板斧:strace、gdb、/proc
信号类问题排查比其他问题更抽象,因为它是异步的,不好复现。我常用的三板斧:
第一,strace。strace -f -e trace=signal,process ./myapp可以打印所有信号相关系统调用。你能看到进程收到了哪些信号,是从谁那里发来的,进程是怎么处理的。比如你怀疑信号丢了,strace出来看到只有一次kill,那就是发送端的问题;看到多次kill但只出现一次rt_sigaction处理,那就是pending合并了,符合预期。
第二,gdb。在gdb里跑程序,遇到信号可以用handle SIGUSR1 stop设置成停下来,然后用堆栈看信号是在哪个位置打断主程序的。还能用signal SIGUSR1手动向被调试进程发送信号,测试handler行为。排查死锁或者handler崩溃时特别好用。
第三,/proc。每个进程的/proc/<pid>/status里有SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt这几个字段,分别是未决信号、屏蔽信号、忽略信号、捕获信号的位图。比如:
cat /proc/1234/status | grep Sig输出是一串十六进制数字,把每个位展开就能看出进程对哪些信号做了特殊处理。我曾经靠这个字段定位过一个问题:某个服务进程莫名收不到SIGTERM,查SigBlk发现被屏蔽了,再往上查代码,发现某个库在初始化时偷偷用sigprocmask屏蔽了一大堆信号。这种问题光看代码很难想到,看/proc一眼就明白了。
6. 关于信号与线程、与主流语言的那些事
6.1 多线程程序里信号投递到哪个线程?
信号发给进程之后,如果进程里有多个线程,到底哪个线程去处理?答案取决于信号类型和线程的屏蔽设置。标准信号的投递规则是:如果某个线程通过pthread_sigmask屏蔽了该信号,那么内核会选择一个没有屏蔽该信号的线程来投递。如果所有线程都屏蔽了,信号就停留在进程级pending。实时信号则优先投递到调用sigwait等函数等待的线程。
这里有个坑:如果你用kill(pid, sig)向进程发信号,它可能投递到任意一个没有屏蔽该信号的线程,不一定是主线程。所以多线程代码里,如果要让某个特定线程处理信号,最好先在该线程调用pthread_sigmask屏蔽掉不归它管的信号,再由它调用sigwait()或ppoll等待信号到来。这是一种常见的做法:用信号驱动线程退出。比如主线程创建了一个工作线程,工作线程先屏蔽SIGUSR1,然后循环工作;主线程需要退出时,调用pthread_kill(worker_thread, SIGUSR1),工作线程中sigwait就会返回,然后清理退出。
再补充一点:raise()在多线程程序里是向当前调用线程发信号,而不是整个进程。这点和kill(getpid())不同,后者仍然走进程级别的分发逻辑。
6.2 Java进程为什么收到SIGKILL?OOM Killer怎么选的
很多Java运维同学遇到过这样的问题:Java进程突然消失,dmesg里看到Out of memory: Kill process ...。这时候发出的信号就是SIGKILL,由内核的OOM Killer直接发出。SIGKILL的特点是进程甚至来不及执行任何清理代码,所以Java进程的关闭钩子(Shutdown Hook)不会运行,堆栈也不会正常输出。
那OOM Killer是怎么选受害者的?内核有一个oom_score,根据进程的内存占用、运行时间、进程优先级等因素计算,分数越高的进程越容易被选为牺牲者。你可以查看/proc/<pid>/oom_score来验证。如果不想让某个重要进程被选中,可以调整/proc/<pid>/oom_score_adj,给它一个负值,最大可调到-1000,表示禁止被OOM Killer杀掉。
工作中我遇到过数据库进程被OOM Killer误杀,排查后发现是因为机器上其他进程吃光了内存。后来除了优化内存,还在启动脚本里给数据库进程设置了oom_score_adj = -500,优先级立刻提升。注意这个操作需要root权限,而且不能把不重要的进程也设成-1000,否则OOM时内核只能重启你自己了。
6.3 嵌入式Linux与信号:一个点亮LED的例子
在嵌入式Linux项目里,信号常被用作事件通知。简单设备上通常会有一个业务守护进程,需要监听GPIO中断、串口数据、网络包,然后根据输入控制LED或电机。因为这些硬件事件发生时间不规则,用轮询不够实时,用信号刚好合适。
假设有一个/dev/led设备节点,我们希望SIGIO信号触发时点亮LED。这涉及到fcntl设置异步IO通知:进程把设备文件描述符的属主设为自己,并启用F_SETOWN和FASYNC,然后注册SIGIO的handler。当设备内核驱动检测到IO事件时,会向属主进程发送SIGIO信号。这是经典的“异步IO”方式。
#include <fcntl.h> #include <signal.h> #include <unistd.h> static int led_fd; static void sigio_handler(int sig) { char c; read(led_fd, &c, 1); if (c == '1') { write(led_fd, "1", 1); // 点亮LED } } int main() { struct sigaction sa = {.sa_handler = sigio_handler}; sigemptyset(&sa.sa_mask); sigaction(SIGIO, &sa, NULL); led_fd = open("/dev/led", O_RDWR); fcntl(led_fd, F_SETOWN, getpid()); // 设置接收SIGIO的进程 int flags = fcntl(led_fd, F_GETFL); fcntl(led_fd, F_SETFL, flags | O_ASYNC); // 开启异步通知 while (1) pause(); }注意,嵌入式内核里的驱动必须主动调用kill_fasync(),否则用户态设置了半天也没用。很多同学在用户态搞了半天,灯就是不亮,最后发现是驱动没有实现fasync。这是驱动开发里典型的两端配合问题。
当然,我现在做嵌入式Linux项目,更推荐用epoll或者eventfd来做事件驱动,因为信号处理函数限制太多,而且不易调试。但掌握信号仍然很有价值,因为很多老代码和第三方库还在用,出了问题你必须能看懂。
最后分享一个我自己的习惯:每次面试新人,我几乎都会问信号丢不丢的问题。能回答出“标准信号会丢,实时信号会排队,但实时信号队列也有限制”,并且能讲出自己在实际项目中怎么选用信号和IPC的候选人,Linux功底一般不会差。信号这个东西,初学觉得简单,用到深处处处是玄机。多写几个小实验,亲手看看被合并的pending信号、被EINTR打断的read,才能真正理解它。这也是我写这一讲的初衷。