场景其实特别常见:排查线上服务时,发现进程突然消失,翻日志却什么都没打印,最后一看是Linux网络编程里那个“老熟人”——socket的send()函数返回了错误,进程直接终止退出。很多人第一反应是“send返回-1我处理了啊”,但真正的问题往往不只是错误码,而是进程在错误码还没到手之前,先被SIGPIPE信号干掉了。这条链路的坑很深,涉及socket读写语义、内核信号机制、阻塞与非阻塞行为,我打算把这几年调这类问题攒下来的经验完整拆一遍,给做网络服务的同学一个可直接照抄的排查和修复方案。
这个内容适合所有用C/C++在Linux下写网络程序的人,尤其是搞即时通信、消息推送、游戏服务器、代理网关这类高并发长连接业务的。不管你是刚接手一段老代码,还是正在写新服务的socket封装,搞清楚send()与SIGPIPE的关系、错误码的语义、以及怎么安全封装发送函数,都能少踩很多不必要的坑。
1. 现象重现:send()出错怎么会把整个进程带走
1.1 从一场线上事故说起
之前帮某推送服务排查过一次故障。现象很诡异:服务运行几分钟到几个小时不等,进程就会消失,没有core dump,没有业务异常日志,systemd里显示的是异常退出。一开始怀疑内存问题,查了OOM记录没有;怀疑死锁,看线程栈也没发现;最后用gdb挂着跑,等它崩,这才定位到真正的退出点。
使用gdb -p <pid>挂上去,等服务崩掉,gdb会停下来提示收到了SIGPIPE。再看调用栈,栈顶函数就是send(),底层是__libc_send。也就是说,进程是在调用send()发数据时,被系统送来的SIGPIPE信号终止的。很多同学在这里就会疑惑:send()明明有返回值,出错时会返回-1,我们只要判断返回值不就行了?为什么进程还没等我们的代码判断,就已经没了?
因为SIGPIPE信号的默认动作就是终止进程,它根本不等你的代码反应过来。信号是内核在检测到异常发送条件时立刻投递并执行默认处理的,而send()返回错误码只是信号处理完之后,系统调用退出的副产品。进程都没了,后面if (n < 0)这段代码永远不会执行。
1.2 事故现场的典型特征
这类故障有几个很明显的特征,符合两条以上基本可以往SIGPIPE方向查:
- 进程突然消失,业务日志里没有对应记录;
- dmesg或journal里看不到OOM、段错误之类的痕迹;
- 服务通常运行在长连接场景,有大量客户端主动断开或异常断网;
- 用
echo $?看退出码是141(等于128+13,13就是SIGPIPE的信号编号); - 如果用
strace -p跟踪崩溃前的系统调用,能看到send相关调用后面跟着SIGPIPE事件。
很多团队遇到“进程莫名其妙退出”的第一反应是查内存、查锁,这没错,但如果所有方向都没问题,我建议第一时间想一下是不是信号杀的。Linux下被信号终止的程序,shell显示退出码141的时候,就在明明白白告诉你:进程死于SIGPIPE。
2. 内核机制拆解:SIGPIPE为什么和socket绑定在一起
2.1 从管道到socket:同一套“写已关闭管道”规则
SIGPIPE的设计初衷其实是管道。在shell里执行cat file | head -1,head读一行就退出,cat还在往管道写,内核发现管道读端已经关闭,就给写端进程发SIGPIPE,默认动作就是终止,从而避免产生大量无意义的数据写操作和无用进程。
socket机制在底层借用了一部分管道语义。一个TCP socket本质上也是有读端和写端的“双向管道”。当对端关闭连接(或者执行了shutdown关闭了某一半)之后,本地继续调用send()往这个socket上写数据,内核判断这相当于“写一个读端已关闭的管道”,于是触发SIGPIPE信号。
这个行为加上TCP的RST机制之后,表现会更复杂一些。比如客户端直接断开、机器突然断电、中间网络设备发来RST,本端下一次send()可能就会收到ECONNRESET或者触发SIGPIPE。虽然触发路径不一样,但结论一致:对端已经不读你的数据了,你还傻傻地继续发,内核就会动用SIGPIPE这个“强制刹车”来终止你。
2.2 send()错误码与信号出现的先后顺序
很多人以为send()返回EPIPE错误,然后我们在代码里判断errno,这是一个先有鸡还是先有蛋的问题,实际上信号在前,错误码在后。内核在调用send()时发现socket的写半部已经关闭,或者对端RST导致连接不可用,流程是这样的:
- 在copy数据到发送缓冲之前,检查socket状态;
- 发现写端对端已经关闭,触发SIGPIPE发送给当前进程;
- SIGPIPE的默认处理是终止进程,进程直接退出;
- 如果进程忽略或屏蔽了SIGPIPE,系统调用才会返回-1,errno置为EPIPE,代码才有机会处理。
所以,想要吃掉EPIPE错误码,前提是你得先处理SIGPIPE信号。忽略SIGPIPE之后,再看send()返回的EPIPE才有意义。这是整个问题里最关键的一个认知。
还有一点需要注意:不是每个错误码都会伴随SIGPIPE。比如缓冲区满了返回EAGAIN,不会触发SIGPIPE;连接还在但路由不可达返回EHOSTUNREACH,也不会。只有EPIPE这类“对端已经关闭写入通道”的情况才触发SIGPIPE。理解这个区别,能帮你避免盲目把信号和错误码混为一谈。
3. 线上最实用的三种处理方案:怎么把“自杀”关掉
3.1 方案一:全局忽略SIGPIPE,让send()回归普通函数
最直接、最常用的做法,在程序入口处把SIGPIPE忽略掉。对于服务端程序,我推荐这种做法,因为服务端一般有自己的连接池和断连重连机制,不需要系统来“帮忙”终止进程。
#include <signal.h> void init_net_env(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = SIG_IGN; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(SIGPIPE, &sa, NULL) < 0) { /* 记录启动日志,理论上不会失败 */ } }这样设置之后,所有线程的SIGPIPE都被忽略了(信号设置是进程级的),后续send()如果遇到对端关闭的情况,就会正常返回-1并置errno为EPIPE。之后代码里再增加对EPIPE、ECONNRESET的处理即可。
为什么不直接用signal(SIGPIPE, SIG_IGN)?因为signal()在不同系统中的语义有差异,尤其在信号处理函数执行期间是否自动屏蔽信号、系统调用是否自动重启这些细节上,历史版本实现不同。sigaction更可控,而且SA_RESTART能减少EINTR误报。不过设置SA_RESTART也有一个副作用:一部分被信号中断的系统调用会自动重启,而不是返回EINTR。放在这里,我们的目的就是让业务逻辑尽量不感知SIGPIPE的干扰,所以没问题。
3.2 方案二:MSG_NOSIGNAL标志,只在发送时闭嘴
如果不想改全局信号处理,只想在某个send调用点屏蔽SIGPIPE,使用MSG_NOSIGNAL:
ssize_t n = send(fd, buf, len, MSG_NOSIGNAL); if (n < 0) { if (errno == EPIPE) { /* 对端已关闭,做连接清理 */ } }这是Linux专门提供的send标志位。加了这个标志,本次send调用即使触发写关闭管道,也不会向进程发送SIGPIPE,而是直接返回-1,errno是EPIPE。实际效果等同于“本次调用的局部忽略信号”。
这个方案的问题在于它只对当前这个send调用有效。如果你的代码里有多个发送点,或者调用了第三方静态库发送数据,第三方库依然可能踩中SIGPIPE。所以工程上把两者结合使用,全局忽略+S指定位置显式处理错误码,效果最稳。
3.3 方案三:SO_NOSIGPIPE与跨平台陷阱
存在另一个叫SO_NOSIGPIPE的socket选项,在macOS和部分BSD系统上可以用:
int on = 1; setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));它可以从socket级别屏蔽SIGPIPE,作用范围比MSG_NOSIGNAL更广,但这个选项在Linux上并不存在。在Linux上设置SO_NOSIGPIPE,setsockopt()会返回-1,errno是ENOPROTOOPT。写跨平台网络库的同学经常会在这上面踩坑,最好的写法是平台宏区分:
#ifdef __linux__ int n = send(fd, buf, len, MSG_NOSIGNAL); #elif defined(__APPLE__) int on = 1; setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); int n = send(fd, buf, len, 0); #endif三种方案对比:
| 方案 | 作用范围 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| sigaction全局忽略 | 进程级,所有socket | 一行配置全覆盖,兼容性好 | 全局生效,需代码内正常处理EPIPE | 服务端程序、长连接服务 |
| MSG_NOSIGNAL | 单次send调用 | 精确控制,不影响全局 | 容易遗漏,第三方库仍可能触发 | 局部核心发送逻辑 |
| SO_NOSIGPIPE | 单个socket(非Linux) | 精确到连接 | Linux不支持 | 跨平台客户端、macOS环境 |
我个人的习惯是:服务端一律采用方案一加方案二组合;客户端库则优先方案二,并在入口也忽略SIGPIPE,防止第三方组件炸掉整个App进程。
4. 实战代码:写一个健壮的send()封装
4.1 核心封装:把错误码语义全部处理干净
解决了信号问题之后,真正考验功力的是错误处理。下面这个封装我用了很久,核心逻辑很清晰:处理部分发送、处理EINTR重试、区分致命错误与可重试错误,并统一返回逻辑,方便上层做连接回收。
#include <sys/socket.h> #include <errno.h> #include <stdio.h> #include <string.h> #include <unistd.h> ssize_t safe_send(int fd, const void *buf, size_t len, int flags) { const char *p = (const char *)buf; size_t left = len; ssize_t total = 0; while (left > 0) { ssize_t n = send(fd, p, left, flags); if (n > 0) { p += n; left -= n; total += n; continue; } if (n == 0) { /* 阻塞模式下send几乎不会返回0,防御性处理 */ return total > 0 ? total : -1; } if (errno == EINTR) { /* 被信号打断,重试 */ continue; } if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 非阻塞模式下缓冲区满,交给上层等待POLLOUT */ return total > 0 ? total : -1; } if (errno == EPIPE) { /* 对端关闭,通常走连接管理逻辑 */ return -1; } if (errno == ECONNRESET) { /* 对端RST,同样走连接管理逻辑 */ return -1; } if (errno == ENOTCONN) { /* socket未连接完成 */ return -1; } /* 保温抓:其余错误 */ return -1; } return total; }为什么要在循环里处理EINTR?因为信号恢复之后,send()可能还没有写入任何数据就被中断。尤其当你用了SA_RESTART又同时处理其他信号时,EINTR虽然在自动重启机制下不常出现,但服务端信号多了之后,总会有一两个漏网之鱼。正确处理EINTR是稳定性的一部分。
为什么EAGAIN时要返回已发送字节而不是一直循环?这涉及阻塞粒度。阻塞模式下send()通常会阻塞到全部发完或出错,但设置了SO_SNDTIMEO后,超时也会返回EAGAIN。非阻塞模式下频繁遇到EAGAIN。如果在这里自旋重试,CPU会被白白吃掉。正确做法是返回,上层使用poll/epoll监听POLLOUT事件,等缓冲区有空间后再继续调用。
补充一点,如果调用方要求“必须全部发完”才返回,比如在阻塞socket上想死等,可以设置合理超时后,在EAGAIN分支写usleep或poll等待,但这种模式在高并发服务端不推荐,阻塞发送会拖垮线程。真正高并发的服务几乎都走epoll边沿触发加非阻塞socket,发送逻辑配合发送队列异步完成。
4.2 非阻塞模式下配套的超时与缓冲机制
写服务端的发送逻辑,光封装一个send函数远远不够。非阻塞socket配合SO_SNDTIMEO,可以在发送卡住时及时退出。下面的代码演示了怎么设置发送超时:
int snd_timeout_ms = 3000; struct timeval tv; tv.tv_sec = snd_timeout_ms / 1000; tv.tv_usec = (snd_timeout_ms % 1000) * 1000; setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));设置了SO_SNDTIMEO之后,阻塞socket的send()在超时返回时会带上EAGAIN/EWOULDBLOCK,此时不完全是“缓冲区满”,也可能是发送超时。对于业务层来说,EAGAIN统一处理为“稍后再试”或“优先清除这个连接”,取决于你的设计。
配合epoll的时候,建议用LT模式管理写事件。当send()返回EAGAIN时,向epoll注册EPOLLOUT事件,等可写事件到了再取出发送队列中的数据继续发。千万别在EAGAIN后无限重试,那等同自旋。事件驱动模式下,发送队列长度也要有上限,否则慢客户端会把内存吃掉。常见做法是单连接发送队列超过比如16MB就强制断开,或者直接应用限流策略。
4.3 多线程服务里SIGPIPE特殊注意点
多线程程序里信号是进程级的,任何一个线程触发SIGPIPE,都是整个进程被终止。很多人以为在创建线程之后设置忽略就够了,实际上主线程的signal设置会影响整个进程所有线程。不过,如果有些线程库或动态库在加载时自行修改了信号处理,那就不好说了,所以尽量在main()最开头调用设置函数,越早越好。
还有一点容易踩坑:千万不要在信号处理函数里做复杂操作。如果你不是忽略SIGPIPE而是自定义了handler,不要在handler里调用printf、malloc、free这类非异步信号安全函数,否则可能死锁或数据损坏。最安全的做法就两件事:要么SIG_IGN,要么用sigaction设置一个只写全局volatile标志位后配合sigwait或self-pipe处理。对于SIGPIPE,我甚至不建议自定义handler,直接忽略是最省心的。
实际上绝大多数网络服务需要的不是“捕获SIGPIPE然后提示”,而是“彻底忽略SIGPIPE并依赖错误码”。加了自定义handler反而引入更多复杂度。
5. 问题排查实录与结果速查
5.1 用gdb和strace定位一遍完整过程
如果你遇到“进程莫名退出”而还没有处理好SIGPIPE,可以按下面这个顺序快速坐实问题。
先用gdb挂住进程,等待崩溃:
gdb -p <pid>崩溃后执行:
bt info signals SIGPIPE如果gdb提示收到SIGPIPE,并且bt栈顶是send/sendto相关函数,基本就是坐实了。进一步用info signals看SIGPIPE配置,默认是Stop(终止),说明没有代码屏蔽它。
用strace跟踪退出逻辑更直观:
strace -f -e trace=sendto,send,write -p <pid>等到退出时,会看到类似这样的输出:
sendto(3, "...", 1024, 0, NULL, 0) = -1 EPIPE (Broken pipe) --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER} --- +++ killed by SIGPIPE +++看到killed by SIGPIPE这一行,问题就完全清楚了。这里有个细节:输出里有时是先显示EPIPE再显示SIGPIPE,但在代码层面,进程是先收到信号被杀死的;strace只是把系统调用返回和信号事件记录在同一行附近,不要被顺序迷惑。
如果是事后排查没有gdb挂载,可以检查core文件。前提是系统打开了core dump:
ulimit -c unlimited然后用gdb 程序 core查看信号来源。如果当前环境没有core,也可以从退出码反推。shell脚本里执行程序,echo $?返回141,就基本确定是SIGPIPE(128 + 13)。
5.2 send()常见错误码与处理对照
| 错误码 | 常见触发场景 | 处理建议 |
|---|---|---|
| EPIPE | 对端关闭连接或半关闭后继续写 | 关闭本端连接,从连接池剔除,重连并重试(如果业务允许) |
| ECONNRESET | 对端RST,连接被重置 | 同上,通常不需要重发,因为数据可能已被对端看到 |
| EAGAIN / EWOULDBLOCK | 非阻塞发送缓冲区满,或SO_SNDTIMEO超时 | 等待POLLOUT事件,或关闭慢连接 |
| EINTR | send被信号中断 | 立即重试send |
| ENOTCONN | socket没有完成连接 | 检查握手流程,确认connect完成或需要重新connect |
| EBADF | fd无效或已被关闭 | 排查fd的并发关闭与重复释放问题 |
| EMSGSIZE | 发送报文超过系统限制(常见于UDP) | 拆分数据报或检查socket缓冲区设置 |
| EHOSTUNREACH | 路由不可达,常见于UDP和长时间无活动的TCP | 重试或向监控告警 |
| ETIMEDOUT | 发送超时 | 需要结合业务超时策略处理,不该无限等待 |
这么多错误码看着多,实际归纳起来就三类:一类是“对端已经不想收”,包括EPIPE、ECONNRESET、ENOTCONN;一类是“现在发不出去,但以后可能可以”,包括EAGAIN、EINTR;剩下的属于“资源或参数错了”,比如EBADF、EMSGSIZE。不同类别,处理策略完全不同,第一类直接断连,第二类重试或等待,第三类修代码。
5.3 避坑清单:这些坑我基本都踩过
先别急着上生产,有五个容易忽略的地方值得单独记:
第一,只判断返回值不够,很多团队在代码里写了if (n == -1) break;,但没屏蔽SIGPIPE,进程还是活不下来。顺序是:先忽略SIGPIPE,再处理返回值。忽略SIGPIPE不是可选项,而是基础前提。
第二,send()返回成功也不代表对端真的收到了数据。它只代表数据已经拷贝到内核发送缓冲区。对端是否收到,需要业务层ACK或者超时机制来确认。如果服务只依赖send返回值做可靠性判断,设计上就有问题。
第三,TLS/SSL层也有类似问题。使用OpenSSL的SSL_write()时,底层可能调用send()或者write(),如果底层socket没有屏蔽SIGPIPE,SSL通信同样可能把整个进程带走。用SSL_set_mode(ssl, SSL_MODE_AUTO_RETRY)可以少处理一些内部重试,但底层信号问题必须先解决。
第四,自定义SIGPIPE handler时要格外小心。handler里调用非异步安全函数,轻则数据错乱,重则直接死锁。我见过有人想在SIGPIPE里打日志做统计,结果进程因为日志锁死锁,更难查。SIG_IGN是最简单的答案。
第五,关闭socket时的SO_LINGER设置会影响发EPIPE还是ECONNRESET。比如设置了linger超时且超时时间内数据发不完,close会继续发RST。这种场景不会直接触发SIGPIPE,但会在对端引发ECONNRESET,形成相互踩坑的循环。排查时要双向看。
6. 一次完整案例复盘:底层连接池的健康检查
6.1 场景描述
某模拟项目X是一个长连接代理服务,自己维护到后端的一组TCP连接。某个后端节点重启后,代理还维持着旧连接,等到业务请求到来时,代理往这条已经死掉的连接上写数据,进程就消失了。表面看是“socket通信导致进程挂掉”,深层原因其实是两层:一是没有及时识别死连接;二是send()遇错时被SIGPIPE带走了进程。
这种“死连接”非常隐蔽。TCP没有时刻在线的心跳,断电、拔网线、虚拟机漂移都不会让本地立即感知。等到你往这条连接上写数据时,内核才发现对端不可达,要么返回超时,要么直接收到RST。
复现过程我总结成一个固定模板:
- 本地建立到后端的TCP长连接;
- 后端节点直接kill掉进程(模拟对端断电);
- 等端口释放后,代理继续用旧连接send;
- 观察代理进程退出,gdb确认死于SIGPIPE。
6.2 排查和修复的完整动作链
第一步,用strace抓现场,确认是EPIPE加SIGPIPE组合。第二步,在代码入口加上sigaction忽略SIGPIPE。第三步,重跑压测,这次进程不再退出,但日志里开始出现大量EPIPE报错。这一步是必经之路:之前EPIPE没机会打印,被SIGPIPE秒杀了。加上屏蔽后,错误浮出水面,你才能真正做业务层的健康检查与重连。
第四步,对连接池增加主动健康检查。最简单的方案:每次从连接池取连接时,先做一次非阻塞的0字节send或者recv(fd, buf, 0, MSG_PEEK|MSG_DONTWAIT)探测。0字节send在连接正常时返回0,在连接关闭时返回-1并置EPIPE/ECONNRESET。但这里要小心:0字节send的检测不是绝对可靠,它依赖内核已经发现连接异常。更可靠的是应用层心跳,定时写一个Ping包并期待Pong响应,超时则剔除连接。
第五步,针对可能阻塞的发送,在socket上设置收发超时,同时把fd设为非阻塞,配合epoll管理写事件。这样即使后端节点再次异常,最坏结果也只是某条连接EAGAIN超时被回收,而不是进程崩溃。
6.3 这套方案的最终形态
修复后的连接管理逻辑是清晰的三层:
第一层,进程级安全网。入口处sigaction忽略SIGPIPE,让所有错误都能暴露为errno,不再直接杀死进程。主力逻辑里send统一走safe_send封装,EINTR重试,EAGAIN交还事件循环,EPIPE和ECONNRESET直接淘汰连接。
第二层,超时保护。设置SO_SNDTIMEO为2到3秒,连接建立后如果长时间无IO,定期发送应用层心跳。心跳连续两次超时就把连接关闭并触发重连。这个心跳间隔需要配合对端负载,别太频繁,否则自己刷爆连接。
第三层,连接池回收。取连接时校验健康状态,归还时标记最后使用时间。每次使用完成后扫描所有空闲连接,超过空闲阈值的主动关闭。这样即使某个后端节点挂掉,最迟一个心跳周期内就能把坏连接剔除干净。
最后分享一个小经验:很多网络问题看起来“玄学”,实际上是层层叠加的。SIGPIPE把EPIPE的痕迹抹掉,EPIPE又把死连接的问题掩盖住,死连接又让上层业务看到“偶发超时”。排查的时候,先把进程存活问题解决,再处理连接问题,最后优化业务层重试。次序对了,大部分诡异故障都能被捋顺。我后来写新项目,只要涉及socket,第一行就是忽略SIGPIPE,这已经成为肌肉记忆了。