Linux下SIGPIPE信号致进程退出的排查与处理:从send()到服务端稳定性

发布时间:2026/10/18 3:44:27
Linux下SIGPIPE信号致进程退出的排查与处理:从send()到服务端稳定性
做Linux服务端开发的人大概都经历过这种半夜被叫醒的场面服务进程消失了日志没有报错core dump没生成dmesg干干净净。排查一圈下来最后发现是socket通信里send()函数触发的SIGPIPE信号把进程带走了。这个坑在TCP长连接场景里非常普遍尤其是做消息推送、网关代理、在线IM这类业务的朋友几乎早晚会撞上。这篇文章我会完整还原这类问题的排查过程讲清楚send()为什么能把进程搞死以及两种修复手段的取舍和一系列容易被忽略的边界场景希望能帮你少走弯路。1. 一次诡异的进程退出现象与初步排查1.1 先说说我遇到的现场我接手过某公司一个TCP长连接推送服务负责给几千个在线终端维持连接并下发指令。服务是C写的基于epoll事件循环单进程多线程模型线上跑了大半年都没事。某天运维告警说进程掉了重启之后过一阵又掉看起来没有任何规律。诡异的地方在于普通段错误会有core文件OOM会在dmesg里留下Killed process的记录主动kill也会在进程管理工具的日志里体现。而这个进程什么都不留。日志最后一条是很普通的消息发送记录之后就没了。第一次遇到这种情况如果只盯着业务日志看真的会一头雾水。1.2 我当时的排查链路这里把完整排查链路写出来给同样踩坑的朋友参考第一步确认退出状态。如果进程是由shell直接启动的终端会显示 Killed。如果是由systemd、supervisor这类工具托管的看退出码。SIGPIPE是第13号信号shell里看到的退出码一般是12813141。对照一下137是OOM1289139是段错误12811141就是SIGPIPE。这个对照表我后来基本刻在脑子里了排查问题第一步就能锁定方向。第二步用gdb复现。直接gdb跑服务等它再次崩溃时gdb会明确提示Program terminated with signal SIGPIPE, Broken pipe。这一刻基本就把问题定性了。第三步查调用栈。在gdb里执行bt能看到进程最后停在send()或者write()的系统调用上。结合日志里最后一条发送消息的记录基本能断定是往一个已经关闭的socket连接发送数据导致的。这里有个重要经验SIGPIPE导致的进程终止在dmesg里不会有任何记录。它不是硬件异常、不是非法内存访问只是信号默认处理逻辑把进程结束了。所以排查时不要死磕系统日志先算退出码再上gdb效率最高。1.3 为什么业务日志帮不上忙很多服务打日志是异步或者缓冲的进程被信号终止时还没来得及flush的日志就丢了。再加上发送消息这块代码往往只打印发送成功级别的日志很少打印send()的返回值判断分支所以故障现场看起来就是毫无征兆的死亡。这也是我后来坚持所有网络发送关键路径都必须检查返回值并且打error日志的原因没有返回值检查的发送代码出了事等于没有现场。2. 拆解send()与SIGPIPE的完整机制2.1 一个socket连接在关闭时发生了什么要理解这个问题得先理清TCP关闭过程中的两个关键包FIN和RST。正常情况下一方调用close()关闭连接内核会发送FIN包给对方表示我要关闭发送通道。对端收到FIN后如果它也想关闭就回FIN双方完成四次挥手连接优雅关闭。这种关闭方式是协商式的对端能感知到连接状态变化。另一种情况是RST包。RST是TCP协议里的硬重置收到RST的一方会直接丢弃连接状态所有未完成的数据都作废。什么情况下会产生RST呢最常见的就是对端已经完全关闭了连接或者根本没有对应端口在监听你却还在往这个连接上发送数据对端内核收到数据后找不到对应连接状态就会回一个RST。2.2 send()出错时信号是谁发出来的关键点来了。对于一个已经建立了TCP连接的socket如果你在收到RST之后再调用send()Linux内核的socket层会向当前进程发送SIGPIPE信号。注意第一次send()往往不会立刻触发RST而是把数据正常发出去了紧接着对端回RST第二次send()才会触发SIGPIPE。所以很多同学做实验时发现第一次send()没问题就容易忽略这个坑。我再用一个生活化的类比解释下你把一封信投进邮筒邮递员发现收件人已经搬家把信退回来告诉你查无此人。你第二次再投邮递员直接就不收了。第一次send相当于信投出去第二次send就是内核告诉你别再写了对方不存在。在socket层面这个邮递员就是内核协议栈它发现RST之后再收到写入请求就直接发SIGPIPE。2.3 为什么写socket和写管道会触发同样的信号从Unix设计哲学出发就很好理解write()到管道、write()到socket在内核里都走类似的写缓冲机制。对端关闭后写缓冲不可再写内核直接给进程一个信号而不是仅仅让write返回-1。这个设计在shell管道时代非常合理比如 cat file | grep 关键字如果grep提前退出cat继续写管道就会收到SIGPIPE进程结束shell正好收场。但同样的逻辑放到长连接服务器上就成了灾难——你只是遇到一个掉线的客户端而已整个服务却要陪葬。这就是SIGPIPE被称为服务端第一杀手的原因。当然这个设计也不是没有道理如果内核不给信号、只返回错误码那些没检查返回值的代码就会无限循环地往死连接写数据浪费CPU还污染日志内核用SIGPIPE强行打断某种程度上是在保护系统整体健康。3. 最容易踩中这个坑的三类业务场景3.1 客户端断连后服务端继续发送最典型的场景移动端的App在弱网环境下TCP连接实际上已经断了但服务端还没感知到。服务端下一次推送消息时第一次send()正常返回等RST回来第二次send()就触发SIGPIPE进程直接退出。我画了个简化流程方便大家对照自己的代码客户端崩溃或者网络断开操作系统发FIN/RST到服务端。服务端事件循环还没来得及处理关闭事件甚至有些代码压根没注册读事件只注册了写事件。业务线程调用send()第一次成功实际数据发到对端被RST回应过来。第二次调用send()内核发现连接已死发出SIGPIPE进程挂掉。这类场景在消息推送、实时行情推送、在线游戏服务端里极其常见。客户端数量一上来几乎每天都有那么几个异常断开如果没有处理服务必然动不动就神秘退出。3.2 连接池里的半开连接另一个高发场景是连接池。很多服务为了省去握手开销维护了一堆空闲连接用的时候直接拿来send()。空闲连接在长时间没有数据流动时对端可能因为超时策略把它关了但服务端不知道。这种服务端认为连接还活着、对端早就关了的连接叫半开连接。半开连接上的第一次send()有很大概率触发对端RST第二次send()SIGPIPE就来了。尤其在高并发请求下大量线程同时往失效连接上写数据进程猝死概率成倍上升。我见过最夸张的一次事故某网关服务维护了上万条到下游的TCP长连接下游做了一次负载均衡切换所有连接被对端直接RST网关进程在几秒钟内就死掉了。当时的修复方案就是在启动时一行signal(SIGPIPE, SIG_IGN)进程存活率立刻恢复。这让我深刻理解到连接池越大越要重视SIGPIPE。3.3 一些反直觉的触发细节还有几个容易忽略的触发细节想单独拿出来强调如果对端调用close()前先设置了SO_LINGER且linger时间为0close()会直接发RST而不是FIN。这种情况服务端甚至不需要第二次send()第一次send()就可能撞上RST。UDP的sendto()一般不会触发SIGPIPE因为UDP无连接状态但这个坑在TCP上雷打不动。除了socket管道write()同样会触发SIGPIPE所以某些临时用pipe做进程间通信的程序也会莫名退出。多线程环境下SIGPIPE发给进程的哪个线程是不确定的所以就算你在某个工作线程里试图捕获SIGPIPE也不一定能兜住正确的做法是全局忽略而不是局部捕获。4. 修复方案忽略信号与MSG_NOSIGNAL4.1 方案一全局忽略SIGPIPE最简单粗暴但也是最长效的做法就是在程序启动阶段加上一句#include signal.h int main(int argc, char *argv[]) { // 忽略SIGPIPE让send()通过返回值报错而不是杀掉进程 signal(SIGPIPE, SIG_IGN); // ... 其他初始化逻辑 }signal换成sigaction更可靠因为signal()在不同平台上的语义略有差异而且可能被某些库覆盖。生产环境我建议这样写#include signal.h static void setup_signal_handler(void) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler SIG_IGN; sigemptyset(sa.sa_mask); sa.sa_flags 0; if (sigaction(SIGPIPE, sa, NULL) 0) { perror(sigaction(SIGPIPE)); exit(1); } }忽略之后内核不再发信号send()会返回-1同时errno被设置为EPIPE。代码里只要正确处理send()的返回值就能优雅地跳过这个坏连接不影响整个服务。对于大多数单体服务进程这个方案就够用了。一行代码把进程的存活和单个连接的故障彻底解耦性价比极高。注意必须在主线程最早期的阶段调用因为服务起来之后会有多个线程如果某个线程先触发了一次SIGPIPE再设置忽略进程已经死了设置就没意义。4.2 方案二send()调用时指定MSG_NOSIGNALLinux还提供了一个更精细的选项在send()的flags参数里带上MSG_NOSIGNAL。这样即便是同一次调用触发了对端RST内核也不会发SIGPIPEsend()通过返回值表达错误。ssize_t n send(sockfd, buf, len, MSG_NOSIGNAL); if (n 0) { if (errno EPIPE || errno ECONNRESET) { // 对端已关闭连接走连接清理流程 close_conn(sockfd); return; } // 其他错误按业务需求处理 }MSG_NOSIGNAL的好处是作用域精确到单次send()调用不会影响进程里其他那些希望保持SIGPIPE默认行为的地方。坏处是得记得在每一处send()都写上如果代码里send()调用分散在几十个文件很容易漏掉一两处。4.3 两种方案怎么选我用一张表总结下我的取舍经验方案生效范围优点缺点适用场景sigaction(SIGPIPE, SIG_IGN)整个进程一处设置全局生效代码改动小影响所有SIGPIPE场景无差别忽略大多数守护进程、服务端程序send(..., MSG_NOSIGNAL)单次调用精确控制不改变全局信号行为每处send()都要写容易遗漏对信号行为有严格要求的部分模块如果是在维护老项目我推荐先全局忽略SIGPIPE把服务救活再说。如果是新项目可以在封装网络层时统一带上MSG_NOSIGNAL并严格要求调用方检查返回值。我自己的习惯是两者同时做全局sigaction(SIGPIPE, SIG_IGN)兜底网络模块内部调用send/recv时再显式加MSG_NOSIGNAL双保险。提示不要试图捕获SIGPIPE然后打日志再返回。SIGPIPE信号的捕获时机不可控在多线程环境下处理信号极其容易出问题而且信号处理函数里能做的操作非常受限。老老实实SIG_IGN就好错误信息通过errno拿。5. 修复之外让网络层更抗造5.1 把send()返回值当成一等公民忽略SIGPIPE只是第一步真正让服务稳下来的是严谨的返回值处理。很多代码里send()之后根本不检查返回值这是隐藏炸弹。合理的写法是ssize_t n send(sockfd, buf, len, MSG_NOSIGNAL); if (n -1) { // 对端关闭 if (errno EPIPE) { log_error(client closed, fd%d, sockfd); close(sockfd); return; } // 连接被重置 if (errno ECONNRESET) { log_error(connection reset, fd%d, sockfd); close(sockfd); return; } // 非阻塞socket缓冲区满等待可写事件再发 if (errno EAGAIN || errno EWOULDBLOCK) { add_to_writable_queue(sockfd, buf, len); return; } // 其他错误 log_error(send error, fd%d, errno%d, sockfd, errno); close(sockfd); }这种做法把每种errno归类处理EPIPE和ECONNRESET都说明连接已经不可用该关就关EAGAIN说明发送缓冲区满要等事件循环下次可写事件再发而不是死循环重试。做到这一步至少不会再出现进程莫名消失的问题。5.2 用epoll的EPOLLERR和EPOLLHUP及时清理坏连接在很多事件循环架构里服务端只关注EPOLLIN和EPOLLOUT事件忽略了EPOLLERR和EPOLLHUP。当对端异常断开时内核会往epoll里塞这两个事件中的一个。如果代码里没处理这个连接就会一直躺在fd集合里成为僵尸连接一旦被业务线程捞起来发送就是隐患。所以epoll的事件判断里一定要对EPOLLERR和EPOLLHUP单独处理直接关闭连接并清理资源if (events[i].events (EPOLLERR | EPOLLHUP)) { // 对端异常或挂断直接关闭连接 close_conn(events[i].data.fd); continue; }配合send()返回值检查双管齐下才能把坏连接及时清出去。我见过不少服务明明SIGPIPE已经处理了但fd泄漏和僵尸连接还在慢慢累积最后把fd耗尽问题换了个马甲又回来了。5.3 心跳机制让死连接现形TCP本身有个SO_KEEPALIVE选项默认2小时探测一次对大部分业务来说太慢了。要快速发现死连接还是得靠业务层心跳。一个简单可落地的做法是服务端基于epoll_wait的超时时间做周期性扫描记录每个socket最近一次收到数据的时间如果超过阈值比如90秒就主动close。客户端这边则定时发送心跳包。这样服务端的fd集合里很少会堆积半开连接send()撞上SIGPIPE的概率也大幅降低。心跳包的实现不复杂但要注意不要把心跳逻辑和业务逻辑混在一起建议单独用一个定时器线程或者时间轮来处理避免阻塞主循环。5.4 监控与告警要做在事前经历过一次凌晨三点查进程为什么没了之后我养成了两个习惯。第一个是在所有send()/write()出错的地方打结构化日志至少包含fd、errno、连接存活时长、对端地址这几个字段。第二个是把进程被信号终止也纳入监控不要只监控业务日志——像退出码为141这种信号退出只要监控系统愿意看退出码马上就能发现。还有一个容易忽略的点当进程收到SIGPIPE时系统不会产生core dump除非显式设置了相关rlimit所以不要指望core文件能救你。真正能救你的只有代码里对send()返回值的敬畏以及早早在启动阶段设置好的信号处理。最后分享一个个人经验做网络编程一定要把连接是会死的这句话刻在脑子里。不管是客户端还是服务端任何一条TCP连接随时可能因为网络波动、对端崩溃、NAT超时等原因失效代码里每一处send()都要假设它可能失败并且准备好处理失败。我在修复完这个案例之后还专门把所有历史代码里裸奔的write()/send()调用都扫了一遍把返回值检查补全顺手把心跳和EPOLLERR处理也加了。这个习惯后来帮我避免了很多类似的线上事故。如果你正在为服务进程无缘无故消失发愁不妨先看一眼退出码是不是141八成就是SIGPIPE在作怪。