ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:这是一份系统梳理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、protocol
bind()把 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 才是真正和客户端通信的 fd,listen_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

现象:服务端用 Ctrl+C 停止后马上重新启动,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)。

维度selectepoll
fd 上限受 FD_SETSIZE 限制,默认 1024取决于系统内存
效率每次线性扫描全部 fd只返回就绪 fd,回调驱动
可移植性几乎所有平台Linux 专用
操作接口每次重置 fd_setepoll_ctl 动态增删

所以连接数在几百以内,select 完全够用;连接数上万,或者高并发场景,直接上 epoll。注意 epoll 有 LT(水平触发)和 ET(边缘触发)两种模式。LT 是默认,只要缓冲区有数据就会反复通知;ET 只在状态变化时通知一次,对编程要求更高,必须一次性把数据读完,否则可能漏读。我的建议是新手先用 LT,稳定之后再考虑 ET 提升性能。epoll 的代码结构比 select 稍微复杂一些,核心三步:epoll_create 创建表,epoll_ctl 加 fd,epoll_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 trace=accept,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 编程的翻车点,到最后都是基础细节的疏忽。把这些土办法用起来,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进