ARTICLE DETAIL

资讯详情

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

Linux Socket编程实战:从TCP握手到粘包排查与并发模型

Linux Socket编程实战:从TCP握手到粘包排查与并发模型 简介这是一份关于Linux Socket编程的入门实战文档面向网络编程初学者及需要快速掌握TCP通信原理的开发者。内容从网络进程通信的基本问题切入依次讲解Socket的由来与设计思想梳理socket()、bind()、listen()、connect()、accept()、read()/write()、close()等核心API的用途与参数并结合TCP协议详细剖析三次握手建立连接和四次挥手释放连接的完整流程末尾配有可运行的实例代码便于读者对照理解从服务端监听、客户端连接到数据收发与关闭的完整过程。文档共1个docx文件包体大小77KB结构紧凑、条理清晰适合用于课程学习、面试复习或项目开发前的快速查阅。已有241人学习下载是入门Linux网络编程的一份实用参考。1. Linux Socket编程先看清它解决什么问题再动手敲第一行代码有没有遇到过这种场景你写的 Linux 程序在本机跑得好好的换到另一台机器上就连不上或者客户端发过来一条消息你recv了好几次才凑全再或者服务端一重启就报Address already in use。这些问题的根子都在 Linux Socket 编程这一层。socket 网络编程要解决的核心问题只有一句让两个进程跨越网络边界建立一条可靠的双向数据通道并且知道通道什么时候断了、数据从哪里到哪里。它适合三类人刚从嵌入式 Linux 项目转向应用层的开发正在刷 Linux 面试题的求职者以及想把单机工具改成局域网服务的人。这篇笔记不打算从教科书的概念讲起我直接用最小可运行的 C 代码把 socket 从建连、通信到排错这条链路拆给你看。跟着敲完你至少能独立写一个不被粘包和断线坑死的服务。2. 建立TCP心智模型函数调用顺序就是三次握手的轨迹很多人背过socket、bind、listen、accept这四个词但不知道它们和 TCP 三次握手之间的关系。这里要先把心智模型建起来socket 编程的每一个函数基本都对应着内核协议栈里的一个状态迁移。你写代码的顺序其实就是连接的建立过程。2.1 服务端要四件事socket、bind、listen、accept服务端的第一步是socket()它创建一个协议族为AF_INET、类型为SOCK_STREAM的文件描述符。AF_INET表示 IPv4SOCK_STREAM表示流式套接字对应 TCP。这之后你手里拿到的 fd只是一个“空的通信端点”还没有地址也没有进入可连接状态。第二步是bind()把 fd 绑定到指定的 IP 和端口。这里有两个关键参数需要说明sin_addr.s_addr和sin_port。很多新手直接写成saddr.sin_port 8080;这在 x86 小端机器上会是错的必须用htons(8080)做字节序转换。我一般会先把struct sockaddr_in整个memset成 0再逐字段赋值避免没初始化的 padding 位导致 bind 失败。第三步是listen(fd, backlog)它把 fd 从“未连接”状态切换到“被动监听”状态。第二个参数backlog是内核完成队列的最大长度表示最多允许多少个已完成三次握手、但还没被accept()取走的连接排队等待。常见做法是填 16 或 32填太大没有意义因为真正决定上限的是内核参数somaxconn。第四步是accept()它从完成队列里取出一个已经握好手的连接返回一个新的 fd。注意监听 fd 永远只有一个而每个连接都有独立的 fd。这就是为什么后续收发数据用的是accept返回的 cfd而不是 lfd。下面这张表总结了调用与内核行为的对应关系系统调用作用对应的内核/协议栈状态socket()创建通信端点创建 socket 结构分配 fdbind()绑定本地地址建立本地地址与 socket 的映射listen()进入被动监听状态切到 LISTEN开启握手队列accept()取出已完成连接从已完成队列摘除返回连接 fd2.2 客户端两行半socket、connect、然后读写客户端更简单先socket()创建 fd然后直接connect()。connect()这个函数内部会触发三次握手客户端发 SYN服务端回 SYNACK客户端再回 ACK。整个过程对用户态是透明的connect()返回成功意味着这条 TCP 连接已经处于 ESTABLISHED 状态。connect()最常见的两种失败一是目标端口没人监听返回ECONNREFUSED二是目标 IP 不可达或防火墙丢包表现为长时间卡住直到超时返回ETIMEDOUT。这里有个排查习惯如果connect超时先ping一下对端再ss -tlnp确认对端端口是否真的在监听。很多所谓“连不上”的问题其实是服务端根本没起来。连接建立之后客户端和服务端的地位就完全对等了剩下的只有read()和write()。TCP 是流式协议没有消息边界你write进去的字节对端read出来时可能是任意分片。这一条是后续所有坑的源头下一章专门讲。2.3 让回声服务跑起来源码、Makefile与telnet自测说不清原理的时候先跑起来。下面是最小可运行的回声服务端监听 8080 端口把收到的数据原样返回// server.c #include arpa/inet.h #include netinet/in.h #include stdio.h #include stdlib.h #include string.h #include sys/socket.h #include unistd.h int main() { int lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); exit(1); } int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); struct sockaddr_in saddr; memset(saddr, 0, sizeof(saddr)); saddr.sin_family AF_INET; saddr.sin_addr.s_addr htonl(INADDR_ANY); saddr.sin_port htons(8080); if (bind(lfd, (struct sockaddr *)saddr, sizeof(saddr)) 0) { perror(bind); exit(1); } if (listen(lfd, 16) 0) { perror(listen); exit(1); } printf(listening on 8080\n); while (1) { int cfd accept(lfd, NULL, NULL); if (cfd 0) { perror(accept); continue; } char buf[1024]; ssize_t n read(cfd, buf, sizeof(buf)); if (n 0) write(cfd, buf, n); close(cfd); } }SO_REUSEADDR这个选项我建议在写任何 TCP 服务端时都加上它解决的是服务端重启时端口被 TIME_WAIT 状态占用的问题后面的避坑章节会展开。INADDR_ANY表示监听所有网卡地址这个数据已经是网络字节序所以要用htonl而不是htons。配套客户端代码// client.c #include arpa/inet.h #include netinet/in.h #include stdio.h #include stdlib.h #include string.h #include sys/socket.h #include unistd.h int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); exit(1); } struct sockaddr_in saddr; memset(saddr, 0, sizeof(saddr)); saddr.sin_family AF_INET; saddr.sin_port htons(8080); inet_pton(AF_INET, 127.0.0.1, saddr.sin_addr); if (connect(fd, (struct sockaddr *)saddr, sizeof(saddr)) 0) { perror(connect); exit(1); } write(fd, hello socket, 12); char buf[1024] {0}; ssize_t n read(fd, buf, sizeof(buf)); if (n 0) printf(echo: %s\n, buf); close(fd); return 0; }inet_pton比inet_addr更安全它会把点分十进制的 IP 字符串转成s_addr需要的网络字节序不用你手动再做一次htonl。编译用下面的 Makefile 就行CC gcc CFLAGS -Wall -O2 all: server client server: server.c $(CC) $(CFLAGS) -o $ $ client: client.c $(CC) $(CFLAGS) -o $ $编译运行make ./server ./client应该能看到输出echo: hello socket。没有客户端也能验证服务端直接用 telnet 连telnet 127.0.0.1 8080看到连接建立后输入几个字符服务端会原样回显。这也是我在生产环境排查端口是否正常时的习惯先ss -tlnp确认监听再用 telnet 或 nc 做一次最小连通性测试。如果跑在虚拟机上注意把客户端的 IP 改成虚拟机的实际 IP用ip addr查看不要固定写127.0.0.1。3. 从回声到可交付拆掉粘包、半包与假死连接这三座大山第 2 章的代码能跑但它离可交付还差得远。回声服务每次只read一次可真实业务里对端发送的数据可能大于你的缓冲区也可能两个业务消息连在一次到达。这一章专门处理三个问题粘包、半包、断线识别。3.1 一次send不一定一次recv缓冲区与粘包从哪来TCP 是流协议内核不保证应用层每次write的数据会被对端一次read读出来。比如客户端连续发送“ABC”和“DEF”两个业务包服务端可能一次read读回“ABCDEF”也可能读回“ABC”后下次再读到“DEF”。前一种叫粘包后一种叫半包。为什么会有这种现象因为数据在发送路径上要经过发送缓冲区、网络分段、接收缓冲区。Nagle 算法会把多个小包合并发送接收端的内核缓冲区又可能在一次read时返回累积的多个包。说白了TCP 只保证字节的顺序和可靠性不保证消息边界。识别消息边界是应用层自己的责任。知道了原因方案无非三种。一是定长消息每条消息固定 N 字节读满 N 字节才算一个包二是分隔符比如按行分隔读到\n才算一条完整消息三是定长包头加变长包体包头里写清包体长度这是目前最通用的做法。第三种我在下面展开。3.2 用定长包头和变长包体绕开粘包问题recv_all 与 htonl首先要做一个“读满指定字节数”的函数因为read不一定一次返回你要的长度// recv_full.c #include errno.h #include stdint.h #include sys/types.h #include unistd.h // 从 fd 中读满 n 字节返回读到的字节数连接关闭或出错返回 -1 ssize_t recv_full(int fd, void *buf, size_t n) { char *p buf; size_t left n; while (left 0) { ssize_t r read(fd, p, left); if (r 0) return -1; // 对端关闭连接 if (r 0) { if (errno EINTR) continue; // 被信号打断重试 return -1; // 真正的 IO 错误 } p r; left - r; } return n; }关键参数是left它记录还差多少字节没读完。只要没读满就继续read直到凑齐一个包头或包体。这个函数是后续所有协议解析的地基。有了它再来定义消息格式。我一般用 4 字节无符号整数作为包头存包体长度包体紧跟其后// proto.c 发送一条完整业务消息 #include arpa/inet.h #include stdint.h #include string.h #include unistd.h static int write_all(int fd, const void *buf, size_t n) { const char *p buf; size_t left n; while (left 0) { ssize_t r write(fd, p, left); if (r 0) { if (errno EINTR) continue; return -1; } p r; left - r; } return 0; } int send_message(int fd, const char *body, size_t len) { uint32_t be_len htonl((uint32_t)len); // 把主机字节序转成网络字节序 if (write_all(fd, be_len, sizeof(be_len)) 0) return -1; if (write_all(fd, body, len) 0) return -1; return 0; }接收端先用recv_full(fd, raw_len, 4)读 4 字节ntohl得到真实长度再recv_full读包体。为什么包长选 4 字节而不是 2 字节2 字节最大能表示 65535如果业务单包可能超过这个值就必须用 4 字节。如果你的场景固定短消息2 字节能省两字节带宽但换来的是解析代码里多一个长度上限校验。在做嵌入式 Linux 项目时我常把包体上限设成16 * 1024超过直接丢弃连接避免恶意客户端用一个假长度把内存耗尽。3.3 心跳与接收超时识别“看起来还活着”的死连接TCP 本身有 keepalive 机制但默认最快要两小时才探测一次而且很多操作系统默认不开启。用内核 keepalive 做应用层断线检测等发现时用户早就投诉了。应用层必须自己做心跳。常见做法是服务端每 30 秒发一个心跳包客户端如果超过 90 秒没收到任何数据就认为连接已经死了。但更轻量的方案是只设接收超时把超时本身当断线信号// so_rcvtimeo.c 设置接收超时并感知假死连接 #include errno.h #include sys/socket.h #include sys/time.h #include unistd.h // 在连接 fd 上设置 5 秒读超时 void set_recv_timeout(int fd, int seconds) { struct timeval tv { .tv_sec seconds, .tv_usec 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); } // 调用 recv 后判断超时 int handle_recv(int fd, char *buf, size_t size) { ssize_t n recv(fd, buf, size, 0); if (n 0 errno EAGAIN) { // 超时了连接大概率还占着但应用层已经没在干活 return 0; // 触发主动心跳或重连逻辑 } if (n 0) return -1; // 对端关闭 return (int)n; }SO_RCVTIMEO的参数是struct timeval精度可以到微秒。注意超时返回的错误码是EAGAIN不是ETIMEDOUT这个很多人会记反。设置了超时之后如果你还想在这条连接上执行长时间阻塞的其他操作要重新评估协议设计——心跳超时和业务超时混在一起会很难办。我通常的做法是长连接超过 15 秒没有业务消息服务端主动断开让客户端重连断线重连比试图修复一条半死连接靠谱得多。4. 从单客户端到并发连接select事件循环与三种并发模型选型第 3 章的代码一次只能服务一个连接因为accept之后程序就阻塞在read上。真实服务不会这么干。这章讲清楚三种并发模型的边界并给出能直接跑的 select 版多客户端服务。4.1 三种并发模型选型fork、select、epoll面对多客户端第一反应可能是fork。每次accept后 fork 一个子进程处理这条连接父进程继续accept。这套方案在连接数小的时候很直观但代价是每来一个连接就要创建进程进程数上去之后上下文切换开销巨大还要处理僵死进程的回收。select 用一套事件循环单线程同时盯着几十上百个 fd。它的问题是受FD_SETSIZE限制通常默认为 1024其次每次调用都要把整个 fd 集合从用户态拷贝到内核态在 fd 很多时性能会下降。epoll 是为大规模连接设计的它把 fd 集合留在内核态只通过事件通知应用层“哪些 fd 就绪了”。在 Linux 上做高并发最终都要走向 epoll但 epoll 的编程复杂度更高还要处理水平触发和边缘触发的问题。模型适合连接数编程成本典型场景fork几十到一两百低但要注意回收子进程连接少、单连接计算量大select几十到几百中单线程事件循环内网中小型服务、教学和面试epoll数千到数十万高需要回调式编程网关、IM、海量长连接我自己的选择标准很简单连接数不超过几百业务不复杂用 select超过一千直接上 epoll不要再在 select 上优化因为FD_SETSIZE是编译期决定的改起来很别扭。4.2 用select实现一个多人聊天室的核心事件循环下面这段代码是 select 版多客户端 echo 服务的核心监听的客户端的消息会原样回给发送者// select_chat.c 核心循环 #include errno.h #include stdio.h #include sys/select.h #include unistd.h void run_event_loop(int server_fd) { fd_set allfds, readfds; FD_ZERO(allfds); FD_SET(server_fd, allfds); while (1) { readfds allfds; // select 会改写 readfds必须每次重新赋值 int ready select(FD_SETSIZE, readfds, NULL, NULL, NULL); if (ready 0) { if (errno EINTR) continue; // 被信号打断继续跑 perror(select); break; } for (int fd 0; fd FD_SETSIZE; fd) { if (!FD_ISSET(fd, readfds)) continue; if (fd server_fd) { int cfd accept(server_fd, NULL, NULL); if (cfd 0) continue; FD_SET(cfd, allfds); printf(new client, fd%d\n, cfd); } else { char buf[1024]; ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { // 客户端关闭或出错回收这个 fd close(fd); FD_CLR(fd, allfds); printf(client fd%d closed\n, fd); } else { write(fd, buf, n); } } } } }这段代码有三个关键点。第一readfds allfds必须放在每次循环内因为select返回时会把没有就绪的 fd 清掉。第二第一个参数我直接填FD_SETSIZE省去维护maxfd 1的麻烦代价是每次遍历 1024 位在 fd 数量少时可忽略但这也正好说明 select 为什么撑不过千级连接。第三accept拿到的 cfd 默认是阻塞的如果某个客户端一直不发送数据read会卡住这个 fd导致整个 select 循环卡住生产环境要给每个 cfd 设置O_NONBLOCK并把EAGAIN当作正常情况处理。4.3 非阻塞IO与边缘触发异步编程里最常见的纠结想把 select 循环写稳必须理解非阻塞 IO。默认情况下 cfd 是阻塞的没有数据时read会睡眠等待。select 虽然告诉你这个 fd 可读但如果对方刚好在这期间把数据读走你的read还是可能返回EAGAIN。解决办法是显式把 fd 设为非阻塞// set_nonblock.c 设置非阻塞 fd #include fcntl.h #include unistd.h void set_nonblock(int fd) { int fl fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, fl | O_NONBLOCK); }设置之后没有数据时read会立即返回-1errno置为EAGAIN。你的处理逻辑要把EAGAIN和真正的错误区分开前者是正常的“暂无数据”后者才是异常。异步编程的大部分坑其实都出在对EAGAIN的处理上——有人把它当错误记日志日志刷爆磁盘有人忽略它导致事件循环空转。至于边缘触发和水平触发可以这样理解水平触发是“只要还有数据就一直通知”边缘触发是“只有状态从无到有时通知一次”。select 只有水平触发epoll 两种都支持。边缘触发性能更好但如果你没把数据读完剩下的数据可能要等到下一次新数据到达才被通知处理不及时就会造成数据堆积。新手阶段我建议先用水平触发把逻辑跑正确再谈边缘触发优化。5. Linux Socket编程常见问题排查5个必踩的坑与定位命令这一章是血泪经验。以下每个坑我都对应给出“现象 → 原因 → 解决”的完整路径并且附上排查用的命令。照着这套流程走能少走很多弯路。5.1 Address already in useTIME_WAIT不是玄学现象服务端程序刚退出马上重启就报bind: Address already in use。原因是主动关闭连接的一侧会进入 TIME_WAIT 状态这个状态默认持续 2MSL通常约 60 秒。TIME_WAIT 期间同一组四元组源 IP、源端口、目标 IP、目标端口不能被重新绑定。你感觉服务已经退出了但内核里还有半打连接没完全消失。解决在bind之前设置SO_REUSEADDR。它允许新 socket 绑定到处于 TIME_WAIT 状态的地址。常见做法是创建监听 fd 后立刻执行int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on));排查时先用ss -tanp | grep 8080看一眼状态列如果大量连接卡在 TIME_WAIT那说明服务端主动断开连接的频率太高还要检查是不是协议设计里让服务端主动 close 了本应由客户端关闭的连接。5.2 recv返回0这不是空数据是对端关闭了连接现象客户端一直连着但服务端recv返回 0你以为只是没数据继续循环等待。实际原因是对端调用了close()TCP 会发送 FIN收到 FIN 后recv返回 0。它和EAGAIN有本质区别EAGAIN是暂无数据但连接还在0 是连接已经彻底结束。解决把recv返回 0 当作连接释放的唯一判据代码里写清楚ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { close(fd); FD_CLR(fd, allfds); // 从事件循环里移除 }很多线上“连接泄漏”其实是这个判据没写对。连接明明已经关闭你还在 fd 集合里留着它fd 被耗尽新连接就进不来。5.3 EINTR信号打断的慢系统调用现象服务运行一段时间后某个连接莫名断开日志里出现EINTR。原因是 accept、read、select 这类慢系统调用在等待时收到了信号比如终端挂断、子进程退出内核抛回EINTR错误。如果不对errno做处理程序可能错误地关闭连接甚至直接退出。解决在所有阻塞系统调用的失败分支里检查errno EINTR是则重试。示例在上一章的recv_full里已经写过。如果系统有明确的信号处理需求也可以在sigaction里加SA_RESTART标志让内核自动重启被中断的系统调用但更稳妥的做法还是手动处理EINTR因为SA_RESTART对不同系统调用的行为并不一致。5.4 字节序错了端口和IP无端被“翻过来”现象服务端收到客户端连接打印出来端口是0x1F90而不是8080。原因是直接用整型赋值给sin_port没有做字节序转换。x86 系列是小端存储网络协议规定字段必须是大端。你写入的小端数值在网络上看起来就是反的。解决凡是填struct sockaddr_in的字段一律用htons、htonl从协议包头里解析长度字段时用ntohl转回主机序。检查标准就一条往 sockaddr 里填用h开头的函数从网络数据里读用n开头的函数。这个检查很简单但能省掉一个晚上的定位时间。5.5 本机能连、跨机不通防火墙与内核参数的边界现象在本机telnet 127.0.0.1 8080完全正常换成局域网其他机器连接就是超时。这类问题在“Linux 运维故障案例”里出现过太多次根因往往不在 socket 代码而在网络边界。常见原因有三个防火墙 drop 了目标端口服务端只监听了127.0.0.1而不是0.0.0.0目标机器内核参数限制或没开启 IP 转发。解决路径按下面这个顺序排查ss -tlnp | grep 8080 # 看监听地址是不是 0.0.0.0 ping server_ip # 看主机是否可达 nc -vz server_ip 8080 # 测端口通不通 sudo iptables -L -n | grep 8080 # 看防火墙规则注意ss的输出里监听地址如果是127.0.0.1:8080只有本机能连监听地址是*:8080或0.0.0.0:8080外部才能访问。这是代码里INADDR_ANY和第 2 章示例代码的区别很多人把客户端从本机改成远程时忘了检查服务端到底监听在哪块网卡上。6. 最后一个技巧用nc、ss和strace给socket服务做一次外科手术式验证调试 socket 服务最怕把程序当成黑匣子。我最后给你一套不依赖图形化抓包工具的验证套路在服务器上就能快速定位问题。6.1 用nc和ss证明数据流是通的写完了服务端别急着写客户端先用 nc 验证printf healthcheck | nc -4 -q1 127.0.0.1 8080如果服务端把healthcheck原样回显说明监听、accept、read、write 这条链路是通的。再用ss看连接状态ss -tan state established | grep 8080state established这个参数比ss -tanp更精准只显示当前真正建立连接的条目。这一步能直接证明 TCP 已经握完手排除“服务端吞连接”的嫌疑。我再把nc和telnet做个分工telnet 适合人工交互式测试nc 适合脚本化一次性请求。6.2 用strace让系统调用变成白纸黑字如果协议逻辑看起来没问题但数据不对就该上 strace 了strace -ff -e tracenetwork,read,write ./server-ff跟踪所有子进程-e tracenetwork,read,write只过滤网络和 IO 相关系统调用。输出里能看到每个accept返回的 fd、每次读写的大小和字节序。有次我在嵌入式 Linux 项目里遇到一个诡异问题客户端发来的数据总是少几个字节。用 strace 一看read返回值比我预期的少根因是我错误地假设一次read能读完所有数据而实际是半包。从那次之后我写网络代码会先在本地用 strace 跑一遍再上真机。这个习惯帮我省过好几次半夜被叫起来查日志的麻烦。希望这套验证方法也能帮到你。本文还有配套的精品资源点击获取
返回列表