☰
Linux下SIGPIPE信号致进程退出的排查与处理:从send()到服务端稳定性
2026/10/18 3:44:17 网站建设 项目流程

做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里看到的退出码一般是128+13=141。对照一下:137是OOM(128+9),139是段错误(128+11),141就是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,进程直接退出。

我画了个简化流程,方便大家对照自己的代码:

  1. 客户端崩溃或者网络断开,操作系统发FIN/RST到服务端。
  2. 服务端事件循环还没来得及处理关闭事件(甚至有些代码压根没注册读事件,只注册了写事件)。
  3. 业务线程调用send(),第一次"成功",实际数据发到对端被RST回应过来。
  4. 第二次调用send(),内核发现连接已死,发出SIGPIPE,进程挂掉。

这类场景在消息推送、实时行情推送、在线游戏服务端里极其常见。客户端数量一上来,几乎每天都有那么几个异常断开,如果没有处理,服务必然动不动就"神秘退出"。

3.2 连接池里的半开连接

另一个高发场景是连接池。很多服务为了省去握手开销,维护了一堆空闲连接,用的时候直接拿来send()。空闲连接在长时间没有数据流动时,对端可能因为超时策略把它关了,但服务端不知道。

这种"服务端认为连接还活着、对端早就关了"的连接叫半开连接。半开连接上的第一次send(),有很大概率触发对端RST;第二次send(),SIGPIPE就来了。尤其在高并发请求下,大量线程同时往失效连接上写数据,进程猝死概率成倍上升。

我见过最夸张的一次事故,某网关服务维护了上万条到下游的TCP长连接,下游做了一次负载均衡切换,所有连接被对端直接RST,网关进程在几秒钟内就死掉了。当时的修复方案就是在启动时一行signal(SIGPIPE, SIG_IGN),进程存活率立刻恢复。这让我深刻理解到:连接池越大,越要重视SIGPIPE。

3.3 一些"反直觉"的触发细节

还有几个容易忽略的触发细节,想单独拿出来强调:

  • 如果对端调用close()前先设置了SO_LINGER且linger时间为0,close()会直接发RST而不是FIN。这种情况服务端甚至不需要"第二次send()",第一次send()就可能撞上RST。
  • UDP的sendto()一般不会触发SIGPIPE,因为UDP无连接状态,但这个坑在TCP上雷打不动。
  • 除了socket,管道write()同样会触发SIGPIPE,所以某些临时用pipe做进程间通信的程序,也会莫名退出。
  • 多线程环境下,SIGPIPE发给进程的哪个线程是不确定的,所以就算你在某个工作线程里试图捕获SIGPIPE,也不一定能兜住,正确的做法是全局忽略,而不是局部捕获。

4. 修复方案:忽略信号与MSG_NOSIGNAL

4.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_NOSIGNAL

Linux还提供了一个更精细的选项:在send()的flags参数里带上MSG_NOSIGNAL。这样即便是同一次调用触发了对端RST,内核也不会发SIGPIPE,send()通过返回值表达错误。

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在作怪。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询