ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TCP客户端从零实现:核心代码、重连排查与粘包心跳实战

TCP客户端从零实现:核心代码、重连排查与粘包心跳实战 开始写作一篇 TCP 客户端实现的实战博文前阵子有个朋友跑来问我说他想让电脑上的一个小程序往某台网络设备发一条指令试了一圈现成工具要么太重要么不知道里面到底封装了什么心里不踏实。我的回答向来只有一句想踏实就自己动手写一个 TCP 客户端从 socket 开始把每一个函数调用都搞明白。别看网上讲 TCP 的文章铺天盖地真正能从头到尾把客户端写得明明白白、还能对付各种疑难杂症的并不多。这篇就按我实际写代码的习惯从底层原理到完整代码再到踩坑排查一步步把我常用的实现方式分享出来适合刚接触网络编程的学生、转行的开发也适合那些天天用 TCP 但没仔细看过客户端代码的人。1. 先弄明白你写的客户端在 TCP 协议里到底干了什么1.1 三次握手不是你在代码里握的很多人一听到TCP 三次握手就觉得这得自己在代码里手动实现其实根本不是这么回事。TCP 协议栈早在操作系统内核里就替你完成了三次握手的全部细节你写代码时看到的只有一个 connect 函数调用。这个调用的背后内核会帮你的程序发一个 SYN 包出去对端回一个 SYNACK你再回一个 ACK连接建立成功后 connect 才返回。整个过程就像你去银行办业务——你只需要把身份证递进去调用 connect柜员审核、盖章、叫号这些内部流程三次握手跟你没关系你只管等着拿号就行。这套机制平时运行得很好但有两个细节值得你留个心眼。第一connect 是阻塞式的默认情况下如果对端一直不回应你的程序就卡在那儿不动了后面我会专门讲怎么给 connect 加超时控制。第二三次握手只保证连接能通不保证对方已经在 recv。也就是说connect 成功只代表 SYN 被对端内核协议栈接收了不代表对端的应用程序已经调用了 recv 开始读数据。很多初学者在这里产生错觉以为 connect 一成功就能立刻发数据结果数据确实发出去了但对端程序还没准备好直接丢弃或者延迟处理这在调试时会让人摸不着头脑。1.2 协议栈、socket 和端口之间的关系如果把网络通信比作寄快递那么网卡就是你的快递员网络协议栈就是快递公司的分拣中心而 socket 则是你面前的寄件窗口。你调用 socket() 创建一个 socket相当于在快递公司开了一个寄件窗口你告诉它我要走 TCP 协议SOCK_STREAM相当于选择了挂号信服务——保证不丢件、不串件、按顺序送达。之后你的所有数据都是从这个窗口递进去由分拣中心协议栈负责打包、寻址、路由最后交给快递员网卡送出去。端口在这套体系里的作用类似门牌号。一台机器的 IP 地址相当于整栋楼的地址而端口就是楼道里的一个个门牌。服务器监听某个端口就是在某个门口等着客户来敲门客户端发起连接时自己这边也会被分配一个临时端口。这个临时端口是内核自动选的范围通常从 32768 开始往上走不同系统略有差异。你如果自己用 bind 指定了端口那么在主动关闭连接后这个端口会因为 TIME_WAIT 状态被占用很长一段时间具体原因我在第三章展开讲这也是客户端重连时报地址已在使用这个经典问题的根源。1.3 客户端与服务器各自扮演的角色客户端和服务器的区别不在于谁的代码复杂而在于谁主动。服务器是守株待兔的一方它先创建 socketbind 到一个固定端口然后 listen 进入监听状态再 accept 等待客户上门。客户端是主动出击的一方它创建 socket 之后不需要 bind也完全不需要 listen 和 accept直接用 connect 去指定一个服务器的 IP 和端口即可。整个连接建立过程中客户端只需要发起方这半边逻辑代码量天然就少很多因此标题里简单的 TCP 通讯确实名副其实。不过角色边界不是一成不变。有些应用里客户端也要承担类似服务器的功能比如 P2P 场景客户端之间互相连接每台机器既是客户端又是服务器。但在绝大多数业务系统里角色划分是清晰的你有一个中心服务器N 个客户端上来连接、发数据、收数据。理解了这个模型你写客户端代码时脑子里就会有一条主线我主动去找谁我要发什么我要收什么我什么时候断开。2. 用 C 语言从零撸一个 TCP 客户端核心代码逐段拆解2.1 环境准备Linux 环境为主Windows 的差异要清楚我平时主要用 Linux所以下面的代码以 Linux 环境为准。如果你用的是 Windows核心逻辑完全一样但头文件和库有差别Windows 上要用 Winsock2需要调用 WSAStartup 初始化链接 ws2_32 库关闭 socket 用 closesocket 而不是 close。为了让代码清晰我把它限定在 Linux 环境后面讲原理的部分对任何语言都适用——无论你用 Python、Java、C# 还是 Gosocket 的底层机制都是内核那一套。环境准备其实就一条命令的事确认系统里有 gcc 即可。如果没有在 Ubuntu/Debian 上执行sudo apt install build-essentialCentOS/RHEL 上执行sudo yum install gcc。为了后面测试方便建议再装一个 netcatnc作为临时调试用的服务端sudo apt install netcat-openbsd。2.2 完整代码逐行注释版本下面这段代码就是我常用的 TCP 客户端模板功能很简单——连接服务器、发一条消息、读回响应、然后关闭。代码里我加了详细的注释连每个函数返回值怎么判断都写清楚了方便你直接抄去改。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include netinet/tcp.h #include arpa/inet.h #define SERVER_IP 127.0.0.1 // 目标服务器地址改成你的实际 IP #define SERVER_PORT 9000 // 目标服务器端口 #define BUFFER_SIZE 4096 int main(void) { int sockfd; struct sockaddr_in server_addr; char send_buf[BUFFER_SIZE]; char recv_buf[BUFFER_SIZE]; ssize_t n; // 1. 创建 socket // AF_INET 表示 IPv4SOCK_STREAM 表示面向流的 TCP0 表示使用默认协议 sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { printf(socket() 创建失败: %s\n, strerror(errno)); exit(EXIT_FAILURE); } // 2. 填充服务器地址结构体 // 注意整个结构体先清零避免残留脏数据 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; // IPv4 协议族 server_addr.sin_port htons(SERVER_PORT); // 端口号转网络字节序 if (inet_pton(AF_INET, SERVER_IP, server_addr.sin_addr) 0) { printf(inet_pton() 解析 IP 失败\n); close(sockfd); exit(EXIT_FAILURE); } // 3. 连接服务器这一步触发三次握手 if (connect(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { printf(connect() 失败: %s\n, strerror(errno)); close(sockfd); exit(EXIT_FAILURE); } printf(已连接到服务器 %s:%d\n, SERVER_IP, SERVER_PORT); // 4. 发送数据 memset(send_buf, 0, BUFFER_SIZE); snprintf(send_buf, BUFFER_SIZE, hello, tcp server, this is a tcp client); n send(sockfd, send_buf, strlen(send_buf), 0); if (n 0) { printf(send() 失败: %s\n, strerror(errno)); close(sockfd); exit(EXIT_FAILURE); } printf(发送 %zd 字节: %s\n, n, send_buf); // 5. 接收响应 // 注意 recv 是阻塞的会一直等到有数据可读或对端关闭连接 memset(recv_buf, 0, BUFFER_SIZE); n recv(sockfd, recv_buf, BUFFER_SIZE - 1, 0); if (n 0) { printf(recv() 失败: %s\n, strerror(errno)); close(sockfd); exit(EXIT_FAILURE); } else if (n 0) { printf(服务器主动关闭了连接recv 返回 0\n); } else { printf(收到 %zd 字节: %s\n, n, recv_buf); } // 6. 关闭 socket close(sockfd); return 0; }这就是客户端的最基本骨架。看起来代码不长但每个环节都有讲究我们一个个说。2.3 核心函数逐个解释socket、bind、connect、send、recv、close先说 socket() 的三个参数。AF_INET指定 IPv4 地址族SOCK_STREAM指定流式套接字对应 TCP最后一个参数协议号填 0 让内核自动选择。如果你写的是 UDP 客户端第二个参数就要换成SOCK_DGRAM流程也完全不同这里先不展开。然后是地址结构体struct sockaddr_in。这是网络编程里最容易犯错的地方结构体里有三个关键字段——sin_family必须填AF_INETsin_port必须用htons()转成网络字节序大端序sin_addr要用inet_pton()把点分十进制的 IP 字符串转成二进制的 in_addr 结构。忘记字节序转换是新手最常见的错误之一典型现象就是连接一个常见的端口比如 8080结果连上后发现服务器日志里看到的源端口乱七八糟或者干脆连接失败。connect() 算是整个客户端最关键的一步。它的执行会触发三次握手阻塞等待握手完成才返回。如果服务器不可达、IP 错了、端口没监听connect 会返回 -1这时候一定要通过strerror(errno)看具体错误。最常遇到的是Connection refused服务器端口没开着和Network is unreachableIP 配置不对或路由不通这两种错误在网络编程调试里占了八成。send() 和 recv() 返回值的语义也要彻底吃透。send 返回的是实际发送成功的字节数在阻塞模式下它通常等于你请求发送的长度除非发生错误或中断recv 返回的是实际收到的字节数当返回 0 时代表对端正常关闭了连接返回 -1 时代表出错。很多人在 recv 返回 0 时还在继续读导致死循环或者 busy loop这在第四章我会展开讲怎么处理。最后是 close()。Linux 下关闭 socket 用 close()如果进程里所有指向该 socket 的 fd 都关闭了内核就会释放相关资源。注意 close 之后这个 fd 就不能再用了再往上面发数据就是无效操作程序可能段错误。Windows 下则是 closesocket()并且需要额外调用 WSACleanup() 清理 Winsock 环境这些细节跨平台时最容易踩。2.4 编译、运行和验证几个必踩的坑编译命令很简单gcc -o tcp_client tcp_client.c -Wall -Wextra-Wall -Wextra打开所有常见警告我的习惯是警告全清理干净再跑防止有隐藏的未定义行为。如果没报错先别急着连真服务器用 nc 起一个临时服务端来做回环测试# 终端 A 起一个监听在 9000 端口的服务端回显收到的数据 nc -l 127.0.0.1 9000然后在另一个终端运行你的客户端./tcp_client正常情况下你会看到 nc 那边打印出你的消息。这里要注意一个问题nc 只是一个测试工具它不会主动回响应数据所以你的客户端在 recv 那一步会一直阻塞住。用 CtrlC 把 nc 停掉客户端的 recv 就会返回 0然后按代码里的逻辑就正常退出了。很多新手在测试时发现程序卡住不动其实就是 recv 阻塞等数据而测试服务端没回任何东西这不是 bug是工作方式如此。更真实的测试可以写一个极简的 Python 回显服务端放在旁边几十行代码就有了既能自动接收连接、又能原样返回数据测试起来远比 nc 顺手import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((127.0.0.1, 9000)) server.listen(5) print(Listening on 9000...) while True: conn, addr server.accept() print(fNew connection from {addr}) data conn.recv(4096) print(fReceived: {data!r}) conn.sendall(data) # 原样回显 conn.close()这算是一个最简单的 TCP 服务端模板拿来做联调足够用了。2.5 常见错误排查表下面这个表格是实际调试中出现频率最高的几个错误场景照着它排查能省很多时间。错误现象可能原因排查手段connect 返回Connection refused服务器没启动或端口没监听ss -lntp查看端口监听状态telnet IP 端口测连通性connect 返回Network is unreachableIP 设置错误、路由不通、跨网段没配路由ip route查看路由表ping目标 IPconnect 长时间无反应防火墙 drop 了 SYN或者对端 IP 不可达但不回 RSTping、tcpdump -i any port 9000抓包看 SYN 是否发出send 返回 -1 且 errno 为EPIPE对端已经断开连接继续往关闭的 socket 上写数据检查业务层是否对 recv 返回值做过判断recv 一直阻塞不返回对端没有发送数据也没有关闭连接用抓包工具看对端是否发了数据检查自己的超时设置客户端自己 bind 固写端口后重连报EADDRINUSETIME_WAIT 状态导致端口被占用用ss -tan查看 TIME_WAIT下一章专门讲解决方案这些都排查过一遍之后你的客户端基本就能稳定跑起来了。但真正的工程挑战才刚刚开始——特别是在重连这个场景下你可能马上会撞上那个经典错误Address already in use。3. 重连报错地址已在使用根因排查与四种解法3.1 先复现这个经典问题我最早遇到这个问题时是在给一个嵌入式设备写断线自动重连的客户端。程序逻辑很简单——连接失败或者连接断开后隔几秒重连一次。第一次跑完全没问题断开重连也正常但加了固定端口 bind 之后重连第二次就报错了bind() 失败: Address already in use。很多人的第一反应是程序里没释放干净于是到处找资源泄漏找半天也没结果。其实问题不在代码里而在 TCP 协议栈自身的设计上。不过先提醒一句如果你和我上面写的基础版一样客户端压根没有 bind、完全由内核自动分配临时端口那你正常情况下几乎不会遇到这个错误。因为内核分配的是随机端口TIME_WAIT 状态占住的只是某一个具体端口这次被占用了下次换个端口就行。只有以下几种情况才会高频踩雷客户端的服务方对端以某种方式固定了客户端端口比如防火墙只放行固定源端口客户端是半服务器角色既主动连接别人、又接受别人的连接P2P 场景某些协议要求客户端使用固定端口比如部分工业通讯协议为了调试方便你在代码里手动 bind 了一个端口一旦你手动 bind 了固定端口这个坑就非常容易踩。3.2 排查完整链路从报错到 TIME_WAIT排查这个问题的过程值得完整走一遍我按当时的思路来还原。第一步确认报错出现在哪一行。在代码里对 bind 和 connect 都做了错误处理的情况下报错日志明确指向 bind。这就说明操作系统拒绝了你对某个本地端口的绑定请求原因就是内核认为这个端口目前还被占用。第二步查看端口状态。用ss -tan看本地端口的状态ss -tan | grep 9001假设你 bind 的本地端口是 9001你会在输出中看到类似这样的一行TIME-WAIT 0 0 127.0.0.1:9001 127.0.0.1:9000关键就是TIME-WAIT这个状态。TCP 连接主动关闭的一方在发送完最后一个 ACK 之后不会立刻释放连接资源而是进入 TIME_WAIT 状态要等待 2MSLMaximum Segment Lifetime报文最大生存时间通常为 2 分钟才能完全消失。这么做的原因有两个一是保证最后一个 ACK 如果丢了能被重发二是防止旧连接的延迟报文出现在新连接里造成串扰。内核宁可多占一会儿资源也要保证协议的可靠性。第三步理解为什么重连会撞上 TIME_WAIT。你的连接建立后是你先调用 close 的——也就是你主动关闭了连接——那么你这一侧就会进入 TIME_WAIT。如果你 bind 的端口就是刚才那个固定端口2 分钟内内核不允许新的绑定于是报 Address already in use 是必然的。如果是连接本来就被对端断开、然后你要重连同一个对端和同样的本地端口同样会撞上这个状态。所以这里有个反直觉的结论不是程序没释放而是内核故意扣留了端口。3.3 解决方案逐个对比SO_REUSEADDR、SO_LINGER、连接复用解法一设置 SO_REUSEADDR。这是最常用、也是我首选的方案。在 bind 之前对 socket 设置这个选项int optval 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, optval, sizeof(optval));这个选项的含义是允许内核重用处于 TIME_WAIT 状态的本地端口。注意它只影响 bind 这一步不会消除 TIME_WAIT 状态本身。把这段代码加上去固定端口下重连的问题基本就解决了。这里有个常见误区有人听说设置 SO_REUSEADDR 就行了但没搞懂它只作用于主动 bind 的场景。如果你的客户端从不 bind、由内核随机分配端口那设置它没什么坏处但也基本没什么用。解法二SO_LINGER 的硬关。TCP 里 close socket 默认是温和关闭——把缓冲区的数据尽量发完然后走四次挥手流程。而 SO_LINGER 允许你配置 close 时的行为设置l_onoff1, l_linger0后 close 会立即发送 RST 强制重置连接直接跳过四次挥手也就不会产生 TIME_WAIT 状态。听起来很完美但代价很大强制关闭可能导致对端收不到你尚未发出的数据破坏协议状态TCP 的可靠传输被打破了。我强烈不建议在业务代码里用这个方案除非你明确知道自己在做什么比如进程即将退出且连接上的数据无所谓。解法三不手动 bind让内核分配临时端口。上一章的基础版代码就是这个思路内核自动从临时端口范围里挑端口TIME_WAIT 占住一个就换下一个几乎不会冲突。这是最简单、最干净的方案能不加 bind 就不加。解法四复用同一个 socket 做重连连接复用。如果你需要反复连接同一个服务器不用每轮都创建新 socket —— 把客户端重连设计成如果连接断开就重新 connect 同一个 socket fd其实不行TCP 一旦关闭 fd 就对连接挥手了正确的做法是保持同一个 socket 只处理应用层业务如果连接层断开就创建新 socket。这里说的复用是指应用层逻辑上保持会话而不是 socket 本身复用。四种方案的对比总结如下表方案原理适用场景注意事项SO_REUSEADDR允许重用 TIME_WAIT 端口客户端需要固定端口时只影响 bind不影响连接状态SO_LINGER 硬关RST 强制断连不进 TIME_WAIT极少数明确知道会丢数据的场景破坏 TCP 可靠性慎用内核随机端口自动分配临时端口绝大多数普通客户端最简单可靠推荐设计上避免固定端口从架构上避开问题灵活重构时最根本的解法3.4 重连机制的工程设计指数退避与最大重试次数解决了端口占用问题重连的逻辑还远不止sleep 3 秒再连这么简单。这里有一个我在实际工业场景里总结出的经验重连间隔应该采用指数退避策略。第一次重连失败后等 1 秒第二次等 2 秒第三次等 4 秒以此类推直到上限比如 60 秒。这么做是为了避免一种典型的重连风暴——服务器临时故障后一百台客户端同时竭力重连把服务器打得更死恢复时间反而更慢。伪代码可以这样组织int retry_interval 1; int max_interval 60; int max_retries 10; for (int i 0; i max_retries; i) { if (connect_server() 0) { break; // 连接成功 } sleep(retry_interval); retry_interval retry_interval * 2; if (retry_interval max_interval) { retry_interval max_interval; } }另一个容易忽略的点是connect 失败后socket 这个 fd 不能再直接拿来重试。因为 connect 失败后 socket 状态是不确定的最安全的做法是 close 掉旧 fd重新 socket() 再 connect。我在代码里写过不少次复用 fd 重连结果越连越怪的坑重新创建 socket 是最干净的做法。4. 真正能用的客户端还有这几个坎超时、粘包与心跳4.1 给 connect 加超时非阻塞 connect 与 select在我的基础模板里connect 是阻塞的如果服务器防火墙把 SYN 包丢弃了程序会卡在 connect 上几分钟甚至更久这在客户端初始化阶段是致命的——界面卡死、日志无输出用户还以为程序崩了。标准解法是把 socket 设为非阻塞然后调用 connect再用 select或者 poll、epoll等待结果。核心逻辑分三步先fcntl(sockfd, F_SETFL, O_NONBLOCK)设置非阻塞然后调 connect——注意这时 connect 几乎总是立刻返回 -1errno 为 EINPROGRESS这不算失败而是正在后台建立连接接着用 select 把这个 fd 放进写集合写事件触发表示连接完成并设置一个超时时间select 返回后通过 getsockopt 检查 SO_ERROR 确认连接结果。相关代码片段#include sys/select.h #include fcntl.h int connect_with_timeout(int sockfd, struct sockaddr_in *addr, int timeout_sec) { int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); int ret connect(sockfd, (struct sockaddr *)addr, sizeof(*addr)); if (ret 0 errno ! EINPROGRESS) { return -1; } fd_set wset; struct timeval tv; FD_ZERO(wset); FD_SET(sockfd, wset); tv.tv_sec timeout_sec; tv.tv_usec 0; ret select(sockfd 1, NULL, wset, NULL, tv); if (ret 0) { return -1; // 超时或出错 } int error 0; socklen_t len sizeof(error); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, error, len); if (error ! 0) { return -1; } return 0; }一句话总结正常网络环境可能用不上但在服务器没开、网线断了、防火墙拦截这类场景里这个函数能让你的客户端从卡死几分钟变成超过一秒立即反馈。建议直接抄进你的工具库。4.2 粘包与半包TCP 是字节流没有消息边界这是 TCP 网络编程中的顶级大坑几乎每个新手都会踩。TCP 是面向字节流的协议——它就像一个水管子你往里面倒 100 个字节和 200 个字节对端收到的可能是一次 300 字节也可能是 50 字节加 250 字节。也就是所谓的粘包和半包。很多人在客户端里天真地以为发一次消息对应收一次 recv结果在弱网、高频场景下收到乱七八糟的拼包代码就崩了。解决方案是在应用层自定义消息帧格式。最经典也最实用的做法是长度字段 载荷消息开头用固定长度的字段比如 4 字节大端序整数说明后续数据有多长接收方先读够这个长度字段再按它读够整个消息体。这样收发双方都按同一套协议格式来解析TCP 的字节流被还原成了一条条消息。客户端发送时这样打包// 假设 msg 是待发送的字符串len 是它的长度 uint32_t net_len htonl(len); // 转网络字节序 send(sockfd, net_len, sizeof(net_len), 0); send(sockfd, msg, len, 0);接收方则要先收满 4 字节长度头再根据长度收剩余数据。这里还要处理一个细节recv一次不一定收回完整长度头或完整载荷你需要循环接收直到凑齐。工程上通常用一个接收缓冲区 状态机来管理把每次 recv 得到的字节累积起来解析出一个完整消息后抛给业务层。这块逻辑写在客户端里虽然代码量不大但状态机设计得好不好直接决定了后续业务代码会不会到处处理收到半个包的尴尬。4.3 处理 recv 的返回值和 EINTR/EAGAINrecv 返回值的处理其实比很多人想得要重要。一个健壮的客户端里recv 的所有可能返回值都要有明确的处置逻辑recv 返回值含义客户端该做的 0收到 n 字节数据拼接进接收缓冲区尝试解析消息0对端优雅关闭连接关闭 socket进入重连流程-1, errnoEINTR信号中断不是真实错误立即重试 recv-1, errnoEAGAIN/EWOULDBLOCK非阻塞模式下暂时无数据继续等后续事件绝不算错误-1, 其他 errno真实网络错误记录日志关闭 socket重连很多业务代码把 -1 一律当错误处理这在非阻塞模式下会带来一堆无意义的日志刷屏。而如果对 recv 返回 0 没有处理就直接陷入收发循环就会造成死循环或者空转CPU 占满。务必把 recv 当成一个状态切换的入口来对待——它返回 0 往往意味着整个连接断了得启动断线重建流程。4.4 心跳保活TCP keepalive 与业务层心跳TCP 协议栈其实自带了保活机制——keepalive。开启这个选项后协议栈会周期性地探测对端是否存活发现对端消失了就报错。问题是默认参数极其保守空闲 2 小时才开始探测探测失败还要等 2 小时才断开。默认值适合服务器维护场景对客户端来讲根本没法用——断线后最快也要 4 小时才能发现这在移动端或工业现场完全不可接受。所以真正的工程客户端都有一套自己的业务层心跳定一个时间比如 30 秒周期性地向服务器发一个心跳包内容可以是一个带序号的简短消息如果超过 N 个周期没收到服务器的任何数据包括心跳响应客户端就判定连接已死主动关闭并重连。这种方案的好处是周期完全可控缺点是应用层代码得多写一些定时逻辑。对于可靠性要求高的场景基础做法是 TCP keepalive 业务心跳双保险keepalive 兜底崩溃不响应的情况业务心跳提供快速感知。调 keepalive 参数的代码片段Linuxint keepalive 1; int keepidle 30; // 30 秒空闲后开始探测 int keepintvl 5; // 每 5 秒探测一次 int keepcnt 3; // 连续 3 次没回应就断开 setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, keepalive, sizeof(keepalive)); setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, keepidle, sizeof(keepidle)); setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, keepintvl, sizeof(keepintvl)); setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, keepcnt, sizeof(keepcnt));这个配置相当于如果 30 秒没跟对端通信就开始探测每 5 秒测一次连测 3 次没反应立即判定连接断开。比起默认两小时实用得多了。5. 我实测中的几个经验细节5.1 日志先打 errno 和 strerror别只打失败了这是我调试 TCP 客户端时最深的一个体会。刚开始写代码我也贪省事出错只打一句send() failed结果排查问题全靠瞎猜。后来养成习惯每个系统调用出错时都打errno加strerror(errno)一行日志就能定位八成问题。比如 connect 失败看到errno111 Connection refused你立刻知道是服务器没监听看到errno113 No route to host立刻知道是网络路由问题看到errno115 Operation now in progress你会意识到这是非阻塞 connect 的正常状态。这个习惯不值钱但真的能省大量时间。另外日志里建议加上时间戳、对端 IP 和端口、当前 socket fd。断线重连时这些上下文信息能帮助你快速判断是哪个连接出了状态尤其是多个连接并发存在的时候。5.2 本地环回地址和局域网差异巨大调试时我建议先用 127.0.0.1 做环回测试然后再换真实 IP 到局域网测试。环回地址不走网卡没有物理链路延迟、丢包等问题三次握手一瞬间完成非常适合验证基本逻辑。但正因为太完美环回测试通过不代表局域网也能通过。到了真实网络环境你才会遇到connect 超时、大包发送失败MTU 分片、服务器组织响应慢导致客户端 recv 超时等真实问题。我在调试时通常先在环回跑一遍基本流程然后立刻切到局域网一端放服务器一端放客户端专门测超时和重连。测试工具层面有个细节不要过分依赖 nc 做服务端——nc 对多连接、回显、指定时间延迟的支持都非常简陋。自己写一个几十行的 Python 回显服务端可控性高得多还能模拟慢响应、随机断开等场景。这一套配合下来客户端才算是真正测透了。5.3 客户端断线自动恢复把重连做成独立状态机最后说说重连的整体架构。很多新手把重连逻辑直接塞进业务主循环里结果代码越写越乱业务收发、断线检测、重连逻辑、数据缓冲全部搅在一起。我现在的写法是把客户端抽象成一个简单的状态机DISCONNECTED - CONNECTING - CONNECTED - DISCONNECTED每个状态对应一个处理函数。CONNECTING 状态下只做带超时的 connect 和失败退避CONNECTED 状态下才允许收发业务数据一旦收到 recv 返回 0 或心跳超时立刻切回 DISCONNECTED清空缓冲启动重连计时。这个架构看着朴素但维护三五个 TCP 长连接时那种条理性比把所有事情都堆在 while 循环里舒服得多。还有一个小习惯断线重连前把客户端这边未发送完的应用数据缓存到队列里恢复连接后可以决定是补发还是丢弃。具体业务哈不同方案但至少要保证不会在无连接状态下往 socket 上写数据。我早期的代码就栽在这里——重连循环里没判断连接状态直接 send结果发送到已经关闭的 fd 上返回 EPIPE还触发了 SIGPIPE 信号把整个进程给干掉了。现在我在 send 之前都会先判断连接状态同时用signal(SIGPIPE, SIG_IGN)在程序启动时忽略这个信号双保险。
返回列表