做嵌入式上位机、写网关程序、或者自己搭一套小型服务端的人几乎都绕不开同一件事用 Linux socket 从零搭一个 TCP 服务器。框架满天飞的今天还有必要手写吗我的答案是至少在两种情况下必须会一是跑在资源受限的设备上塞不进重型框架二是线上出了问题日志只剩下一个 errno你得能凭对 socket API 的理解把它定位出来。这篇就按我实际写这类程序的顺序讲——从模型选型、核心 API 的坑、完整可编译的代码到线上排查的那几个经典故障最后给出并发模型升级的路径。适合刚接触网络编程、能写 C 但没写过硬骨头服务端的读者也适合写过一阵但总觉得能跑就行、想搞清楚每个参数为什么这么填的人。1. 先把设计想清楚别急着敲 socket1.1 一个能跑和能用之间的差距通常出在模型选型新手写 TCP 服务器最典型的写法是socket→bind→listen→while(1){ accept; read; write; close; }测试的时候用一个客户端连上去收发自如觉得完事了。问题在于这个程序是串行的只要第一个连接的客户端不断开第二个客户端连上来只能排队接受队列满了之后新连接会被内核直接丢掉或者挂起。我在一个采集类项目上就踩过这个坑——设备端每 5 秒上报一次数据客户端偶尔有个网络抖动没发 FIN那个连接就僵在那里整个服务端表现为突然不收数据了重启就好。查了半天才发现是单线程阻塞模型下,一个连接把主循环卡死了。所以选型要先确定三件事它们的组合基本决定了后面所有代码的形态维度可选方案适用判断IO 模型阻塞 / 非阻塞 / IO 多路复用连接数 10 且交互简单阻塞够用超过几十个连接必须上多路复用并发模型单线程串行 / 每连接一线程 / 线程池 / 多进程短连接高频用线程池长连接低频用 epoll 单线程协议形态裸字节流 / 定长包 / 长度前缀 / 分隔符只要有业务结构就一定要有边界否则必然粘包我个人的默认选择是连接数少、逻辑重的控制类服务用每连接一线程 超时踢出连接数多、单条消息轻的采集类服务用epoll 单线程 长度前缀协议。前者代码十分钟能写完后者前期多花两小时但后面扩容不用重写。提示不要为了显得专业一上来就写 epoll。一个只有三个终端连上来的调试端口用 epoll 反而让后来接手的人看不懂。模型选型的标准是连接数形态和消息频率不是代码看起来高不高级。1.2 阻塞与非阻塞的差别本质是谁来等很多人对阻塞的理解停留在卡住这个字面意思上。说得准确一点阻塞的read在数据没到时会把自己挂到内核的等待队列上让出 CPU直到数据到达或者连接关闭才被唤醒非阻塞的read立刻返回-1errno设成EAGAIN意思是现在没有你待会儿再来问。这两者的代价完全不同。阻塞模型下等待这件事是内核替你做的代码简单但你的线程被绑死在一个连接上非阻塞模型下等待这件事变成你自己的责任你必须有一个地方统一检查哪些 fd 可读这个地方就是多路复用接口select、poll、epoll。所以多路复用不是更快的阻塞它是非阻塞模型的管理器两者是配套出现的。有个细节容易忽略listen之后的监听 fd和accept返回的已连接 fd是两个性质不同的东西。监听 fd 只能用来accept不能read已连接 fd 才承载数据。我做代码 review 时见过把accept返回值丢掉、继续在监听 fd 上read的写法表现是永远读不到数据还被阻塞排查起来特别费时间。1.3 协议边界要在写第一行代码之前定下来TCP 是字节流不是消息流。这句话被说烂了但真正理解它的人不多。send调 3 次、每次 100 字节对端recv可能一次收到 300 字节也可能分 5 次收到这完全取决于内核缓冲区、MSS、Nagle 算法和网络路径跟你的调用次数没有对应关系。所以只要你的业务有一条消息的概念就必须自己在应用层画出边界。三种常见做法我按推荐度排序长度前缀包头固定 4 字节存消息体长度网络字节序后面跟消息体。解析简单、无转义、内存可预期是绝大多数私有协议的首选。定长包每条消息固定 N 字节。实现最简单但字段一变就要改协议浪费带宽。分隔符类似 HTTP 的\r\n或者自定义0x7E。可读性好但消息体里一旦出现分隔符就要做转义很容易写出 bug。选长度前缀有个必须注意的地方长度字段要设上限。我见过对端传过来一个0xFFFFFFFF的长度服务端老老实实malloc4GB直接 OOM 挂掉的案例。收到包头后第一件事是校验长度是否在合理范围内比如超过 1MB 直接断开这一步不能省。2. 核心 API 逐个拆坑都在参数里2.1 socket()协议族和类型必须配对int lfd socket(AF_INET, SOCK_STREAM, 0);三个参数分别是协议族、套接字类型、具体协议。第三个参数填 0 表示让内核根据前两个自动选择AF_INET SOCK_STREAM会选中 TCP这是绝大多数情况下的正确写法。写IPPROTO_TCP也行但不必要。这里有个新手常犯的错误把SOCK_STREAM写成SOCK_DGRAM然后疑惑为什么listen报错。UDP 是无连接的没有listen/accept的概念bind之后直接recvfrom就行。反过来如果你想用 IPv6协议族要换成AF_INET6地址结构体也要换成sockaddr_in6端口字段的填充方式不变。返回值是文件描述符失败返回-1。每个系统调用都要检查返回值这句话听起来像废话但服务端程序里漏检一处后面就是一场灾难。我建议养成一个习惯写socket/bind/listen这类初始化调用时失败分支一律perror加return让程序在启动阶段就死掉而不是带着半初始化状态跑起来。2.2 sockaddr_in 填充字节序是第一个真正的大坑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(9000);这一段里有三个必须理解的点。第一memset清零不能省。sockaddr_in里有一个 8 字节的sin_zero填充字段不初始化的话里面是栈上的垃圾值某些实现下会导致bind失败或者行为异常。第二端口和 IP 必须经过字节序转换。x86 是小端网络字节序是大端。9000 这个数在内存里是0x2328小端存储是28 23大端是23 28不转的话你bind的端口会变成 10264 之类的奇怪数字。规则很简单所有写进结构体的整数都要htons16 位或htonl32 位所有读出来展示的整数都要ntohs/ntohl。地址字符串用inet_pton转成二进制反过来用inet_ntop这两个函数不带h/n因为它们内部已经处理好了。第三INADDR_ANY表示监听本机所有网卡。只监听本地回环就写inet_pton(AF_INET, 127.0.0.1, addr.sin_addr)这样外部机器连不进来调试期间更安全。我习惯在开发阶段绑回环上线配置里再改成INADDR_ANY避免本地调试的服务被同网段的人扫到。注意端口号小于 1024 需要 root 权限。调试时用 8080、9000 这类高位端口别用 80否则你会花时间在权限问题上而不是业务上。2.3 bind() 与 SO_REUSEADDR为什么必须加这一行int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on));不加这一行你会遇到一个非常烦人的现象程序CtrlC退出后立刻重新启动bind报Address already in use得等一分钟才能再起来。原因是上一次运行中有连接处于TIME_WAIT状态端口还占着。加上SO_REUSEADDR之后处于TIME_WAIT的端口可以立即被重新绑定。这个选项必须在bind之前设置设置在bind之后无效这一点经常被写错。另外它只影响绑定行为不影响已经建立的连接。还有一个容易混淆的SO_REUSEPORT它允许多个进程/线程绑定同一个端口内核在accept层面做负载均衡。多进程服务器用它来实现多个 worker 各自 accept性能不错但要注意它要求所有绑定者使用相同的用户 ID而且不适合主进程监听、子进程处理那种共享 fd 的模式。两个选项语义不同别混着用。2.4 listen()backlog 到底填多少if (listen(lfd, 128) 0) { perror(listen); return 1; }listen把主动套接字转成被动套接字第二个参数是等待队列长度。这里有个历史包袱在 Linux 上实际生效的值是min(backlog, /proc/sys/net/core/somaxconn)而somaxconn默认值在不同发行版上差别很大老的可能是 128新的常见 4096。这意味着你填 1024 但系统somaxconn是 128实际只有 128。所以高并发场景下除了代码里填大一点还要检查内核参数cat /proc/sys/net/core/somaxconn # 需要时临时调大 sysctl -w net.core.somaxconn1024至于填多少我的经验是短连接高频场景比如每次请求新建连接填 512 到 1024长连接场景填 128 就够因为连接建立后立刻被accept走队列基本是空的。填太小的后果是突发流量下客户端收到连接超时或者被 RST表现是服务端明明没挂但一部分客户端连不上。2.5 accept()别忘了拿对端地址出问题时全靠它struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(lfd, (struct sockaddr *)cli, len);accept返回一个新的 fd 专门服务这个连接监听 fd 保持不变继续等下一个。第三个参数是值-结果参数传进去时告诉内核缓冲区多大返回时内核写回实际地址长度。所以那个len必须在每次accept之前重新赋值为sizeof(cli)不能只在循环外面初始化一次这是很隐蔽的 bug——第二次之后len被改小了拿到的地址可能不完整。强烈建议在accept成功后立刻打印客户端 IP 和端口char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, cli.sin_addr, ip, sizeof(ip)); printf(client %s:%d connected, fd%d\n, ip, ntohs(cli.sin_port), cfd);这行日志在你排查到底是谁在疯狂重连是不是某个客户端一直在断线重连的时候价值极高。我做的每一个服务端程序都留着这段日志只在生产环境把它降级成环形缓冲避免刷爆磁盘。accept失败的处理也要区分情况errno EINTR是被信号打断直接continue重试errno EMFILE是 fd 耗尽需要特殊处理后面第 4 章细讲其他错误一般意味着监听 fd 出问题了应该退出重建。2.6 read() 与 write()短读短写是常态不是异常ssize_t n read(cfd, buf, sizeof(buf));read的返回值必须分三种情况处理这是服务端代码的基本功返回值含义正确处理 0读到了 n 个字节处理数据继续读 0对端发送了 FIN优雅关闭关闭本端 fd退出该连接循环 0出错看 errnoEINTR重试EAGAIN非阻塞无数据ECONNRESET对端 RST关闭最常见的错误是把read返回 0 当成没读到数据继续循环结果陷入死循环疯狂空转CPU 打满。记住0 是结束信号不是空信号。write的问题在另一边它不保证把len个字节全写出去返回值可能小于请求长度这叫短写。阻塞 fd 上短写少见但存在比如信号打断、缓冲区满非阻塞 fd 上短写是家常便饭。所以严谨的写法是包一层循环static int write_full(int fd, const void *buf, size_t n) { const char *p (const char *)buf; size_t left n; while (left 0) { ssize_t w write(fd, p, left); if (w 0) { if (errno EINTR) continue; return -1; } p w; left - (size_t)w; } return 0; }对应的read_full也建议写一个后面做长度前缀协议时直接复用。注意write返回-1且errno EPIPE的情况说明对端已经关闭而且内核给本进程发了SIGPIPE信号。这个信号的默认行为是终止进程也就是说对端一断你的整个服务端就没了。这个问题在第 4 章单独讲。2.7 close()谁先关谁背 TIME_WAITclose看起来最简单其实有讲究。TCP 断开需要四次挥手主动发起关闭的一方会进入TIME_WAIT状态持续 2MSLLinux 上大约 60 秒。这段时间内那个四元组源 IP、源端口、目的 IP、目的端口不能被复用。对服务器来说如果服务端主动close在高频短连接场景下会积累大量TIME_WAIT占满本地端口范围新的出站连接就建不起来了。所以设计上的经验是尽量让客户端先关服务端读到read返回 0 之后再close这样TIME_WAIT落在客户端侧。当然这只是缓解SO_REUSEADDR才是让服务端能快速重启的关键。另外如果连接上有未发完的数据直接close会触发 RST对端可能收不全。要优雅一点可以shutdown(cfd, SHUT_WR)先发 FIN等对端也关了再close。大部分业务场景用不上这么细但涉及发完最后一条响应再断开的逻辑时这个细节必须处理。3. 从零跑通一个能用的 TCP 服务器3.1 完整代码骨架先跑通单连接回显这一版故意保持简单单线程串行目的是先把所有 API 串起来、验证参数填对别一上来就写并发模型。#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 volatile sig_atomic_t g_stop 0; static void on_signal(int sig) { (void)sig; g_stop 1; } int main(void) { /* 1. 忽略 SIGPIPE避免对端关闭时进程被信号杀掉 */ signal(SIGPIPE, SIG_IGN); /* 2. 注册退出信号让主循环能优雅结束 */ struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler on_signal; sigaction(SIGINT, sa, NULL); sigaction(SIGTERM, sa, NULL); /* 3. 创建监听套接字 */ int lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); return 1; } int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); /* 4. 填充地址并绑定 */ 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(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(lfd); return 1; } if (listen(lfd, BACKLOG) 0) { perror(listen); close(lfd); return 1; } printf(listen on 0.0.0.0:%d, fd%d\n, LISTEN_PORT, lfd); /* 5. 主循环 */ char buf[BUF_SIZE]; while (!g_stop) { struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(lfd, (struct sockaddr *)cli, len); if (cfd 0) { if (errno EINTR) continue; perror(accept); continue; } char ip[INET_ADDRSTRLEN] {0}; inet_ntop(AF_INET, cli.sin_addr, ip, sizeof(ip)); printf(client %s:%d connected, fd%d\n, ip, ntohs(cli.sin_port), cfd); /* 单连接回显读到什么发回什么 */ for (;;) { ssize_t n read(cfd, buf, sizeof(buf)); if (n 0) { if (write(cfd, buf, (size_t)n) ! n) { perror(write); break; } } else if (n 0) { printf(client fd%d closed\n, cfd); break; } else { if (errno EINTR) continue; perror(read); break; } } close(cfd); printf(fd%d released\n, cfd); } close(lfd); printf(server exit\n); return 0; }这一版有三个刻意的设计。第一SIGPIPE忽略放在最前面signal和sigaction都行我习惯用sigaction因为行为更可控。第二主循环用g_stop标志而不是直接exit这样CtrlC之后能把lfd关掉、把资源释放干净配合valgrind检查不会有泄漏。第三回显用的是同一个write返回值直接和n比较——回显这种小数据量在这个场景下不会短写但如果你要改成发送大文件就必须换成 3.2 节那个write_full版本。3.2 加一层帧头把粘包问题彻底解决上面这版回显程序用nc测试看着一切正常一旦接入真实业务就会出问题你连发两条消息服务端可能一次读出来两条粘在一起也可能把一条拆成两半。所以下一步是加长度前缀。协议定成这样4 字节大端无符号整数表示消息体长度后面紧跟消息体。最大长度限制 1MB超了直接断开。#define MAX_BODY (1024 * 1024) /* 循环读满 n 字节返回 0 成功-1 失败或对端关闭 */ static int read_full(int fd, void *buf, size_t n) { char *p (char *)buf; size_t left n; while (left 0) { ssize_t r read(fd, p, left); if (r 0) { p r; left - (size_t)r; } else if (r 0) { return -1; /* 对端关闭 */ } else { if (errno EINTR) continue; return -1; } } return 0; } /* 读一条完整消息成功返回消息体长度调用方负责 free失败返回 -1 */ static ssize_t recv_packet(int fd, char **out) { uint32_t nlen 0; if (read_full(fd, nlen, sizeof(nlen)) 0) return -1; uint32_t blen ntohl(nlen); if (blen 0 || blen MAX_BODY) { fprintf(stderr, bad frame length: %u\n, blen); return -1; } char *body malloc(blen 1); if (!body) return -1; if (read_full(fd, body, blen) 0) { free(body); return -1; } body[blen] \0; *out body; return (ssize_t)blen; }配套的发送函数static int send_packet(int fd, const void *body, uint32_t blen) { uint32_t nlen htonl(blen); if (write_full(fd, nlen, sizeof(nlen)) 0) return -1; if (blen 0 write_full(fd, body, blen) 0) return -1; return 0; }这段代码有两个值得说的处理。一是blen 0也当成非法合法的空消息用长度 0 表达在协议上是允许的但我选择直接拒绝因为实际业务里空消息基本都是对端出 bug 了早点暴露比默默处理更好。二是malloc(blen 1)多分配一个字节并且置\0让消息体能直接当 C 字符串用省掉调用方到处memcpy加结尾的麻烦一个字节的代价换代码清洁度我觉得值。有了这层封装主循环里就变成了while (recv_packet(cfd, msg) 0) { handle(msg); free(msg); }业务逻辑彻底不用关心 TCP 边界这是我最推荐的结构。3.3 编译、运行与联调测试编译时建议把警告全开很多 socket 相关的类型错误比如socklen_t和int混用、指针符号问题只有开了警告才看得见gcc -Wall -Wextra -O2 -g -o tcpserver tcpserver.c跑起来之后第一件事是确认端口真的在监听./tcpserver ss -lntp | grep 9000看到LISTEN 0 128 0.0.0.0:9000就说明绑成功了。第二列那个数字就是实际的 backlog可以顺便确认有没有被somaxconn截断。联调分三层。最浅的一层用ncnc 127.0.0.1 9000敲几个字符回车如果回显正常说明accept/read/write链路通了。注意这一层测不了长度前缀协议因为nc发的是裸字节。第二层写个十行的测试客户端专门验证帧格式int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in a {0}; a.sin_family AF_INET; a.sin_port htons(9000); inet_pton(AF_INET, 127.0.0.1, a.sin_addr); connect(fd, (struct sockaddr *)a, sizeof(a)); send_packet(fd, hello, 5); char *reply NULL; ssize_t n recv_packet(fd, reply); printf(got %zd bytes: %s\n, n, reply); free(reply); close(fd);第三层才是抓包验证。用tcpdump看真实的字节流确认长度字段的字节序对不对tcpdump -i lo -A -s 0 tcp port 9000如果你发 hello 看到的是00 00 00 05 68 65 6c 6c 6f说明大端序正确如果看到05 00 00 00那就是漏了htonl。3.4 别急着上并发先把单连接跑稳这件事做完我见过太多人写完 accept 循环立刻开始写线程池然后卡在为什么线程里 read 一直返回 0。回头一看是把cfd当参数传进线程后主线程又close了一次。单连接能跑稳的标志是这四件事都验证过连续发 1000 条消息不丢不乱序客户端CtrlC强杀时服务端日志打出 closed 而不是 read: Connection reset服务端CtrlC时能打印 server exit 并正常退出用valgrind --leak-checkfull跑一遍没有 definitely lost。这四条过了再往上叠并发模型出问题时你才能确定问题在新加的那一层。4. 常见问题与排查技巧实录4.1 端口占用Address already in use这个报错出现频率最高原因有四种处理方式不同现象原因处理刚CtrlC就重启报错旧连接在TIME_WAIT加SO_REUSEADDR必须在 bind 前一直报错ss -lntp能看到别的进程端口被别的程序占了kill掉或换端口只有 root 能起端口 1024换高位端口别硬上bind返回EADDRNOTAVAIL绑了本机不存在的 IP检查ip addr里的实际地址排查顺序永远是先ss -lntp | grep 端口号看清楚到底是谁占着、处于什么状态。我遇到过一次很诡异的情况ss显示没有进程监听但bind一直失败。最后发现是绑定的那个 IP 属于一个已经 down 掉的网卡地址不在内核里了属于第四种情况。4.2 SIGPIPE对端一断进程直接没了这个问题的迷惑性在于它不在你的代码里而是内核在你调用write时发现对端已关闭顺手给你的进程发了个SIGPIPE默认行为是终止。表现是服务端毫无征兆地退出日志里什么都没有。两种解决办法我通常两个都用/* 方法一全局忽略最省事 */ signal(SIGPIPE, SIG_IGN); /* 方法二单次调用规避更精确 */ send(cfd, buf, len, MSG_NOSIGNAL);全局忽略之后write会返回-1并且errno EPIPE你就能正常走错误处理分支了。对于只用write的代码方法一是必须的如果代码里已经用send加上MSG_NOSIGNAL可以避免影响进程里其他模块。注意忽略SIGPIPE是进程级的如果你的程序里还嵌了别的库要确认它们不依赖这个信号的默认行为。实际工程里这是极小概率但知道有这回事总比出事时抓瞎强。4.3 accept 返回 EMFILEfd 耗尽了怎么办长跑的服务端迟早会遇到这个。ulimit -n默认可能是 1024每个连接占一个 fd加上日志文件、epoll fd、定时器等实际能撑的连接数远小于 1024。一旦耗尽accept返回-1且errno EMFILE而且会持续返回因为监听 fd 上的连接你没能力接走新连接一直堆在队列里。直接continue的后果是 CPU 被这个错误刷满。业界常用的预留 fd技巧是这样的int idle_fd open(/dev/null, O_RDONLY); /* 启动时预留一个 */ /* accept 失败分支里 */ if (errno EMFILE) { close(idle_fd); /* 先释放一个 fd */ int tmp accept(lfd, NULL, NULL); /* 接走这个连接 */ if (tmp 0) close(tmp); /* 立刻关掉给对端一个干净的 FIN */ idle_fd open(/dev/null, O_RDONLY); /* 重新占位 */ continue; }这个技巧的作用是踢掉一个连接换回喘息空间让服务端不至于雪崩。但它是止血不是治病。真正的解法是同时做三件事用setrlimit把RLIMIT_NOFILE提到需要的值给每个连接加空闲超时把僵尸连接清掉在应用层限制单 IP 的最大连接数。顺便提一下ulimit -n改了只对当前 shell 生效systemd 管理的服务要改LimitNOFILE容器里要改--ulimit nofile这几个地方不一样我踩过一次。4.4 阻塞导致整体卡死现象和定位方法现象很典型服务端运行一段时间后不再响应任何新连接ss -lnt看监听队列在涨但就是没人处理。原因通常是某个连接上的read永远不返回——对端既不发数据也不发 FIN可能是网线拔了、可能是客户端进程被挂起了。排查的第一招是看线程栈gdb -p pid -batch -ex thread apply all bt如果看到所有工作线程都停在read上那就确认是阻塞等待。strace也能看strace -f -p pid -e tracenetwork,read,write会看到卡在某个 fd 的read上不动。定位到具体 fd 之后用ss -tnp找到对应的对端地址就知道是谁在挂着。解决办法是给所有 socket 设置超时不要相信对端会主动关struct timeval tv { .tv_sec 30, .tv_usec 0 }; setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); setsockopt(cfd, SOL_SOCKET, SO_SNDTIMEO, tv, sizeof(tv));设了之后read超时会返回-1且errno EAGAIN或EWOULDBLOCK你就能主动把这个连接关掉。30 秒这个值看业务心跳间隔一般是它的三分之一比较合适。4.5 排查工具箱我常用的几条命令遇到网络问题别靠猜按这个顺序走一遍基本能定位# 1. 端口在不在监听backlog 多大 ss -lntp | grep 9000 # 2. 当前有多少 Established / TIME_WAIT / CLOSE_WAIT ss -tan state established ( sport :9000 ) | wc -l ss -tan state time-wait | wc -l ss -tan state close-wait | wc -l # 3. 系统调用卡在哪 strace -f -e tracenetwork ./tcpserver # 4. 真实字节流对不对 tcpdump -i any -A -s 0 tcp port 9000 and host 127.0.0.1 # 5. 连接队列有没有溢出关键指标 netstat -s | grep -i listen第 5 条特别值得说。netstat -s里的listen queue of a socket overflowed和SYNs to LISTEN sockets dropped这两个计数如果一直在涨说明backlog或者somaxconn不够或者accept处理太慢。这是判断服务端到底有没有扛住最直接的证据比看 CPU 有用得多。另外CLOSE_WAIT堆积是个强信号说明对端已经发了 FIN但你的代码没有close。这几乎百分百是代码 bug——某个分支里read返回 0 之后忘了关 fd或者异常路径直接return跳过了close。看到CLOSE_WAIT数量持续增长去查close的调用路径就对了。5. 并发模型升级与性能调优5.1 epoll 版本连接数上百之后的必经之路单线程串行跑通之后如果连接数上到几十上百就该换 epoll 了。核心结构是一个事件循环 一个 fd 到缓冲区的映射用epoll_wait拿就绪事件每个事件上做非阻塞读写。int epfd epoll_create1(0); struct epoll_event ev, events[64]; ev.events EPOLLIN; ev.data.fd lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, ev); while (!g_stop) { int n epoll_wait(epfd, events, 64, 1000); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd lfd) { /* 一次 accept 尽量接干净但也要有上限防止饿死其他事件 */ for (int k 0; k 64; k) { int cfd accept(lfd, NULL, NULL); if (cfd 0) break; set_nonblock(cfd); /* 关键必须设非阻塞 */ ev.events EPOLLIN; ev.data.fd cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, ev); } } else { /* 处理数据注意读到 EAGAIN 为止 */ } } }几个必须记住的要点。第一accept返回的 fd 一定要设成非阻塞否则一个慢客户端就能把整个事件循环卡住epoll 的优势荡然无存。第二accept要循环接因为 epoll 是水平触发时如果只接一个剩下的连接会反复触发事件但循环要有上限一次接太多会让其他 fd 的事件迟迟得不到处理。第三非阻塞下read要读到EAGAIN才算读完中途返回 0 代表对端关闭。epoll的 LT水平触发和 ET边缘触发之争我的实际建议是先用 LT。LT 模式下没读完的数据下次还会通知你代码容错性高ET 模式必须一次读到EAGAIN而且必须配非阻塞稍不注意就丢数据调试成本很高。等你的程序稳定了、确实遇到性能瓶颈了再考虑改 ET。5.2 TCP_NODELAY 与缓冲区两个真正影响体验的参数默认情况下 TCP 开着 Nagle 算法会把小包攒起来一起发目的是减少网络上的小包数量。这对批量传输是好事但对请求-响应式的小消息是灾难你发一个 20 字节的请求它可能等上几十毫秒才发出去因为要等对端的 ACK 或者攒够数据。如果你的业务是高频小消息比如控制指令、实时状态同步一定要关掉int flag 1; setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, flag, sizeof(flag));关掉之后每个write都立即发出去延迟从几十毫秒降到毫秒级。代价是小包变多带宽利用率下降但对内网的控制类通信完全不是问题。判断标准很简单如果业务对延迟敏感且消息小关如果是文件传输、日志上传这类大流量场景开着更好。另一个经常被忽略的是 socket 缓冲区大小。默认的收发缓冲区在几十 KB 到几百 KB 之间自动调节。如果你明确知道自己的业务是大块数据吞吐可以手动调大int sz 256 * 1024; setsockopt(cfd, SOL_SOCKET, SO_RCVBUF, sz, sizeof(sz)); setsockopt(cfd, SOL_SOCKET, SO_SNDBUF, sz, sizeof(sz));注意 Linux 会把设定值翻倍内核内部记账需要而且受net.core.rmem_max限制设太大也会被截断。调之前先用ss -tmi看当前的skmem别盲目调。5.3 心跳、超时与优雅退出让服务端能跑上几个月一个能跑一晚上的服务端和一个能跑三个月的服务端差距不在核心逻辑在这三件事上。心跳。TCP 的连接状态是内核维护的网线拔了、中间设备断了内核可能几分钟甚至几十分钟才发现。SO_KEEPALIVE默认要 7200 秒才探测第一次太慢了。要么调参数int idle 60, intvl 10, cnt 3; setsockopt(cfd, SOL_SOCKET, SO_KEEPALIVE, (int){1}, sizeof(int)); setsockopt(cfd, IPPROTO_TCP, TCP_KEEPIDLE, idle, sizeof(idle)); setsockopt(cfd, IPPROTO_TCP, TCP_KEEPINTVL, intvl, sizeof(intvl)); setsockopt(cfd, IPPROTO_TCP, TCP_KEEPCNT, cnt, sizeof(cnt));要么在应用层自己做心跳包我更倾向后者因为可控、可观测、跨平台还能顺带检测对端业务是否真的活着内核 keepalive 只能证明 TCP 层通。做法是每条连接记一个last_active时间戳事件循环里每秒扫一遍超过 3 倍心跳间隔没动静的直接关掉。优雅退出。CtrlC时不能直接return要让accept循环退出、把所有连接关掉、日志刷盘。我通常用signalfd或者全局标志位 epoll_wait超时来配合信号处理函数里只置标志真正的清理在主循环里做。直接exit()的后果是某些缓冲区里的日志丢了出问题时什么线索都没有。连接数上限。不管内存多大都要设一个硬上限。超过之后新连接直接accept然后立刻close不给业务处理机会。这比让所有连接都变慢要好至少服务对已有连接还是可用的。我在一个项目里就是因为没有这个限制被一个重连风暴打到内存耗尽最后 OOM killer 把进程干掉了日志一条没留。最后分享一个我在实际项目里固定会加的东西每处理 N 条消息或者每 10 秒打一行汇总日志包含当前连接数、累计收发字节数、错误计数。这行日志在事后复盘时价值巨大——你能看出问题是从哪个时间点开始劣化的而不是只知道它挂了。写了这么多年 socket 程序我的体会是能跑起来的代码半小时就写完能跑三个月不出事的代码多出来的时间基本都花在这些不起眼的兜底逻辑上。