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

Linux Socket编程实战:从系统调用到select/epoll并发

Linux Socket编程实战:从系统调用到select/epoll并发 ★ FEATURED ARTICLE
简介这是一份系统梳理Linux Socket编程原理与实战的docx文档适合正在学习网络编程、需要理解TCP/IP通信机制的基础开发者。资源以通俗讲解与函数分析结合的方式完整覆盖socket地址标识、socket()、bind()、listen()、connect()、accept()、read()/write()、close()等核心接口并专门用详细步骤剖析TCP三次握手建立连接与四次挥手释放连接的细节最后附带可运行实例帮助读者将原理落地到服务端/客户端代码中。全包仅1个docx文档体积77KB内容集中便于快速阅读与随时查阅文档结构由“进程通信—Socket概念—基础操作—TCP握手连接—连接释放—实例验证—思考延伸”逐步递进读完可对跨网络进程通信建立较为完整的知识框架。目前已有241人学习下载适合想夯实Socket基础并接触TCP连接管理细节的初学者作为精炼参考资料。1. 为什么你必须亲手写一次 Linux Socket在开发一个跨机器通信的模拟项目时我发现自己对 Socket 的理解只停留在「两个程序能用它传数据」的层面。真正动手写服务端和客户端才明白 Linux Socket 不是一套神秘的 API而是内核在网络层与应用层之间露出的一个文件描述符。这篇文章用实例拆解 Linux Socket 编程的主线从 socket() 到 bind、listen、accept再到客户端 connect以及中间必然遇到的粘包、连接重置、端口占用这些坑。适合刚接触网络编程、想用最小代价跑通一个可靠服务的开发者也适合复习底层细节的熟手。看完你能照着写出第一个可运行的 C 语言回显服务知道参数怎么调知道出问题时去哪里找证据。2. Socket 系统调用与协议选型看懂内核接口再动手2.1 一个 socket 到底是个什么东西很多人学 Socket 第一个困惑socket() 返回的 int 是什么其实它就是一个文件描述符跟 open() 返回的 fd 没有本质区别。Linux 的设计哲学是「一切皆文件」网络连接也一样。你可以用 read()/write() 读写它也可以用 close() 关闭它。验证方法很简单程序运行中在另一个终端执行ls -l /proc/pid/fd/会看到socket:[12345]这类条目。这意味着网络收发最终会走到内核协议栈而应用层拿到的只是一个可读写的句柄。系统调用的核心关系可以用一张表记住调用作用关键参数socket()创建套接字返回 fddomain、type、protocolbind()把 fd 绑定到本地地址和端口地址结构指针listen()标记该 fd 为被动监听backlog等待队列长度accept()从完成握手队列中取出一个连接新连接 fd 与对端地址connect()主动发起连接对端地址结构send()/recv()数据收发本质上也是读写缓冲区、长度、标志位这张表是主线。服务端按 socket → bind → listen → accept 走客户端按 socket → connect 走。很多人喜欢先记调用顺序其实更重要的是理解每个调用的输入输出比如 accept() 返回的是一个全新的 fd而不是原来的监听 fd原来的 fd 继续负责接收新连接。这一点没想透写多线程服务时会反复出错。我见过一个同学写并发服务时用监听 fd 去 read结果永远只能读到第一个连接的数据这就是类型混淆的典型翻车现场。2.2 TCP 还是 UDP按场景选不按喜好选协议选型直接决定后面代码的写法。TCP 提供可靠、有序的字节流适合需要完整数据的场景UDP 提供不可靠、无序的数据报适合允许少量丢失、追求低延迟的场景。可以用一张表做决策维度TCPUDP数据完整性可靠丢了会重传不可靠丢了就没了到达顺序有序可能乱序连接状态需要三次握手建立连接无连接直接发包传输效率有确认与重传开销少延迟低典型场景HTTP、数据库、文件传输实时音视频、游戏位置同步判断方法就一句话你的数据是不是少一块都不行是走 TCP不是且你更在意延迟可以走 UDP。TCP 代码里最麻烦的是粘包因为它是字节流没有消息边界UDP 每个 sendto 对应一个 recvfrom报文边界天然保留但要自己处理丢包和乱序。选型时还要注意UDP 也能做到可靠但那要在应用层实现确认和重传复杂度不比 TCP 低多少。2.3 阻塞与非阻塞从第一个 read() 开始默认创建的 socket 是阻塞的。阻塞的意思是一个调用没完成就不返回比如客户端没连上来时accept() 会一直卡在那里对端没发数据时read() 也会一直卡住。这种模型写小工具没问题但想在一个线程里处理多个连接就没办法了。非阻塞模式会让这些调用立即返回没有数据时返回 -1并设置 errno 为 EAGAIN 或 EWOULDBLOCK。设置非阻塞的通用代码int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);fcntl 的 F_GETFL 先取出当前文件状态标志然后用 F_SETFL 加上 O_NONBLOCK。这样设置之后后续 accept、read 都不会卡住。注意 EAGAIN 和 EWOULDBLOCK 在 Linux 上是同一个值判断时两个宏都写。非阻塞本身不是目的它是配合 I/O 多路复用select/epoll的前提你告诉内核「这个 fd 有动静了再通知我」内核做好了准备再调用非阻塞 read就能避免数据没到时的空转。这里先把概念立住第五章会写出完整的多路复用代码。3. 最小回显服务实例从 socket() 到 accept() 的完整代码3.1 服务端socket-bind-listen-accept 最小闭环写一个回显服务客户端发什么过来服务端原样发回去。先看服务端代码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define BACKLOG 5 int main() { // 创建 TCP 套接字返回文件描述符 int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } // 设置端口复用避免 TIME_WAIT 导致重启 bind 失败 int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); // 构造 IPv4 地址结构绑定本机所有网卡的 8888 端口 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(PORT); if (bind(listen_fd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, BACKLOG) 0) { perror(listen); exit(1); } // 接受一个客户端连接accept 返回的是通信用的新 fd struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr*)client_addr, len); if (conn_fd 0) { perror(accept); exit(1); } // 循环读取并回显 char buf[1024]; ssize_t n; while ((n read(conn_fd, buf, sizeof(buf) - 1)) 0) { buf[n] \0; printf(recv: %s\n, buf); write(conn_fd, buf, n); // 原样写回 } close(conn_fd); close(listen_fd); return 0; }代码逻辑分四段创建、绑定、监听、接受连接。socket() 的第一个参数 AF_INET 表示 IPv4第二个参数 SOCK_STREAM 表示 TCP第三个参数 0 表示按前两个参数选择默认协议。bind() 的关键在于地址结构INADDR_ANY表示监听所有本地网卡htons(PORT)把端口号从主机字节序转换成网络字节序。BACKLOG是内核为这个监听 socket 维护的等待队列长度不是最大连接数而是还没被 accept 取走的已完成握手的连接上限。注意 accept() 返回的 conn_fd 才是真正和客户端通信的 fdlisten_fd 继续留着监听。这个示例只接受一个连接循环结束后关闭两个 fd。我在代码里加了 SO_REUSEADDR这是一个后悔药没有它服务重启时很可能会报 Address already in use原因后面讲。3.2 客户端socket-connect 与一次收发客户端的逻辑更简单创建 socket构造服务端地址connect然后发送和接收。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); exit(1); } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; // 用 inet_pton 代替已过时的 inet_addr处理点分十进制地址 inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr); server_addr.sin_port htons(PORT); if (connect(fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect); exit(1); } const char *msg hello socket; write(fd, msg, strlen(msg)); char buf[1024]; ssize_t n read(fd, buf, sizeof(buf) - 1); if (n 0) { buf[n] \0; printf(recv: %s\n, buf); } close(fd); return 0; }connect() 需要的是对端地址协议族必须填 AF_INET。inet_pton把字符串 IP 转成网络字节序的二进制第三个参数要传server_addr.sin_addr的地址。这里没写 send() 而是直接 write()因为 socket 是文件描述符read/write 是通用的。收数据时注意 read() 的返回值大于 0 是实际读到的字节数等于 0 表示对端已关闭小于 0 表示出错。这个返回值判断是后来的血泪经验很多人只判断了大于 0导致连接关闭时程序行为异常。3.3 编译、运行与验证一条命令看连接状态把服务端保存为 server.c客户端保存为 client.c然后编译gcc -Wall -o server server.c gcc -Wall -o client client.c先启动服务端再启动客户端。如果一切正常客户端会打印recv: hello socket服务端会打印recv: hello socket。运行期间可以在另一个终端用ss -tlnp查看监听状态ss -tlnp | grep 8888输出里能看到LISTEN 0 5 0.0.0.0:8888第二列是当前连接队列大小第三列是 backlog 配置值。如果启动时报错按顺序检查bind 失败看端口是否被占用listen 失败基本只可能是 fd 类型不对connect 失败看 IP 和端口是否匹配、服务端是否真的在监听。还有一个常用的验证工具是nc没有客户端程序时可以用echo test | nc 127.0.0.1 8888直接测服务端是否回显。这些验证手段能帮你把问题快速隔离到本机还是对端。4. 避坑指南五个让我翻过车的 Socket 细节4.1 服务重启时 bind 报 Address already in use现象服务端用 CtrlC 停止后马上重新启动bind() 直接报错。原因主动关闭连接的一方连接会进入 TIME_WAIT 状态默认持续 2MSL约 60 秒。内核还在管理原来的端口和地址新 bind 同一个端口就冲突了。这在开发时非常常见几乎每个人都遇到过。解决在 bind() 之前设置 SO_REUSEADDR。这告诉内核即使 TIME_WAIT 状态下的连接使用该端口也允许绑定。注意 SO_REUSEADDR 和 SO_REUSEPORT 不同后者是多个进程同时监听同一端口做负载均衡前者才是解决端口重用问题。所以监听 socket 上加上setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt))就够了。4.2 客户端 connect 卡住几十秒才失败现象客户端 connect 一个不存在或者对端防火墙丢弃请求的 IP进程卡了很久才返回超时错误。原因connect 是阻塞的内核默认的 TCP 连接超时很长包含 SYN 重传时间可能要一两分钟。这在运维场景下是致命的用户等不了那么久。解决给 connect 设置超时。常见做法是把 fd 设成非阻塞然后调用 connect再用 select 等它可写。代码思路如下int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); int ret connect(fd, ...); if (ret 0 errno EINPROGRESS) { fd_set wset; FD_ZERO(wset); FD_SET(fd, wset); struct timeval tv {3, 0}; // 3秒超时 ret select(fd 1, NULL, wset, NULL, tv); }select 返回大于 0 说明连接已建立。判断成功与否还要再用 getsockopt 查 SO_ERROR避免出现「连接建立但立即失败」的边界。这样超时完全可控不会让用户干等着。4.3 客户端一次 write 的数据被服务端 read 拆成两半现象客户端发送 100 字节服务端第一次 read 只读到 40 字节第二次才读到剩余 60 字节。业务层如果把两次数据当成两个消息处理就会乱。原因TCP 是字节流它只保证字节的先后顺序不保证应用层的「消息边界」。一次 write 的数据可能被分成多个 TCP 报文也可能多个 write 合并到一个报文。这是 TCP 编程最经典的坑也是最容易忽略的。解决应用层必须自己定义帧格式。最简单的方案是「长度前缀」每条消息前固定加 4 字节网络序长度接收方先读 4 字节得到长度再读满对应字节数。示例如下uint32_t len htonl(strlen(msg)); write(fd, len, 4); write(fd, msg, strlen(msg));接收方用 readn 循环读到完整长度再读 payload。不要奢望一次 read 拿到完整消息。这个习惯在写任何 TCP 协议时都必须养成。4.4 客户端断开后服务端一 write 就被 SIGPIPE 杀掉现象服务端往已关闭的连接写数据进程直接退出看不到任何报错。或者客户端也遇到同样问题。原因内核检测到对端已关闭再 send/write 时产生 SIGPIPE 信号默认动作是终止进程。很多新手服务端因此毫无征兆地挂掉。解决忽略这个信号同时发送时加 MSG_NOSIGNAL 标志。signal(SIGPIPE, SIG_IGN); send(fd, buf, n, MSG_NOSIGNAL);忽略 SIGPIPE 后写入错误会以返回值 -1 和 errno EPIPE 暴露出来你就能在业务代码里抓住错误并清理连接。这是长期运行服务的必需操作。4.5 本地调试连 127.0.0.1 没问题跨机器就超时现象服务端监听127.0.0.1客户端在本机可以连换成局域网 IP 就连不上。原因127.0.0.1是回环地址只监听本机。跨机器访问必须监听0.0.0.0或者具体的网卡 IP。另外还可能被防火墙拦了。解决bind 时用INADDR_ANY即 0.0.0.0监听所有网卡。排查时先用ss -tlnp看监听地址是不是 0.0.0.0再用telnet ip port或nc测端口是否存在。如果端口通但程序连不上再看防火墙策略用iptables -L或firewall-cmd --list-services检查。记住程序里监听地址写错了是白屏系统墙拦了是真黑匣子。5. 多客户端并发select 与 epoll 的落地对比5.1 为什么单线程 accept 处理不了多个连接第三章的服务端一次只能服务一个客户端因为 accept() 返回第一个连接后主线程就陷入 read() 循环第二个客户端连上车队也只能等第一个断开。要处理多个连接最简单的想法是每个连接开一个线程。这样能跑但连接多到几千时线程上下文切换开销非常大而且你还要处理线程同步和退出逻辑后续维护成本很高。更优雅的方案是 I/O 多路复用一个线程同时监控多个 fd内核告诉我们哪个 fd 就绪了再去做对应读写。select 和 epoll 是 Linux 上最主流的两种。select 历史悠久几乎所有平台都有epoll 是 Linux 专有性能更好。搞清楚两者的差异你才能给当前项目做出正确的选择。5.2 用 select 改写多客户端回显服务select 的模型是把一组 fd 交给内核内核阻塞等待其中任意一个可读或可写然后返回就绪集合。核心代码框架如下fd_set read_set; int max_fd listen_fd; FD_ZERO(read_set); FD_SET(listen_fd, read_set); while (1) { fd_set tmp read_set; // select 会修改集合所以每次复制 struct timeval tv {1, 0}; // 1秒超时 int ret select(max_fd 1, tmp, NULL, NULL, tv); if (ret 0) break; if (FD_ISSET(listen_fd, tmp)) { int conn_fd accept(listen_fd, NULL, NULL); FD_SET(conn_fd, read_set); // 新连接加入集合 if (conn_fd max_fd) max_fd conn_fd; } for (int fd 0; fd max_fd; fd) { if (FD_ISSET(fd, tmp) fd ! listen_fd) { // 有数据可读进行非阻塞 read char buf[1024]; ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { close(fd); FD_CLR(fd, read_set); } else write(fd, buf, n); // 回显 } } }关键点有三个第一select 会修改传入的 fd_set所以必须用一个临时变量去调用 select。第二第一个参数必须是所有 fd 中的最大值加 1而不是固定值。第三fd_set 是位图有默认上限通常是 1024超过就会出错。这段代码的 read 没有设为非阻塞严格来说在多路复用下应该配合非阻塞 fd因为一个 fd 就绪后只能保证至少可读一次不能保证后续 read 不阻塞。实际项目里要在 accept 后立刻把新 fd 设为非阻塞。5.3 epoll 与 select 的取舍当连接数过万select 的问题在于每次调用都需要把整个 fd_set 从用户态拷贝到内核态且内核要线性扫描所有 fd 找就绪的复杂度 O(n)。epoll 解决的是这两个问题它在内核中维护一个事件表通过 epoll_ctl 增删 fd不需要每次重新拷贝全部集合就绪 fd 通过回调机制放入一个就绪链表用户用 epoll_wait 取走时只需要遍历就绪的复杂度 O(k)。维度selectepollfd 上限受 FD_SETSIZE 限制默认 1024取决于系统内存效率每次线性扫描全部 fd只返回就绪 fd回调驱动可移植性几乎所有平台Linux 专用操作接口每次重置 fd_setepoll_ctl 动态增删所以连接数在几百以内select 完全够用连接数上万或者高并发场景直接上 epoll。注意 epoll 有 LT水平触发和 ET边缘触发两种模式。LT 是默认只要缓冲区有数据就会反复通知ET 只在状态变化时通知一次对编程要求更高必须一次性把数据读完否则可能漏读。我的建议是新手先用 LT稳定之后再考虑 ET 提升性能。epoll 的代码结构比 select 稍微复杂一些核心三步epoll_create 创建表epoll_ctl 加 fdepoll_wait 取事件。本质上是事件驱动适合做高性能服务但业务的读写逻辑和 select 案例是一致的。6. 验证 Socket 程序的三个土办法和一个收尾习惯6.1 用 ss 和 lsof 检查端口与连接状态写完程序不要急着交差。先用ss -tlnp确认服务端在监听再看ss -tn的连接状态。如果看到大量 TIME_WAIT说明服务端主动关闭了很多连接再去代码里查是谁在主动关。lsof -i :8888能列出占用端口的进程 pid排查端口冲突时最直接。这两个命令比任何日志都诚实因为它们是内核状态的第一手呈现。6.2 用 strace 跟踪系统调用调一个问题反复查不出原因用 strace 看系统调用是一剂猛药。比如服务端 accept 不返回执行strace -p pid -e traceaccept,read,write你能看到进程到底卡在哪个系统调用上errno 是多少。有一次我遇到 send 返回成功后客户端没收到数据最后 strace 发现 send 被中断程序忽略了 EINTR。这种问题靠看代码很难发现系统调用跟踪是最终答案。6.3 用 tcpdump 看报文到达链路本机一切正常跨机器就出问题查网络链路用tcpdump -i eth0 host 192.168.1.10 and port 8888看 SYN、FIN、RST 报文到底有没有到网卡防火墙丢包能直接看出来。比如 SYN 重发三次无响应问题大概率在对端或中间设备如果 SYN 到了但对端回 RST说明端口没服务在监听。这个工具能帮你把问题域从应用层切到网络层避免在代码里瞎找。最后说一个习惯每次启动服务前先检查一次端口和 TIME_WAIT 状态每次改完代码用一套固定的验证流程跑一遍再把日志留档。我曾经因为没设置 SO_REUSEADDR在演示时重启服务失败演示现场一度空气凝滞从那以后我把端口复用设置成了所有服务端的固定模板。Socket 编程的翻车点到最后都是基础细节的疏忽。把这些土办法用起来希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站