昨晚一个朋友拉我去排障,他刚写的 Linux 服务程序一启动就挂,日志里只留了一行:bind: only one usage of each socket address。我问他是不是动过监听端口,他说没有,就是快速重启了好几次。我说你把SO_REUSEADDR加上再试试,他半信半疑改完,服务果然起来了。这种场景在 socket 编程里太常见了,几乎每个写过网络服务的人都会撞上。
这篇文章聊的就是 Linux 下的 socket。不是单调地罗列 API,而是从“socket 到底是什么”这个最朴素的问题出发,把 TCP、UDP 服务端和客户端完整写一遍,再集中拆解高频报错和长连接里的坑。适合刚学完 C 语言、想搞清楚两个进程怎么通过网络通信的初学者,也适合已经能跑通 demo、但总在 bind、粘包、心跳设计上反复翻车的开发者。你不需要提前懂太多网络知识,我会把每个关键点都掰开讲明白。
1. socket 到底是什么:网络世界的文件句柄
1.1 一切皆文件的抽象,在网络上同样成立
Unix/Linux 有一个深入骨髓的设计思想叫“一切皆文件”。你打开磁盘上的文件,open()返回一个文件描述符 fd,之后read()/write()/close()走完整个生命周期。socket 虽然是网络通信,不是操作磁盘,但它复用了同一套接口模型。你用一个整数 fd 代表一条网络连接,之后看似在读写文件,实际数据是在内核协议栈里流转,从网卡进出,或者在回环接口内部直接完成拷贝。
如果你之前只写过文件操作,可以把socket()类比成open():向内核申请一个网络文件的句柄;bind()是为这个句柄绑定一个“地址名”;listen()/connect()是建立双向通道;send()/recv()就是read()/write();最后close()释放句柄。这个类比不够精确,但能帮新手先把程序骨架记在脑子里。
很多人初学时的卡点不是函数记不住,而是“我知道怎么打开文件,但不知道如何打开网络”。当你把 socket 想成网络世界的 fd,代码的流程一下就顺了:创建、绑定、监听、接受、收发、关闭,每步都有对应的系统调用。
1.2 五元组:内核靠什么唯一标识一条连接
更进一步的问题是:内核怎么区分同时存在的成千上万条连接?答案是五元组:源 IP、源端口、目的 IP、目的端口、协议类型。TCP 场景下,协议类型就是 TCP。IP 和端口标识了通信双方的位置,协议标识了这套连接使用的传输方式。
服务端bind()时指定本地 IP 和端口,相当于把一个固定门牌号挂到 socket 上。客户端connect()时不需要手动指定源端口,内核会从系统临时端口范围里自动挑一个。可以用sysctl net.ipv4.ip_local_port_range查看本机临时端口范围,通常类似 32768 到 60999。
理解五元组对排查问题极有帮助。遇到Address already in use,核心原因就是尝试绑定的 IP + 端口在当前状态不允许复用;遇到频率很高的“端口明明没进程用却 bind 失败”,十有八九和时间等待状态 TIME_WAIT 有关,具体在第 6 章讲。
1.3 SOCK_STREAM、SOCK_DGRAM、SOCK_RAW:三种不同的“脾气”
socket()函数的第二个参数决定 socket 的类型,三种常用类型有着完全不同的通信语义。
SOCK_STREAM对应面向连接的可靠字节流,通常配合 TCP 使用。它保证数据有序到达、不重复、不丢失。为了这些保证,内核要维护连接状态,处理三次握手、超时重传、拥塞控制。代价是 CPU 和内存开销高,而且没有消息边界,应用层需要自己切分消息。
SOCK_DGRAM对应无连接的数据报,通常配合 UDP 使用。每次sendto()发出一个独立报文,对端recvfrom()一次收一个报文,消息天然有边界。但 UDP 不保证送达,也不保证顺序,丢包全凭网络心情,可靠性和顺序检测都得由应用层自己补。
SOCK_RAW是原始套接字,直接读写 IP 层甚至更底层的报文,常用于抓包工具、Ping 程序、协议栈调试。业务开发我强烈建议别碰,它对权限有要求,数据结构复杂,稍不留神就会踩进内核协议的深坑。
第三个参数 protocol 常规填 0,表示让内核根据 type 自动选择默认协议。只有同一种 type 下存在多个可选协议时,才需要显式指定,比如SOCK_RAW下方可能需要填IPPROTO_ICMP这类值。
2. 先别写代码:用 nc 亲眼看一下 socket 链路
2.1 127.0.0.1、0.0.0.0 和局域网 IP 的区别
写服务端之前,先搞清楚地址怎么填,这一步能让后面少踩很多坑。
127.0.0.1是回环地址,数据只在内核里绕一圈,不出网卡,相当于自己对着镜子说话。0.0.0.0不是一个真实 IP,而是“所有本机地址”的通配符。服务端 bind 到0.0.0.0,代表对当前主机所有网卡上的这个端口都进行监听。局域网 IP 比如192.168.1.10则只接受从该网卡进来的流量。
我见过不只一次这样的排障现场:服务端在配置里写了127.0.0.1监听,然后拿手机连局域网 IP 访问,怎么都连不上,折腾半天最后才发现监听地址写错了。测试阶段先用127.0.0.1可以避开防火墙策略的干扰,但如果要验证局域网访问,就老老实实 bind0.0.0.0。
2.2 用 nc 起服务端:三分钟跑通一次通信
想要最快速度感受 socket 链路,不需要写 C 代码,用netcat就够了。先确认系统里有没有 nc:
which nc || sudo apt install netcat-openbsd -y在一个终端起服务端:
nc -lv 127.0.0.1 9000再开一个终端执行连接:
nc 127.0.0.1 9000两边连上之后,随便在一端输入几个字符回车,另一端马上就能看到。这就是最朴素的 TCP 文本传输,数据已经完整走过了“创建 socket -> bind -> listen -> connect -> accept -> send -> recv -> close”这条链路。
注意不同发行版的 netcat 参数有细微差异,比如某些老版本里-l表示监听,端口需要跟在-l后面写,-p参数和-l同时用反而会报错。还是以本机nc -h的提示为准。
2.3 tcpdump 看三次握手:把抽象的网络概念变成可见的包
代码能通了,再往下一层观察内核态到底发生了什么。起一个 tcpdump:
sudo tcpdump -i lo port 9000 -nn然后在另一个终端重新执行一次nc 127.0.0.1 9000。tcpdump 会输出一长串报文,仔细观察前三个包:
- 客户端发 SYN,标志位里只有 SYN
- 服务端回 SYN-ACK,同时带 SYN 和 ACK
- 客户端再回 ACK,三次握手完成
连接建立后,你输入文字时能看到P(PSH)和ACK同时置位的包;关闭连接时,发起关闭的一方发 FIN,另一方回 ACK,然后反向再走一次 FIN/ACK。
我第一次在 tcpdump 输出里亲眼看到三次握手时,很多名词突然就不抽象了。之后排查网络问题,我也养成了一个习惯:不靠猜,先抓包,用证据一步步缩小范围。tcpdump 是 socket 开发者的好朋友。
3. 手写 TCP 服务端:从 socket() 到 accept() 的每一步
3.1 最小服务端代码:逐行读一遍比背十个 API 有用
下面这段 C 代码实现了最简单的 TCP 服务端:监听0.0.0.0:9000,每来一个客户端就发送一句话然后关闭连接。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 9000 int main() { int server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); 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(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(server_fd, 16) < 0) { perror("listen"); exit(1); } printf("listening on 0.0.0.0:%d\n", PORT); while (1) { struct sockaddr_in client_addr; socklen_t len = sizeof(client_addr); int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &len); if (client_fd < 0) { perror("accept"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &client_addr.sin_addr, ip, sizeof(ip)); printf("accept from %s:%d\n", ip, ntohs(client_addr.sin_port)); const char *msg = "hello from server\n"; send(client_fd, msg, strlen(msg), 0); close(client_fd); } close(server_fd); return 0; }编译运行:
gcc -Wall -o server server.c ./server几个容易被忽略的细节,同时是很多 bug 的来源:
第一,sockaddr_in必须先用memset清零。不初始化就直接填字段,未设置的填充字节会变成不可预测的垃圾值,bind 偶尔成功偶尔失败,非常诡异。
第二,IP 和端口从主机字节序转换到网络字节序,分别用htonl和htons。写成addr.sin_port = 9000的后果是端口在高位和低位上被颠倒,监听的根本不是你预想的端口。字节序问题对新手极不友好,但记住“传输一律走网络字节序”就不会错。
第三,accept()返回的是新的 socket 描述符。监听 socket 还继续留在原处等待下一个连接,而新描述符才代表和这个客户端的独立会话。很多人以为 accept 之后还应该用同一个 fd 收发数据,这是最常见的误解。
3.2 SO_REUSEADDR 为什么现在就要加
我在代码里加上了一行容易被新手跳过的setsockopt:
int opt = 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));它的作用是允许端口在 TIME_WAIT 状态下被重新绑定。TCP 连接关闭时,主动关闭方会进入 TIME_WAIT 状态,默认持续约 60 秒。如果服务端主动断开连接并快速重启,不设置这个选项,bind()就会报Address already in use。这个场景可以用一句话总结:进程起来了,端口却在等内核回收。
几乎所有的 TCP 服务端代码里都有这一行,不是玄学,是必备配置。
3.3 listen 的 backlog:accept 队列能容纳多长
listen(server_fd, 16)这里的 16 是 backlog 参数,不少人直接抄一个数字,从没想过它到底控制什么。
内核为监听 socket 维护两个队列:半连接队列(SYN 队列)和全连接队列(accept 队列)。backlog 主要控制全连接队列的长度。当客户端的三次握手已经完成,但应用层还没来得及调用accept(),这个连接会临时待在 accept 队列里。如果队列满了,新的连接请求就可能被内核丢弃。
用ss -lnt可以查看监听 socket 的 Send-Q 和 Recv-Q,其中 Recv-Q 可以粗略反映当前积压了多少个等待 accept 的连接。如果发现 Recv-Q 经常很高,说明accept()处理不过来,这时加 backlog 只能缓解,根本办法是优化 accept 之后的数据处理逻辑,或者把连接转发给更多工作进程。
backlog 也不是越大越好,每个连接都要占内核内存,填一个天文数字反而可能导致资源被轻易耗尽。生产环境里常见的取值在 128 到 1024 之间,具体需要压测确认。
3.4 accept 之后交给谁处理:单进程串行会卡死
上面的示例代码是单进程串行处理:一次只服务一个客户端,发送完数据关闭连接,才回到accept()等下一个。
如果某次会话需要长时间 recv 等数据,比如一个客户端连上来后 30 秒不发消息,服务端在 recv 上阻塞,其他所有客户端都会排队等待。这在生产环境里是不可接受的。
最朴素的多连接方案是 fork:
pid_t pid = fork(); if (pid == 0) { close(server_fd); // 子进程使用 client_fd 与客户端通信 // 通信结束后 close(client_fd); exit(0); } close(client_fd);这里有两个非常容易遗漏的 close:子进程要关闭继承来的 server_fd,因为它不需要继续接受新连接;父进程要关闭 client_fd,因为它不参与这个会话的数据收发。fd 是引用计数的,每个进程都持有自己的描述符表,只有所有相关引用都关闭后,内核才会真正释放这个文件对象。不 close 的后果是连接关闭后 fd 泄漏,跑一段时间进程描述符耗尽,服务彻底假死。
3.5 ss 命令:确认监听状态的利器
服务端启动后,用ss -lntp | grep 9000查看监听状态:
ss -lntp | grep 9000输出里的 Local Address 如果显示0.0.0.0:9000,说明监听成功;State 为 LISTEN;Process 列会显示进程名和 PID。排查“客户端连不上服务端”时,第一步永远不是改代码,而是先确认服务端有没有真的在监听。这个习惯能省下大量时间。
4. TCP 客户端与数据收发:connect、send、recv 的真实行为
4.1 最小客户端代码
服务端有了,客户端代码同样短:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define SERVER_IP "127.0.0.1" #define SERVER_PORT 9000 int main() { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { perror("socket"); exit(1); } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, &addr.sin_addr); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); exit(1); } char buf[128] = {0}; ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0); if (n > 0) { buf[n] = '\0'; printf("recv: %s", buf); } close(fd); return 0; }注意inet_pton把字符串形式的 IP 转换成二进制的in_addr结构,它比老旧的inet_addr更严谨,支持 IPv6,是推荐用法。
启动服务端后,开另一个终端运行客户端:
gcc -Wall -o client client.c ./client程序会输出服务端发来的hello from server,然后正常退出。
4.2 connect 是一个“有情绪”的系统调用
默认情况下connect()是阻塞的,也就是说,它会一直等到 TCP 三次握手完成才返回。如果目标 IP 不可达,客户端可能卡在 connect 上好几十秒甚至更久,直到内核超时。
最常见的错误是Connection refused,意思是 ICMP 报文明确告知目标主机上这个端口没有服务在监听。排障顺序先确认服务端是否启动、监听的 IP 和端口对不对、防火墙有没有放行。
非阻塞 connect 是另一个话题:把 fd 设为非阻塞后,connect()会立即返回,但连接可能还没有建立。如果返回值是 -1 且 errno 是EINPROGRESS,说明连接正在后台进行,你需要用select()或poll()等待这个 fd 变成可写,再通过getsockopt查SO_ERROR判断最终成功与否。这个技巧在需要控制连接超时的客户端里非常实用。
4.3 send/recv:真正读写的是内核缓冲区
send()的返回值是成功写入本机发送缓冲区的字节数,不是对端recv()收到的字节数。这是一个经常被误解的点。数据从发送缓冲区到对端接收缓冲区,中间经过网卡、协议栈、重传机制,这些都是内核异步完成的。
recv()返回的是本次从接收缓冲取出的字节数。返回 0 有一个非常明确的含义:对端已经关闭了连接,你再怎么读也读不到新数据。处理这个场景时,正确姿势是调用close()释放本地 socket,而不是继续循环 recv。
还有个实际现象值得留意:TCP 是字节流协议,send()1000 字节,对端可能一次recv()读回 1000,也可能分两次读回 600 和 400。这和发送端、接收端的缓冲区大小、网络拥塞状态都有关系。所以不能假设一次 send 对应一次 recv,这个边界问题直接引出了第 6 章的“粘包与半包”。
4.4 shutdown 和 close:告别方式完全不同
close()会减少 socket 的引用计数,只有引用计数降到 0,内核才会发起 FIN 关闭连接。如果同一个 socket 被 fork 到父子进程,两边各自持有引用,只有两边都 close 才会真正断开。
shutdown(fd, SHUT_WR)则是立即切断发送方向的数据流,发送 FIN 给对端,但保留接收方向继续读数据。这个能力在处理“客户端发完请求但还想等服务端响应”时非常有用,是优雅关闭协议的基础。
实际开发中,接收方如何知道“对端已经发完数据”?最自然的方式就是recv()返回 0,因为对端调用close()或shutdown(SHUT_WR)后,FIN 使接收端半关闭。这个语义构成了很多应用层协议的设计前提。
5. UDP 编程:无连接带来的自由和坑
5.1 UDP 最小服务端与客户端
UDP 代码比 TCP 轻盈不少,因为它没有 listen、accept、握手这些阶段。服务端 bind 后直接 recvfrom,客户端 socket 后直接 sendto。
服务端:
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 9001 int main() { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { perror("socket"); exit(1); } 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(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } char buf[1024]; while (1) { struct sockaddr_in peer; socklen_t peer_len = sizeof(peer); ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&peer, &peer_len); if (n < 0) { perror("recvfrom"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)); printf("recv %zd bytes from %s:%d\n", n, ip, ntohs(peer.sin_port)); sendto(fd, buf, n, 0, (struct sockaddr *)&peer, peer_len); } close(fd); return 0; }客户端:
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define SERVER_IP "127.0.0.1" #define SERVER_PORT 9001 int main() { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { perror("socket"); exit(1); } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(SERVER_PORT); inet_pton(AF_INET, SERVER_IP, &addr.sin_addr); const char *msg = "hello udp"; sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)&addr, sizeof(addr)); char buf[1024]; struct sockaddr_in peer; socklen_t peer_len = sizeof(peer); ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr *)&peer, &peer_len); if (n > 0) { buf[n] = '\0'; printf("recv: %s\n", buf); } close(fd); return 0; }注意两点:第一,每次收包都要从 recvfrom 的peer参数里取出发送端地址,因为 UDP 没有连接状态,内核不会替你记住上一个包的来源。第二,recvfrom 一次调用只会从队列里取一个完整报文,一个 UDP 报文最大受 MTU 限制,超过 MTU 后在 IP 层可能出现分片,应用层要尽量避免发超大报文。
5.2 UDP 的“断连”:谁会告诉你对端不在了
UDP 是面向无连接的,说的极端一点,发完包就松开手,对端在不在、收不收,你全都不管。
当对端主机拒绝连接时,系统可能在 ICMP 包层面给出“端口不可达”信息。如果这个 UDP socket 曾经connect()过固定对端,后续recv()或send()可能收到ECONNREFUSED;但如果只是普通sendto(),这个错误码不一定会及时上报。
更常见的情况是:UDP 对端进程崩溃或机器断网,本端毫不知情,消息继续发送,无人接收。所以在需要感知对端状态的 UDP 应用里,必须自己设计心跳,定期发探测包,超过时间没收到回应就判定对端不可用。
5.3 TCP 还是 UDP:这不是速度之争,是容忍度问题
用一张表说清楚核心差异:
| 维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,有三次握手 | 无连接,直接发数据 |
| 可靠性 | 可靠,重传保证 | 不可靠,丢了就丢了 |
| 顺序 | 保证到达顺序 | 不保证顺序 |
| 消息边界 | 无边界,字节流 | 有边界,一个包一条消息 |
| 开销 | 维护状态,头部开销大 | 头部小,无状态 |
| 适用场景 | 文件传输、RPC、数据库 | 音视频、游戏同步、DNS |
很多人在“TCP 比 UDP 快”这个问题上有执念。实际体验中,UDP 的低延迟来自无需握手和重传,但它在弱网环境下丢包会让体验变得非常不稳定。如果你做的是转账、数据库同步这类不容丢数据的业务,哪怕 UDP 省了那点握手时间,应用层要自己补的可靠性代码也会把省下的复杂度加倍奉还。
5.4 给 UDP 补可靠性:应用层要做什么
如果业务确实需要 UDP 的低延迟,又希望获得类似 TCP 的可靠性,可以在应用层实现以下机制:
- 每个报文带自增序号,接收方据此检测丢包和乱序
- 发送方启动重传定时器,未收到 ACK 就重发
- 接收方定期回 ACK,或者采用 NACK 方式只反馈“缺哪些序号”
- 引入拥塞控制,避免大量丢包后无节制重传把链路打满
这套逻辑听起来就是简化版 TCP。现实中很多高性能方案走的是 QUIC 这种基于 UDP 的可靠协议,把握手和重传放到用户态,内核里只做 UDP 收发。如果项目有时间钻研,这条路可以走;否则老老实实 TCP 更稳妥。
6. 高频报错排查与长连接设计:这些年踩过的 socket 坑
6.1 bind 报 Address already in use:TIME_WAIT 与 SO_REUSEADDR 的恩怨
文章开头提到的bind: only one usage of each socket address,是 socket 初学者的第一个拦路虎。
当 TCP 连接关闭时,主动关闭方会进入 TIME_WAIT 状态,等待 2 倍 MSL 时间后才彻底释放。这样设计是为了防止“旧连接的延迟报文”被新连接错误接收。socket 底层地址和端口在 TIME_WAIT 期间看起来仍被占用,所以立即重启服务去 bind 同一个端口就会失败。
查看哪些连接还停留在 TIME_WAIT:
ss -tan state time-wait如果确认是自己的服务频繁重启导致,SO_REUSEADDR是最好的解药,它允许监听 socket 绑定到仍在 TIME_WAIT 状态的端口。绝大多数 web 服务都会设置这个选项,因为这关系到服务能不能秒级重启。
如果端口被完全不相关的进程占用,先用lsof -i :9000查看占用进程,确认没有业务在上面跑之后再做处理。切忌无脑 kill,先搞清楚这个端口为什么被占用。
另一个容易误用的选项是SO_REUSEPORT,它允许多个 socket 绑定同一个端口,用于多进程负载均衡。它和SO_REUSEADDR不是一回事,使用条件也更苛刻,新手不要顺手加上,否则会导致连接被随机分发到多个进程,行为非常难排查。
6.2 Connection refused、No route to host、Operation now in progress
这三个报错是我在排障现场最常听见的,含义完全不同。
Connection refused:目标主机可达,但目标端口没有进程在监听,或者防火墙主动回了 RST。排查链路按顺序来:先确认对端服务是否启动,再看服务监听的是不是0.0.0.0,最后检查防火墙策略有没有挡住端口。
No route to host:说明三层网络层面就没打通,通常是 IP 配错、路由缺失或对端主机宕机。先ping目标 IP,不通就查网卡和路由表;通但连不上特定端口,再回到端口层排查。
Operation now in progress则出现在非阻塞 connect 场景,它不算失败,而是告诉你“连接正在建立中,稍后再看结果”。如果把它当成错误立即重试,反而会制造一堆无意义连接。配合select()等 fd 可写后再检查SO_ERROR是标准做法。
如果这些手段都查不出来,上 tcpdump 抓包看 SYN 包有没有发出去、有没有回包、回的是 RST 还是 ICMP,证据永远比猜测可靠。
6.3 粘包、半包:TCP 字节流没有边界
很多人第一次做长连接时都会遇到这样的问题:A 端连续 send 了两条消息,B 端接收到的却是一整个大包;或者 A 只 send 了一条消息,B 第一次 recv 只读到其中一半。这就是 TCP 字节流特性带来的“粘包”和“半包”。
TCP 不关心你应用层怎么划分消息,它只负责按序搬运字节。发送端两次 send 的数据可能被内核拼在一个数据段里发出去,接收端一次 recv 就会读到两条消息;反之,一条大消息可能被拆成多个 TCP 段,接收端需要多次 recv 才能拼完整。
解决思路只有一种:在应用层定义消息边界。常见方案有三种:
- 固定长度:每条消息固定 1024 字节,不足补零,读满为止。简单但浪费带宽,不适合变长消息。
- 分隔符:比如 HTTP 的
\r\n\r\n,读到分隔符才算一条消息完整。实现简单,但消息内容里不能出现分隔符,需要转义。 - 长度前缀:每条消息开头用 4 字节网络字节序表示消息体长度,接收端先读长度,再按长度读体。这是最常用、最通用的方案。
伪代码如下:
// 接收端:先读4字节长度 char len_buf[4]; recv_all(fd, len_buf, 4); uint32_t msg_len = ntohl(*(uint32_t *)len_buf); // 再读 msg_len 字节的消息体 char *body = malloc(msg_len); recv_all(fd, body, msg_len);注意这里的recv_all必须循环调用 recv,直到凑满期望的字节数,否则仍可能遇到半包。
6.4 心跳设计:TCP keepalive 别指望太多
TCP 层确实有 keepalive 机制,但默认配置非常保守,探测间隔通常以小时计算,很多系统里默认不开启或参数极长。对于一个需要快速感知对端掉线的长连接系统,只依赖内核 keepalive 不现实。
应用层心跳是常规做法:双方约定每隔 N 秒发一个心跳包,连续 M 次没收到对端的任何数据,就判定连接失效,主动断开并触发重连。N 和 M 的取值要根据业务容忍度来定,比如 N=30,M=3,也就是 90 秒内无任何数据就判定断线。
心跳包的本质是让连接在空闲期也有数据流动,从而触发 TCP 的保活、NAT 映射刷新,顺便让对端有机会发现异常。有的协议会把心跳合并到业务消息里,只要在 N 秒内收到任意包就算存活,不必单独发空包,这样可以减少无效流量。
6.5 本机通信别忽略 Unix domain socket:MySQL 报错里藏着它
互联网上有大量“ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'”的提问。这个报错里的 socket 不是网络上用的 TCP socket,而是 Unix domain socket,也就是本地 socket 文件。
Unix domain socket 不经过 IP 和端口,只通过文件系统路径标识通信端点。内核在同机进程间直接拷贝数据,不走网卡,因此它比 TCP 回环还要快,也更安全,因为外部机器根本不可能访问到这个路径。
MySQL 客户端在本机连接时会优先走/tmp/mysql.sock。报错通常是以下几种原因:mysqld 没启动、socket 文件路径不一致、目录权限不对。排查时先看文件是否存在:
ls -l /tmp/mysql.sock再确认 mysqld 进程状态,最后检查配置里 socket 路径和服务端是否一致。理解了 socket 远不止“网络编程”这四个字,这类报错就不会让你手足无措。
6.6 从阻塞到 epoll:高并发场景的下一步
当连接数从几十涨到几千、几万,多线程的“一个线程一个连接”方案会很快撞上资源瓶颈。这时候需要用 IO 多路复用:用一个线程同时监视大量 fd 的读写事件。
最早是select(),但有 fd 数量上限,默认通常是 1024,且每次调用都要把整个 fd 集合复制到内核,性能随 fd 数量线性下降。poll()去掉了数量上限,但依然是线性扫描。真正适合高并发的方案是 epoll:
int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = client_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev); // 在循环里等待事件 struct epoll_event events[1024]; int n = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i < n; i++) { // 处理 events[i].data.fd 上的可读事件 }epoll 只在有事件发生的 fd 上通知你,不用每次遍历全部连接,所以适合大量空闲连接的场景。但别迷信 epoll,如果你的服务只有几十个连接,用select()或线程池维护起来更简单,代码也更易读。架构选型永远是在复杂度、性能和可维护性之间做取舍,没有银弹。
—— 以上这些,是我把 socket 从“会用”到“能排障”过程中反复验证过的内容。带新人时我习惯让他们按三步做实验:第一步用 nc 验证端口通不通,第二步写单连接服务端和客户端,第三步改成多连接并自己加上粘包处理和心跳,过程中随时用 tcpdump 对照抓包。三步走完,再回头看网上那些 socket 面试题,基本都能理解背后的原理。你也试试这个路径,遇到报错别慌,抓包看证据,一步一步把问题缩小,socket 没那么难。