☰
Linux socket从零搭建TCP服务器:模型选型、API陷阱与并发调优
2026/10/27 3:23:50 网站建设 项目流程

做嵌入式上位机、写网关程序、或者自己搭一套小型服务端的人,几乎都绕不开同一件事:用 Linux socket 从零搭一个 TCP 服务器。框架满天飞的今天还有必要手写吗?我的答案是至少在两种情况下必须会:一是跑在资源受限的设备上,塞不进重型框架;二是线上出了问题,日志只剩下一个 errno,你得能凭对 socket API 的理解把它定位出来。这篇就按我实际写这类程序的顺序讲——从模型选型、核心 API 的坑、完整可编译的代码,到线上排查的那几个经典故障,最后给出并发模型升级的路径。适合刚接触网络编程、能写 C 但没写过硬骨头服务端的读者,也适合写过一阵但总觉得"能跑就行"、想搞清楚每个参数为什么这么填的人。

1. 先把设计想清楚,别急着敲 socket

1.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立刻返回-1,errno设成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 算法和网络路径,跟你的调用次数没有对应关系。所以只要你的业务有"一条消息"的概念,就必须自己在应用层画出边界。

三种常见做法,我按推荐度排序:

  1. 长度前缀:包头固定 4 字节存消息体长度(网络字节序),后面跟消息体。解析简单、无转义、内存可预期,是绝大多数私有协议的首选。
  2. 定长包:每条消息固定 N 字节。实现最简单,但字段一变就要改协议,浪费带宽。
  3. 分隔符:类似 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 之类的奇怪数字。规则很简单:所有写进结构体的整数都要htons(16 位)或htonl(32 位),所有读出来展示的整数都要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));

不加这一行,你会遇到一个非常烦人的现象:程序Ctrl+C退出后立刻重新启动,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.somaxconn=1024

至于填多少,我的经验是:短连接高频场景(比如每次请求新建连接)填 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出错看 errno:EINTR重试;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_WAIT

close看起来最简单,其实有讲究。TCP 断开需要四次挥手,主动发起关闭的一方会进入TIME_WAIT状态,持续 2MSL(Linux 上大约 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,这样Ctrl+C之后能把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截断。

联调分三层。最浅的一层用nc:

nc 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 条消息不丢不乱序;客户端Ctrl+C强杀时服务端日志打出 "closed" 而不是 "read: Connection reset";服务端Ctrl+C时能打印 "server exit" 并正常退出;用valgrind --leak-check=full跑一遍没有 definitely lost。这四条过了,再往上叠并发模型,出问题时你才能确定问题在新加的那一层。

4. 常见问题与排查技巧实录

4.1 端口占用:Address already in use

这个报错出现频率最高,原因有四种,处理方式不同:

现象原因处理
刚Ctrl+C就重启报错旧连接在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 返回 EMFILE:fd 耗尽了怎么办

长跑的服务端迟早会遇到这个。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 trace=network,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 trace=network ./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 倍心跳间隔没动静的直接关掉。

优雅退出。Ctrl+C时不能直接return,要让accept循环退出、把所有连接关掉、日志刷盘。我通常用signalfd或者全局标志位 +epoll_wait超时来配合,信号处理函数里只置标志,真正的清理在主循环里做。直接exit()的后果是某些缓冲区里的日志丢了,出问题时什么线索都没有。

连接数上限。不管内存多大,都要设一个硬上限。超过之后新连接直接accept然后立刻close,不给业务处理机会。这比让所有连接都变慢要好,至少服务对已有连接还是可用的。我在一个项目里就是因为没有这个限制,被一个重连风暴打到内存耗尽,最后 OOM killer 把进程干掉了,日志一条没留。

最后分享一个我在实际项目里固定会加的东西:每处理 N 条消息或者每 10 秒,打一行汇总日志,包含当前连接数、累计收发字节数、错误计数。这行日志在事后复盘时价值巨大——你能看出问题是从哪个时间点开始劣化的,而不是只知道"它挂了"。写了这么多年 socket 程序,我的体会是:能跑起来的代码半小时就写完,能跑三个月不出事的代码,多出来的时间基本都花在这些不起眼的兜底逻辑上。

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

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

立即咨询