1. 先把 socket 的本质说透:它就是个文件描述符
第一次接触 socket 那会儿,我脑子里全是"网络""协议栈""三次握手"这类大词,代码抄下来跑通了,但完全不知道自己在干什么。真正让我开窍的是一句话:在 Linux 里,socket 的返回值就是一个整数,和 open() 打开文件拿到的那个 fd 地位完全一样。这个认知一旦建立,后面一大堆看起来玄学的东西就都顺了。
1.1 一切皆文件不是一句口号,read/write 真的能直接怼上去
你可以在一个已经建立连接的 socket fd 上直接调read()和write(),不需要什么socket_read之类的专用函数。原因很朴素:内核给文件描述符挂的那张操作函数表里,socket 对应的实现里同样填了read_iter和write_iter。你在用户态调read(fd, buf, n),内核顺着 fd 找到对应的struct file,再找到struct socket,最终落到 TCP 接收队列上。
这意味着很多通用技巧可以直接迁移过来。比如select/poll/epoll能同时监听普通文件和 socket,比如dup()能复制一个 socket fd,比如close()和文件一样会触发引用计数递减。我在排查一个句柄泄漏问题时,就是靠ls -l /proc/<pid>/fd看到同一个 socket 被 fork 出来的子进程继承了多份,父进程 close 之后连接居然还在,折腾了半天才想明白引用计数的逻辑。
不过"一切皆文件"也有失灵的地方。lseek()对 socket 是无效的,返回ESPIPE,因为 TCP 是流,没有"偏移量"这个概念。你不能像读文件那样随机跳到第 100 字节再读,只能从当前读到的位置继续往后取。这一点想通了,后面理解粘包问题就毫无障碍了。
1.2 socket() 的三个参数,每一笔都在决定后面能干什么
socket(int domain, int type, int protocol)看着简单,但每个参数都在给你的连接"定终身",选错了要么直接报错,要么跑出诡异行为。
domain决定地址家族。做本机进程间通信,AF_UNIX比AF_INET快得多,因为它绕过了整个网络层协议栈,数据直接从内核缓冲区搬到对方缓冲区,实测在本地高频小包场景下能省掉三成以上的 CPU。做跨机通信就老老实实AF_INET,要 IPv6 就AF_INET6,现在更推荐直接用AF_INET6加双栈选项,一套代码兼顾两种地址。
type里最常见的是SOCK_STREAM和SOCK_DGRAM。前者对应 TCP 的可靠字节流,后者对应 UDP 的数据报。这里有个容易被忽略的位:SOCK_NONBLOCK和SOCK_CLOEXEC可以按位或进type里。SOCK_CLOEXEC尤其重要,它能保证exec系列函数执行新程序时这个 fd 自动关闭,避免泄露给子进程。我见过一个服务在 fork 之后 exec 外部命令,结果外部命令一直跑不完,就是因为管道和 socket 的写端被意外继承,对端永远等不到 EOF。用SOCK_CLOEXEC一次性解决,比事后fcntl去设FD_CLOEXEC可靠得多,因为那中间存在竞态窗口。
protocol一般填 0,让内核根据前两个参数自动推断。只有在SOCK_RAW这种场景下才需要显式指定,普通业务代码里填 0 就对了。
1.3 内核在你按下 socket() 的那一刻做了什么
理解这一层,排查问题时会快很多。调用socket()时,内核会创建一个struct socket,再挂一个struct sock上去(TCP 的话就是tcp_sock),然后把这个结构注册进文件系统,分配一个没被占用的最小 fd 返回给你。注意,这一步完全没有发生任何网络交互,对面根本不知道你的存在。
真正的连接建立发生在connect()或accept()。服务端listen()之后,内核会维护两个队列:一个是已完成三次握手的连接队列(accept队列),一个是半连接队列(SYN 队列)。accept()做的事情就是从已完成队列里摘一个出来,新建一个 fd 返回。
这解释了一个经典困惑:为什么服务端accept()不及时,客户端connect()也可能成功?因为三次握手是内核替你完成的,只要队列没满,客户端握手完成后就认为连上了,压根不管你的应用层有没有调accept。等accept队列被挤满,新的 SYN 就会被丢掉,客户端开始超时重传,表现出来就是"连不上但服务进程还活着"。
BACKLOG参数也常被误解。它设的不是"最大连接数",而是已完成队列的长度上限。Linux 上实际生效值取min(backlog, net.core.somaxconn),somaxconn默认值在老发行版上是 128,现在多数是 4096。高并发服务上线前一定要检查这个值,不然listen(fd, 1024)写得很漂亮,实际生效的只有 128,压测一上来就丢连接。
2. 地址结构体和字节序:翻车率最高的两个地方
代码写不对报个错还好,最怕的是不报错但行为诡异。地址结构和字节序就属于这一类,填错了可能连到一台莫名其妙的机器上,或者 bind 到一个你完全没想到的地址。
2.1 sockaddr_in 的每个字段,都别凭感觉填
struct sockaddr_in一共四个字段,看着少,但每个都有讲究。
sin_family必须是AF_INET,这个大概没人会填错。sin_port必须经过htons()转换,忘了转换的典型症状是:你 bind 到 9001,netstat里看到的却是 36871(9001 的字节互换结果)。这个错误非常隐蔽,因为 bind 不会失败,只是监听在了别的端口上。
sin_addr是struct in_addr,里面就一个in_addr_t(本质是 uint32)。这里最容易搞混的是 "地址的值"和"地址的字节序"是两件事。你想表示 192.168.1.10,写成inet_addr("192.168.1.10")得到的是网络字节序的整数;你要是手写0x0A01A8C0这种十六进制,那是把大端表示直接当小端整数用,很容易错。
sin_zero[8]这个填充字段现在很少有人提了,它的作用是让sockaddr_in和sockaddr大小一致,方便指针强转。关键点在于:用之前必须memset清零。结构体里有未初始化字节时,某些平台上的比较或者序列化会出问题,而且这个 bug 极难复现。我现在的习惯是把memset当成肌肉记忆,写多少遍都不嫌多。
| 字段 | 类型 | 填法 | 常见错误 |
|---|---|---|---|
| sin_family | sa_family_t | AF_INET | 填成 PF_INET(虽然值一样,但语义不对) |
| sin_port | in_port_t | htons(端口) | 忘记转字节序,端口错乱 |
| sin_addr | struct in_addr | inet_pton 或 INADDR_ANY | 手写十六进制导致地址反了 |
| sin_zero | char[8] | memset 清零 | 不清零导致比较异常 |
2.2 字节序这件事,不转就是等着出玄学
网络字节序是大端,x86 是小端。端口号是 16 位,IP 地址是 32 位,所以才有htons/htonl和ntohs/ntohl四个函数。名字里的 h 是 host,n 是 network,中间的 s 是 short,l 是 long。
很多人在本地测试时一切正常,部署到另一台机器就炸,往往就是字节序漏了——因为两端如果都是小端机器,且都忘了转换,反而能对上。这类 bug 一旦进了生产环境,排查成本极高。
更稳妥的写法是完全不要碰字节序:地址用inet_pton()从字符串转,端口用htons()转,读回来的时候用inet_ntop()和ntohs()转。中间不要出现任何手写的数字常量。我现在的代码里基本看不到htonl直接操作 IP 的情况,全是字符串进、字符串出,可读性和正确性都好很多。
另外提一句inet_ntoa,它返回的是指向静态缓冲区的指针,不可重入。在信号处理函数或者多线程里用它会出大问题。inet_ntop需要你自己提供缓冲区,虽然麻烦一点,但安全。这个坑我在一个多线程日志模块里踩过,日志里打印的客户端 IP 偶尔会串成别人的地址,查了两天才定位到。
2.3 SO_REUSEADDR 到底解决了什么问题
bind报Address already in use,几乎是每个写网络程序的人都遇到过的第一个错误。原因通常是上一次的服务进程退出后,连接进入TIME_WAIT状态,端口还被占着。
TIME_WAIT存在的意义是这样的:主动关闭连接的一方,在发完最后一个 ACK 之后要等 2MSL(一般是 60 秒)才能释放端口。为什么要等?一是为了保证最后一个 ACK 能到达对面(万一丢了,对面会重传 FIN,你还能重发 ACK),二是为了让这条连接上残留的旧数据包在网络中自然消亡,不至于串到新连接里。
服务端重启时,如果它是主动关闭方,端口就会被占着。这时候加上:
int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));就能在TIME_WAIT期间重新 bind。注意这里说的是"重新 bind",不是"允许两个进程同时监听同一个端口",那个是SO_REUSEPORT,是另一回事,多进程负载均衡场景才用。
还有个高频误解:SO_REUSEADDR能减少TIME_WAIT的数量。并不能。它只是让你能在TIME_WAIT存在的情况下成功 bind。真要减少TIME_WAIT,得调整内核参数或者让对端先关。我个人的做法是,服务端永远开启SO_REUSEADDR,成本几乎为零,收益是重启时不至于卡一分钟。
3. 把一个能跑的服务端和客户端写出来
原理说得再多,不如亲手跑一遍。下面这套代码是我平时用来做快速验证的最小骨架,剪掉了业务逻辑,只保留网络部分,方便你直接改。
3.1 服务端的五步曲:socket、bind、listen、accept、收发
服务端的流程是有严格顺序的,每一步失败都意味着后面不用走了。
socket()拿到监听 fd,bind()把地址和端口绑上去,listen()把 fd 从"主动"变成"被动",内核开始替你接收 SYN,accept()从已完成队列里取连接,拿到一个新的 fd 用于和这个客户端通信。
这里有个很重要的点:监听 fd 和连接 fd 是两个不同的东西。监听 fd 只负责接收新连接,不会参与任何数据收发。连接 fd 才是每个客户端独有的那条通道。初学者常犯的一个错误是在循环里反复close(listen_fd),或者拿listen_fd去read,前者导致服务只能接一个连接,后者会报ENOTCONN或者读到乱七八糟的东西。
accept()失败要分情况处理。EINTR是信号打断,直接continue重试就行;EMFILE/ENFILE是 fd 用完了,说明有泄漏或者并发太高,这时候不能简单 continue,否则会陷入死循环疯狂重试,CPU 直接打满。比较稳的做法是记一条日志,sleep一小会儿再继续,给系统一个喘息的机会。这个细节在压测时特别关键,我见过一个服务就是因为没处理EMFILE,一晚上把机器拖死了。
3.2 完整可跑的服务端代码
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <arpa/inet.h> #include <sys/socket.h> #include <netinet/in.h> #define PORT 9001 #define BACKLOG 128 #define BUF_SIZE 4096 int main(void) { int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (listen_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } int opt = 1; if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { perror("setsockopt"); close(listen_fd); exit(EXIT_FAILURE); } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } if (listen(listen_fd, BACKLOG) < 0) { perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } printf("listening on port %d\n", PORT); for (;;) { struct sockaddr_in cli; socklen_t cli_len = sizeof(cli); int conn_fd = accept(listen_fd, (struct sockaddr *)&cli, &cli_len); if (conn_fd < 0) { if (errno == EINTR) { continue; } perror("accept"); usleep(100000); continue; } char ip[INET_ADDRSTRLEN] = {0}; inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip)); printf("client %s:%d connected\n", ip, ntohs(cli.sin_port)); char buf[BUF_SIZE]; ssize_t n; while ((n = read(conn_fd, buf, sizeof(buf))) > 0) { ssize_t off = 0; while (off < n) { ssize_t w = write(conn_fd, buf + off, (size_t)(n - off)); if (w < 0) { if (errno == EINTR) { continue; } fprintf(stderr, "write failed: %s\n", strerror(errno)); break; } off += w; } } if (n < 0) { fprintf(stderr, "read failed: %s\n", strerror(errno)); } printf("client %s:%d closed\n", ip, ntohs(cli.sin_port)); close(conn_fd); } close(listen_fd); return 0; }编译命令很朴素:
gcc -O2 -Wall -Wextra -g server.c -o server-Wall -Wextra这两个选项一定要加,很多类型转换和未使用变量的警告能提前暴露问题。尤其是send/recv的第三个参数是size_t,传有符号整数会有隐式转换警告,这类警告背后往往是真实的 bug。
3.3 客户端代码与调用顺序
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <arpa/inet.h> #include <sys/socket.h> #include <netinet/in.h> #define PORT 9001 int main(int argc, char *argv[]) { const char *ip = (argc > 1) ? argv[1] : "127.0.0.1"; int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); if (fd < 0) { perror("socket"); exit(EXIT_FAILURE); } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(PORT); if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) { fprintf(stderr, "invalid ip: %s\n", ip); close(fd); exit(EXIT_FAILURE); } if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); close(fd); exit(EXIT_FAILURE); } printf("connected to %s:%d\n", ip, PORT); char line[1024]; while (fgets(line, sizeof(line), stdin) != NULL) { size_t len = strlen(line); size_t off = 0; while (off < len) { ssize_t w = write(fd, line + off, len - off); if (w < 0) { if (errno == EINTR) { continue; } perror("write"); close(fd); exit(EXIT_FAILURE); } off += (size_t)w; } char buf[1024]; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n <= 0) { printf("server closed\n"); break; } buf[n] = '\0'; printf("echo: %s", buf); } close(fd); return 0; }跑起来是两个终端的事,先起服务端,再起客户端。输入几行文字,能看到回显就说明通路没问题。这一步看起来简单,但它验证了地址填充、字节序、连接建立、读写循环四条链路,任何一环有问题都会立刻暴露。
3.4 短读短写:为什么你的代码在本地没事,上线就丢数据
上面代码里我给write加了循环,read那边只读了一次。严格来说两边都该循环,但客户端这个例子是交互式的,每次输入一行,服务端一次性回显,短读概率低。生产代码里绝对不能这么写。
read返回的字节数可能小于你要求的数量,这太正常了。TCP 是字节流,内核缓冲区里有多少就给你多少,可能你请求 4096 字节,实际上只到了 100 字节。这不代表出错,只代表对端还没发完。
write也一样会短写。当你往一个非阻塞 socket 或者缓冲区快满的 socket 写大量数据时,内核只接受一部分,剩下的需要你自己接着写。
判断 EOF 的正确姿势是read返回 0,而不是-1。返回 -1 是出错,要看errno:EINTR重试,EAGAIN/EWOULDBLOCK是非阻塞下暂时没数据,其他基本就是连接真出问题了。
SIGPIPE这个坑必须单独说。当你往一个已经被对端关闭的连接上写数据,内核默认会给你发SIGPIPE信号,进程直接被杀掉,连清理都来不及。解决方案有两种:全局忽略这个信号,代码里写signal(SIGPIPE, SIG_IGN);;或者用send(fd, buf, len, MSG_NOSIGNAL)。我倾向于两个都做,双保险。这个坑在长连接服务里迟早会踩,早预防早省事。
4. TCP 是字节流,粘包不是"包"的问题
几乎每个刚写完 echo 服务的人,下一步都会尝试发结构体,然后发现接收端解析出来的数据是乱的,或者一次读到两条消息黏在一起。这就是所谓的"粘包",但这个名字其实有误导性——TCP 根本没有包的概念。
4.1 粘包和拆包的真实成因
TCP 是面向字节流的协议,它在传输层做的是把字节切片塞进报文段,接收端按序重组,然后往接收缓冲区里堆。至于你的应用层是怎么划分消息边界的,它完全不管。
所以会出现两种情况:你连着发了两个 100 字节的消息,接收端一次read读了 200 字节,这叫粘。你发了一个 1000 字节的消息,接收端read只拿到 600 字节,这叫拆。
诱发因素主要有三个:发送端的 Nagle 算法会把小包攒起来一起发;接收端的缓冲区大小决定了单次能读多少;网络 MTU 限制会让大包被切分。这三个都不受你的应用层控制,所以任何"一次 read 就是一条完整消息"的假设都是错的。
我踩过最典型的一次坑是:协议里用\n分隔消息,本地测试一次发一条,完美运行;压测时客户端批量发送,接收端一次读到好几行,代码只取了第一行剩下的丢弃,导致大量消息丢失。修复方式很简单,收到数据先追加到一个缓冲区里,然后反复查找分隔符,找到一个就切一条出来处理,剩下的继续留在缓冲区等后续数据。
4.2 三种常用的消息定界方案
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 分隔符 | 用特定字符(如 \n)切分 | 简单、可读 | 分隔符不能出现在正文里,需要转义 | 文本协议、命令行交互 |
| 长度前缀 | 头部固定 4 字节表示长度 | 高效、无转义问题 | 需要处理头部本身的拆包 | 二进制协议、内部服务 |
| 固定长度 | 每条消息长度恒定 | 实现最简单 | 灵活性极差 | 特定硬件协议 |
长度前缀是我最常用的方案。实现上有几个细节:头部的 4 字节本身也可能被拆成两次到达,所以要先攒够 4 字节解析出长度,再攒够这个长度才能处理一条消息。另外长度字段一定要做上限校验,不然一个恶意客户端发一个0x7FFFFFFF,你的代码就去申请 2G 内存,直接被打挂。我一般会设一个 1MB 左右的上限,超过就断开连接。
4.3 心跳和空闲检测,别等 TCP 自己告诉你
很多人以为连接断了read会立刻返回 0。实际情况是,如果对面机器断电、网线拔掉、中间路由器挂了,你的read会一直阻塞着,可能十几分钟都没反应。TCP 自己有keepalive,但默认是 2 小时才开始探测,对业务来说太晚了。
常规做法是在应用层做心跳:客户端每隔固定时间发一个轻量的心跳包,服务端记录每个连接最后一次活跃的时间,后台起一个定时器扫描,超过阈值就主动关掉。阈值一般设成心跳间隔的 2 到 3 倍,既能容忍偶发的网络抖动,又不会让死连接占着资源太久。
SO_KEEPALIVE也可以顺手打开,虽然它慢,但作为兜底多一层保障。真要调的话,TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT这三个选项能把探测间隔压到分钟级,实测在跨机房场景下挺有用。
5. 阻塞、非阻塞和并发模型,选错了就是自找麻烦
上面那个 echo 服务有个致命问题:accept之后直接在循环里read,一个客户端连上来不说话,整个服务就卡死了,别的客户端根本连不进来。这就是阻塞 IO 的天然缺陷。
5.1 阻塞带来的问题不止一个
默认创建的 socket 是阻塞的。accept会阻塞到有新连接,read会阻塞到有数据,connect会阻塞到握手完成,write会阻塞到数据进了发送缓冲区。单连接场景下这很方便,但一旦要处理多个连接,就必须换思路。
最粗暴的方案是每个连接开一个线程。这个方案在小规模下能跑,几十上百个连接没问题,但连接数上千之后,线程切换的开销和每个线程默认 8MB 的栈空间就成了负担。而且线程模型下的资源竞争、连接生命周期管理都会变得很麻烦。
更常见的方案是把 fd 设成非阻塞,用一个事件循环统一管理。设置方式有两种:socket()时直接或上SOCK_NONBLOCK,或者事后用fcntl(fd, F_SETFL, flags | O_NONBLOCK)。前者是原子的,后者有竞态窗口,推荐前者。
非阻塞之后,所有调用都要处理EAGAIN。read返回 -1 且errno == EAGAIN,说明暂时没数据,不是错误,回去等下次可读事件;accept返回 -1 且EAGAIN,说明队列空了,同样回去等。
5.2 select、poll、epoll 的实际取舍
这三个都是 IO 多路复用,用途一样,但机制和适用场景差很多。
select是最老的,用位图表示 fd 集合,有FD_SETSIZE(通常 1024)的硬限制,而且每次调用都要把整个集合从用户态拷到内核态,返回后还要遍历一遍才知道谁就绪了。fd 数量一多,性能下降非常明显。
poll用数组替代了位图,解除了数量限制,但每次仍要全量拷贝和遍历。几百个连接时够用,几千个就开始吃力。
epoll是 Linux 特有的,通过epoll_ctl把 fd 注册进内核的一个红黑树,事件就绪时由内核通过回调把它们挂到就绪链表上,epoll_wait直接取链表就行,不需要遍历全部 fd。连接数越多,优势越明显。
| 对比项 | select | poll | epoll |
|---|---|---|---|
| fd 数量限制 | 有(1024 左右) | 无 | 无 |
| 每次调用是否全量拷贝 | 是 | 是 | 否(注册时拷贝一次) |
| 就绪检测方式 | 遍历 | 遍历 | 回调 + 就绪链表 |
| 时间复杂度 | O(n) | O(n) | O(1)(活跃数) |
| 适用连接数 | 少量 | 中等 | 大量 |
epoll有两种触发模式:水平触发(LT)和边沿触发(ET)。LT 是默认的,只要缓冲区里有数据就会一直通知你,写起来简单,不容易漏事件。ET 只在状态变化时通知一次,必须一次把数据读干净,直到返回EAGAIN为止,否则剩下的数据可能永远不再触发通知。ET 性能略好,但代码要求高,我一般在新项目里先上 LT,压测发现瓶颈了再考虑换。
还有个细节:epoll的 fd 是可以被dup的,但只有原 fd 在监控里,复制出来的不在。这个在热更新或者多线程共享监听 fd 的场景下要注意。
5.3 端口耗尽和连接数上限,别等报警了才查
高并发客户端场景下,Cannot assign requested address这个错误很常见,本质是本机临时端口用完了。Linux 默认的临时端口范围是 32768 到 60999,差不多 2.8 万个。如果服务需要对外建大量短连接,很容易打满。
排查方式很直接:
ss -s cat /proc/sys/net/ipv4/ip_local_port_range第一个命令能看到当前的总连接数和各状态分布,第二个看端口范围。如果确实是端口耗尽,可以扩大范围,同时让客户端尽量复用连接,别每次请求都新建。
服务端的连接数上限则受限于三处:进程的RLIMIT_NOFILE、系统的fs.file-max、以及内存。ulimit -n看进程限制,改的话在 systemd 服务文件里加LimitNOFILE=65535比改/etc/security/limits.conf更可靠,因为后者对 systemd 启动的服务经常不生效,这个坑我踩过不止一次。
6. 常见错误与排查实录
写网络程序,一半时间在写代码,另一半时间在跟errno打交道。下面这些是我这些年反复遇到的,整理成速查表,遇到问题先对号入座。
6.1 errno 速查表
| 错误码 | 出现位置 | 常见原因 | 处理方式 |
|---|---|---|---|
| EADDRINUSE | bind | 端口被占用或处于 TIME_WAIT | 开 SO_REUSEADDR,或换端口 |
| EADDRNOTAVAIL | bind | 绑定了本机不存在的 IP | 检查网卡地址,或改用 INADDR_ANY |
| EACCES | bind | 端口小于 1024 且非特权用户 | 换高位端口,或用 capabilities 授权 |
| ECONNREFUSED | connect | 目标端口没服务监听 | 检查服务是否启动,防火墙是否放行 |
| ETIMEDOUT | connect | 网络不通或对端无响应 | 检查路由、安全组、抓包看 SYN 有无回包 |
| EMFILE | accept/socket | 进程 fd 用尽 | 查 fd 泄漏,提高 ulimit |
| EPIPE | write | 对端已关闭 | 忽略 SIGPIPE,检查连接状态 |
| EAGAIN | read/write | 非阻塞下暂时不可读写 | 不是错误,等下次事件 |
| EINTR | 各种系统调用 | 被信号打断 | 直接重试 |
6.2 六个真实场景的排查路径
场景一:bind 一直报 Address already in use,但ss -tlnp看不到占用。大概率是TIME_WAIT或者被别的用户空间的进程占了你看不到。用ss -tan | grep 端口看所有状态,注意TIME_WAIT的本地地址那一列。开SO_REUSEADDR基本能解决。
场景二:客户端能连上,但服务端 accept 不到。先看ss -tln的 Send-Q 那一列,如果数字接近 backlog,说明 accept 队列满了。要么是 accept 逻辑有阻塞,要么是 backlog 设太小。
场景三:大批连接同时断开。检查是不是服务端accept之后没有正确close,导致 fd 泄漏,达到上限后所有操作失败。用lsof -p <pid> | wc -l能快速确认。
场景四:接收端读到的数据长度不对。先用tcpdump抓包确认数据是否完整到达,然后检查应用层有没有正确处理拆包。抓包命令:
tcpdump -i any -nn -s 0 -A 'tcp port 9001'-A参数能直接看 payload,排查协议问题特别直观。
场景五:连接偶尔超时,日志里什么都没有。看netstat -s | grep -i retrans的重传统计,如果重传率明显偏高,可能是网络质量或者接收窗口太小。再看dmesg有没有 conntrack 表满的告警,这个在容器环境下很常见。
场景六:程序运行一段时间后 CPU 100%。优先怀疑是循环里没处理EAGAIN或者EMFILE,导致不停重试空转。用strace -p <pid> -c看系统调用分布,如果某个调用次数异常高,基本就锁定了。
6.3 几个省钱省时间的实操心得
关于调试工具的选择。遇到连接问题,先看状态再看代码。ss -tanp一条命令能看到所有连接的状态、队列、进程,比读代码快十倍。strace -f -e trace=network能跟踪所有网络系统调用,参数和返回值一目了然,非常适合确认"我到底传了什么参数进去"。
关于连接关闭的顺序。服务端主动关闭连接时,如果还有数据没发完就close,对端可能收不到完整数据。稳妥做法是先shutdown(fd, SHUT_WR)发 FIN,等对端也关了再close。shutdown和close的区别在于,前者只关一个方向,后者释放整个 fd。
关于零拷贝。如果是纯文件转发的大数据量场景,sendfile能省掉两次数据拷贝,性能提升很明显。但它对协议有要求,不能加密、不能改内容,适合静态文件服务这类场景。普通业务报文没必要上,收益有限。
关于缓冲区大小。SO_RCVBUF和SO_SNDBUF的默认值本身是内核自动调节的,手动设死反而可能降低吞吐。真要调,先压测看带宽延迟积,再决定值。盲目设成一个很大的数,可能造成内存浪费和延迟上升。
关于内存对齐。如果要在 socket 上传结构体,务必注意两端的内存布局。不同编译器、不同架构下的对齐规则可能不一致,最稳的做法是定义明确的序列化格式,一个字段一个字段地按字节写,别直接memcpy整个结构体。这个坑在跨平台项目里几乎必踩。
我个人在实际项目里的体会是,socket 编程真正的门槛不在 API 有多少,而在对失败路径的敏感度。每一条read都可能短读,每一次write都可能被打断,每一个accept都可能因为 fd 耗尽而失败。把这些当成常态而不是异常,代码的健壮性会上一个大台阶。前面提到的那套最小骨架,我建议你至少亲手改三遍:一遍加长度前缀协议,一遍换成非阻塞加 epoll,一遍加上心跳和空闲回收。三遍走完,后面再看任何网络框架的源码,都是熟悉的味道。