写网络程序这行当,入门容易做深入难。TCP 三次握手、TIME_WAIT、粘包、半包、backlog 积压……随便拎出来一个都能让新手挠头。尤其是刚从“照着教程敲一遍能跑”的兴奋期走出来,真到自己动手写一个能上生产环境的服务时,才发现网上那些 demo 根本不顶用。这篇文章不打算给你堆一堆 API 手册,我会从套接字编程里最容易被忽略的底层逻辑讲起,再逐步展开到单机高并发服务必须面对的模型选择、超时处理、内存缓冲、异常恢复,把我这些年排查过的真实故障和误判过的问题一并说出来。无论你是刚接触 Linux socket 编程的学生,还是已经写了几个月后端业务、想往底层网络多走一步的开发者,这篇内容都值得你花半小时慢慢看。
1. 套接字到底是什么:别停在"会调函数"这个层面
1.1 文件描述符帮你把网络操作"伪装"成了文件操作
很多人第一次接触 socket 编程,是在课本上看那段经典代码:socket()、bind()、listen()、accept(),然后read()/write()。背下来容易,但要是问一句"套接字本质上是什么",大部分人答不上来。其实在新老代码里,这个答案都能落在同一个词上——文件描述符。
Linux 设计哲学里有一条很朴素的原则:一切皆文件。普通文件是文件,管道是文件,设备节点是文件,网络套接字——也同样是一个文件描述符。当你在进程里open()一个磁盘文件,内核返回一个小整数;当你调用socket(AF_INET, SOCK_STREAM, 0),内核同样只是返回一个小整数。区别在于,这个整数背后的"文件"并不指向磁盘上的某个 inode,而是指向一个由内核维护的套接字对象,它内部包含了发送缓冲区、接收缓冲区、连接状态、协议控制块等一整套网络协议栈状态。
理解了这一点,很多行为就顺理成章了。比如read()一个已经对端关闭的 socket,返回 0;write()一个对端已经 RST 的 socket,会触发 SIGPIPE 信号,默认行为是把进程干掉。这些行为和文件操作的表现形式很像,但背后是 TCP 协议的状态机在起作用。如果你只是停留在"调用 API"层面,不去想内核在数据路径上做了什么,后续排查线上事故基本会寸步难行。
1.2 从三次握手反推代码顺序:accept 到底在等什么
TCP 建连的三次握手,大学教材里画过无数遍。问题是很少有人把这张图和代码里listen()、accept()的调用顺序真正对上号。我遇到过不少同学问:为什么服务端要先listen()表示愿意接收连接,然后accept()才会返回一个已连接的 socket?为什么不能先accept()再listen()?
这里必须澄清一个常见的误解:accept()并不参与三次握手。它是从内核的已完成连接队列里取出一个已经完成握手的连接。真正干活的是内核协议栈,当客户端发出 SYN,服务端内核自动回 SYN+ACK,客户端再回 ACK,这时候连接进入 ESTABLISHED 状态,被放入 accept 队列。accept()只是“取货”而已。
这种设计有一个很微妙的含义:服务端进程即使因为某些原因暂时没调用accept(),内核也照样能完成连接建立。这就是所谓的 backlog 机制。listen()的第二个参数传入的 backlog,控制的是内核为这个监听 socket 维护的等待队列上限,超出部分的连接会被内核直接丢弃,客户端表现为连接超时或者 connect 返回 ECONNREFUSED。所以在高并发场景下,backlog 设太小,内核队列满了,请求就会被悄悄丢掉,而你的服务端程序可能完全感知不到。
我见过一个非常典型的案例:某服务对内暴露一个 HTTP 接口,压测 rps 到 800 左右就疯狂报 connection reset。看了半天业务代码没发现问题,最后ss -ltn一查,Send-Q队列已经打满,net.ipv4.tcp_abort_on_overflow默认又是 0,内核直接把新连接扔掉,客户端还以为服务端崩了。调大 backlog、同时把应用层的 epoll 循环加上,问题才解决。
2. 手写一个 TCP 服务端:从教科书 demo 到能扛压的版本
2.1 最小可运行版本,以及它为什么扛不住
先贴一个最基础的服务端。这段代码几乎在每本 Linux 网络编程书里都有,注释也写得比较全:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } int opt = 1; // 允许端口重用,否则服务重启时可能 bind 失败 setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(9527); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } if (listen(listen_fd, 128) < 0) { perror("listen"); return 1; } while (1) { struct sockaddr_in client_addr; socklen_t len = sizeof(client_addr); int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len); if (conn_fd < 0) { if (errno == EINTR) continue; perror("accept"); continue; } char buf[1024]; while (1) { int n = read(conn_fd, buf, sizeof(buf)); if (n <= 0) break; write(conn_fd, buf, n); } close(conn_fd); } close(listen_fd); return 0; }这段代码的问题一眼就能看出来:单线程串行处理。accept()拿到一个连接后,程序就卡在read()上,直到对端关闭或数据到了才继续。如果第一个客户端一直不发数据,第二个客户端就算连上了,也永远得不到服务。这是最原始的accept + read + write + close循环,作为教学演示可以,离可用差得远。
2.2 多进程与多线程模型:有改进,但各有各的坑
最容易想到的改法,是每个连接来了就 fork 一个子进程或者开一个线程去处理。这种方式写起来很直白,对于并发量几十上百的小服务也能用。我当时就是这么干的。
多进程版本,主循环里accept()拿到conn_fd后直接fork()。子进程继承了这个 fd,处理完客户端请求后_exit(),父进程负责waitpid()回收。单看逻辑没问题,但实际跑起来你会发现两个隐患:
第一,fork 开销大。每次连接到来都创建进程,在高频短连接场景下,p99 延迟会明显被 fork 的系统调用成本拖累。第二,子进程退出时所有 fd 都被关闭,如果父进程处理回收不及时,会积累一大批僵尸进程。更隐蔽的问题是,如果子进程继承了父进程的监听 fd,并且父进程忘了及时 close,连接会一直挂着不释放。
线程版本类似:pthread_create创建新线程,处理完连接后pthread_detach。多线程版本省去了进程创建的复制开销,但要自己处理线程安全问题——所有可写状态变量都要加锁,而且线程栈默认 8MB,每开一个线程就占一大块虚拟内存,连接数一多,内存先撑不住。
这种“一连接一线程”的模型,我用在过一个内部监控采集服务上。量级大约同时在线 300 个连接,数据每秒一个点,跑了一个月没出大问题。但后来接入的客户端数量翻到 2000,问题就来了:线程数暴涨后上下文切换把 CPU 吃满,单机吞吐不升反降,最后不得不重新设计。
2.3 io 多路复用:用一个线程管几百个连接的关键
既然每连接消耗一个线程不可行,自然就要考虑让一个线程同时监测多个 fd。这时就轮到select/poll/epoll上场了。
先说select。它对 fd 数量有硬上限,FD_SETSIZE默认 1024,要改得重编内核宏定义,而且每次调用都要把整个 fd 集合从用户态拷贝到内核态,返回后还得线性遍历找哪些 fd 就绪。连接少的时候能用,超过几百个性能就开始难看。
poll把 fd 数组从内核移到了用户态,用pollfd结构体传递,突破了 1024 限制,但依然是“每次全量拷贝 + 线性扫描”的路子。
epoll才是 Linux 下高并发的主力。它把注册和等待拆开:epoll_ctl负责把关心的事件注册进内核,epoll_wait负责等待就绪事件。内核只把真正有事件发生的 fd 通过就绪链表返回,不用每次全量扫描,复杂度从 O(n) 降到 O(就绪数)。
用 epoll 改造上面那个回声服务,核心逻辑可以浓缩成三块:创建 epoll 实例、注册监听 fd、循环等待事件并分发。先看创建和注册:
int epfd = epoll_create1(0); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { perror("epoll_ctl"); return 1; }主循环里:
#define MAX_EVENTS 1024 struct epoll_event events[MAX_EVENTS]; while (1) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listen_fd) { struct sockaddr_in client_addr; socklen_t len = sizeof(client_addr); int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len); // 把新连接也注册进 epoll struct epoll_event conn_ev; conn_ev.events = EPOLLIN; conn_ev.data.fd = conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &conn_ev); } else { int fd = events[i].data.fd; // 非阻塞读取,读不到数据且 errno == EAGAIN 说明读完了 while (1) { char buf[4096]; int n = read(fd, buf, sizeof(buf)); if (n > 0) { write(fd, buf, n); } else if (n == 0) { close(fd); break; } else { if (errno == EAGAIN) break; if (errno == EINTR) continue; close(fd); break; } } } } }这里有一个极易踩的坑:上面的代码在write()时没有校验对端是否真的把数据接收完。实际应用中,write()只是把数据写进内核的发送缓冲区,如果对端消费慢,缓冲区满了,write()会被阻塞,或者以非阻塞模式返回EAGAIN。在这种简单实现里,一个慢客户端就足够拖垮整个事件循环。生产级服务必须区分“读就绪”和“可写就绪”,写事件也需要注册和等待,没准备好就把数据缓存到应用层,等EPOLLOUT触发再发。这是许多初版并发服务器遇到延迟增大的根源。
3. 协议设计比你想的重要:字节序、粘包和内存管理
3.1 大端小端:网络字节序不是可选项
很多教程都强调htons()、ntohl()处理端口和 IP 地址的字节序,但到了设计业务协议时反而忘得一干二净。我审过不少同事的代码,自定义协议头里直接定义int32_t length,本地机器是小端,写进 socket 再被大端的机器读出来,长度字段直接变成一个天文数字。
字节序的规则总结起来就两句话:网络传输统一使用大端字节序,应用程序自己在内存里用什么序内核不管。所以:
- 固定长度整数字段,发送方用
htonl()/htons()转换后写入缓冲区,接收方用ntohl()/ntohs()还原。 - 浮点数、字符串没有所谓的“字节序规范”,字符串按字节原样传,浮点我建议直接用 ASCII 或者固定位宽传递,避免跨平台解析差异。
- 如果嫌每个字段手动转换累,可以在结构体里先定义成网络序表示,再用
memcpy整体拷贝。只要发送和接收两端约定一致即可。
一个最实用的自描述报文体,我一般建议这样设计:4 字节的魔数 + 2 字节的版本号 + 2 字节的消息类型 + 4 字节的消息体长度 + 4 字节的校验和 + 消息体本身。所有固定头统一用大端序写入,消息体按照协议规则编码。魔数可以过滤掉那些因为网线松动、对端程序紊乱发来的垃圾包,版本号方便协议演进时优雅兼容。
3.2 粘包与半包:read 一次不等于拿到一条完整消息
这是每个写 TCP 协议的人绕不开的问题。TCP 是流式协议,没有消息边界。发送方调两次send(),可能一次recv()就全部收到;反过来,发送方调了一次send(),data 很大时,接收方可能分多次recv()才拿完。这就是所谓的“粘包”和“半包”。
半包的场景特别容易在压测时暴露出来:客户端一次性发了一个 64KB 的报文,服务端read()只返回了 4KB,业务代码却迫不及待去解析报文头里的长度字段,结果长度字段读出来的值也不对——它只拿到了一半。
解决半包的唯一可靠方案是以包长度字段驱动读取状态机。具体做法:
- 维护一个接收缓冲区,每次
read()到的数据先追加进去。 - 检查缓冲区里是否至少有固定头长度(比如 12 字节),不够就继续等。
- 解析出消息长度 length,判断
当前缓冲区总长 >= 12 + length,满足则说明一个完整包已经到达。 - 按 length 取出消息体,若尾部还残留数据,继续下一轮解析。
这样就不会因为一次网络读取而破坏包边界。我曾经维护过一个网关项目,最初实现时就漏了这个状态机,用“读一次就当成一条消息”的方式处理,本地联调没问题,一上预发环境,大量消息错乱,服务端进程直接崩溃。原因就是在一定并发下 TCP 层把多个包拼合送达。换上长度解析后,就再没出过这类问题。
粘包其实算是一件好事——多包合并,减少了系统调用次数,反而提升了吞吐。真正的麻烦是那些不按长度解析、试图靠recv()返回次数来切分消息的程序。
3.3 接收缓冲区的设计:别用固定数组硬扛
我早期写服务时,接收缓冲区直接开一个固定char buf[8192],每次read()到 buf 里,解析完就扔。这在大包场景下必然出大问题:一个 100KB 的消息,8192 字节的数组会被切十几片,我那个初版状态机只能处理“单次 read 就是完整包”的情况,碰上大包直接解析错乱。后来我改成动态缓冲区:
- 先按“已累积大小”判断,不足就
realloc扩容(一开始给 4096,增长按 2 倍); - 完整解析一条消息后,把剩余数据
memmove到缓冲区头部; - 如果内存吃紧,还可以用环形缓冲区避免频繁搬移,但
memmove在包不大时性能不是瓶颈。
realloc并不是越调越慢,你只要控制好增长策略,避免频繁在边界上伸展,大多数业务规模根本无感。真正高效的做法是用readv配合iov,把数据直接读进两个不连续的缓冲区,但那是后面优化的事情了。
这些设计虽小,却决定了你的服务器在异常流量和超大报文面前是“岿然不动”还是“雪崩式崩溃”。
4. 高并发服务背后的系统级考量:epoll 模型的完整链路
4.1 阻塞与非阻塞 IO:为什么都在讨论 EAGAIN
再来谈一个常常被忽略的问题:accept 回来的连接 fd,默认是阻塞模式。在 epoll 循环里,如果读数据时用阻塞read(),你很可能把整个线程卡住,因为 epoll 的“可读”不代表这一次能读到数据——尤其是对端的数据被拆成多个 TCP 段时,read()读完了当前段,回来再read(),如果此时内核缓冲区里已经空了,阻塞模式下进程就一直睡在那里。
因此,被 epoll 管理的 fd 一定要设置为非阻塞,然后在循环里读数据,直到read()返回 -1 且errno == EAGAIN,才认为当前这次读事件已经处理完,可以回到epoll_wait继续等。同理,write()也可能返回 EAGAIN,表示发送缓冲区已满,这时候要等待EPOLLOUT事件而不是强行阻塞刷写。
设置非阻塞的标准写法是:
int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);在accept()拿到conn_fd之后立刻执行。这个步骤太常被省略了,但省掉它的代价是间歇性卡死事件循环。
4.2 水平触发和边缘触发:一个只差一点点就让你怀疑人生
epoll 有EPOLLLT(水平触发)和EPOLLET(边缘触发)两种模式。水平触发是默认的:只要 fd 上有数据没读,每次epoll_wait都会返回这个 fd。边缘触发只在状态变化时通知一次,如果你没把数据读完,下一次不会再因为剩余数据通知你,只能等新数据到达。
边缘触发听起来省事件,但坑极多。我第一次用边缘触发时,某个 fd 上有两次不同时刻到达的数据包,第一次事件到了,我read()到一半,第二次数据又到了,epoll 只通知了第二次“新数据到达”状态变化,结果第一次剩余的数据被我一直挂在缓冲区里,直到等到第三次事件才处理。
那次排查花了我一个下午,最后用 tcpdump 抓包比对,才发现是对端拆包导致的。从此我的原则是:没有足够的把握,默认用水平触发。水平触发更适合循序渐进地理解事件驱动模型,效率损失在绝大多数场景下可以忽略。真要上边缘触发,也必须保证每次读到 EAGAIN 为止,并且在应用层维护好完整的“半包”状态。这个建议同样适用于你项目里那些喜欢炫耀技术选型的队友——稳定压倒一切。
4.3 事件循环里的锁竞争与任务拆分
当连接数上千,一线程 epoll 事件循环往往忙不过来,需要多线程协作。最朴素的思路是:主线程只负责 accept,然后把conn_fd通过队列分发给多个工作线程。但这里有个容易被忽略的问题:连接被注册进哪个 epoll 实例,后续事件就得由哪个线程处理,你没法把已经注册的事件随便迁移,除非重新注册。
我见过一个方案:每个工作线程维护自己的 epoll 实例和事件队列,主线程 accept 后按负载均衡原则,把 fd 传给某个工作线程注册。这个方案能分散事件处理压力,代价是实现复杂、调试困难。如果你的服务是 I/O 密集型且单个连接数据量不大,单线程 epoll + 一个线程池(做业务计算)往往是最容易维护的架构。真实项目里,大部分瓶颈根本不在收发包本身,而在业务逻辑的处理速度和锁竞争。
多线程锁竞争这件事必须清醒:如果你为了共享一个计数器在每次事件处理时都加锁,1000 个连接下锁开销或许还能忍,但如果总线上消息量达到每秒几十万条,锁竞争就会把 CPU 烧成空转。能用无锁队列解决的,就不要用锁;能用每线程私有数据的,就不要共享。
5. 细节决定生死:超时、心跳、优雅关闭和异常恢复
5.1 绝不无限等待:超时的设计粒度
服务器最忌讳的就是无条件阻塞。设计一个可上线的服务器,你至少要想清楚三个超时:
accept 超时:对监听 fd 而言,
accept本身不设置超时——它只有在有已连接队列时才返回。但如果调用方希望“有连接就处理,没有也别一直卡在 accept 上”,就必须把监听 fd 也加入 epoll 管理(就像前面示例代码那样),由epoll_wait的 timeout 参数提供整体轮训周期。读写超时:业务层要防止一个连接长时间占资源不读不写。比较简单的做法是记录每个连接最后活跃时间,每次事件触发更新,主循环周期扫描,超过阈值就直接
close()。更细粒度的做法是给SO_RCVTIMEO和SO_SNDTIMEO设置套接字超时,recv / send 超时后返回 -1 且errno == EAGAIN,这样能按操作粒度控制,但要注意底层阻塞/非阻塞模式的差异。连接建连超时:在非阻塞 connect 模式下,
connect()返回之后你要通过EPOLLOUT事件来判断连接是否建立成功,再配合定时器检测超时。直接调用阻塞 connect 的代价是进程生死全握在对端手里。
5.2 心跳机制:别把连接一直留着当僵尸
TCP 的keepalive很多人听说过,默认两小时探测一次,间隔和次数都可以用TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT三个 socket 选项调整,但它是为“保活”设计的,不是为“及时清理死连接”设计的。
业务层的心跳是另一回事。以这条经验来处理内部微服务之间连接很实用:客户端每隔 N 秒发一个 PING 包,服务端收到任何数据都更新活跃时间,超过 N*3 秒没收到任何数据就判定超时,主动关闭连接。注意心跳包和数据包要一起走同一个连接,不要单独开一个端口,否则无法反映数据传输链路的真实状态。
我在一个 IM 网关项目里,就把心跳和业务消息都放在同一个协议头里,用一个 type 字段区分。服务端只管“任何包都算活跃”,客户端则每 30 秒发一个空 PING。这样即使网络中间设备空闲回收了 TCP 连接,客户端也能在 90 秒内发现并重连,整个服务的恢复时间被压得很低。心跳一定要轻量,一个 PING 包几十字节足够,别把什么鉴权数据全塞进去,白白增加链路负担。
5.3 内核参数调优:几个我实测有效的系统配置
Linux 内核网络参数对服务器性能的影响,常常被应用层程序员忽略。整理几个我实测过对服务稳定性提升有明显帮助的:
| 内核参数 | 作用 | 推荐值 |
|---|---|---|
net.ipv4.tcp_tw_reuse | 允许客户端复用 TIME_WAIT 状态的端口连接到新的远端 IP | 1(只在客户端启用) |
net.ipv4.ip_local_port_range | 本地端口范围,影响高频短连接的可用端口数 | 建议扩到 1024~65000 |
net.core.somaxconn | listen 等待队列最大值,受 listen() 参数和该值双重限制 | 大于等于你的 backlog 设置,如 1024 |
net.ipv4.tcp_max_syn_backlog | 半连接队列大小 | 常见配置 4096 及以上 |
net.core.netdev_max_backlog | 设备层接收队列上限 | 压测打满时必要时调大 |
有一个参数要特别小心:tcp_tw_recycle。它在 NAT 环境下会导致同一 NAT 出口后的客户端建连失败,症状是间歇性超时。现代内核版本已移除或不再推荐使用,看到网上旧帖子里推荐开启它,请直接无视。tcp_tw_reuse只适用于客户端主动回收 TIME_WAIT 端口,服务端开启它没什么意义,因为服务端一般不限源端口数量。
这些参数的修改都需要放到/etc/sysctl.conf里持久化,改完执行sysctl -p生效。别在压力测试时临时改一下测完就丢,那样线上环境照样坑你。
5.4 信号处理与进程崩溃前的自救
最后聊两个服务稳定性上非常关键但很容易被新手无视的点。
第一个是SIGPIPE。前面提过,往一个已经收到 RST 的连接里写数据,内核默认会向进程发送SIGPIPE信号,进程如果不处理,直接死掉。对服务器程序来说,最常见的规避方式是启动时显式忽略 SIGPIPE:
signal(SIGPIPE, SIG_IGN);或者在发送端用send(fd, buf, len, MSG_NOSIGNAL)代替write(),这样发送失败只会返回 EPIPE,程序不会意外退出。绝多数网络服务框架都会做这一步,但如果你自己手写 socket 代码,可别把它忘了——否则对端半关连接后随意发一条消息,你的进程就悄悄没了。
第二个是SIGCHLD。如果用了多进程模型,父进程必须处理子进程退出信号,否则子进程变成僵尸。处理方式非常简单,忽略信号即可(因为子进程退出本身不要求父进程真的去 wait 拿状态):
signal(SIGCHLD, SIG_IGN);但如果需要知道子进程的退出码来做监控告警,那就必须在信号处理函数里waitpid循环收割。
6. 调试套接字程序的实用手法:从 tcpdump 到 strace
6.1 先用 tcpdump 看到真相
很多人排错时喜欢直接在业务代码里打日志,但有些问题代码层面根本看不到。比如连接被内核丢弃、对端发送了 RST、奇怪的包被重组——这些都得靠抓包工具。
最常用的命令是:
tcpdump -i eth0 -nn -s0 tcp port 9527 -w /tmp/capture.pcap-i指定网卡,-nn不做域名和端口反解,-s0抓完整包,-w保存成 pcap 文件方便用 Wireshark 分析。如果只想看握手过程,过滤条件可以加'tcp[tcpflags] & (tcp-syn) != 0'。
我排一个“客户端偶发连接超时”问题时,就是 tcpdump 抓到服务端完全没有回 SYN+ACK,进而查出是半连接队列溢出的。当时如果只盯着应用日志,恐怕永远找不到根因。
6.2 strace 看系统调用返回值
判断应用是否在某个 socket 调用上卡住,strace是利器:
strace -f -p 1234 -e trace=network -o /tmp/trace.log-f跟随子进程,-e trace=network只跟踪网络相关调用,减少噪音。当别人跟你说“服务卡住”,你先看一眼accept、read、write是否长时间没有返回,或者一直返回 EAGAIN。这些信息比臆想快得多。
注意,strace会让进程性能明显下降,生产环境尽量不要长时间挂,抓一小段即可。
6.3 通过 /proc 和 ss 快速定位状态
ss命令是排查 socket 状态的第一选择。
ss -tnp # 查看所有 TCP 连接,显示进程名 ss -s # 汇总统计 ss -ant | grep LISTEN # 看监听队列重点关注Recv-Q和Send-Q在 LISTEN 上表示已完成队列当前容量与最大容量,在 ESTABLISHED 上表示用户态尚未读走和内核尚未发送的数据量。Recv-Q一直很大说明应用读得慢,Send-Q一直很大说明对端消费慢或网络拥塞。
/proc/net/sockstat和/proc/net/tcp也能看到很多原始信息。比如/proc/net/tcp每一行就代表一个 TCP 连接,可以看到本地端口、对端地址、状态、收发队列长度。脚本按状态统计时,这两个文件比ss更轻量。
7. 一些踩坑后的经验沉淀
网络编程的坑,从入门到进阶这几年我踩了无数个,挑几个印象最深的分享出来。
第一,永远不要关闭 SO_REUSEADDR 的调用。调试时频繁重启服务端,如果上一个进程还停留在 TIME_WAIT 状态,bind 就会报Address already in use,而你的开发流程想杀掉进程重启,可能还要等 60 秒。加了SO_REUSEADDR之后,短时间内重启不会被同一个端口的 TIME_WAIT 状态卡住。注意它只对 TIME_WAIT 状态有效,并不能随意绑定一个已经被别的进程 LISTEN 的端口。
第二,注意 accept 返回的 fd 已经继承了监听 socket 的特权属性。这意味着如果你对监听 fd 设置了非阻塞,accept 返回的 fd 并不自动继承非阻塞。换句话说,你必须在每个新连接上重新设置 O_NONBLOCK。这一点教科书代码通常不会主动演示,但线上环境吃过亏——事件循环忽然卡住,排查半天才发现某个 fd 是阻塞的。
第三,发送端不要高频小包轰炸。一次业务包含 10 个字段,每个字段单独send(),吞吐会比合并成一次send()低一个数量级,而且极易触发 Nagle 算法延迟。业务层尽量拼好完整包再一次性发送。至于 Nagle 算法和 TCP_NODELAY,要在理解延迟与吞吐的权衡后谨慎使用,不要一股脑关掉。
第四,套接口关闭前要考虑数据是否发完。直接close()一个仍有未发送数据的 socket,内核默认行为是发完再关,但如果有对端还在向这个连接写数据,RST 可能随时到来,把已排队的发送数据冲掉。比较稳的主动断开流程是:先shutdown(fd, SHUT_WR)告诉对端“我这边不发数据了”,等待对端关闭自己那侧,再close()。这个流程在处理 HTTP/1.1 长连接关闭时尤其重要。
说道理再多,都不如自己亲手去搭一遍。我的建议是拿一个真实的小业务练手,比如写一个支持多个客户端同时连接、能接收 JSON 格式请求并返回处理结果的 TCP 服务,先用阻塞 I/O、多线程版本跑通,再逐步改成 epoll 版本。每改一步,都用ab或自写压测脚本观察吞吐和连接数变化。多写几种版本,你才会真正理解为什么生产环境没人敢用“一连接一线程”去扛高并发。这个过程里踩到的坑,才会真正变成你自己的经验。