服务端进程重启,bind直接报Address already in use,明明上一个进程已经退出了,ps也查不到,端口却像是被谁焊死在那里。这个场景我见过太多次,提问的人通常还会补一句“我代码没改过,昨天还是好的”。问题往往不在代码改没改,而在于对 TCP 服务端从socket到accept这条调用链的内核行为缺少一层理解。这篇就把TCP 协议常用 API和TCP 客户端服务端的实现拆开讲透:每个调用的入参在表达什么、返回值在告诉你什么、哪些事情内核替你做了、哪些必须你自己兜住。适合刚写完第一个 echo 服务的同学,也适合写过不少但一直靠改参数试出来的朋友。
1. 先搞清楚内核和你的代码各自负责哪一段
写 TCP 程序最容易产生的一个错觉,是以为send就是“把数据发到网线上”、recv就是“从网线上把数据收下来”。真实的模型更像是你去寄快递:你把包裹交到网点,签收单上写的只是“网点已收件”,不代表收件人已经拿到。TCP 的“可靠”是由内核的发送缓冲区、重传定时器、滑动窗口、拥塞控制这一整套机制共同保证的,应用层能做的只有两件事——把数据正确地交给内核,以及正确地理解内核交回来的结果。想明白这个分层,后面所有 API 的行为都能推导出来,而不是靠背。
1.1 三次握手被拆散到了哪几个调用里
教科书上讲三次握手是一张时序图,落到代码上它其实是被切碎分配到不同调用里的。服务端的listen是把 socket 从“主动连接”状态切换为“被动监听”状态的分界线,调用之后内核才开始接受 SYN 报文;客户端调用connect发出 SYN,此时内核开始重传定时;服务端内核收到 SYN 后回 SYN+ACK,这条连接进入“半连接队列”;客户端收到 SYN+ACK 回 ACK,同时connect返回成功;服务端内核收到最后一个 ACK,把连接从半连接队列挪到全连接队列,等待accept把它取走。
这里有两个非常关键的推论。第一个:connect返回成功,只代表客户端这一侧的握手完成了,服务端的应用层可能压根还没调用accept。所以客户端连上就发数据、服务端还没准备好读取,是完全正常的,数据会躺在服务端的接收缓冲区里。第二个:服务端listen之后不调用accept,连接照样能建立,最多堆到全连接队列满,之后新来的 SYN 会被丢弃或收到 RST。这解释了很多“服务端起没起来”的误判——端口在监听不代表业务逻辑在跑。
1.2 缓冲区是这个模型里最容易被忽略的角色
每个 TCP socket 在内核里都挂着发送缓冲区和接收缓冲区。send干的事情是把用户态数据拷进发送缓冲区,然后就返回了,真正什么时候发、发多少、要不要重传,全由内核决定。recv则是从接收缓冲区把已经到达并且按序排好的数据拷到用户态。缓冲区的大小直接决定了send会不会阻塞、recv一次能拿到多少。
我见过一个很典型的误解:有人以为recv传入 4096 字节的缓冲区,对端发 100 字节就会返回 100,对端发 8000 字节就会分两次各返回 4096。前半句大致成立,后半句在边界上不成立——TCP 是字节流,不保留任何消息边界。对端连续调用了三次send,每次 100 字节,你这边一次recv完全可能拿到 300 字节,也可能拿到 100、50、50 这样拆开的片段。这不是实现 bug,是协议本来的设计。理解这一条,后面“粘包”那一节就不用再纠结了。
2. 服务端这五个调用的边界,比参数本身更重要
一个最简 TCP 服务端的骨架是socket → bind → listen → accept → 读写 → close。代码短到可以背下来,但每个调用都有几个“不看文档就会踩”的细节。我把它们按调用顺序拆开说,顺带解释每个参数为什么是这个值。
2.1 socket 与 bind:地址、端口、字节序三处容易翻车
socket(AF_INET, SOCK_STREAM, 0)这三个参数分别定的是协议族、套接字类型和具体协议。第三个参数填 0 表示由前两个推导,AF_INET + SOCK_STREAM推导出来就是 TCP,所以绝大多数情况下填 0 即可。返回的是一个文件描述符,本质就是进程 fd 表里的一个索引,所以用完一定要close,否则 fd 泄漏,跑到EMFILE的时候accept会一直失败。
bind的坑主要集中在地址结构体上。sin_port必须用htons转成网络字节序,sin_addr用INADDR_ANY表示监听本机所有网卡。字节序这件事,如果不是从小就写网络程序,很容易忘。判断方法很简单:小端机器上htons(8080)得到的值跟 8080 完全不一样,你printf一下就知道自己有没有转。
#include <arpa/inet.h> #include <netinet/in.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> #include <unistd.h> int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } int opt = 1; 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_port = htons(9000); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); close(listen_fd); return 1; } printf("bound on port %d\n", ntohs(addr.sin_port)); close(listen_fd); return 0; }这段代码里SO_REUSEADDR是故意加的,它的作用放到第 6 节展开。地址转换不要再用inet_addr,它返回的是in_addr_t,失败时返回INADDR_NONE,跟广播地址255.255.255.255撞车,判断不了错误。用inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr),返回值 1 表示成功,0 表示地址格式不合法,-1 表示协议族不支持,三种情况分得清清楚楚。反向转换用inet_ntop,比inet_ntoa安全,因为后者返回的是静态缓冲区,多线程下会互相覆盖。
2.2 listen 的 backlog 不是“最大连接数”
listen(fd, backlog)这个 backlog 被误解的频率极高。它控制的不是“服务端最多能服务多少个客户端”,而是已完成三次握手、等待应用层accept取走的连接队列长度上限。Linux 上实际生效的值是min(backlog, /proc/sys/net/core/somaxconn),很多发行版somaxconn默认是 4096,老版本是 128。你传 1024,但系统上限是 128,那实际就是 128。
为什么这个值重要?如果应用层accept的速度跟不上新连接涌入的速度,全连接队列会满。队列满了之后,Linux 的默认行为(tcp_abort_on_overflow=0)是静默丢弃客户端的 ACK,让客户端以为握手还没完成,触发重传。表现出来就是客户端偶发连接超时,而服务端日志里什么异常都没有,因为连接根本没进到应用层。这种问题极难从应用日志看出来,得看ss -lnt输出里Send-Q那一列,或者netstat -s | grep -i listen里的 overflow 计数。
实践中我的做法是:backlog 传SOMAXCONN,同时保证accept循环里不要在取到连接后做耗时操作。取到连接立刻扔给工作线程或事件循环,accept本身要尽快回到下一轮。
2.3 accept 返回的是新 fd,监听 fd 一直留在原地
accept成功后返回一个全新的文件描述符,代表这条具体连接。原监听 fd 什么都不变,继续监听。很多初学者在这里写出这样的代码:拿到返回值之后把它赋给了listen_fd,或者在循环外只accept一次,结果第二个客户端连不上。
for (;;) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int conn_fd = accept(listen_fd, (struct sockaddr *)&peer, &len); if (conn_fd < 0) { if (errno == EINTR) continue; /* 被信号打断,重试 */ if (errno == EMFILE) { /* fd 耗尽,见下文 */ } perror("accept"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)); printf("client %s:%d connected\n", ip, ntohs(peer.sin_port)); /* 处理这条连接 */ close(conn_fd); }accept的错误处理里有三个必须区分的情况。EINTR表示被信号打断,直接continue重试就行,不重试就等于丢了一次连接机会。ECONNABORTED表示客户端在握手完成前后放弃了,也重试。EMFILE表示进程 fd 用完,这个最麻烦——此时连接还在队列里,你accept不出来,只能等有 fd 释放。业界有个小技巧:预留一个 fd(启动时open("/dev/null")),遇到EMFILE就close掉它,accept一条连接立刻close,然后再把预留 fd 打开,这样能用最小代价把堆积的连接清掉,避免把监听端口拖死。
3. 客户端代码短,但 connect 的返回值不能只看 -1
客户端骨架是socket → connect → 读写 → close,比服务端少两个调用,陷阱却一点不少。最核心的一条是:connect返回 -1 不代表连接一定失败,得分阻塞和非阻塞两种模式看。
3.1 阻塞模式下 connect 的超时到底有多长
阻塞connect返回 0 表示握手完成,返回 -1 表示失败。但这里的“失败”有个时间尺度问题:如果没有SO_SNDTIMEO干预,内核会按tcp_syn_retries重传 SYN,默认 6 次,总耗时大约 127 秒。也就是说,对一个不可达的地址调用connect,你的程序可能卡两分多钟。生产环境里这基本不可接受。
想控制连接超时,标准做法是把 socket 设成非阻塞,connect立刻返回 -1 且errno == EINPROGRESS,然后用select或poll等这个 fd 可写,配一个自己定的超时时间。可写之后不能直接认为成功,必须用getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len)把待处理的错误取出来,err == 0才算连上。
int connect_with_timeout(const char *ip, int port, int timeout_ms) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) return -1; struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) { close(fd); return -1; } int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); if (ret == 0) { fcntl(fd, F_SETFL, flags); return fd; } if (errno != EINPROGRESS) { close(fd); return -1; } fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); struct timeval tv; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; ret = select(fd + 1, NULL, &wfds, NULL, &tv); if (ret <= 0) { close(fd); return -1; } /* 超时或出错 */ int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); if (err != 0) { close(fd); return -1; } fcntl(fd, F_SETFL, flags); /* 恢复阻塞,后续读写按阻塞模型走 */ return fd; }注意最后那行恢复O_NONBLOCK的操作。很多人忘了这一步,后面recv直接返回 -1 加EAGAIN,然后误判成对端断开,排查半天。另外select的可写条件在出错时也会触发,所以那一步必须用SO_ERROR复核,不能省。
3.2 客户端什么时候必须自己 bind
绝大多数客户端不需要bind,内核会自动选一个临时端口。但有几种场景必须自己绑:需要固定源端口做访问控制的、需要绑到特定网卡做多路径分流的、以及某些协议要求源端口在特定范围内(比如按端口规则做的白名单)。一旦自己bind,就要处理端口被占用的问题,还要注意SO_REUSEADDR在客户端侧的行为和服务端不完全一样——它解决的是本地端口冲突,不是服务端那种 TIME_WAIT 占用。
还有一种情况是 IPv4/IPv6 双栈。用AF_INET6加IPV6_V6ONLY=0可以让一个 socket 同时接受两种地址,省掉两套代码。但如果对端地址可能是 v4 映射地址,getaddrinfo返回的地址结构要按ai_family分别处理,不能统一往sockaddr_in里塞,这是崩溃和乱码的高发区。
4. send 和 recv 的返回值才是真正的“协议约定”
写完能连上之后,下一个坎就是收发。这个地方的判断逻辑如果写错,表现出来的现象是“数据偶尔少一段”“偶尔收到乱码”“进程莫名被杀”,都很隐蔽。
4.1 短写:send 返回了不代表请求的量都发出去了
send(fd, buf, len, 0)的返回值是实际拷进发送缓冲区的字节数,可能是len,也可能小于len。阻塞模式下如果缓冲区满了,send会一直卡着直到有空间;非阻塞模式下会直接返回 -1 且errno == EAGAIN或EWOULDBLOCK。还有一种情况是返回小于len的正数,说明只写进去一部分。
所以一个能用的发送函数必须带循环:
ssize_t send_all(int fd, const void *buf, size_t len) { size_t sent = 0; const char *p = buf; while (sent < len) { ssize_t n = send(fd, p + sent, len - sent, MSG_NOSIGNAL); if (n > 0) { sent += n; continue; } if (n < 0 && (errno == EINTR || errno == EAGAIN)) continue; return -1; } return (ssize_t)sent; }这里MSG_NOSIGNAL是关键,它避免了向已关闭连接写入时触发SIGPIPE。如果不加这个标志,也必须在进程启动时signal(SIGPIPE, SIG_IGN),否则默认行为是直接杀掉进程。我见过线上服务因为一个客户端提前断开、服务端继续推送心跳,整个进程被SIGPIPE干掉,日志里没有任何痕迹,只有一句被内核记录的信号退出。这次经历之后,我所有网络服务的main函数第一行都是忽略SIGPIPE。
那为什么不干脆全部用MSG_NOSIGNAL就行了?MSG_NOSIGNAL是 Linux 特有的(send系列支持),用write的时候没法传标志。所以更保险的组合是:进程级忽略SIGPIPE兜底,发送时再加MSG_NOSIGNAL。双保险,多花不了什么成本。
4.2 recv 返回 0 的语义只有一种:对端关闭了写方向
recv的返回值有三种情况,必须分开处理:
| 返回值 | 含义 | 正确处理 |
|---|---|---|
| 大于 0 | 读到了这么多字节 | 累加到缓冲区,继续读或处理 |
| 等于 0 | 对端发送了 FIN,关闭了写方向 | 关闭本端连接,不要重试 |
| 小于 0 | 出错 | 按 errno 分支:EINTR/EAGAIN 重试,ECONNRESET 等直接关闭 |
把返回 0 当成“暂时没数据”去continue,是一个能烧满一个 CPU 核的死循环。而把EAGAIN当成断连去close,是另一个方向的错误。这两个错误我在不同项目里都见过,前者表现为进程 CPU 打满,后者表现为连接频繁无故断开。
另外要特别注意recv的flags参数。传MSG_WAITALL会让recv尽量读到请求的字节数,但对端关闭或出错时仍可能提前返回,不能依赖它来“读满一个包”。传MSG_PEEK只窥探不消费,适合判断连接是否还活着。这两个标志都不是什么场景都合适,尤其是MSG_WAITALL,如果协议对端发的是变长数据,用它会让整个线程卡住。
4.3 粘包是应用层没定边界,不是 TCP 的毛病
TCP 是字节流协议,只保证字节按序到达,不保证“消息边界”。对端两次send的内容,接收方一次recv全部拿到,这叫“粘包”;对端一次send的内容,接收方分两次recv拿到,这其实连“拆包”都不能算,就是正常的字节流读取。两者都是协议允许的,所以应用层必须自己定义边界。
常见的三种定界方式,各有适用面:
- 定长消息:每条消息固定 N 字节,读满 N 字节就算一条。实现最简单,但长度不可变,浪费带宽,只适合控制指令类场景。
- 分隔符:用
\n或\r\n之类不会出现在正文里的字符分隔,HTTP 的头部、Redis 的 RESP 都用了类似思路。要处理“分隔符恰好被切在两个 recv 之间”的情况,所以接收缓冲要能跨包保留。 - 长度前缀(TLV 的 L):包头固定 4 字节大端整数表示后面的正文长度,先读 4 字节算出长度,再读正文。这是最通用、最高效的方案,绝大多数 RPC 协议都这么设计。
我一般用长度前缀加一个魔数校验,结构大概是:magic(2字节) + version(1字节) + type(1字节) + length(4字节) + payload。魔数用来快速发现“两边协议版本对不上”或者“连错了端口”这类问题,比拿到乱七八糟的 payload 再去反序列化报错要容易排查得多。解析状态机要能处理任意切分,所以实现上是一个循环:尽量从缓冲区头部取,取不够就等下一次recv。
还要注意长度字段的合法性校验。如果不校验length,攻击者发一个length = 0xFFFFFFFF,你的程序可能直接malloc4GB 然后挂掉。设一个业务上合理的上限,比如 16MB,超了直接断开,这是必须写的防御。
5. 从单连接到多连接:阻塞模型撑不住的那一天
只有一个客户端的时候,阻塞模型完美工作。第二个客户端连进来,你会发现它一直卡着,因为服务端还在第一条连接的recv上阻塞。这是所有网络编程入门者都会撞上的墙。
5.1 为什么 fork 一个进程或线程不是长久之计
最直观的改法是每条连接fork一个子进程或者pthread_create一个线程。几百个连接的时候还行,上到几千个,线程栈(默认 8MB)和上下文切换开销就会变成主导。而且线程之间的数据共享和生命周期管理会迅速变复杂,尤其在需要广播、需要统计全局状态的时候。
真正适合高并发的模型是“一个线程管很多个 socket”,靠 IO 多路复用把“哪个 fd 有新事件”这件事交给内核。这就是select、poll、epoll这一族系统调用的意义。
5.2 select / poll / epoll 怎么选
| 机制 | 数据结构 | 每次调用的开销 | fd 数量上限 | 适用场景 |
|---|---|---|---|---|
| select | 位图 | 需要把 fd 集合从用户态拷到内核,返回后还要遍历 | 受 FD_SETSIZE 限制,通常 1024 | 教学、古董代码、跨平台兼容 |
| poll | 结构体数组 | 同样要拷贝整个数组 | 没有固定上限 | 连接数不大、需要跨平台 |
| epoll | 内核红黑树 + 就绪链表 | 只在epoll_ctl时注册,epoll_wait只返回就绪的 | 受 fd 上限约束 | Linux 下高并发首选 |
epoll的核心优势是“事件注册一次,之后只取就绪的”,所以每次epoll_wait的开销跟你监听了多少个 fd 基本无关。它的两个触发模式要分清楚:水平触发(LT,默认)只要缓冲区还有数据就会一直通知你,写起来不容易漏;边缘触发(ET)只在状态变化时通知一次,必须配合非阻塞 fd 和循环读到底,否则数据会一直留在缓冲区里没人处理。ET 效率略高但坑更多,我的建议是除非确实在追性能,否则先用 LT 把逻辑写对。
int epfd = epoll_create1(0); struct epoll_event ev, events[64]; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); for (;;) { int n = epoll_wait(epfd, events, 64, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { int conn = accept(listen_fd, NULL, NULL); if (conn < 0) continue; set_nonblocking(conn); ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev); } else { /* 读事件处理,ET 模式下要循环到 EAGAIN */ } } }这段骨架里,set_nonblocking是必须的。ET 模式下如果用阻塞 fd,读到没有数据时就会卡死整个事件循环。另外连接关闭时的清理要用epoll_ctl(EPOLL_CTL_DEL)显式移除,虽然close会自动移除,但要考虑同一个 fd 号被复用的时序问题,先 DEL 再 close 更稳。
6. 上线前值得逐条过的几件事
代码跑通和能上线之间还隔着一层,我把这一层里跟 TCP API 直接相关的几条列一下。
6.1 SO_REUSEADDR 到底解决什么,和 SO_REUSEPORT 有什么区别
回到开头那个Address already in use。原因通常是上一条连接处于TIME_WAIT状态,主动关闭方会在这个状态停留 2MSL(Linux 上大约 60 秒),期间这个四元组不能复用。如果服务端是被动关闭方,一般不会遇到;但如果你在服务端里主动close了客户端连接(比如超时踢人),服务端就成了主动关闭方,重启时bind就会撞上。
SO_REUSEADDR的作用是允许bind到一个处于TIME_WAIT状态的本地地址,但它不允许多个进程同时监听同一个端口。很多人把它和SO_REUSEPORT搞混。SO_REUSEPORT才是真正的“多进程共享监听”,多个进程可以bind同一个端口,内核在它们之间做负载均衡。做多进程 worker 模型时,主进程创建监听 fd 后fork给子进程共享,是不需要SO_REUSEPORT的;让每个子进程自己bind同一端口,才需要它。两者的粒度不同,别混用。
顺带说一个常见的错误配置:有人为了“避免端口冲突”,在服务端和客户端都加SO_REUSEADDR,然后发现端口还是冲突。原因是他把两个不同的 socket 绑到了完全相同的地址和端口上——SO_REUSEADDR处理的是TIME_WAIT和通配地址(0.0.0.0与具体 IP)之间的冲突,不是让两个活跃 socket 共享端口。
6.2 TCP_NODELAY 与 Nagle:小包延迟从哪来
TCP 有个 Nagle 算法,会把多个小数据合并成一个大的段发出去,以提升带宽利用率。这个行为对大批量传输友好,对交互式场景就是灾难:你发一个小请求,内核说“等会儿,看还有没有更多数据”,等了几十毫秒才发出去。表现为请求-响应式的协议(比如数据库客户端、RPC)延迟莫名偏高。
设置setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one))可以关掉 Nagle。但也不是所有场景都该关——如果是持续的大流量传输,关掉反而会让网络里多出一堆小包。判断标准是:你的协议是不是请求-响应式的、单条消息是不是通常小于 MSS(约 1460 字节),是就关掉。
还有个容易忽略的组合问题:Nagle 和延迟 ACK 同时生效时,会出现互相等待的死锁,延迟被放大到 200ms 量级。这类问题在本地测不出来,只有真实网络才出现,排查的时候不要只盯着自己的代码,抓包看 TCP 层的时间间隔会快很多。
6.3 关闭连接:close 之前该做什么
close是个很粗暴的操作:它把 fd 的引用计数减一,减到 0 才真正关闭,同时如果还有未发送的数据,内核会尝试发完再发 FIN。但如果你只是想让对端知道“我不再发数据了”,应该用shutdown(fd, SHUT_WR),这会立刻发一个 FIN,同时本端仍然可以继续recv。HTTP 里的半关闭、一些文件传输协议都会用到这种模式。
还有个坑:多线程环境下,一个线程在recv阻塞,另一个线程直接close(fd),行为是未定义的,可能卡住也可能崩溃。正确做法是先用shutdown唤醒阻塞的recv(它会返回 0),等线程退出后再close。这个问题我在做服务优雅退出的时候踩过,重启时偶发挂住不退出,查了很久才发现是这个原因。
另外别忘了设置SO_LINGER的默认行为:默认情况下close立即返回,内核在后台把数据发完。如果进程紧接着退出,数据可能丢。对可靠性要求高的场景,要么等业务层确认,要么显式设置SO_LINGER让close阻塞到数据发完(l_onoff=1, l_linger=正数),但这会让关闭变慢,要权衡。
7. 我排查 TCP 连接类问题的固定动作
最后分享一套我自己的排查顺序,基本上从现象能快速定位到是哪一层的问题,比漫无目的地看代码高效得多。
先看连接到底建没建起来。ss -antp看状态分布,ESTABLISHED数量对不对,SYN_RECV堆积说明服务端在丢握手包,CLOSE_WAIT堆积说明应用层没有正确close,TIME_WAIT数量大说明主动关闭方在本地。这四个状态各自的成因完全不同,看到哪一个就往对应的方向想。
再看队列有没有溢出。ss -lnt里Recv-Q是全连接队列当前长度,Send-Q是 backlog 上限,Recv-Q逼近Send-Q就说明accept跟不上。这时候别急着调大 backlog,先看看accept循环里是不是干了什么耗时的事。
接着抓包。tcpdump -i any -nn port 9000 -w tcp.pcap抓下来用工具看时序,重点看三件事:SYN 有没有回 SYN+ACK、有没有 RST、有没有重传。RST 出现的位置特别有信息量——如果客户端刚连上就被 RST,多半是服务端accept队列满了或者程序崩了;如果传输中途出现 RST,通常是某一方在已关闭的连接上写了数据。
最后才看代码。把send和recv的返回值都打印出来,包括errno。我见过太多“数据没收到”的案例,最后发现是send返回了 -1 加EPIPE,而代码里压根没检查返回值。网络编程里,每一个系统调用的返回值都必须检查,这不是代码洁癖,是这个领域唯一可靠的调试手段。
写 TCP 程序写到最后,你会发现真正难的从来不是那几个 API 怎么调,而是对“字节流 + 缓冲区 + 半开半关”这套模型的理解是否到位。API 参数查文档就有,但什么时候该关 Nagle、什么时候该用shutdown、粘包该怎么定边界,这些得靠一次次踩坑攒出来的判断。我自己现在的习惯是:任何新的 TCP 服务,先把send_all、recv_some、定界解析这三块写成可复用的独立模块,然后写一个能自动重连、能发随机长度消息的压力小工具跑一轮,很多边界问题在这一步就暴露了,比上线之后再查要省太多事。