
在 Linux 上写一个 TCP 服务器第一版代码往往只花二十分钟就能跑通客户端 telnet 上去回显正常心里踏实。可一旦把它放进真实的网络环境问题就会成串冒出来——端口明明没人用却 bind 失败、压测到几百连接进程就卡死、跑了两天描述符一路涨到上限、大包传输时进程一声不吭地退出。这些问题几乎都不是语法错而是对 socket 这套接口的语义理解有偏差。下面这份内容就是围绕 Linux socket 搭建 TCP 服务器C语言这条主线把从 socket() 到 close() 的每一步拆开讲清楚哪些参数有隐藏前提、哪些返回值必须逐个分支处理、并发模型怎么选、线上跑久了会在哪里翻车。无论你是刚接触网络编程、能照抄代码但说不清原理的新手还是工作几年想把这些细节重新捋一遍的开发者都能从里面找到可以直接对照的写法。1. 先把TCP服务器的骨架立起来socket 这条链路上每个函数的职责边界1.1 从文件描述符这个统一视角看 socketLinux 把网络连接抽象成文件描述符这件事听起来像是一句教材废话但它解释了后面几乎所有行为的根源。既然是一枚 fd那么 read、write、close、fcntl、select 这些原本操作文件的系统调用对 socket 同样适用反过来说文件世界里的一些直觉放到 socket 上就会出错比如write 返回成功就代表对方收到了这个假设并不成立——write 只是把数据交给了内核发送缓冲区至于什么时候真正到达对端、对端是否还在内核并不向你保证。理解这一点之后代码里很多防御性写法就有了理由。比如为什么写完要处理短写、为什么关闭前要判断对方是否已断开、为什么非阻塞模式下 read 返回 -1 且 errno 为 EAGAIN 是正常现象而非错误。这些分支不是为了健壮性多加几行而是 socket 本身就是这么工作的。还有一层需要建立起来的认知内核为每个 socket 维护了接收缓冲区和发送缓冲区应用的 read 是从接收缓冲区取数据write 是往发送缓冲区放数据。这两块缓冲区的大小和状态直接决定了滑动窗口的取值、决定了发送端会不会被阻塞、也决定了你调用 write 一次能写进去多少。后面讲的粘包、短写、阻塞写超时全部是围绕这两块缓冲区展开的。1.2 五个函数的调用顺序与依赖关系服务端这条链路上的函数顺序是固定的不能乱因为后面每个函数都依赖前一个函数的产物。用一个表格把职责理清楚比背文档更有效函数产出/作用依赖的前提典型失败原因socket()返回一枚用于监听的 fd无描述符耗尽EMFILE、协议族不支持bind()把 fd 与本地 IP、端口绑定已有 socket fd端口被占EADDRINUSE、权限不足EACCESlisten()把主动套接字转为被动内核开始接收握手已 bind参数非法、fd 不是 SOCK_STREAMaccept()从已完成连接队列取出一个连接返回新 fd已 listen被信号打断EINTR、描述符耗尽close()释放该 fd 对应的连接已有 fd无但要注意关闭时机这里最容易出问题的是 listen 和 accept 的关系。listen 之后内核才开始替你完成三次握手握手完成的连接进入已完成连接队列accept 只是从这个队列里取一条出来。也就是说accept 不参与握手它只负责交货。理解这一点才能解释为什么 backlog 设得太小会丢连接、为什么在高并发下 accept 会返回 EMFILE。再看 accept 返回的新 fd。监听 fd 和连接 fd 是两回事监听 fd 只用来接收新连接不承载任何业务数据每个连接 fd 才是真正的数据通道。这个区分如果没建立起来就会出现在 listen_fd 上 recv这种根本跑不通的写法。1.3 一个能编译能跑的最小服务器下面这段代码是我平时用来做基线测试的版本功能是把收到的数据原样回显保留了必要的错误分支可以直接gcc server.c -o server编译运行然后用nc 127.0.0.1 9000验证#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include signal.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h #define LISTEN_PORT 9000 #define BACKLOG 128 #define BUF_SIZE 4096 static int send_all(int fd, const char *buf, size_t len) { size_t sent 0; while (sent len) { ssize_t n send(fd, buf sent, len - sent, MSG_NOSIGNAL); if (n 0) { if (errno EINTR) continue; return -1; } if (n 0) return -1; sent (size_t)n; } return 0; } int main(void) { signal(SIGPIPE, SIG_IGN); int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } int on 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); 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(LISTEN_PORT); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(listen_fd); return 1; } if (listen(listen_fd, BACKLOG) 0) { perror(listen); close(listen_fd); return 1; } for (;;) { struct sockaddr_in peer; socklen_t peer_len sizeof(peer); int conn_fd accept(listen_fd, (struct sockaddr *)peer, peer_len); if (conn_fd 0) { if (errno EINTR) continue; if (errno EMFILE || errno ENFILE) { usleep(10000); continue; } perror(accept); break; } char ip[INET_ADDRSTRLEN] {0}; inet_ntop(AF_INET, peer.sin_addr, ip, sizeof(ip)); fprintf(stderr, accept %s:%u fd%d\n, ip, ntohs(peer.sin_port), conn_fd); char buf[BUF_SIZE]; for (;;) { ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) { if (errno EINTR) continue; perror(recv); break; } if (n 0) break; /* 对端关闭 */ if (send_all(conn_fd, buf, (size_t)n) 0) break; } close(conn_fd); } close(listen_fd); return 0; }代码里几个细节值得单独说。signal(SIGPIPE, SIG_IGN)放在 main 第一行因为默认情况下向已关闭的连接写数据会触发 SIGPIPE进程直接被杀掉连日志都来不及打忽略它之后send 会返回 -1 并置 errno 为 EPIPE程序就能正常走错误分支。send用MSG_NOSIGNAL标志是更精确的做法两者可以叠加使用双保险。send_all这个包装函数处理的是短写send 一次没写完不报错只返回实际写进去的字节数循环直到全部写完。很多人第一版代码直接写send(fd, buf, len, 0)就完事小数据量下看不出问题一旦报文超过发送缓冲区就会只发出去一半对端解析必然出错。recv 返回 0 的判断也容易被漏掉。返回 0 意味着对端执行了关闭这是正常结束信号不是错误把它当成错误处理会打出一堆无意义的日志。另外 recv 返回 -1 且 errno 为 EAGAIN 的情况在阻塞 socket 上基本不会出现但如果后面把 fd 改成非阻塞这个分支就必须补上否则会把暂时没数据误判成连接出错。提示这个版本的服务器是单连接串行处理的第二个客户端连上来会一直等待直到第一个断开。它适合做协议验证和本地调试不适合直接上线。并发部分放在第 5 节展开。2. bind 之前必须想清楚的三件事地址、端口与选项2.1 INADDR_ANY 与具体地址的区别别在开发机上想当然sin_addr.s_addr htonl(INADDR_ANY)这行代码几乎出现在所有示例里但它的含义经常被误解成监听本机所有 IP这句话的简化版。更准确的说法是内核会把这张网卡上所有本地地址都视为可接受的连接目标包括 127.0.0.1、内网地址以及任何后加入的地址。这个行为在绝大多数服务端场景下是对的但有两种情况要特别注意。第一种是需要限制只允许本机访问的场景。有些内部管理接口只想让同一台机器上的进程连通这时候应该显式绑127.0.0.1而不是 INADDR_ANY反过来如果绑了 ANY外部只要能路由到这台机器就能连上来安全性差很多。第二种是机器上有多个网卡、需要按业务分流的情况比如一个网卡对接内网、一个对接公网出口绑定具体地址能让连接只从指定网卡进来。还有一个常见坑把htonl和htons用混。地址是 32 位整数用 htonl端口是 16 位用 htons。写反了编译不报错运行时表现是端口号莫名其妙或者地址错乱排查起来很费时间。我自己的习惯是地址一律写htonl(INADDR_ANY)端口一律写htons(PORT)看到不是这两个形式就停下来检查。2.2 SO_REUSEADDR 到底解决的是什么问题很多人加SO_REUSEADDR是照着抄的说不清它防的是什么。准确的答案是它解决的是 TIME_WAIT 状态下的地址复用问题。当服务器主动关闭连接时这条连接会进入 TIME_WAIT持续 2MSL在 Linux 上通常是 60 秒在此期间该四元组本地 IP、本地端口、对端 IP、对端端口不能被重新使用。如果服务器重启bind 同一端口就会返回 EADDRINUSE。加上 SO_REUSEADDR 之后新 socket 可以绑定一个仍有 TIME_WAIT 连接的本地端口前提是四元组不完全冲突。这在开发调试阶段价值极高——每次改完代码重启不用等一分钟。需要澄清一个常见误解SO_REUSEADDR 不是允许多个进程绑同一个端口那是 SO_REUSEPORT 的职责。SO_REUSEPORT 让多个 socket 绑同一端口由内核做负载均衡适合多进程模型而 SO_REUSEADDR 只影响 TIME_WAIT 的复用判断。两者名字很像作用完全不同混用会导致意料之外的行为。代码里必须在 bind 之前设置int on 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)) 0) { perror(setsockopt SO_REUSEADDR); }注意 setsockopt 的第四个参数是地址第五个是长度习惯上写成on, sizeof(on)on 用 int 类型。见过有人传sizeof(int)却把 on 定义成 char结果只设置到了最低字节行为不确定。2.3 端口占用的排查手法bind 返回 -1 且 errno 为 EADDRINUSE说明端口被占。这时候先别急着换端口先确认是谁占着。最直接的办法是ss -lntp | grep 9000输出里能看到监听该端口的进程和 PID。如果看到的是自己的进程还在但状态是 TIME_WAIT那就是上面说的情况加 SO_REUSEADDR 即可如果看到的是另一个进程那就得决定是杀掉它还是换端口。还有一种更难查的情况bind 成功但客户端连不上。这通常不是 bind 的问题而是防火墙规则或者安全组没放行。排查顺序是先用ss -lntp确认端口确实在监听再从本机telnet 127.0.0.1 9000确认本地能通最后从远端机器测试。如果本地通、远端不通问题基本就锁定在网络策略层跟代码无关。另外提一个和权限相关的点1024 以下的端口需要 root 权限才能绑定。用普通用户跑服务时如果习惯性写 80、443会拿到 EACCES。生产环境的常见做法是让服务监听高位端口再由前置组件转发而不是直接给业务进程提权。3. listen 与 accept连接队列的两个长度参数不是随便填的3.1 backlog 的真实语义与内核行为listen(fd, backlog)里的 backlog 经常被写成 5 或者 10理由是示例里就这么写。这个值的作用是限制已完成连接队列的长度——注意它限制的不是同时能有多少个连接而是还没来得及被 accept 取走的连接最多能排多长。内核在 Linux 上实际使用的值会被截断到net.core.somaxconn的值默认通常是 4096不同发行版有差异可以通过/proc/sys/net/core/somaxconn查看。如果代码里传 128而系统 somaxconn 是 4096那么生效值就是 128反过来传 8192 会被截到 4096。所以在高并发场景下光改代码不改系统参数是没用的。backlog 设得太小的后果是在瞬时连接洪峰到来时握手完成的连接因为队列满了被丢弃或者按 tcp_abort_on_overflow 的配置返回 RST客户端表现为连接超时或者连接被重置。这种问题在压测时特别明显但在日常低流量下完全看不出来属于典型的上线才发现的坑。我的经验值是把 backlog 设在 512 到 1024 之间同时把 somaxconn 调到 4096 以上具体值取决于服务的 QPS 和 accept 的处理速度。如果 accept 循环里有耗时操作比如写日志、查数据库那 backlog 要留更大余量因为连接在队列里等待的时间变长了。3.2 accept 返回的新描述符意味着什么accept 返回的 conn_fd 是一条独立的连接和 listen_fd 没有任何继承关系。这个说法听起来平淡但它带来了几个实际影响。第一conn_fd 有自己的接收和发送缓冲区有自己的 TCP 状态机跟其他连接完全隔离。所以关掉一条连接不会影响别的连接这是并发处理的基础。第二监听 fd 上的属性不会自动传给连接 fd。比如你在 listen_fd 上设了 SO_RCVTIMEO新 accept 出来的 conn_fd 并不继承这个设置需要重新 setsockopt。这一点在实现超时控制时特别容易漏表现是明明设了超时还是卡住不动。第三accept 本身会阻塞。如果队列为空进程就停在 accept 上睡大觉直到有新连接进来或者被信号打断。被信号打断时返回 -1 且 errno 为 EINTR这时候应该直接 continue 重新 accept而不是当成错误退出。很多老代码在这里判断成致命错误结果进程收到一个无关信号就退出了。3.3 阻塞 accept 的退出策略与信号打断处理一个只会for(;;) accept()的服务器收到 SIGTERM 时的表现是信号处理函数设个标志位但进程还卡在 accept 里直到下一个连接进来才有机会检查标志位。如果一直没有新连接进程就永远不退出被 kill -9 才结束这在容器环境里会导致优雅停机失败。解决办法有三条路各有取舍。第一条是给 accept 加超时。用setsockopt设置 SO_RCVTIMEO 对 accept 无效正确做法是把 listen_fd 设为非阻塞或者用 select/poll 带超时等待可读再 accept。非阻塞方式下 accept 返回 -1 且 errno 为 EAGAIN 就表示暂时没有连接这时可以检查退出标志。第二条是用自管道self-pipe技巧。创建一个管道把读端加到 select 的监听集合里信号处理函数往写端写一个字节select 被唤醒后检查标志位退出。这个技巧在只处理少量 fd 的场景下很实用。第三条是直接用 epoll把 listen_fd 挂进去用 epoll_wait 的超时来周期性检查标志。这也是我目前最推荐的方式因为后面做高并发迟早要上 epoll不如一开始就把结构搭成事件驱动。/* 非阻塞 accept 的典型写法 */ int flags fcntl(listen_fd, F_GETFL, 0); fcntl(listen_fd, F_SETFL, flags | O_NONBLOCK); for (;;) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { if (g_should_exit) break; continue; /* 这里可以配合 epoll_wait 的超时 */ } if (errno EINTR) continue; if (errno EMFILE || errno ENFILE) { usleep(10000); continue; } break; } /* 处理新连接 */ }EMFILE 的处理值得单独说一句。当进程的描述符数量达到 RLIMIT_NOFILEaccept 会返回 EMFILE。这个时候进程可能已经处于无法接受新连接但也无法处理现有连接的僵死状态。比较实用的做法是预留一个空闲 fd遇到 EMFILE 时先 close 掉这个预留 fdaccept 一次拿到连接后立刻 close再重新打开一个空闲 fd 占位这样能争取时间处理已有连接避免雪崩。这个技巧在资源受限的环境里很有价值。4. 收发数据这一层read/write 的返回值必须逐个分支处理4.1 部分读写与 TCP 字节流的本质TCP 是字节流协议不保留消息边界。这句话人人都会背但它带来的具体后果经常被低估。发送端连续两次 write 100 字节接收端可能一次 read 到 200 字节也可能分三次读到 30、80、90甚至可能只读到 100 字节剩下 100 字节在下一次 read 才到达。所有这些情况都是合法的协议栈没有任何义务保证你的写入块和对方的读取块一一对应。更麻烦的是短写。send 的返回值 n 小于请求长度 len 时说明发送缓冲区满了剩下的字节需要重新发送。这个分支如果没处理报文就会被截断对端解析时可能报长度错误也可能静默错位——后者的危害大得多因为错误会一直向后传染。/* 完整发送处理短写与 EINTR */ static int send_all(int fd, const char *buf, size_t len) { size_t sent 0; while (sent len) { ssize_t n send(fd, buf sent, len - sent, MSG_NOSIGNAL); if (n 0) { if (errno EINTR) continue; return -1; } if (n 0) return -1; sent (size_t)n; } return 0; }读取端同理。期望读满 N 字节时要循环 read 并累计直到读够 N 或者遇到对端关闭static int recv_full(int fd, char *buf, size_t len) { size_t got 0; while (got len) { ssize_t n recv(fd, buf got, len - got, 0); if (n 0) { if (errno EINTR) continue; return -1; } if (n 0) return (int)got; /* 对端关闭返回已读字节 */ got (size_t)n; } return (int)got; }注意返回值的语义设计。recv_full在对端正常关闭时返回实际读到的字节数调用方拿这个数和期望长度比较就知道是完整收到还是对方提前断开。如果直接返回 0 或 -1 而不区分调用方就分不清连接断了和参数错了。4.2 应用层怎么定边界长度前缀 vs 分隔符既然 TCP 不保边界应用层就得自己定一套分帧规则。常见方案有两种各有适用面。长度前缀方案是在报文前面加固定长度的长度字段比如 2 字节或 4 字节的大端整数接收方先读固定长度头部解析出 body 长度再读 body。这种方案解析简单、效率高、支持二进制内容是我在绝大多数自定义协议里的默认选择。/* 发送4 字节长度前缀网络序 body */ static int send_frame(int fd, const char *body, uint32_t len) { uint32_t be_len htonl(len); if (send_all(fd, (char *)be_len, sizeof(be_len)) 0) return -1; if (len 0 send_all(fd, body, len) 0) return -1; return 0; } /* 接收先读头部再按长度读 body */ static int recv_frame(int fd, char *buf, size_t cap, uint32_t *out_len) { uint32_t be_len 0; int n recv_full(fd, (char *)be_len, sizeof(be_len)); if (n ! (int)sizeof(be_len)) return -1; uint32_t len ntohl(be_len); if (len cap) return -1; /* 必须做上限校验防恶意大包 */ n recv_full(fd, buf, len); if (n ! (int)len) return -1; *out_len len; return 0; }len cap这个校验是必须的。如果不校验攻击者发一个长度为 0xFFFFFFFF 的头部接收端就会尝试分配 4GB 内存直接被打垮。这种事情在实际环境里发生过很多次属于安全底线而不是可选优化。分隔符方案是用特定字节序列比如\n或\r\n作为报文结束标志适合文本协议。它的优点是人工调试方便用 telnet 就能敲缺点是不支持消息内容中出现分隔符除非做转义而且解析时需要处理分隔符被拆在两次 read 之间的情况。实现上一般用一个缓冲区累积数据每次 read 后扫描分隔符找到就切出一帧剩下的留在缓冲区等下次。4.3 SIGPIPE 与对端关闭的识别对端关闭连接有两种情况正常关闭发 FIN和异常关闭发 RST。recv 返回 0 对应的是正常关闭返回 -1 且 errno 为 ECONNRESET 对应的是 RST。这两种都要能识别出来否则日志里会是一片未知错误。向已经关闭的连接 send 数据时第一次 send 可能返回成功数据进了本端缓冲区第二次才返回 -1 并置 EPIPE。如果没忽略 SIGPIPE进程在第一次 send 后就被信号杀掉了连错误分支都走不到。这就是为什么signal(SIGPIPE, SIG_IGN)或者MSG_NOSIGNAL必须加上。还有一类情况容易被忽略客户端机器断电或者网络中断没有发 FIN 也没有发 RST服务端这边看起来连接一直正常recv 一直阻塞这条连接就变成了僵尸连接占用着描述符和内存。靠应用层握手心跳或者设置 SO_KEEPALIVE 才能发现。SO_KEEPALIVE 的默认探测时间在 Linux 上是 7200 秒太长需要配合 TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 一起调整int keepalive 1; setsockopt(conn_fd, SOL_SOCKET, SO_KEEPALIVE, keepalive, sizeof(keepalive)); int idle 60, intvl 10, cnt 3; setsockopt(conn_fd, IPPROTO_TCP, TCP_KEEPIDLE, idle, sizeof(idle)); setsockopt(conn_fd, IPPROTO_TCP, TCP_KEEPINTVL, intvl, sizeof(intvl)); setsockopt(conn_fd, IPPROTO_TCP, TCP_KEEPCNT, cnt, sizeof(cnt));这组参数的含义是空闲 60 秒后开始探测每隔 10 秒探测一次连续 3 次没回应就判定连接失效。总时长约 90 秒比默认的两小时实用得多。不过要注意心跳探测只解决发现死连接的问题业务层如果需要更精确的活性判断还是得自己做应用层心跳。5. 并发模型怎么选从阻塞单线程到 epoll 的演进路径5.1 多进程与多线程模型的实际适用面最直观的扩展方式是 accept 之后 fork 一个子进程或者 pthread_create 一个线程把 conn_fd 交给它处理。这两种模型在连接数不多、每个连接处理耗时较长的场景下工作得不错代码改动量也小。多进程模型的优点是隔离性好某个连接处理出问题导致子进程崩溃不影响主进程和其他连接主进程还能通过 waitpid 回收并记录。缺点是进程创建开销大几千连接就是几千个进程上下文切换和内存占用都会很可观。另外父子进程之间共享状态需要 IPC稍微复杂一点就要引入共享内存或者管道。多线程模型的开销比进程小共享数据方便但带来的是同步问题。如果多个线程要修改同一个连接表就必须加锁锁的粒度设计不好性能反而不如单线程。另外线程栈默认是 8MB虚拟内存实际按需分配连接数上千时内存压力也会显现。这两个模型共同的软肋是连接数上限。每个连接占用一个线程或进程当连接数到几千时调度开销就开始吃掉大部分 CPU。所以它们适合的是连接数中低、单连接处理耗时长的场景比如内部的一些批处理任务。面向大量并发短连接的对外服务就得换思路。5.2 select/poll 的瓶颈在哪里I/O 多路复用的思路是一个线程管很多个连接通过 select 或 poll 等待其中任意一个变得可读或可写然后逐个处理。这样就摆脱了一连接一线程的限制几千连接也能用一个线程扛住。但 select 有几个硬伤。第一fd 集合用位图表示内核里有 FD_SETSIZE 限制通常是 1024超过就不可靠。第二每次调用都要把整个 fd 集合从用户态拷贝到内核态连接数越多拷贝开销越大。第三返回后要线性扫描所有 fd 才知道哪些就绪时间复杂度是 O(n)。这三个问题叠加连接数上千后性能就明显下滑。poll 用数组代替位图突破了 1024 的限制但拷贝和线性扫描的问题依然存在。所以在现代 Linux 上除非是连接数很少的简单场景一般不再选 select/poll。5.3 epoll 的边沿触发与水平触发代码层面差在哪epoll 把注册和等待分开了epoll_ctl 只在增删改时调用一次fd 集合常驻内核epoll_wait 只返回就绪的 fd不需要遍历全部。这样就把 O(n) 的扫描降到了 O(就绪数量)连接数上万时优势非常明显。int epfd epoll_create1(EPOLL_CLOEXEC); struct epoll_event ev, events[1024]; ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); for (;;) { int n epoll_wait(epfd, events, 1024, 1000); /* 1 秒超时 */ if (n 0) { if (errno EINTR) continue; break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { /* 接受新连接设为非阻塞注册 EPOLLIN | EPOLLET */ } else { /* 处理数据读写 */ } } }真正需要想清楚的是水平触发LT和边沿触发ET的选择。LT 是默认模式只要缓冲区里还有数据每次 epoll_wait 都会返回这个 fd写法上和阻塞模型接近不容易漏数据适合大多数场景。ET 只在状态变化时通知一次效率略高但要求必须一次把数据读完——也就是 read 要循环到返回 EAGAIN 为止否则剩下的数据再也没有通知连接就卡死了。ET 模式下有一个必须遵守的规则连接 fd 必须设为非阻塞。因为在循环读取时最后一次 read 一定会因为没有数据而阻塞如果是阻塞模式整个事件循环就停在那里了。这个坑我见过不止一次症状是服务端处理完第一个请求后就不再响应任何请求排查时看日志完全正常就是因为卡在了最后一次 read 上。/* ET 模式下的读循环必须读到 EAGAIN 为止 */ for (;;) { ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) { /* 处理数据注意可能只是半包 */ continue; } if (n 0) { /* 对端关闭从 epoll 摘除并 close */ break; } if (errno EAGAIN || errno EWOULDBLOCK) { break; /* 数据读完了正常退出 */ } if (errno EINTR) continue; /* 真正的错误 */ break; }另外在 ET 模式下写数据也要小心。如果 send 返回 EAGAIN说明发送缓冲区满了剩下的数据得先存到应用层缓冲区然后注册 EPOLLOUT等可写事件到来时再继续发。这个写缓冲 EPOLLOUT的机制是 ET 模式写数据的标准做法少了它就会出现数据丢失或者事件循环被阻塞。至于 epoll 的惊群问题如果多个进程或线程共享同一个 epfd 做 accept老版本内核会把所有等待者都唤醒只有一个能成功其余白跑一趟。较新的内核已经通过 EPOLLEXCLUSIVE 标志缓解了这个问题。如果是单线程事件循环就不存在这个问题这也是我在大多数场景下倾向的结构。6. 资源回收与稳定性那些线上跑久了才暴露的问题6.1 close 的时机与 TIME_WAITclose 看起来是最简单的调用但它决定了连接以什么方式结束进而影响 TIME_WAIT 的归属。主动关闭的一方会进入 TIME_WAIT被动关闭的一方不会。所以在服务端如果业务上是由服务端主动断开连接比如短连接服务处理完就关TIME_WAIT 就会大量堆积在服务端。TIME_WAIT 的存在是有意义的它保证最后一个 ACK 能可靠送达也保证旧连接的延迟报文不会干扰新连接。所以直接调小它的时长或者调整相关内核参数是有风险的会让协议可靠性下降。比较稳妥的做法是从设计上减少服务端主动关闭能用长连接就用长连接必须短连接的话尽量让客户端先关。还有一个细节close 在默认情况下是优雅关闭会把发送缓冲区里残留的数据尽量发完再关闭而 SO_LINGER 设置为 0 时close 会直接发 RST丢弃缓冲区里的数据。后者在某些快速释放连接的场景下有人用但代价是对端可能收到 RST 而丢失未处理的数据非必要不要碰。另外在有多个进程或线程共享同一个连接的场景下比如 fork 之后父子进程都持有 conn_fdclose 只是把引用计数减一真正关闭要等最后一个持有者 close。如果某个子进程忘了关连接就会一直挂着不释放。这类描述符泄漏在 fork 模型里非常常见。6.2 描述符泄漏的自查方法描述符泄漏的表现很典型服务跑几个小时后accept 开始返回 EMFILE然后彻底不接受新连接。排查的第一步是确认泄漏# 查看进程当前打开的描述符数量 ls /proc/pid/fd | wc -l # 查看上限 cat /proc/pid/limits | grep open files # 看具体是哪些 fd以及指向什么 ls -l /proc/pid/fd | head -50如果 fd 数量持续增长且不回落基本可以确认泄漏。接下来要看出现在哪个环节如果大量 fd 指向 socket说明连接处理完后没有 close如果指向普通文件说明文件句柄没关。常见的泄漏原因有四个。一是错误分支里忘了 close比如 recv 返回 -1 后直接 break 出循环但 close 写在循环外面某条路径上没走到。二是 accept 之后某个初始化步骤失败就直接返回conn_fd 没关。三是多线程环境下某个线程异常退出时持有的 fd 没释放。四是 fork 之后子进程继承了父进程的大量 fd 但没有关闭虽然有 FD_CLOEXEC 可以缓解但 fork 场景下不起作用需要手动关。最省事的防御手段是把 close 统一收口到一个清理函数里所有退出路径都走这个函数。代码结构上可以写成这样static void handle_conn(int conn_fd) { char buf[4096]; for (;;) { ssize_t n recv(conn_fd, buf, sizeof(buf), 0); if (n 0) break; if (send_all(conn_fd, buf, (size_t)n) 0) break; } close(conn_fd); /* 唯一出口不会漏 */ }把连接的整个生命周期收在一个函数里入口是 fd出口只有一个 close这样审查代码时一眼就能看出有没有漏。这个习惯我保持了几年能挡掉大部分低级泄漏。6.3 超时、心跳与僵尸连接清理只靠 TCP 自身的机制一条对端断电的连接可能要很久才能被发现。除了前面提到的 SO_KEEPALIVE 参数调整还有几种做法。一是给读写设置超时。SO_RCVTIMEO和SO_SNDTIMEO能让阻塞的 recv/send 在超时后返回 -1 并置 EAGAIN程序就能借这个机会检查是否需要清理连接。struct timeval tv { .tv_sec 30, .tv_usec 0 }; setsockopt(conn_fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); setsockopt(conn_fd, SOL_SOCKET, SO_SNDTIMEO, tv, sizeof(tv));要注意这两个选项是每次调用的超时不是整个连接的超时。如果对端每隔 29 秒发一个字节连接永远不会被判超时。所以它适合作为底层保护业务层还需要自己的空闲检测。二是应用层心跳。客户端每隔一段时间发一个心跳包服务端维护每条连接的最后活跃时间定时扫描超过阈值就主动断开。这套机制的实现成本不高但能精确控制判定时长也方便在日志里记录。扫描可以放在事件循环的定时器里比如每 10 秒扫一遍把超时的连接挑出来 close。三是限制单连接的处理时长。对于处理时间不确定的业务可以给每条连接设定一个总生存时间超时就强制断开避免某条慢连接长期占用资源。这个策略在有大量短请求的场景下很有用。另外提一个易被忽略的设置TCP_NODELAY。默认情况下 Nagle 算法会把小的写合并发送减少报文数量但会引入最多几十毫秒的延迟。对于交互式或者需要低延迟的场景应该关掉它int nodelay 1; setsockopt(conn_fd, IPPROTO_TCP, TCP_NODELAY, nodelay, sizeof(nodelay));而对于批量传输、吞吐优先的场景保留 Nagle 反而更划算。这个取舍取决于业务特点没有一个放之四海皆准的答案。7. 我踩过的几个真实坑与排查记录7.1 bind 返回 EADDRINUSE 但端口明明没人监听这个问题我第一次遇到时很困惑ss -lntp看不到任何进程占用该端口但程序就是 bind 不上。后来用ss -antp | grep :9000才看到端口上有大量 TIME_WAIT 状态的连接它们是上一次运行时服务端主动关闭留下的。这就回到了前面说的服务端主动关闭会留下 TIME_WAIT持续 60 秒。这期间如果没有 SO_REUSEADDRbind 就会失败。加上这个选项之后问题消失。所以现在的习惯是只要写服务端socket 创建后第一件事就是设 SO_REUSEADDR不管当前场景需不需要。还有一次更隐蔽的情况是端口确实被别的进程占用了但那个进程监听在 IPv6 的地址上::而我的程序绑的是 IPv4 的0.0.0.0。在某些配置下这两个会互相冲突。用ss -lntp看的时候要留意输出里的地址列是0.0.0.0:9000还是[::]:9000看错了就会绕远路。7.2 客户端显示已连接服务端却毫无反应现象是用工具连上去界面提示连接成功但发任何数据都没回应服务端日志也没有 accept 记录。这个现象很有迷惑性因为连接成功说明三次握手确实完成了。排查思路是这样走的先确认服务端进程还活着再用ss -ant查看该端口的状态。如果看到大量 SYN_RECV说明握手没走完如果看到 ESTABLISHED 但进程里没有对应的 accept 日志那就是连接进了已完成队列但没被取走。我遇到的那次是 accept 循环里有个阻塞操作把整个循环卡住了——在 accept 之后直接调了一个会阻塞很久的函数导致后面的连接都在队列里排队。表现就是有的客户端能连上并立刻得到响应有的连上后一直等。把那个阻塞操作改成异步之后恢复正常。还有一次原因更简单listen 的 backlog 设成了 5压测时瞬间进来几十个连接队列满了之后新的握手被丢弃客户端那边看到的是连上就被断开或者超时。把 backlog 调到 512 就好了。这类问题在单机低流量测试时永远复现不出来必须在有并发压力的环境下验证。7.3 大数据量传输时进程无声退出这个坑的典型症状是传输小报文一切正常一旦发送超过几 MB 的数据服务端进程就消失了日志里没有任何错误core dump 也没有。原因几乎都是 SIGPIPE。当对端已经关闭或者网络异常导致连接被重置向这个连接写数据会触发 SIGPIPE默认行为是终止进程。因为信号导致的退出不走正常的错误处理路径所以日志里什么都没有。确认方法是在 shell 里运行echo $?看退出码SIGPIPE 对应的退出码是 141128 13。看到这个数字基本就能锁定。解决方式前面提过signal(SIGPIPE, SIG_IGN)加上 send 时用MSG_NOSIGNAL。两个都加前者管住所有写入路径后者保证即使某处没被覆盖也不触发信号。加了之后 send 会正常返回 -1 并置 EPIPE程序就能走错误分支清理资源。顺带说一句这个坑在压测时特别容易复现因为压测工具经常在收到足够数据后就断开连接服务端还在继续写正好命中 SIGPIPE。所以做压测时如果发现服务端莫名退出第一反应就该去看是不是这个原因。8. 把这些细节串成一个可维护的服务器结构上面讲了这么多点真正落到工程里比较合理的组织方式是把服务器拆成三层监听层负责 socket、bind、listen、accept连接层负责单条连接的生命周期和数据收发事件层负责 epoll 循环和定时任务。三层之间通过连接对象传递每条连接有自己的读缓冲、写缓冲和最后活跃时间。读缓冲的设计要能容纳半包recv 到的数据先追加到缓冲区然后循环尝试从缓冲区里切出完整帧切不出来就等下次数据。这样就把数据到达和消息完整两件事解耦了代码逻辑会清晰很多。写缓冲同理send 返回 EAGAIN 时把剩余数据挂在连接的写缓冲上注册 EPOLLOUT可写时继续发发完再取消注册。定时任务可以挂在 epoll_wait 的超时上每次超时返回时扫一遍连接表把超过空闲阈值的连接关掉。扫描的复杂度是 O(连接数)如果连接数很大可以再按最后活跃时间做分桶减少每次扫描的量。不过在几万连接的规模下简单线性扫描通常也够用不必过早优化。配置项建议全部从命令行参数或者配置文件读取监听地址、端口、backlog、最大连接数、读超时、空闲超时、日志级别。把这些硬编码在代码里换个环境就要重新编译调试成本会高很多。我在项目里一般会留一个--help输出所有参数和默认值运维接手时不用翻代码。关于日志至少要有三类启动时的监听信息、每条连接的建立和关闭带对端地址和耗时、以及所有错误分支带 errno 和可读的错误描述。错误日志里用strerror(errno)把错误码翻译成文字排查效率能提高一大截。见过不少代码只打recv failed连是哪条连接、错误码多少都不知道等于没打。最后再分享一个我觉得很值的小习惯写这类服务端代码时先用手写的测试脚本跑一遍边界情况——空报文、超长报文、半包、连续快速连接断开、对端不发 FIN 直接断网。这几项过一遍上线后能少掉一大半的半夜告警。工具不用复杂一个 nc、一个自己写的小客户端加上ss看状态足够定位绝大多数问题。