首页 / 资讯中心 / 文章详情

Linux TCP socket编程从入门到实战:bind、listen、accept核心解析

Linux TCP socket编程从入门到实战:bind、listen、accept核心解析 ★ FEATURED ARTICLE
Linux 下基于 TCP 协议的客户端/服务端网络通信是每一个走服务端方向的开发者绕不过去的第一道坎。前两天一个刚转语言栈的朋友拿着网上抄的一段代码来问我为什么服务端 bind 之后还要 listen为什么 accept 返回的 fd 和监听 fd 不是同一个为什么客户端一断开服务端就莫名其妙退出了。这几个问题看着基础但每一句都踩在 TCP socket 编程的命门上。这篇文章我就把完整流程拆开讲从 socket 创建到 bind、listen、accept、connect再到收发数据和关闭连接每一步都贴可运行的代码、讲清楚背后的原理最后附上我这些年实际排过的坑。适合刚接触 Linux 网络编程的初学者也适合那些 demo 能跑通但讲不出细节的开发者。1. 整体设计先搞清楚 TCP 通信的骨架1.1 客户端与服务端的角色分工TCP 是面向连接的、可靠的、基于字节流的传输层协议。翻译成大白话就是通信双方要先建立一条稳定的“专线”然后在这条线上按顺序传输数据丢了会重传乱序会调整收不到会有确认。代价就是开销大、建立连接要握手、断开要挥手不适合那种发完就不管的小数据量场景。但凡是需要可靠传输的比如文件传输、HTTP 请求、数据库访问底层基本都是 TCP。在这个模型里通信双方的角色是完全不对称的。服务端是被动的一方它创建 socket 之后要绑定地址、声明监听、然后阻塞等待客户端的连接请求到来。客户端是主动的一方它只需要知道服务端的 IP 和端口发起连接请求三次握手完成后就能直接收发数据了。搞清楚这个不对称性你才能理解为什么两边的代码流程完全不同——服务端有 bind 和 listen客户端没有服务端有 accept客户端只有 connect。这个角色关系打个比方就像服务台和打电话的人。服务台要先安装电话线、公布电话号码、然后一直等着铃声响起打电话的人只需要知道号码拨过去接通就能说话。TCP 的编程模型和这个几乎一模一样理解了这个类比后面每个函数的用途都能对号入座。1.2 核心调用链一句话记住的流程整个 TCP socket 编程的流程压缩成两条调用链你先把这两行刻在脑子里服务端socket → bind → listen → accept → recv/send → close客户端socket → connect → send/recv → close服务端比客户端多出来的 bind、listen、accept 三个环节解决的是“如何让别人能找到我”和“如何接待多个来访者”的问题。bind 告诉内核“我在这个 IP 的某个端口上等消息”listen 告诉内核“你要把外面来的连接请求排队”accept 则是从队列里取一个已经建立好的连接交给你处理。我见过很多初学者把 bind 和 listen 当成可有可无的步骤直接从 socket 跳到 accept。结果编译能过运行就崩或者根本接收不到连接。其实每一步都有明确的职责少一个环节链路就断了。connect 和 accept 是配对出现的connect 发起连接请求accept 确认接收请求它们之间完成的就是 TCP 三次握手的逻辑。1.3 为什么选 C 语言从 socket API 看 TCP 编程本质说到 Linux 下的 TCP 编程大多数人默认的第一个选择就是 C 语言这背后是有道理的。socket API 本身就是 C 语言的接口Linux 的内核网络栈也是 C 写的用 C 写网络程序能让你直接面对系统调用看到每一层数据是怎么传的。Python、Java、Go 当然也能写但那些语言把很多细节封装掉了比如字节序转换、fd 的管理、阻塞模式切换你可能写一辈子都不会碰到底层的东西。这不是说 C 就一定好。如果只是写个临时脚本把数据从 A 机器搬到 B 机器Python 的 socket 模块十几分钟就能搞定没必要跟指针较劲。但如果你的目标是真正理解 TCP 通信机制或者要去做高性能服务器这类对资源控制要求极高的系统C 是绕不过去的一课。用 C 写完一遍这个流程你再去看任何一种语言的网络框架都能知道它在底层做了什么。2. 核心细节socket、bind、listen、connect 的坑与原理2.1 socket() 创建套接字三参数分别是什么所有 TCP 通信都从 socket 函数开始它在内核里创建一个网络端点返回一个文件描述符。这一点很多人没意识到在 Linux 里socket 本质上就是一个文件只不过这个文件的“内容”是网络数据。这也是为什么后面可以用 read 和 write 来收发数据虽然专业做法是用 recv 和 send但它们底层是同一套机制。int sockfd socket(AF_INET, SOCK_STREAM, 0);第一个参数 AF_INET 表示使用 IPv4 地址族如果你要支持 IPv6就用 AF_INET6。第二个参数 SOCK_STREAM 表示流式套接字这是 TCP 专用的对应 UDP 的是 SOCK_DGRAM表示数据报。第三个参数传 0 即可意思是让内核根据前两个参数自动选择协议AF_INET 加 SOCK_STREAM 组合出来就是 TCP。有些教材会写成 SOCK_STREAM, IPPROTO_TCP效果一样但传 0 更通用。这个函数失败时返回 -1并设置 errno。常见的失败原因包括权限不足、进程打开的文件描述符数量达到上限等。写代码的时候一定要检查返回值我不止一次看到有人不检查结果后面拿着一个 -1 去 bind报错还找半天不知道问题在哪。2.2 bind() 绑定地址与 listen() 监听为什么必须分开bind 的作用是把 socket 和一个具体的 IP 地址、端口绑定在一起。为什么不 bind 不行因为不 bind内核就不知道这个 socket 要服务哪个端口外部也就无法找到它。你可以让内核自动分配端口但那样的话每次启动端口都变客户端根本没法连接。struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(8888); if (bind(listenfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind failed); exit(1); }这个 sockaddr_in 结构体是整个环节里容易被诅咒的地方。三个字段有两个都需要字节序转换地址和端口。为什么因为网络传输规定了统一的大端字节序而你的 CPU 可能是小端比如 x86 架构。htonl 是 host to network longhtons 是 host to network short把本机字节序转换成网络字节序。记不住就死记写到 socket 地址结构体里的多字节字段必须经过 htonl/htons。反过来从结构体里读出端口和 IP 时用 ntohs/ntohl。我见过有人在这里踩坑的方式很诡异写了server_addr.sin_addr.s_addr inet_addr(192.168.1.10)bind 倒是成功了但换一个网卡环境就报错。原因是他绑定了特定的 IP而不是监听所有接口。正确写法是用htonl(INADDR_ANY)这个宏的值是 0.0.0.0表示“绑定本机所有网络接口”。这样不管你机器上有几个 IP客户端连哪个都行。bind 成功之后必须调用 listen 让 socket 进入被动监听状态if (listen(listenfd, 64) 0) { perror(listen failed); exit(1); }listen 的第二个参数是 backlog它的含义在很多教材里被讲得模糊。它并不是“最大连接数”而是内核为这个监听 socket 维护的已完成连接队列accept 队列的最大长度。也就是说当多个客户端同时发起连接三次握手完成后还没来得及被 accept 取走的连接会排队backlog 就是这个队列的容量上限。如果队列满了新的连接请求会收到拒绝或延迟但系统性能不会因此爆炸。真正的“支持多少个并发连接”由你的代码架构决定——如果你写的是单线程循环 accept那么同一时间只能处理一个连接如果用多进程或多线程才算真正扩展了并发能力。backlog 只是在握手完成到 accept 被调用之间提供一个缓冲。2.3 connect() 与 accept()三次握手在编程中的落点客户端这边调用 connect 发起连接struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(atoi(argv[2])); inet_pton(AF_INET, argv[1], server_addr.sin_addr); if (connect(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(connect failed); exit(1); }connect 是阻塞式的它会一直等到三次握手完成才返回。如果服务端机器没有运行监听程序或者端口不对connect 会返回 -1errno 通常是 ECONNREFUSED也就是“连接被拒绝”。如果服务端能 ping 通但端口被防火墙挡住connect 可能会一直等直到超时报 ETIMEDOUT这个在排查问题的时候要格外注意后面章节会详细说。服务端这边用 accept 接客struct sockaddr_in client_addr; socklen_t addrlen sizeof(client_addr); int connfd accept(listenfd, (struct sockaddr *)client_addr, addrlen);accept 干了什么事情它是阻塞的会一直等到全连接队列里有一个连接然后把它取出来返回一个全新的文件描述符 connfd。这个 connfd 才是真正用来跟客户端通信的 socket。监听用的 listenfd 只负责“监听”永远不参与数据传输。如果你用 listenfd 去 recv一定收到的是错误或者压根没法编译过。这就是很多人第一次写 TCP 代码最容易懵的地方为什么有两个 fd。你只要记住一句话——listenfd 负责接待connfd 负责聊天。accept 返回一次就是一个新连接所以服务端代码的主体一定是一个循环每次 accept 出来一个 connfd处理完它的数据收发关闭它然后再回去 accept 下一个。2.4 send/recv 与 close数据收发和连接关闭的正确姿势连接建立之后数据收发就交给 send 和 recv。这两个函数和 write/read 的核心区别在于send 和 recv 多了第四个参数 flags通常传 0 就行同时它们在语义上更明确——就是往一个 socket 上写数据或从 socket 上读数据。服务端收数据时recv 的返回值特别值得警惕。我直接说结论recv 返回大于 0 表示收到数据返回 0 表示对端正常关闭了连接返回 -1 表示出错了。很多新手会把返回 0 当成“没数据就继续等”然后继续调 recv结果会怎么样如果对端已经关闭并发送了 FIN你再从那个 fd 上读数据会持续读到 0如果没有正确处理就退不出去或者更糟糕你在一个已经关闭的连接上写数据触发 SIGPIPE 信号默认行为是直接终止整个进程。这个坑我在后面问题排查里专门讲。close 的调用时机也没有表面上那么简单。如果你在进程里 fork 过父子进程共享同一个 fd那必须等所有引用的进程都调用 close这个连接才会真正关闭。这背后是文件描述符的引用计数机制。另外还有 shutdown 和 close 的区别shutdown 可以选择只关闭读方向、只关闭写方向或者两个都关而 close 会把整个 fd 从进程里移除。设计协议的时候如果要做“半关闭”比如发送完数据但还要继续读响应shutdown 比 close 更合适。3. 实操从零写一个可运行的 TCP 回声服务端和客户端3.1 服务端完整实现回声服务器的所有代码说了这么多理论现在上手写代码。我们做一个最简单但也最有教学意义的程序回声服务器。客户端发什么服务端原样返回什么。别小看这个程序它能把刚才讲的所有 API 都串起来跑通了之后你就能在这个骨架上加自己的业务逻辑。#include stdio.h #include string.h #include stdlib.h #include unistd.h #include signal.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main() { // 忽略 SIGPIPE对端断开后继续 send 会触发该信号默认杀死进程 signal(SIGPIPE, SIG_IGN); // 1. 创建监听 socket int listenfd socket(AF_INET, SOCK_STREAM, 0); if (listenfd 0) { perror(socket failed); exit(1); } // 2. 设置 SO_REUSEADDR解决 TIME_WAIT 导致的 bind 失败 int on 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); // 3. 绑定地址和端口 struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(8888); if (bind(listenfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind failed); exit(1); } // 4. 开始监听 if (listen(listenfd, 64) 0) { perror(listen failed); exit(1); } printf(server listening on port 8888 ...\n); // 5. 循环 accept 客户端连接 while (1) { struct sockaddr_in client_addr; socklen_t addrlen sizeof(client_addr); int connfd accept(listenfd, (struct sockaddr *)client_addr, addrlen); if (connfd 0) { perror(accept failed); continue; } printf(client connected: %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); // 6. 读写循环收什么回什么 char buf[1024]; while (1) { int n recv(connfd, buf, sizeof(buf), 0); if (n 0) { if (n 0) { printf(client closed connection\n); } else { perror(recv failed); } break; } // 原样回给客户端 send(connfd, buf, n, 0); } close(connfd); } close(listenfd); return 0; }注意几个细节。signal(SIGPIPE, SIG_IGN) 这行放在最前面是保命用的。如果客户端已断开服务端还继续 send内核会向进程发送 SIGPIPE 信号默认行为是终止进程。对服务器来说这就是灾难——一个客户端异常断开整个服务进程直接退出。忽略这个信号后send 失败也会返回 -1你再自己判断怎么处理。setsockopt 设置 SO_REUSEADDR 也是老手习惯它解决的是服务端重启时端口还在 TIME_WAIT 状态导致 bind 失败的问题。这个后面排查章节细讲。accept 失败用的是 continue 而不是 break因为 accept 失败一次不代表整个服务要停比如临时耗尽 fd下次循环可能又恢复了。只有致命的错误才应该退出。3.2 客户端完整实现连接、发送、接收的完整流程客户端代码比服务端短得多但多了两个细节一是要用 inet_pton 把点分十进制的 IP 字符串转成二进制地址二是要处理从标准输入读数据这个交互逻辑。#include stdio.h #include string.h #include stdlib.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h int main(int argc, char *argv[]) { if (argc ! 3) { fprintf(stderr, usage: %s server_ip port\n, argv[0]); exit(1); } // 1. 创建 socket int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) { perror(socket failed); exit(1); } // 2. 构造服务端地址 struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(atoi(argv[2])); if (inet_pton(AF_INET, argv[1], server_addr.sin_addr) 0) { perror(inet_pton failed); exit(1); } // 3. 发起连接三次握手 if (connect(sockfd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(connect failed); exit(1); } printf(connected to server %s:%s\n, argv[1], argv[2]); // 4. 读一行发一行收返回内容打印 char buf[1024]; while (fgets(buf, sizeof(buf), stdin) ! NULL) { send(sockfd, buf, strlen(buf), 0); int n recv(sockfd, buf, sizeof(buf) - 1, 0); if (n 0) { printf(server closed connection\n); break; } buf[n] \0; fputs(buf, stdout); } close(sockfd); return 0; }inet_pton 这个函数可以说是从字符串到二进制地址转换的正规军替代老旧的 inet_addr。它返回 1 表示成功0 表示传入的字符串不是合法的 IP 地址-1 表示地址族不支持。所以这里判断 0其实是把 0 和 -1 都当成错误合理。客户端不需要 bind因为通信端口由内核自动分配。只有服务端才需要固定端口让人家能找到。如果客户端也 bind 一个固定端口反而可能因为端口冲突而失败完全没有必要。3.3 编译运行与验证gcc 编译、本机测试、查看连接状态编译很简单两个文件分别编译成两个可执行文件gcc -o server server.c gcc -o client client.c先启动服务端让它跑在后台或者另开一个终端./server再启动客户端连接本机./client 127.0.0.1 8888输入几行文字比如“hello tcp”你会看到服务端把它原样回显出来。这个 127.0.0.1 是回环地址它不走网卡只能用来本机自测。如果服务端跑在另一台机器上客户端要填那台机器的实际 IP。前提是服务端绑定了 INADDR_ANY也就是 0.0.0.0这样它才会监听所有网络接口的连接。验证连接状态有两个常用命令。netstat 是传统派netstat -tlnp | grep 8888只要看到LISTEN状态的记录并且进程名是 server说明监听正常。连接建立之后再用ss -t你会看到一条 ESTABLISHED 状态的连接本地地址和远端地址分别对应服务端端口和客户端随机分配的端口。客户端那个高位随机端口就是内核自动分配的这也是客户端不需要 bind 的直接证据。如果怀疑握手过程或者数据内容有问题可以用 tcpdump 抓包看一眼但初学者先不急着上工具把基本流程跑通、命令看明白后面再深入分析报文。3.4 多客户端接入用 fork 处理并发连接的实现与取舍上面的服务端是单线程的accept 处理完一个连接进入 recv 循环这期间其他客户端只能排队等着。为了让服务端具备同时服务多个客户端的能力最简单也最经典的办法是 fork。在 accept 返回 connfd 之后fork 一个子进程让子进程负责和这个客户端的通信父进程立刻回去继续 accept 下一个连接while (1) { int connfd accept(listenfd, (struct sockaddr *)client_addr, addrlen); if (connfd 0) { perror(accept failed); continue; } pid_t pid fork(); if (pid 0) { // 子进程处理这个连接 close(listenfd); // 子进程不需要监听 fd char buf[1024]; while (1) { int n recv(connfd, buf, sizeof(buf), 0); if (n 0) break; send(connfd, buf, n, 0); } close(connfd); exit(0); } // 父进程关闭已经移交出去的 connfd继续 accept 下一个 close(connfd); }这一段代码里隐藏着一个重要的引用计数逻辑。fork 之后子进程会复制父进程的所有 fd包括 listenfd 和 connfd。如果不做任何关闭操作listenfd 和 connfd 在父子进程里都存在引用数变成了 2那么这个连接除非父子进程都 close否则永远不会真正关闭。所以子进程里要 close(listenfd)父进程里要 close(connfd)。这一步叫做“剪断继承”把父子各自的职责分开。这个多进程模型的好处在概念上极其清晰每个客户端一个独立进程互不干扰。但缺点也很明显——每次 accept 都要 fork进程创建开销不小并发上万连接时进程数量会压垮系统。所以生产环境真正的高并发服务多用线程池、事件驱动比如 epoll来应对。但作为初学者从 fork 模型理解“每个连接独立的处理上下文”这个思想对未来学习线程池、了解 epoll 的动机非常有帮助。4. 常见问题与排查技巧实录4.1 bind 报 Address already in use 的真相这是 TCP 服务端开发者最常见的报错没有之一。报错原因无非两种一是真的还有进程占着这个端口二是端口处于 TIME_WAIT 状态。第一种查一下再杀掉旧进程就好。第二种明显更隐蔽。TIME_WAIT 产生于 TCP 连接正常关闭时主动关闭方在发送最后一个 ACK 之后不会立刻释放连接而是要等待 2MSL最大报文段生存时间的时间确保最后的 ACK 能到达对端也让网络上残留的报文包彻底消失。如果你的服务端程序经常频繁重启而客户端又先关闭了连接那么服务端这边的端口就会不断积累 TIME_WAIT 状态的连接导致 bind 失败。解决方式就是在 bind 之前加上int on 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on));SO_REUSEADDR 允许端口在 TIME_WAIT 状态下立即被重新绑定。要注意它和另一个选项 SO_REUSEPORT 的区别REUSEPORT 允许两个不同的 socket 绑定同一个端口可以做内核级别的负载均衡但它要求所有绑定该端口的进程都设置这个选项。日常开发先理解 REUSEADDR 就够了REUSEPORT 是进阶话题。顺便说一句TCP 有个冷酷的现实TIME_WAIT 从设计上就是安全的你用了 SO_REUSEADDR 只是提高了快速重启的便利性并没有破坏协议的正确性。真正的生产环境如果 TIME_WAIT 积累太多可以从调整 sysctl 参数或者改造连接关闭方式入手但那又是另一个话题了。4.2 connect 报 Connection refused 的排查路线客户端报 Connection refused这是一条非常明确的拒绝信号。它说明客户端的 SYN 请求到达了目标机器但目标机器上没有进程在监听那个端口于是内核直接回了 RST 包客户端这边就收到了 ECONNREFUSED。翻译一下服务端没启动、端口填错、或者服务端压根没在监听。排查路线我建议按照从近到远的顺序先 ping 通不通。不通的话是网络层面的问题不是 TCP 的问题。在服务端机器上执行netstat -tlnp | grep 端口号看服务进程到底在不在监听。如果服务端绑定的是其他 IP 而不是 0.0.0.0还会有“客户端连不上但本机能连上”的诡异现象。在客户端机器上再确认一下 IP 填的是不是服务端的实际 IP。这里有个老生常谈的坑用 127.0.0.1 连远程机器当然连不上。如果 ping 得通、端口也在监听、但 connect 一直超时那八成是中间有防火墙规则丢弃了 SYN 包。这种情况报错不是 refused 而是 timed out因为防火墙直接把包扔了没有回应。另外一个很容易被忽略的细节服务端 accept 时打印的客户端 IP 是字符串形式的用 inet_ntoa 转换时要注意这个函数返回的是一个静态缓冲区下一次调用会覆盖上一次的结果。在多客户端环境下如果想保存每个连接的 IP需要自己用 strcpy 拷贝一份。4.3 recv 返回 0 还是 -1一张速查表解决对端断开连接时recv 返回 0对端出现异常或网络层收到 RST 包时recv 返回 -1并通过 errno 告诉你具体原因。很多新手在这里犯迷糊为什么正常断开会返回 0而不是干净利落地返回 -1因为 TCP 是面向连接的协议对方调用 close 其实是在发送一个 FIN 包表示“我要关闭写方向了”。你这边 recv 读到数据流的末尾自然得到一个“0 字节”的返回值这正是优雅关闭的标志。我把 recv 返回值的处理逻辑整理成一个速查表写业务代码的时候对着这个表判断基本不会错返回值与 errno含义正确处理n 0成功收到 n 字节正常处理数据n 0对端正常关闭收到 FIN关闭 fd退出循环n -1, errno EINTR被信号中断重新调用 recvn -1, errno ECONNRESET对端异常关闭收到 RST关闭 fd退出循环n -1, errno EAGAIN/EWOULDBLOCK非阻塞模式下暂时无数据稍后重试n -1, errno ETIMEDOUT对端长时间无响应按超时策略关闭连接表格里的 EINTR 值得单拎出来说。当进程收到一个信号时慢速系统调用可能被打断recv 返回 -1 并且 errno 是 EINTR。这不是错误只是中断了最稳妥的做法是继续调用 recv。很多老代码为了简化会直接在信号处理里设置一个全局标志主循环检测到标志再退出这样虽然绕开了 EINTR但会增加代码复杂度。对初学者来说掌握“EINTR 重试”这个规律就行。还需要特别提醒的是 ECONNRESET。这个常见于你往一个已经被对端 RST 掉的连接上继续写数据。比如客户端进程崩溃了内核会发 RST服务端那边如果还挂着这个连接下一次 recv 就会收到 ECONNRESET。这是很多并发服务端“莫名多出一个错误日志”的元凶处理方式很简单关闭这个 fd别想着还能抢救。4.4 粘包与半包TCP 字节流必须处理的问题TCP 是流式协议它没有消息边界。这句话是网络编程里最让人头疼的事实。你连续两次调用 send 发送“hello”和“world”对端调用一次 recv 可能就收到了“helloworld”。反过来你 send 一个大块数据对端一次 recv 可能只收到一部分剩下的留在缓冲区里这是半包。不管哪种情况如果程序把 recv 得到的字节数直接当成一条完整消息来解析都会出错。解决粘包和半包的思路不是靠 TCP 本身而是在应用层里自行约定消息格式。最简单的方案是固定长度每条消息都是 4 字节或者其他固定大小不够用 0 填充。这个方案实现最简单缺点是不够灵活一条消息想发 200 字节还是 2048 字节都得占 2048 字节的带宽。更通用的方案是长度前缀法用 4 个字节存消息长度后面跟着消息内容。收数据时先读够 4 字节解析出长度再读够这个长度的数据才算拿到一条完整消息。伪代码是这样读取4字节头部解析出len while (已读字节数 len) { 继续读取剩余部分 } 拼接成完整消息很多工业级协议比如常见的消息协议核心思路都是这个。对于初学者我建议第一步做的不是立刻封装一套协议而是先在发送和接收时加上自定义的“包头”哪怕只是总长度 数据这样简单的 4 N 结构。你会发现一旦你开始控制消息边界很多逻辑都变得清晰了。4.5 断线重连和心跳检测服务端不能只看 recv 返回值在真实网络环境里客户端不一定总是优雅地 close。比如手机突然断网、路由掉电、客户端进程被 kill -9这时候客户端那边不会发 FIN 包服务端的 recv 会一直阻塞在那里永远等不到返回 0。这就是为什么服务端不能只靠 recv 来判断连接死活。解决这个问题有两条路一条是开启 TCP 的 SO_KEEPALIVE 选项让内核帮你定期探测连接是否还活着。另一条是应用层自己做心跳——客户端每隔一段时间发一个心跳包服务端如果在指定时间没收到任何数据就判定连接失效。实际项目里应用层心跳几乎是标配因为它可控性更强还能顺便传递业务数据。心跳包的设计套路也很固定客户端定时发一个特殊标志的短消息服务端收到后更新这个连接的最后活跃时间同时用一个超时线程定期扫描所有连接把超过阈值没活跃的连接清理掉。这个阈值要结合网络状况和业务容忍度来定比如客户端每 5 秒发一次心跳那服务端检测到 15 秒没动静就可以判定异常。我见过不少案例因为没做心跳检测服务端堆了一堆半死不活的连接fd 被耗光最后新客户端反而连不上。一开始你以为是什么高深的问题查到最后发现就是死连接堆积。这不是吓唬人是生产环境里真实发生过的事。最后再说一个我在实际开发中踩过不下三次的坑把服务端跑起来之后用 CtrlC 终止进程再立刻重启结果很多时候即使加了 SO_REUSEADDR 还是失败原因是你之前启动的服务端进程还没完全退出。这时候应该先netstat -tlnp | grep 8888看看 PID再用kill杀掉旧进程等一下再重启。很多看似玄学的问题查到最后都是进程生命周期管理的问题。TCP 编程里你每写一行不检查返回值的代码都是在给未来的自己埋雷这个习惯越早养成越好。
阅读完成 · 觉得有帮助?
咨询建站