ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux进程信号从原理到实战:kill、sigaction与常见坑

Linux进程信号从原理到实战:kill、sigaction与常见坑 做 Linux 服务端开发的基本都会撞上几件怪事线上进程跑得好好的日志突然停在某一页没报任何异常就没了或者你明明写了kill pid进程却纹丝不动再或者后台脚本莫名其妙变僵尸你kill -9都杀不干净。这些问题表面上是命令用错了内核深处其实是同一个东西在起作用——进程信号。信号在 Linux 整个体系里属于“看着简单细究很深”的那种知识点。它不带业务数据也不存在什么结构体只是一个约定好的数字但就是这个数字可以随时打断一个进程的正常执行流触发它做出某种反应。它是操作系统给进程发“异步通知”的最基础手段也是父子进程协作、进程自杀、终端控制里的底层机制。理解信号等于理解了一半 Linux 下“进程如何被干掉”的故事。这篇文章是进程信号系列的上半篇我打算把信号从产生、挂起、递送到被处理这条完整链路拆开讲再配合一些可以直接抄的代码和实战排查经验。适合刚接触 Linux 编程的同学也适合那些天天用kill但从不深究背后原理的运维和开发。信号相关的坑非常多咱们一个接一个踩平它。1. 信号到底是个什么东西一个随时可能砸到进程头上的编号1.1 先别想着堆栈和上下文信号就是“拍肩膀”很多人第一次接触信号是被kill -9吓到的以为信号就是终结进程的“核弹”。这个理解方向对但不全。信号更像是一种简易的、异步的进程间通知机制内核或者另一个进程觉得需要告诉某个进程“你摊上事了”就往它头上抛一个已经约定好的数字编号。进程收到之后可以选择按默认规则处理也可以忽略或者调用你自己提前注册的处理函数。我经常用“拍肩膀”来类比你在办公室写代码同事走过来拍一下你肩膀说“该吃饭了”。这个拍肩的动作是异步的——你不会在某个固定的指令周期被告知也不知道他什么时候会来。关键是他拍完就走了不会等你把眼前这件事干完再拍。他和你之间传递的信息极少就是“该吃饭了”这个事实本身。信号和进程之间也是这么个意思内核向进程投递一个信号不携带一堆参数只是一个编号告诉你某个事件已经发生了。这个“不携带数据”的特点其实挺重要。信号不是管道不是共享内存它传递的永远是“发生了什么事”而不是“数据是什么”。想用信号传递业务数据方向从一开始就错了。1.2 它是“软件中断”但别跟硬件中断混为一谈教科书里写信号是“软件中断”。很多人看完就开始胡思乱想觉得信号和 CPU 中断一个级别。真要这么理解后面读源码会越读越歪。硬件中断是 CPU 层面的机制设备往中断控制器发信号CPU 收到后暂停当前指令流跳到内核中断处理程序。而信号是内核通过软件手段模拟出来的“中断聊感”它用来打断用户态正在执行的代码。两者层次完全不同但有一个核心思想是共通的都是一种“外部触发”的异步打断接着再恢复之前的执行现场。说信号是“软件中断”主要是因为它和硬中断一样具有异步性和打断性。进程正在跑用户态代码信号来了如果被允许处理进程会临时跳到信号处理函数处理函数跑完再回到原来的指令流接着跑。这段“跳出去再跳回来”的现场保护、恢复是由内核负责的用户态完全感知不到。理解这个区别之后你就明白为什么信号处理函数里的代码要非常克制因为你不知道当前进程到底执行到哪一行了。可能它手里正捏着一把锁可能它的缓冲块里还有半截没写完的数据你的处理函数就直接插进来了。这种随意打断带来的并发问题是信号这玩意儿最磨人的地方。1.3 信号能干什么不能干什么信号能干的活现实中其实很集中终端控制比如按 CtrlC 给前台进程发 SIGINT 终止它按 CtrlZ 发 SIGTSTP 暂停它异常通知比如进程访问了非法内存内核发 SIGSEGV除法除零发 SIGFPE进程间异步通知比如kill(pid, sig)主动通知另一个进程定时器通知alarm()到期后内核发 SIGALRM子进程状态变化通知子进程退出时向父进程发 SIGCHLD。信号不能干什么它做不了精细的双向通信。它没有业务载荷没有应答机制多个同类信号还可能被合并。任何希望“一个信号带点数据过去再带点数据回来”的需求都不该用信号去满足。把信号的定位摆正了再往下看生命周期就不会被各种细节绕晕。2. 一次信号传递的完整链路从产生到进程被真正“弹”一下2.1 信号从哪里来四个常见入口很多人以为信号就是kill命令发出去的其实kill只是最直观的一个入口。信号产生的途径至少有四类用户/外部命令触发kill -s INT 1234、kill -9 1234内部对应kill(pid, sig)系统调用终端按键触发CtrlC 产生 SIGINT、Ctrl\ 产生 SIGQUIT、CtrlZ 产生 SIGTSTP是终端驱动向前台进程组广播的软件条件触发进程调用alarm()定时到期发 SIGALRM调用abort()发 SIGABRT管道对端关闭后写管道会收到 SIGPIPE硬件异常触发CPU 执行指令过程中发现除零、非法内存访问、断点指令由内核负责把异常翻译成对应信号再发给当前进程。有一点容易被忽略硬件异常比如段错误并不等同于信号已经发送完毕。CPU 先陷入内核内核根据异常原因打一个对应的信号标记到当前进程上然后返回用户态。也就是说SIGSEGV 不是内核凭空“想”出来的它原本是一条 CPU 异常内核把它翻译成了信号这种形式让进程有机会在用户态处理。2.2 信号产生之后先“挂账”pending 未决状态信号不是一拍肩膀进程就必须立刻停下手里的事去处理。它还有一个“等待投递”的中间状态叫 pending未决。这里得先分清两个概念信号产生generated和信号递送delivered是两回事。信号产生之后内核会把它记到进程的 pending 位图里但真正让进程停下来跳转到处理函数是在一个特定的检查时机才做的。这个时机就是进程从内核态返回用户态的那一瞬间。为什么不在内核里立刻处理因为信号处理函数是用户态代码内核不能在自己的栈上直接执行用户态函数。内核能做的只是把当前的用户态上下文保存好把指令指针改到处理函数地址等进程切回用户态后自然“跑偏”到处理函数里。所以内核在某个检查点发现进程有 pending 信号就会去安排这段“跑偏”。如果进程当前正跑在 CPU 上、处于用户态信号来了怎么办进程不会马上跳走。它会继续执行当前指令直到下一次进入内核、再从内核返回用户态时才有机会被检查。这意味着信号的“延迟”通常只有几条指令那么短但如果进程一直待在内核里比如阻塞在磁盘 IO 上信号就会一直挂着直到它返回用户态。2.3 内核在哪个检查点执行信号处理内核检查 pending 信号的时机比你想的要多。最基本的检查点在进程从内核态返回到用户态时系统调用结束返回、中断处理结束返回、异常返回。每次返回之前内核都会先看有没有待处理的信号有的话就选择一个未阻塞的信号开始递送。所谓“选择”也是有讲究的标准信号不排队如果同时收到了多个信号内核会按内部策略选择一个先处理。具体顺序一般不需要我们关心因为绝大多数场景下帮忙注意一点就够了不要写“依赖多个 pending 信号的处理先后顺序”的程序标准信号根本不保证你想要的顺序。递送的过程展开说大概是用户态上下文被拷贝到内核栈上处理器状态被修改为进入信号处理函数同时构造一个特殊的返回帧。当处理函数执行return时它不是普通返回调用者而是通过一个叫sigreturn的系统调用回到内核内核再从这个返回帧里恢复原先的用户态上下文。这样进程看起来就像没被打断过一样。2.4 系统调用被信号打断的那些事儿EINTR这是信号机制里非常经典的一个坑值得单独拎出来讲。进程执行read()之类阻塞型系统调用时如果收到一个信号且该信号被捕获那么系统调用不会默默忽略信号继续阻塞而是直接返回一个错误-1且errno等于EINTR。原因很简单信号处理函数需要占用 CPU 执行用户态代码系统调用继续阻塞会违背“尽快处理信号”的语义所以内核干脆把系统调用打断让你先在 handler 里干该干的事。等 handler 执行完系统调用是否可以自动恢复取决于 sigaction 里有没有设置SA_RESTART标志。很多初学者第一次遇到底层网络库或文件读写返回EINTR以为是代码写错了其实这是信号机制的必然结果。处理手段有两个层次在sigaction结构中设置sa_flags | SA_RESTART多数系统调用会被自动重启但有几类比如poll()、select()、epoll_wait()在某些内核版本上不保证自动重启所以代码里仍然要做好EINTR重试更稳妥的做法是封装一层循环遇到EINTR判断一下“是否需要继续”然后重新调用原系统调用。了解了这层机制之后你再看那些“信号一到read 突然返回 -1”的诡异 bug就完全有线索可查了。3. 常见信号逐个过一遍编号、默认行为与典型触发场景3.1 一张表看懂 Linux 常用信号系统完整信号列表不同架构略有差异。x86-64 下这张表是你日常工作中最高频会遇到的信号编号默认行为常见触发场景SIGHUP1终止进程终端挂断或进程被要求重新读配置SIGINT2终止进程CtrlCSIGQUIT3终止并产生核心转储Ctrl\SIGILL4终止并产生核心转储非法指令SIGABRT6终止并产生核心转储abort() 调用SIGFPE8终止并产生核心转储除零、浮点异常SIGKILL9终止进程kill -9无法拦截SIGUSR110终止进程用户自定义事件SIGSEGV11终止并产生核心转储非法内存访问SIGUSR212终止进程用户自定义事件SIGPIPE13终止进程向无读端的管道/ socket 写数据SIGALRM14终止进程alarm() 定时器到期SIGTERM15终止进程kill 默认发送SIGCHLD17忽略子进程停止/退出SIGCONT18继续进程恢复被暂停的进程SIGSTOP19暂停进程kill -STOP无法拦截SIGTSTP20暂停进程CtrlZ这张表不需要背但建议至少把这些信号的默认行为记在肌肉里特别是 9、15、2 三兄弟它们直接关系到你怎么“关掉一个进程”。3.2 为什么 SIGKILL 和 SIGSTOP 动不了信号能捕获、能忽略、能阻塞但不是所有信号都行。SIGKILL9和 SIGSTOP19这两个信号既不能被捕获也不能被忽略。你注册它们的处理函数内核理都不理你在代码里把它们加入阻塞集合也拦不住。这俩是内核交付给系统管理员的“终极手段”。设计上是有意的如果所有信号都能被进程屏蔽那一个恶意或者出 bug 的进程就可以把自己保护得滴水不漏系统管理员拿它毫无办法。所以内核保证了 SIGKILL 和 SIGSTOP 总是能执行前者让进程当场被杀死不会给清理资源的机会后者让进程当场暂停也无法提前注册清理动作。这也带来了一个非常现实的运维教训kill -9是最后手段不是第一手段。因为 SIGKILL 不给进程任何善后机会进程持有的临时文件、锁文件、网络连接状态都可能遗留成一堆烂摊子。日常优雅停机正确顺序是先 SIGTERM15让进程自己清理等一段时间没用再考虑 SIGKILL 兜底。3.3 SIGCHLD 和僵尸进程的关联单独拎出 SIGCHLD是因为僵尸进程几乎每个做后端的人都会撞上。子进程退出时内核会向父进程发送 SIGCHLD 信号。为什么要发因为父进程需要知道这个信息然后调用wait()或waitpid()回收子进程的退出状态。如果父进程一直不回收子进程就变成僵尸进程已经死了但进程表项还在PCB 还占着内核资源。如果把SIGCHLD的处理方式设为SIG_IGN行为会比较特殊非但等于“父进程不关心子进程退出”而且内核会在子进程退出时自动回收它不再产生僵尸。Linux 下这个语义是成立的很多守护进程就是这么避免僵尸的。另一种更主动的办法是写一个 SIGCHLD 的 handler在里面循环调用waitpid(-1, status, WNOHANG)直到返回 0 或 -1。这个写法既能回收子进程又能避免 handler 被打断时序导致漏回收。这里有个细节SIGCHLD 的 handler 里调用waitpid时一定要用WNOHANG并且循环回收否则同一时刻多个子进程退出信号只来一次标准信号不排队你只waitpid一次剩下的子进程就僵在那边了。4. 用代码接住信号signal() 与 sigaction() 的实战对比4.1 为什么我不想用 signal() 注册处理函数很多 C 入门教材教的是signal(SIGINT, handler)上手快、代码短。但只要你写过需要跨平台或稍微复杂一点的专业代码很快就会放弃它。原因很现实signal()在不同 Unix 系统上语义不一致历史上有的实现在处理函数执行完之后会把处置方式重置回默认行为。打个比方你第一次按 CtrlC 有效第二次再按进程就用默认方式直接死给你看。这听起来像老古董问题但在写可移植代码时依然适用。Linux 的 glibc 里signal()目前的语义已经演进得和sigaction基本一致了但 POSIX 并没有彻底统一它的行为。与其赌这个不如一开始就用sigaction——它参数多一点但语义清晰跨平台可控。4.2 sigaction 的正确用法和关键参数sigaction()的原型是这样的#include signal.h int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);重点在struct sigaction它里面有这么几个核心字段sa_handler处理函数指针也可以填SIG_IGN或SIG_DFLsa_mask处理这个信号期间额外阻塞哪些信号。注意它是“在处理函数执行期间临时追加的阻塞集合”不是常驻阻塞集合sa_flags一堆标志位最常用的是SA_RESTART还有SA_SIGINFO用三参数处理函数sa_sigaction当sa_flags设置了SA_SIGINFO时使用这个三参数处理函数可以拿到信号的来源、附加信息等。一个常见的初始化套路是struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGINT, sa, NULL);不熟悉的人会犯的错包括没清空sa_mask就使用里面残留垃圾值没设SA_RESTART导致慢系统调用频繁EINTR以及没注意sa_flags里SA_SIGINFO和sa_handler是共用体关系——设了SA_SIGINFO却填了sa_handler等于没生效。4.3 信号处理函数里千万别乱来可重入与原子操作这是信号编程里最重要的一条红线处理函数里只能调用异步信号安全的函数。什么是异步信号安全简单理解就是这个函数可以被信号处理函数打断、重入执行且不会产生副作用。比如write()、read()、open()、close()、_exit()是安全的而printf()、malloc()、free()、pthread_mutex_lock()这些统统不安全。为什么不安全你想进程主流程正在执行printf()内部持有 stdio 的锁刚把格式化一半的数据写进缓冲区此时信号到达处理函数里也调printf()它就试图去拿同一把锁然后自己把自己锁死。这不仅会导致输出混乱严重时直接死锁。malloc也是类似道理堆管理内部有全局状态主流程和 handler 同时操作堆结构直接损坏。所以业界通用的做法是handler 里只干两件事写一个全局标志变量最好类型是volatile sig_atomic_t或者直接调用_exit()紧急退出。sig_atomic_t保证读写是原子的volatile防止编译器把它优化到寄存器里导致主循环看不到变化。真正的业务清理放到主程序循环里去判断标志之后再执行。4.4 一个可以直接跑的优雅退出示例下面这个例子很小但把上面说的几个要点都覆盖了handler 只置标志主循环轮询标志不使用任何非异步安全的库函数做清理。#include stdio.h #include signal.h #include stdlib.h #include unistd.h static volatile sig_atomic_t g_stop 0; static void handle_sigint(int sig) { g_stop 1; } int main(void) { struct sigaction sa; sa.sa_handler handle_sigint; sigemptyset(sa.sa_mask); sa.sa_flags 0; if (sigaction(SIGINT, sa, NULL) -1) { perror(sigaction); exit(EXIT_FAILURE); } while (!g_stop) { write(STDOUT_FILENO, ., 1); sleep(1); } write(STDOUT_FILENO, \nreceived SIGINT, cleaning up...\n, 34); return 0; }编译运行gcc -o sig_demo sig_demo.c ./sig_demo程序会每秒打一个点按下 CtrlC 后打印清理信息正常退出退出码是 0。这个模式在后端服务里非常常见收到 TERM/INT 信号后置标志停止接收新请求等待正在处理的请求完成再做资源释放最后退出。比在 handler 里直接做一堆复杂操作要稳太多。5. 实战中那些让你抓狂的隐藏坑5.1 进程 exec 之后信号处理器被重置这个坑特别隐蔽你写了一个守护进程在启动脚本里先忽略 SIGINT然后exec了一个子进程。这时你发现子进程好像也忽略了 SIGINTCtrlC 杀不掉它。原因在于 POSIX 规定exec时捕获过的信号处理器会被重置为默认行为但被忽略的信号即设成SIG_IGN的会保持忽略。所以如果父进程继承了“忽略 SIGINT”的设置exec 出的子程序即使代码里本来打算捕获 SIGINT也会发现注册毫无效果——因为它的初始化代码根本还没跑信号处理方式就已经被昨天设定的东西污染了。现实中这个坑常出现在“用脚本 nohup 启动服务”和“在容器里用 PID 1 启动子进程”这两种场景。解决方案是在 exec 出的新程序里尽早对关心信号调用signal()或sigaction()重新设置无论如何不要假设进程启动时的信号处置方式是干净的。真正想清理干净可以在exec之前把信号统统设回SIG_DFL但更工程化的做法是让程序启动入口第一时间显式设置。5.2 不可靠信号会丢标准信号不排队这是标准信号和实时信号最本质的区别。对于 SIGINT、SIGTERM 这些标准信号内核在进程的 pending 位图里只留一个 bit 来表示“这个信号有没有到过”不记录到底来了几次。也就是说进程 ·快速产生三次 SIGUSR1·内核可能只递送一次另外两次就丢了。这不一定是坏事。信号本身表达的是“有事件发生”重点是通知性质不是精确计数。但如果你把信号当计数器用比如“来一个信号就处理一个任务”标准信号很快就露馅。如果确实需要排队就要走另一种方案使用实时信号。它们的编号范围是SIGRTMIN到SIGRTMAX内核以链表形式排队的同一个实时信号可以多次挂起。不过实时信号同样有限制队列长度受RLIMIT_SIGPENDING限制队列满后再次发送会直接丢弃或者返回错误。这个话题比较深属于信号系列的进阶内容之后单独开篇再聊。5.3 处理函数里写 printf我见得太多了毫不夸张地说十个写信号处理函数的初学者九个会在里面写printf。吃亏的方式也都类似程序偶尔崩崩的状态不明日志错乱甚至直接死锁。就算你以为自己只用printf看个调试信息它内部也涉及锁和缓冲区不满足异步信号安全的要求。真要调试信号处理流程建议用write()直接往标准错误输出写一段字节虽然格式控制麻烦但它是异步安全的不会让你调试信号的过程中再叠加一个新 bug。等确认逻辑正确再把那段write拿掉。还有更实用的一招handler 里用内部自增计数器主循环里定时或每隔一段检查一次计数器最后统一用标准库函数记录到日志文件。这也是信号量 主循环协作式处理模式的核心。5.4 查信号状态的手段strace、gdb 和 /proc排查信号问题纯粹靠printf加 log 是很笨的。Linux 提供了好几层工具用kill -l快速列出当前系统所有信号编号和名字用strace -e tracesignal -p pid只看这个进程收到的信号相关系统调用能看到xxx(pid, SIGTERM, ...)调用和返回用/proc/pid/status里的信号位图字段SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt。这些都是十六进制掩码某一位为 1 表示对应编号的信号处于对应状态。比如SigCgt里某位置 1代表进程捕获了这个信号SigBlk里置 1代表该信号被阻塞暂不递送用 gdb 调信号相关的行为也很方便handle SIGPIPE nostop noprint pass控制某个信号是否让程序停下来、是否打印信息、是否继续传给程序info signals能列出当前所有信号的 gdb 默认处置。这套组合拳打下来绝大多数“进程被谁杀了”“进程为什么收不到信号”的问题都能定位。尤其是SigIgn位图往往能直接告诉你“这个进程对 SIGTERM 到底是不是忽略的”比对着代码猜要快得多。6. 这一篇先吃透骨架下一篇再聊阻塞和等待进程信号的上半篇到这里正好是把骨架搭完信号的本质、产生、挂起、递送、处理方式以及写处理器时要带上的“安全锁”。如果你还停留在只是会kill的阶段看到这里你应该已经能理解为什么有时候kill -9是唯一选择为什么kill完进程日志里连一点清理迹象都没有以及为什么某些“应该收到”的信号进程始终像没看见一样。信号更进阶的部分——比如用sigprocmask/pthread_sigmask精确阻塞信号、用sigpending实时查询挂起状态、sigaction里SA_SIGINFO的三参数处理器、sigwaitinfo配合多线程处理信号、以及实时信号的排队机制——这些我准备放在系列的下篇继续。到时候会结合一个实际的“优雅退出 平滑重载”例子把阻塞、等待、递送三者串起来那个场景才是信号真正发力的地方。先把这一篇里的概念吃透下篇的代码你会看得更顺。
返回列表