2.17 套接字高级与网络编程
TCP 是字节流,那「一条消息」从哪里来?为什么读到一个完整的包不代表读到一条完整的消息?长度前缀/分隔符/定长三种分帧方式各适合什么?超时、心跳、优雅关闭、半关闭在代码里怎么写?怎样在进程之间传递一个已经打开的 fd?
写出带读缓冲区、能处理粘包/拆包的长度前缀协议服务端与客户端;用 shutdown(SHUT_WR) 做半关闭并解释它和 close 的区别;用 SO_RCVTIMEO 与 poll 实现超时;用 sendmsg/recvmsg + SCM_RIGHTS 传递 fd。
2.16 的服务端把 socket 当成管道用:读到什么就回什么。这在 echo 测试里没问题,但真实协议不是这样。HTTP 的请求头以空行结束,gRPC 用 5 字节前缀,Redis 用 \r\n 分隔,DNS 用固定格式——每种协议都必须自己定义消息边界,因为 TCP 不提供。
这一章先解决「边界」:长度前缀协议与读缓冲区状态机。再解决「时间」:超时、心跳、TCP_KEEPALIVE。然后解决「结束」:shutdown、半关闭、TIME_WAIT、RST 与 FIN 的区别。最后进入两个高级主题:套接字选项(TCP_NODELAY、SO_RCVBUF、SO_LINGER)与辅助数据(SCM_RIGHTS 传 fd、SO_PEERCRED 取对端身份)。
一条主线:TCP 只承诺字节流,其他都要应用层自己建。消息边界、超时、心跳、关闭语义、身份认证,没有一项是内核替你做的。把这些做对,才谈得上「健壮的服务端」。
2.17.1 字节流与消息边界
read(fd, buf, n) 的返回值只说明「这次拿到了多少字节」,不说明「这是不是一条完整消息」。内核的接收缓冲区是一个字节队列,TCP 按段到达、按序交付,应用读到的可能是:
- 拆包:一条消息分两次到达,第一次
read只拿到前半截; - 粘包:两条消息挨得太近,一次
read把两条都拿回来; - 半条 + 半条:一次
read返回「第一条的尾巴 + 第二条的开头」。
三种分帧方式:
| 方式 | 编码 | 优点 | 缺点 | 典型协议 |
|---|---|---|---|---|
| 长度前缀 | [len][payload] | 解析简单、可预分配、二进制安全 | 需要知道最大长度,防 DoS | gRPC、自定义 RPC |
| 分隔符 | payload\n 或 \r\n | 可读、流式 | 内容不能含分隔符,需转义;单条长度不定 | HTTP/1 头、Redis、SMTP |
| 定长 | 固定 N 字节 | 最简单、可定长索引 | 浪费带宽,灵活性差 | 二进制协议、传感器 |
长度前缀是最通用的选择。协议格式:
+----------------+--------------------------+
| len: 4 字节 | payload: len 字节 |
| 网络字节序 | 任意二进制 |
+----------------+--------------------------+
四个必须定的规则:
- 长度是否包含头部:本文约定「只算 payload」,头部固定 4 字节;
- 字节序:网络字节序(大端),用
htonl/ntohl; - 最大长度:接收端必须在分配内存前检查,否则一个
0xFFFFFFFF就能让服务端 OOM; - 长度为 0 是否合法:本文允许(空消息),但很多协议禁止,需明确。
内核接收缓冲区(字节队列)
┌──────────────────────────────────────────────────────────────┐
│ 00 00 00 05 | 'h' 'e' 'l' 'l' 'o' | 00 00 00 03 | 'a' 'b' 'c'│
└──────────────────────────────────────────────────────────────┘
▲ 第一次 read 可能只拿到前 7 字节:头部完整,payload 缺 3 字节
│
应用读缓冲区
┌───────────────────────────────┐
│ 已收 7 字节:len=5, data="hell"│ ← 状态:等 payload 剩余 1 字节
└───────────────────────────────┘
▲ 下一次 read 补齐 "o",解析出一条消息,缓冲区前移 9 字节
│
└─ 剩余字节 "00 00 00 03 'a' 'b' 'c'" 留在缓冲区,继续解析下一条
读图:解析器必须把「已读但未消费」的字节留在缓冲区里,而不是每次 read 都当成新消息。缓冲区要能处理「一条消息跨多次 read」与「一次 read 含多条消息」两种情况。阻塞式程序可以用「先读满 4 字节头,再读满 len 字节」的简化写法;非阻塞程序(2.18)必须用真正的增量解析器。
服务器模型:从迭代到 pre-fork 与 SO_REUSEPORT
Kerrisk 第 60 章把并发服务器拆成几种模型;在写任何代码前,先选模型:
| 模型 | 结构 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 迭代 | 一个进程,accept 后处理完再 accept | 最简单、无锁 | 一个慢客户端阻塞所有人 | 低并发、教学、健康检查 |
| 每连接 fork | accept 后 fork 子进程 | 隔离好、编程简单 | 进程重、fd/内存开销大 | 中低并发、CGI |
| 每连接线程 | accept 后 pthread_create | 比进程轻、共享状态方便 | 线程栈/同步/取消复杂 | 中并发、阻塞 I/O |
| pre-fork | 启动 N 个进程各自 accept | 无 per-connection fork、稳定 | 负载不均、惊群 | 老式服务 |
| 单线程 epoll | 一个事件循环 | 高并发、无锁 | 不能阻塞、CPU 单核 | 网关/代理 |
| 多线程 epoll | 多个 event loop + SO_REUSEPORT | 多核、隔离 | 连接迁移/负载均衡 | 现代服务 |
| SO_REUSEPORT | 每个 worker 独立 bind 同端口 | 内核分流、无惊群 | 连接不迁移、需调权重 | nginx/Envoy |
最小 fork 版并发 TCP echo:
for (;;) {
int cfd = accept4(lfd, NULL, NULL, SOCK_CLOEXEC);
if (cfd < 0) { if (errno == EINTR) continue; break; }
pid_t pid = fork();
if (pid == 0) {
close(lfd); /* 子进程不持有监听 fd */
echo_loop(cfd); /* 读多少写多少,处理半关闭 */
_exit(0);
}
close(cfd); /* 父进程不持有连接 fd */
while (waitpid(-1, NULL, WNOHANG) > 0) {} /* 回收僵尸 */
}
- 父进程不关
cfd:连接 fd 泄漏,最终EMFILE;子进程不关lfd,监听 fd 永远不释放。 SIGCHLD默认忽略但僵尸仍存在:要waitpid(-1, WNOHANG)循环回收,或SA_NOCLDWAIT。accept的惊群:多进程阻塞在同一lfd上,新连接唤醒所有进程;Linux 用EPOLLEXCLUSIVE/SO_REUSEPORT缓解。- 每连接线程不设栈/上限:默认 8 MiB 栈 × 1000 连接 = 8 GiB 虚拟地址;用
pthread_attr_setstacksize和连接上限。
2.17.2 长度前缀服务端
下面的服务端用阻塞 socket + read_full/write_full 实现。它没有显式的环形缓冲区,因为「读满头部、再读满负载」的写法把缓冲区隐含在两次 read_full 里;内核的接收缓冲区负责保存多余字节。
/* framed_echo_server.c:4 字节长度前缀 + payload 的 echo 服务端 */
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define PORT "12347"
#define BACKLOG 16
#define MAX_FRAME (64 * 1024) /* 单帧上限:防止恶意长度耗尽内存 */
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
/* 读满 n 字节:返回 1 成功,0 对端在边界处关闭,-1 错误 */
static int read_full(int fd, void *buf, size_t n)
{
size_t done = 0;
char *p = buf;
while (done < n) {
ssize_t r = read(fd, p + done, n - done);
if (r == -1) {
if (errno == EINTR)
continue;
return -1;
}
if (r == 0)
return done == 0 ? 0 : -1; /* 头部/负载中途 EOF 是协议错误 */
done += (size_t)r;
}
return 1;
}
/* 写满 n 字节:返回 0 成功,-1 错误 */
static int write_full(int fd, const void *buf, size_t n)
{
size_t done = 0;
const char *p = buf;
while (done < n) {
ssize_t w = write(fd, p + done, n - done);
if (w == -1) {
if (errno == EINTR)
continue;
return -1;
}
done += (size_t)w;
}
return 0;
}
static void handle_conn(int cfd)
{
char *frame = malloc(4 + MAX_FRAME);
if (frame == NULL)
return;
for (;;) {
uint32_t netlen;
int rc = read_full(cfd, &netlen, sizeof netlen);
if (rc == 0)
break; /* 正常关闭 */
if (rc == -1) {
perror("read header");
break;
}
uint32_t len = ntohl(netlen);
if (len > MAX_FRAME) {
fprintf(stderr, "frame too large: %u\n", len);
break; /* 协议错误:直接断开 */
}
if (read_full(cfd, frame + 4, len) != 1) {
fprintf(stderr, "truncated frame\n");
break;
}
/* echo:把长度头与负载原样写回(一次 write,避免两段之间的窗口) */
uint32_t outlen = htonl(len);
memcpy(frame, &outlen, sizeof outlen);
if (write_full(cfd, frame, 4 + (size_t)len) == -1) {
perror("write frame");
break;
}
}
free(frame);
}
int main(void)
{
/* 对端关闭后继续写会触发 SIGPIPE;忽略它,让 write 返回 EPIPE */
struct sigaction ign = {0};
ign.sa_handler = SIG_IGN;
sigemptyset(&ign.sa_mask);
if (sigaction(SIGPIPE, &ign, NULL) == -1)
die("sigaction SIGPIPE");
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
struct addrinfo *res = NULL;
int rc = getaddrinfo(NULL, PORT, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return EXIT_FAILURE;
}
int lfd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
lfd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol);
if (lfd == -1)
continue;
int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1) {
close(lfd);
continue;
}
if (bind(lfd, ai->ai_addr, ai->ai_addrlen) == 0)
break;
close(lfd);
lfd = -1;
}
freeaddrinfo(res);
if (lfd == -1)
die("bind");
if (listen(lfd, BACKLOG) == -1)
die("listen");
printf("framed echo server on %s\n", PORT);
fflush(stdout);
for (;;) {
int cfd = accept(lfd, NULL, NULL);
if (cfd == -1) {
if (errno == EINTR)
continue;
perror("accept");
break;
}
handle_conn(cfd);
close(cfd);
printf("connection closed\n");
fflush(stdout);
}
close(lfd);
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g framed_echo_server.c -o framed_echo_server
./framed_echo_server
三个设计点:
MAX_FRAME检查在分配/读取之前。长度字段来自对端,不能信。len > MAX_FRAME直接断开,不读负载——继续读等于帮攻击者消耗带宽。- 头部与负载一起写回。分两次
write会让对端可能先收到头部、后收到负载(中间还夹着其他连接的数据),虽然 TCP 顺序不变,但多一次系统调用与一次潜在的 Nagle 延迟。 read_full区分「边界处 EOF」与「中途 EOF」。前者是正常关闭(返回 0),后者是截断(返回 -1)。如果把两者都当正常关闭,日志里会丢掉「对端发到一半崩了」的线索。
- 「一次
read就是一条消息」——只在消息小于 MSS 且没有并发时偶尔成立。压测、跨公网、TLS 下都会拆/粘。 - 「
recv返回 0 表示没有数据」——返回 0 是 EOF,对端关闭了写方向;「暂时没数据」是非阻塞下的EAGAIN。 - 「长度前缀可以随便定最大长度」——接收端必须在读取前检查上限。
malloc(ntohl(len))是远程 OOM 的经典入口。 - 「分隔符协议不用转义」——内容里出现分隔符就会提前截断。HTTP 头用
\r\n且禁止字段值含换行,就是这个原因。
2.17.3 客户端与「两个帧一次写」
客户端故意把两个帧拼在一次 send 里,验证服务端的解析不依赖「一次读一条」:
/* framed_client.c:一次写入两个帧,读回两个 echo */
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#define MAX_FRAME (64 * 1024)
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int read_full(int fd, void *buf, size_t n)
{
size_t done = 0;
char *p = buf;
while (done < n) {
ssize_t r = read(fd, p + done, n - done);
if (r == -1) {
if (errno == EINTR)
continue;
return -1;
}
if (r == 0)
return done == 0 ? 0 : -1;
done += (size_t)r;
}
return 1;
}
static int write_full(int fd, const void *buf, size_t n)
{
size_t done = 0;
const char *p = buf;
while (done < n) {
ssize_t w = write(fd, p + done, n - done);
if (w == -1) {
if (errno == EINTR)
continue;
return -1;
}
done += (size_t)w;
}
return 0;
}
int main(int argc, char **argv)
{
const char *host = argc > 1 ? argv[1] : "127.0.0.1";
const char *port = argc > 2 ? argv[2] : "12347";
struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
struct addrinfo *res = NULL;
int rc = getaddrinfo(host, port, &hints, &res);
if (rc != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
return EXIT_FAILURE;
}
int fd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol);
if (fd == -1)
continue;
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break;
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd == -1)
die("connect");
/* 两个帧拼进一个发送缓冲区,一次 write 发出 */
const char *m1 = "alpha";
const char *m2 = "beta";
unsigned char out[4 + 5 + 4 + 4];
uint32_t n1 = htonl((uint32_t)strlen(m1));
uint32_t n2 = htonl((uint32_t)strlen(m2));
memcpy(out, &n1, 4);
memcpy(out + 4, m1, strlen(m1));
memcpy(out + 4 + strlen(m1), &n2, 4);
memcpy(out + 4 + strlen(m1) + 4, m2, strlen(m2));
if (write_full(fd, out, sizeof out) == -1)
die("write");
/* 读回两个帧 */
for (int i = 0; i < 2; i++) {
uint32_t netlen;
if (read_full(fd, &netlen, 4) != 1)
die("read header");
uint32_t len = ntohl(netlen);
if (len > MAX_FRAME)
die("frame too large");
char *buf = malloc(len + 1);
if (buf == NULL)
die("malloc");
if (read_full(fd, buf, len) != 1)
die("read payload");
buf[len] = '\0';
printf("echo %d: %s\n", i + 1, buf);
free(buf);
}
if (shutdown(fd, SHUT_WR) == -1)
die("shutdown");
if (close(fd) == -1)
die("close");
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g framed_client.c -o framed_client
./framed_client 127.0.0.1 12347
echo 1: alpha
echo 2: beta
客户端一次 write 发了 26 字节,服务端内核可能一次交付全部、也可能分两次;无论哪种,read_full 都会先读 4 字节头、再读 5 字节负载,然后循环读下一个头。解析逻辑不依赖内核如何分段,这是分帧协议的价值。
2.17.4 超时、心跳与死连接
TCP 连接可能「看起来还活着,实际上对端已经不存在」:拔网线、NAT 表项过期、对端主机断电。内核不会立刻告诉你——它只会不断重传,直到超时(默认可能十几分钟)。服务端必须自己加超时。
超时分成三个层次:单次 I/O 用 SO_RCVTIMEO/SO_SNDTIMEO;事件循环用 poll/epoll_wait 的超时加每连接 deadline;连接存活用 TCP keepalive 或应用层心跳。
SO_RCVTIMEO 的用法:
struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };
if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv) == -1) {
perror("setsockopt SO_RCVTIMEO");
}
ssize_t n = read(fd, buf, sizeof buf);
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
/* 超时:注意 errno 不是 ETIMEDOUT */
}
超时返回的是 EAGAIN(Linux),不是 ETIMEDOUT。ETIMEDOUT 通常来自 TCP 重传超时(tcp_retries2),两者含义不同:前者是「你设的等待时间到了」,后者是「内核放弃重传」。
TCP_KEEPALIVE 的默认值太长(tcp_keepalive_time 默认 7200 秒),不适合作为业务心跳。可以用 TCP_KEEPIDLE 调短:
int idle = 60, intvl = 10, cnt = 3;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof idle);
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof intvl);
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof cnt);
但 keepalive 只探测「TCP 层是否还能收到 ACK」,不能判断应用是否卡死。应用层心跳(定期发一个 ping 帧、要求 pong)才能发现「进程还在但线程池堵死」的情况。
- 「
SO_RCVTIMEO超时返回ETIMEDOUT」——Linux 返回EAGAIN/EWOULDBLOCK。判断超时要看这个,不是ETIMEDOUT。 - 「设了
TCP_KEEPALIVE就能发现死连接」——只能发现链路层/对端内核不响应;对端进程崩溃但内核还在(比如容器 paused)时,keepalive 仍会成功。 - 「
read超时后连接还能继续用」——可以,但缓冲区里可能残留半个帧。超时后要么丢弃连接,要么把已读字节交给解析器继续等,不能假设「什么都没读到」。
2.17.5 优雅关闭:FIN、RST 与半关闭
close(fd) 与 shutdown(fd, how) 的区别在 2.16.9 讲过。这里补三个工程细节:
1. 先 shutdown(SHUT_WR),再读完对端数据,最后 close。
/* 请求发完了,告诉对端「我不会再写」,但继续读响应 */
if (shutdown(fd, SHUT_WR) == -1)
perror("shutdown");
char buf[4096];
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n == 0)
break; /* 对端也关闭了 */
if (n == -1) {
if (errno == EINTR)
continue;
perror("read");
break;
}
/* 处理响应 */
}
close(fd);
2. 不要在未读完时 close。 如果接收缓冲区里还有未读数据就 close,Linux 会发 RST 而不是 FIN,对端可能丢掉已经发出的响应。要「礼貌地关闭」,先读完,或设 SO_LINGER 让 close 阻塞到数据发完:
struct linger lg = { .l_onoff = 1, .l_linger = 5 }; /* 最多等 5 秒 */
setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof lg);
SO_LINGER 用不好会阻塞事件循环,非阻塞程序慎用。
3. 应用层协议要能表达「关闭意图」。 HTTP 用 Connection: close,WebSocket 用 close frame,gRPC 用 GOAWAY。直接 close 会让对端分不清「正常结束」与「网络中断」。
shutdown(SHUT_RD) 在 Linux 上的行为容易误解:它让本端不再接收,但对端继续发送的数据会触发 RST(因为内核要拒绝到达的数据)。所以「只关读方向」通常不是好主意。
2.17.6 套接字选项:延迟、缓冲与端口复用
| 选项 | 作用 | 什么时候调 |
|---|---|---|
TCP_NODELAY | 关闭 Nagle,小包立即发 | 交互式协议、RPC;大批量传输不要开 |
SO_SNDBUF/SO_RCVBUF | 发送/接收缓冲区大小 | 高带宽延迟积链路;内核会翻倍并受 rmem_max 限制 |
SO_REUSEADDR | 允许绑定 TIME_WAIT 地址 | 服务端必开 |
SO_REUSEPORT | 多个 socket 绑同一端口,内核分流 | 多进程/多线程监听同一端口 |
Nagle 算法会把小包攒起来,等 ACK 或攒够 MSS 再发,目的是减少小包数量。但它与「延迟 ACK」叠加会造成 40 ms 级的延迟,交互式协议(SSH、游戏、RPC)通常要 TCP_NODELAY。反过来,一次性发大量小消息时关掉 Nagle 会增加包数,应该用 writev/MSG_MORE 合并。
SO_REUSEPORT 让多个进程各自 bind 同一端口,内核按四元组哈希分流;它和解决 TIME_WAIT 的 SO_REUSEADDR 不是一回事,Nginx/Envoy 的多 worker 模型依赖它。
2.17.7 辅助数据:sendmsg/recvmsg 与传 fd
send/recv 只能传字节。要传「控制信息」(传递 fd、对端凭证、原始 IP 头),得用 sendmsg/recvmsg,把控制信息放在 ancillary data 里。
最常用的控制信息是 SCM_RIGHTS:通过 UNIX domain socket 把一个已打开的 fd 传给另一个进程。接收方拿到的是同一个打开文件描述的副本——共享偏移、共享文件状态,就像 dup 一样,但跨进程。
/* fd_pass.c:通过 socketpair 传递一个打开的 fd */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int send_fd(int sock, int fd)
{
char data = 'F';
struct iovec iov = { .iov_base = &data, .iov_len = 1 };
char control[CMSG_SPACE(sizeof(int))];
memset(control, 0, sizeof control);
struct msghdr msg;
memset(&msg, 0, sizeof msg);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof control;
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &fd, sizeof fd);
msg.msg_controllen = CMSG_SPACE(sizeof(int));
ssize_t n;
do {
n = sendmsg(sock, &msg, 0);
} while (n == -1 && errno == EINTR);
return n == 1 ? 0 : -1;
}
static int recv_fd(int sock)
{
char data;
struct iovec iov = { .iov_base = &data, .iov_len = 1 };
char control[CMSG_SPACE(sizeof(int))];
memset(control, 0, sizeof control);
struct msghdr msg;
memset(&msg, 0, sizeof msg);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof control;
ssize_t n;
do {
n = recvmsg(sock, &msg, 0);
} while (n == -1 && errno == EINTR);
if (n <= 0)
return -1;
if (msg.msg_flags & MSG_CTRUNC) {
errno = EMSGSIZE; /* 控制信息被截断,fd 已失效 */
return -1;
}
for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg != NULL;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_RIGHTS) {
int fd;
if (cmsg->cmsg_len < CMSG_LEN(sizeof(int))) {
errno = EPROTO;
return -1;
}
memcpy(&fd, CMSG_DATA(cmsg), sizeof fd);
return fd;
}
}
errno = EPROTO;
return -1;
}
int main(void)
{
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == -1)
die("socketpair");
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
close(sv[0]);
int fd = open("/etc/hosts", O_RDONLY | O_CLOEXEC);
if (fd == -1)
die("open");
if (send_fd(sv[1], fd) == -1)
die("send_fd");
close(fd);
close(sv[1]);
_exit(0);
}
close(sv[1]);
int fd = recv_fd(sv[0]);
if (fd == -1)
die("recv_fd");
/* 拿到的 fd 与子进程打开的是同一个文件,位置共享 */
struct stat st;
if (fstat(fd, &st) == -1)
die("fstat");
printf("received fd=%d size=%lld\n", fd, (long long)st.st_size);
char buf[64];
ssize_t n = read(fd, buf, sizeof buf - 1);
if (n > 0) {
buf[n] = '\0';
printf("first bytes: %s\n", buf);
}
close(fd);
close(sv[0]);
int status;
if (waitpid(pid, &status, 0) == -1)
die("waitpid");
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g fd_pass.c -o fd_pass
./fd_pass
received fd=4 size=221
first bytes: 127.0.0.1 localhost
四个必查点:
msg_controllen在发送前要设为CMSG_SPACE(sizeof(int)),不是sizeof control(后者可能更大,内核会拒绝或传输多余字节)。- 接收方检查
MSG_CTRUNC:控制缓冲区不够大时,fd 会被截断丢弃;不检查就会误以为「没有控制信息」。 cmsg_len至少等于CMSG_LEN(sizeof(int)),且接收方必须自己close:每个SCM_RIGHTS里的 fd 都是一次dup,传了 N 个没关就是 N 个泄漏。
传 fd 的典型用途:systemd 把监听 socket 传给服务进程(socket activation);容器运行时把 /dev/null、日志 fd 传进容器;多进程服务器把 accept 到的连接传给 worker。它比重开文件更可靠,因为权限、偏移、文件状态都原样保留。
2.17.8 对端身份:getpeername 与 SO_PEERCRED
TCP 服务端可以用 getpeername 拿对端 IP 与端口,但不能拿对端用户——IP 可以伪造(尤其在 UDP 里),端口也不代表身份。
UNIX domain socket 不一样:内核知道连接双方是谁。SO_PEERCRED 返回对端的 pid、uid、gid:
struct ucred cred;
socklen_t len = sizeof cred;
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == -1) {
perror("getsockopt SO_PEERCRED");
} else {
printf("peer pid=%ld uid=%ld gid=%ld\n",
(long)cred.pid, (long)cred.uid, (long)cred.gid);
}
这是本机服务做认证的常用手段:Docker daemon 用 /var/run/docker.sock 的 SO_PEERCRED 判断调用者是否在 docker 组;pid 可能被回收复用,uid 才是稳定身份。
getpeername 在连接被对端关闭后仍可能返回旧地址;SO_ERROR 才是判断连接状态的手段。
2.17.9 EADDRINUSE 排查
服务启动时报 bind: Address already in use,按顺序查:ss -lntp 'sport = :8080' 看是否已有进程监听;ss -tan 'sport = :8080' | grep TIME-WAIT 看是否是 TIME_WAIT(是就开 SO_REUSEADDR);0.0.0.0 与 127.0.0.1、:: 与 0.0.0.0 的绑定冲突(后者取决于 net.ipv6.bindv6only);容器有独立网络命名空间,宿主机的 ss 看不到容器内监听;SO_REUSEPORT 下多个进程绑同一端口是合法的。
2.17.10 套接字 I/O 全表:send/recv/sendmsg 与 errno
这一节回答:send 与 write 有什么区别?MSG_WAITALL 为什么还会短读?哪些 errno 是「连接坏了」而不是「这次没数据」?
发送与接收
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
send | ssize_t send(int fd, const void *buf, size_t len, int flags) | 已发送字节数(可能短写) | EAGAIN、EINTR、EPIPE、ECONNRESET、ENOTCONN、EMSGSIZE、ENOBUFS | 已连接 socket;flags 见下表 |
recv | ssize_t recv(int fd, void *buf, size_t len, int flags) | 字节数;0 表示 EOF | EAGAIN、EINTR、ECONNRESET、ENOTCONN | 0 不是「暂时没数据」 |
sendto | ssize_t sendto(int fd, const void *buf, size_t len, int flags, const struct sockaddr *dst, socklen_t dlen) | 字节数 | 同上 + EAFNOSUPPORT、EINVAL | 数据报 socket 必须给地址 |
recvfrom | ssize_t recvfrom(int fd, void *buf, size_t len, int flags, struct sockaddr *src, socklen_t *slen) | 字节数 | 同上 + EINVAL | slen 是值-结果参数,调用前必须初始化 |
sendmsg | ssize_t sendmsg(int fd, const struct msghdr *msg, int flags) | 字节数 | 同上 + EINVAL、EMSGSIZE | 可带辅助数据、分散聚合 |
recvmsg | ssize_t recvmsg(int fd, struct msghdr *msg, int flags) | 字节数 | 同上 + EINVAL | 可收辅助数据;msg_flags 返回截断标志 |
sendmmsg | int sendmmsg(int fd, struct mmsghdr *vec, unsigned int vlen, int flags) | 已发送的消息数 | 同 sendmsg | Linux 3.0;批量发送数据报 |
recvmmsg | int recvmmsg(int fd, struct mmsghdr *vec, unsigned int vlen, int flags, struct timespec *timeout) | 收到的消息数 | 同 recvmsg + ETIMEDOUT | Linux 2.6.33;批量接收 |
shutdown | int shutdown(int fd, int how) | 0 | EBADF、ENOTCONN、EINVAL | SHUT_RD/SHUT_WR/SHUT_RDWR;不影响 fd 本身 |
getsockname | int getsockname(int fd, struct sockaddr *addr, socklen_t *len) | 0 | EBADF、EFAULT、ENOTSOCK | 取本地地址;len 值-结果 |
getpeername | int getpeername(int fd, struct sockaddr *addr, socklen_t *len) | 0 | EBADF、EFAULT、ENOTCONN、ENOTSOCK | 取对端地址;连接关闭后可能返回旧值 |
send/recv 与 write/read 在已连接 socket 上等价,区别只有 flags。send 的短写条件比管道更多:发送缓冲区不足、被信号打断、非阻塞、异步错误,都可能只发一部分。永远不要假设一次 send 发完。
flags 位表
| flag | 适用 | 语义 | 常见误用 |
|---|---|---|---|
MSG_DONTWAIT | send/recv | 本次调用非阻塞 | 以为设置了它就能替代 O_NONBLOCK(只影响本次) |
MSG_NOSIGNAL | send | 不产生 SIGPIPE,返回 EPIPE | 忘了它,进程被 SIGPIPE 杀掉 |
MSG_MORE | send | 提示后续还有数据,内核可攒包 | 最后一段忘了去掉,数据被延迟 |
MSG_PEEK | recv | 偷看数据但不消费 | 以为 PEEK 后数据就没了 |
MSG_WAITALL | recv | 尽量等满 len | 信号/对端关闭/出错时仍会短读 |
MSG_TRUNC | recv | 返回数据报的真实长度(即使缓冲区小) | 用在流式 socket 上语义不同 |
MSG_CTRUNC | recvmsg 输出 | 控制信息被截断 | 不检查就使用 fd,导致泄漏/错误 |
MSG_OOB | send/recv | 带外数据(TCP 紧急模式) | 现代协议不应依赖;TCP 只支持 1 字节 |
MSG_ERRQUEUE | recvmsg | 读错误队列(IP_RECVERR) | 需要先开 IP_RECVERR |
MSG_CMSG_CLOEXEC | recvmsg | 收到的 SCM_RIGHTS fd 设 FD_CLOEXEC | 不加则 fd 会泄漏给 exec 的子进程 |
MSG_ZEROCOPY | send | 零拷贝发送(Linux 4.14) | 需要 SO_ZEROCOPY 与完成通知,不是免费加速 |
errno 速查:连接类错误
errno | 典型来源 | 含义 | 处理 |
|---|---|---|---|
EAGAIN/EWOULDBLOCK | 非阻塞 send/recv | 现在不能收发;SO_RCVTIMEO 超时也返回它 | 等 poll/epoll,不要当致命错误 |
EINTR | 任意阻塞调用 | 被信号打断 | 重试;注意已传输字节数 |
EPIPE | send | 对端已关闭读方向 | 关闭连接;配合 MSG_NOSIGNAL 避免 SIGPIPE |
ECONNRESET | recv/send | 对端发了 RST(崩溃、SO_LINGER 0、半开连接) | 关闭连接;不要重试同一连接 |
ENOTCONN | send/recv/getpeername | socket 未连接 | 检查 connect/accept 流程 |
ESHUTDOWN | send | 本端已 shutdown(SHUT_WR) | 不能再写;读方向可能仍可用 |
ETIMEDOUT | connect/send | TCP 重传超时或连接超时 | 关闭;重连要退避 |
ECONNREFUSED | connect | 对端端口没有监听(收到 RST) | 检查服务是否启动/端口是否正确 |
EHOSTUNREACH/ENETUNREACH | connect/send | 路由不可达 | 检查路由/防火墙;通常不可重试 |
EADDRINUSE | bind | 地址/端口已被占用 | 查监听进程与 TIME_WAIT,见 2.17.9 |
EADDRNOTAVAIL | bind | 地址不属于本机 | 检查 IP 是否配置在网卡上 |
EINPROGRESS | 非阻塞 connect | 连接正在进行 | poll(POLLOUT) 后查 SO_ERROR |
EALREADY | 非阻塞 connect 重试 | 上一次连接还没完成 | 继续等,不要重复发起 |
EISCONN | connect 重试 | 已经连接成功 | 视为成功,继续 I/O |
EMSGSIZE | send/sendto | 数据报超过上限 | 分片或改用流式协议 |
ENOBUFS | send | 内核缓冲区/内存不足 | 退避重试;检查是否被压垮 |
结构体
struct msghdr 是 sendmsg/recvmsg 的核心:
| 字段 | 类型 | 方向 | 含义 |
|---|---|---|---|
msg_name | void * | 入/出 | 数据报的对端地址;流式 socket 可为 NULL |
msg_namelen | socklen_t | 入/出 | 地址缓冲区长度;recvmsg 返回实际长度 |
msg_iov | struct iovec * | 入 | 分散/聚合缓冲区数组 |
msg_iovlen | size_t | 入 | 数组元素个数;上限 IOV_MAX(通常 1024) |
msg_control | void * | 入/出 | 辅助数据缓冲区 |
msg_controllen | size_t | 入/出 | 辅助数据缓冲区长度;recvmsg 返回实际长度 |
msg_flags | int | 出 | MSG_CTRUNC/MSG_TRUNC/MSG_EOR 等 |
struct cmsghdr 描述一条辅助数据:
| 字段 | 类型 | 含义 |
|---|---|---|
cmsg_len | size_t | 整条控制消息长度,含头部;用 CMSG_LEN() 计算 |
cmsg_level | int | 协议层,如 SOL_SOCKET |
cmsg_type | int | 类型,如 SCM_RIGHTS |
| 数据 | 紧跟头部 | 用 CMSG_DATA() 取指针;必须按 CMSG_ALIGN 对齐 |
struct ucred(Linux):
| 字段 | 类型 | 含义 |
|---|---|---|
pid | pid_t | 对端进程 ID;可能被回收复用 |
uid | uid_t | 对端真实/有效 UID(SO_PEERCRED 取有效 UID) |
gid | gid_t | 对应对端 GID |
2.17.11 非阻塞 connect:状态机与超时
这一节回答:为什么 connect 会返回 EINPROGRESS?什么时候查 SO_ERROR?poll(POLLOUT) 可写就代表连接成功吗?
阻塞 connect 的超时由内核控制(SYN 重传,tcp_syn_retries 默认 6 次,约 127 秒),SO_SNDTIMEO 不影响 connect。要给连接设一个短超时,必须用非阻塞 connect + poll:
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
int rc = connect(fd, addr, addrlen);
if (rc == -1 && errno != EINPROGRESS) {
/* 立即失败:ECONNREFUSED/EHOSTUNREACH/EADDRNOTAVAIL... */
}
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
rc = poll(&pfd, 1, 3000); /* 3 秒超时 */
if (rc == 0) { /* 超时:close(fd) */ }
if (rc == -1) { /* EINTR:重算剩余时间再 poll */ }
int err = 0;
socklen_t len = sizeof err;
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == -1) { /* ... */ }
if (err != 0) {
errno = err; /* SO_ERROR 的值放进 errno */
/* 连接失败:ECONNREFUSED/ETIMEDOUT/... */
}
/* 连接成功;恢复阻塞模式,或继续用非阻塞 + poll */
int fl = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, fl & ~O_NONBLOCK);
状态机:
| 阶段 | connect 返回 | errno | 下一步 |
|---|---|---|---|
| 首次调用,立即成功(本机) | 0 | — | 直接 I/O |
| 首次调用,进行中 | -1 | EINPROGRESS | poll(POLLOUT) |
| 再次调用,仍在进行 | -1 | EALREADY | 继续 poll |
| 再次调用,已完成 | -1 | EISCONN | 视为成功 |
poll 返回可写 | — | — | 查 SO_ERROR |
SO_ERROR != 0 | — | 错误码 | 关闭,按错误类型重试/放弃 |
三个细节:
poll(POLLOUT)可写不等于连接成功。连接失败时 socket 也会变成「可写」(错误状态可写),必须getsockopt(SO_ERROR)取具体错误。SO_ERROR读取后会被清零,所以只读一次。EINTR不表示连接失败。信号打断poll后,连接仍在进行;重算剩余超时再poll,不要重新connect。connect失败后 socket 状态不确定。除EINPROGRESS/EALREADY/EISCONN外,其余错误都应close后重建 socket;不要在同一 fd 上反复connect。
TCP_USER_TIMEOUT(毫秒)控制的是已建立连接的重传超时,不是连接建立超时;连接建立超时由 tcp_syn_retries 决定。两者不要混。
- 「
poll(POLLOUT)返回就说明连上了」——失败也会可写。必须查SO_ERROR。 - 「
SO_SNDTIMEO能控制 connect 超时」——Linux 上不能。用非阻塞connect+poll。 - 「
connect返回EINTR要重新 connect」——不要。连接仍在进行,重新 connect 会得到EALREADY或打乱状态。 - 「非阻塞 connect 成功后 fd 自动变回阻塞」——不会。
O_NONBLOCK是打开文件描述的状态,连接成功后仍然非阻塞;要fcntl清掉。
2.17.12 辅助数据:SCM_RIGHTS、凭证与截断
这一节回答:CMSG_SPACE 与 CMSG_LEN 为什么不一样?传 fd 时接收方拿到的是什么?MSG_CTRUNC 时 fd 去哪了?
cmsghdr 的对齐规则
辅助数据不是「一段裸字节」,而是按 cmsghdr 对齐的消息序列:
+----------------+----------------+----------------+----------------+
| cmsg_len | cmsg_level | cmsg_type | data ... |
| (size_t) | (int) | (int) | 按 CMSG_ALIGN |
+----------------+----------------+----------------+----------------+
两个宏必须分清:
CMSG_LEN(len)=CMSG_ALIGN(sizeof(struct cmsghdr)) + len:一条控制消息的实际长度,写进cmsg_len;CMSG_SPACE(len)=CMSG_ALIGN(sizeof(struct cmsghdr)) + CMSG_ALIGN(len):这条消息在缓冲区里占用的总空间,用于msg_controllen和数组大小。
CMSG_SPACE >= CMSG_LEN,差额是对齐填充。发送时 msg_controllen 应该用 CMSG_SPACE;接收时缓冲区也要按 CMSG_SPACE 分配,否则最后一条可能被截断。
遍历用 CMSG_FIRSTHDR/CMSG_NXTHDR,不要手算指针:
for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c != NULL;
c = CMSG_NXTHDR(&msg, c)) {
if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_RIGHTS) {
size_t n = (c->cmsg_len - CMSG_LEN(0)) / sizeof(int);
int *fds = (int *)CMSG_DATA(c);
for (size_t i = 0; i < n; i++)
/* 使用 fds[i],最后必须 close */;
}
}
CMSG_NXTHDR 会检查 cmsg_len 是否越界,比自己加偏移安全。CMSG_DATA 返回的指针只在本次 recvmsg 的缓冲区有效;不要把 fd 数组指针存起来跨调用使用。
SCM_RIGHTS 的语义
通过 UNIX domain socket 传 fd,接收方拿到的是同一个打开文件描述的引用,效果类似 dup:
- 文件偏移共享:发送方读了 10 字节,接收方从第 11 字节开始;
- 文件状态标志共享:
O_APPEND、O_NONBLOCK等是打开文件描述的属性; - fd 编号不保留:发送方的 7 到接收方可能是 12;
- 接收方必须
close:每个SCM_RIGHTSfd 都是一次引用,泄漏会耗尽 fd; - 只能同机传递:fd 是内核对象,不能跨网络;跨机要传「重新打开所需的路径/凭证」。
SCM_RIGHTS 的数据是 int 数组,内核上限 SCM_MAX_FD(253)。一次传太多会 EMSGSIZE 或截断。发送方在 sendmsg 成功后就可以 close 自己的 fd;内核已经持有引用,接收方仍能读。
MSG_CTRUNC:截断时 fd 可能已泄漏
如果接收方的控制缓冲区太小,内核会截断控制消息,并在 msg_flags 置 MSG_CTRUNC。此时:
- 不能信任
CMSG_FIRSTHDR返回的内容; - 内核可能已经安装了部分 fd,但应用拿不到它们 → fd 泄漏;
- 正确做法是关闭连接(UNIX socket),并确保进程退出/重建,不要让半截 fd 留在表里。
Linux 在 SCM_RIGHTS 截断时会尽量关闭多余的 fd,但不要依赖这个行为;检查 MSG_CTRUNC 并拒绝整条消息,是唯一可移植的做法。
凭证:SO_PEERCRED 与 SCM_CREDENTIALS
SO_PEERCRED 在连接建立时由内核记录对端凭证,之后 getsockopt 读取:
struct ucred cred;
socklen_t len = sizeof cred;
if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0)
printf("pid=%ld uid=%ld gid=%ld\n",
(long)cred.pid, (long)cred.uid, (long)cred.gid);
它只对 UNIX domain socket 有效;TCP 没有「对端 uid」的概念。pid 可能被回收复用,不要把 pid 当长期身份;uid/gid 才是稳定的权限判断依据。需要跟踪进程生命周期时,用 pidfd_open/SO_PEERPIDFD(Linux 6.5)。
SCM_CREDENTIALS 是每条消息携带凭证,接收方需要先开 SO_PASSCRED:
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_PASSCRED, &one, sizeof one);
/* recvmsg 的 control 里会出现 SOL_SOCKET/SCM_CREDENTIALS + struct ucred */
它适合「同一连接上不同消息来自不同发送者」的场景(例如经过代理转发的 UNIX socket),但 Linux 上普通应用很少需要;SO_PEERCRED 的连接级凭证已经够用。
- 「
msg_controllen填sizeof(control)就行」——发送时应填CMSG_SPACE(len);填大了可能被内核拒绝,填小了会截断。 - 「
cmsg_len用CMSG_SPACE」——cmsg_len用CMSG_LEN,CMSG_SPACE只用于缓冲区大小。 - 「收到的 fd 编号和发送方一样」——不保证。fd 编号是进程本地的。
- 「传完 fd 后发送方可以立刻关,接收方就读不到了」——可以关;内核持有引用,接收方仍能读。
- 「
SO_PEERCRED的 pid 可以当唯一身份」——pid 会复用。用 uid/gid + 应用层令牌,或用 pidfd。
2.17.13 内核路径:从 send 到 sk_buff,从 recvmsg 到 scm
这一节回答:send 的数据在哪一层被复制?accept 返回的新 socket 从哪来?SCM_RIGHTS 的 fd 在内核里怎么挂到消息上?
TCP 发送与接收
| 用户动作 | 内核路径 | 关键数据结构 |
|---|---|---|
send/write | sock_sendmsg → tcp_sendmsg → tcp_sendmsg_locked | struct sock、struct sk_buff |
| 攒包/Nagle | tcp_push → tcp_nagle_test/tcp_cork | tp->nonagle、TCP_NAGLE_OFF |
| 接收 | tcp_recvmsg → skb_copy_datagram_msg | sk_receive_queue、sk_buff |
accept | inet_csk_accept → inet_csk_wait_for_connect | icsk_accept_queue、request_sock |
| 关闭 | tcp_close/tcp_shutdown | TCP_CLOSE/FIN_WAIT 状态机 |
tcp_sendmsg_locked 的核心循环:
/* 按 Linux 6.8 net/ipv4/tcp.c 的结构简化 */
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
while (size > 0) {
struct sk_buff *skb = tcp_write_queue_tail(sk);
int copy = min_t(int, size, skb_tailroom(skb));
if (copy > 0) {
err = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
if (err)
goto do_error;
} else {
skb = sk_stream_alloc_skb(sk, 0, sk->sk_allocation, true);
if (!skb)
goto wait_for_memory;
/* 新 skb 挂到发送队列 */
}
size -= copy;
if (forced_push(tp))
tcp_mark_push(tp, skb);
}
tcp_push(sk, flags, mss_now, tp->nonagle, size_goal);
return copied;
}
三个可观测点:
sk_buff是内核的「包」,用户数据被拷贝进 skb 的线性区或页片段;发送队列满时send阻塞或EAGAIN。- Nagle 与 Cork 都在
tcp_push路径:TCP_NODELAY关 Nagle,TCP_CORK/MSG_MORE延迟发送;它们改变的是「何时 push」,不是「是否可靠」。 send返回的是已拷贝进内核的字节数,不是「对端已收到」。对端 ACK 是后面的事;send成功只代表本端内核接受了数据。
accept 与两个队列
listen(fd, backlog) 之后,内核维护两个队列:
| 队列 | 内容 | 溢出行为 |
|---|---|---|
SYN 队列(request_sock_queue) | 收到 SYN、还没完成三次握手的请求 | 受 tcp_max_syn_backlog 与 net.ipv4.tcp_syncookies 影响 |
accept 队列(icsk_accept_queue) | 已完成握手、等待 accept 的连接 | 满后新连接可能被丢弃或收到 RST |
accept 从 accept 队列取一个 struct sock,创建新的 fd。新 fd 不继承监听 socket 的 O_NONBLOCK 与 FD_CLOEXEC(Linux 行为),但继承大部分 setsockopt 选项。这就是为什么 accept4(SOCK_NONBLOCK|SOCK_CLOEXEC) 存在:避免 accept 后两次 fcntl 的竞态。
UNIX domain socket 与 SCM_RIGHTS
| 用户动作 | 内核路径 | 关键数据结构 |
|---|---|---|
sendmsg + SCM_RIGHTS | unix_stream_sendmsg → scm_send → scm_fp_copy | struct scm_fp_list、struct scm_cookie |
recvmsg | unix_stream_recvmsg → scm_recv → scm_detach_fds | scm_cookie、接收方 files_struct |
SO_PEERCRED | unix_getsockopt → 读取 unix_sk(sk)->peer 的凭证 | struct unix_sock、struct ucred |
scm_fp_copy 把发送方 fd 数组转换成 struct file * 数组(每个 get_file 增加引用计数),挂到 skb 的 scm_cookie 上;接收方 scm_detach_fds 再为每个 struct file 分配一个新的 fd(get_unused_fd_flags),装进接收进程的 fd 表。这就是「传 fd 实际上是传 struct file 引用」的底层解释。
SO_PEERCRED 的凭证在 unix_stream_connect/unix_dgram_connect 时记录,之后不会随对端 setuid 改变;它反映的是连接建立时的对端有效 UID。需要「当前凭证」时,用 SCM_CREDENTIALS 每条消息传递。
发送进程 fd 7 ──▶ struct file(引用计数 +1)
│
skb->scm_cookie->fp[0]
│ (UNIX socket 接收队列)
▼
接收进程 recvmsg ──▶ scm_detach_fds ──▶ 分配 fd 12 ──▶ 同一个 struct file
读图:fd 只是进程 fd 表里的下标,真正的对象是 struct file。SCM_RIGHTS 传递的是 struct file 的引用,所以偏移、状态、权限都共享;关闭发送方的 fd 只是减少引用计数,不影响接收方。
ss -tanp:看ESTABLISHED/TIME-WAIT/CLOSE-WAIT与队列。ss -lntp:看监听队列溢出(Recv-Q/Send-Q)。tcpdump -i any -nn 'tcp port 8080':看 SYN/FIN/RST 与重传。strace -f -e trace=connect,sendmsg,recvmsg,shutdown:看用户态调用与返回值。cat /proc/net/tcp:看内核视角的四元组与状态。bpftrace -e 'kprobe:tcp_sendmsg { @[comm] = count(); }':看谁在发。
2.17.14 程序一:非阻塞 connect 与超时
这个程序在本机起一个 echo 服务端,客户端用非阻塞 connect + poll 连接,验证 EINPROGRESS、SO_ERROR、超时与恢复阻塞模式。
/* nonblock_connect.c — 非阻塞 connect + poll + SO_ERROR */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int read_full(int fd, void *buf, size_t n)
{
char *p = buf;
size_t done = 0;
while (done < n) {
ssize_t r = read(fd, p + done, n - done);
if (r == -1) {
if (errno == EINTR) continue;
return -1;
}
if (r == 0) return -1;
done += (size_t)r;
}
return 0;
}
static int write_full(int fd, const void *buf, size_t n)
{
const char *p = buf;
size_t done = 0;
while (done < n) {
ssize_t w = write(fd, p + done, n - done);
if (w == -1) {
if (errno == EINTR) continue;
return -1;
}
done += (size_t)w;
}
return 0;
}
int main(void)
{
int lfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (lfd == -1) die("socket listen");
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
.sin_port = 0,
};
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) die("bind");
if (listen(lfd, 4) == -1) die("listen");
socklen_t alen = sizeof addr;
if (getsockname(lfd, (struct sockaddr *)&addr, &alen) == -1)
die("getsockname");
printf("server port=%u\n", ntohs(addr.sin_port));
pid_t pid = fork();
if (pid == -1) die("fork");
if (pid == 0) {
int cfd = accept(lfd, NULL, NULL);
if (cfd == -1) _exit(1);
close(lfd);
uint32_t netlen;
if (read_full(cfd, &netlen, 4) == -1) _exit(1);
uint32_t len = ntohl(netlen);
char *buf = malloc(len + 1);
if (buf == NULL || read_full(cfd, buf, len) == -1) _exit(1);
buf[len] = '\0';
printf("server got: %s\n", buf);
uint32_t out = htonl(len);
if (write_full(cfd, &out, 4) == -1 ||
write_full(cfd, buf, len) == -1)
_exit(1);
free(buf);
close(cfd);
_exit(0);
}
usleep(100000); /* 让服务端进入 accept */
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (fd == -1) die("socket client");
int rc = connect(fd, (struct sockaddr *)&addr, sizeof addr);
if (rc == -1 && errno != EINPROGRESS) {
fprintf(stderr, "connect immediate: %s\n", strerror(errno));
return EXIT_FAILURE;
}
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
rc = poll(&pfd, 1, 3000); /* 3 秒连接超时 */
if (rc == 0) {
fprintf(stderr, "connect timeout\n");
return EXIT_FAILURE;
}
if (rc == -1) die("poll");
int err = 0;
socklen_t elen = sizeof err;
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &elen) == -1)
die("getsockopt SO_ERROR");
if (err != 0) {
fprintf(stderr, "connect failed: %s\n", strerror(err));
return EXIT_FAILURE;
}
printf("nonblocking connect ok\n");
/* 恢复阻塞模式,简化后续读写 */
int fl = fcntl(fd, F_GETFL);
if (fl == -1 || fcntl(fd, F_SETFL, fl & ~O_NONBLOCK) == -1)
die("fcntl");
const char *msg = "ping";
uint32_t netlen = htonl((uint32_t)strlen(msg));
if (write_full(fd, &netlen, 4) == -1 ||
write_full(fd, msg, strlen(msg)) == -1)
die("write frame");
uint32_t echo_len;
if (read_full(fd, &echo_len, 4) == -1) die("read header");
uint32_t n = ntohl(echo_len);
char buf[64];
if (n >= sizeof buf || read_full(fd, buf, n) == -1) die("read payload");
buf[n] = '\0';
printf("echo: %s\n", buf);
close(fd);
close(lfd);
int status;
if (waitpid(pid, &status, 0) == -1) die("waitpid");
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
nonblock_connect.c -o nonblock_connect
./nonblock_connect
# server port=xxxxx
# nonblocking connect ok
# server got: ping
# echo: ping
关键行:
SOCK_NONBLOCK在socket()时直接设置,避免connect前再fcntl;connect返回EINPROGRESS是正常路径,不是错误;poll(POLLOUT)只说明「状态有变化」,SO_ERROR才说明「成功还是失败」;- 连接成功后恢复阻塞,后续
read_full/write_full不需要处理EAGAIN。
把 SO_ERROR 检查删掉,再把端口改成没人监听的端口,程序会打印 connect ok 然后 write 失败——这是最常见的非阻塞 connect bug。
2.17.15 程序二:SCM_RIGHTS 传 fd + SO_PEERCRED 鉴权
这个程序通过 socketpair 把子进程打开的临时文件 fd 传给父进程,父进程先用 SO_PEERCRED 校验 uid,再检查收到的 fd 是否带 FD_CLOEXEC。
/* fd_pass_cred.c — SCM_RIGHTS + SO_PEERCRED + MSG_CMSG_CLOEXEC */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static int send_fd(int sock, int fd)
{
char data = 'F';
struct iovec iov = { .iov_base = &data, .iov_len = 1 };
char control[CMSG_SPACE(sizeof(int))];
memset(control, 0, sizeof control);
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof control;
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &fd, sizeof fd);
msg.msg_controllen = CMSG_SPACE(sizeof(int));
ssize_t n;
do {
n = sendmsg(sock, &msg, 0);
} while (n == -1 && errno == EINTR);
return n == 1 ? 0 : -1;
}
static int recv_fd(int sock)
{
char data;
struct iovec iov = { .iov_base = &data, .iov_len = 1 };
char control[CMSG_SPACE(sizeof(int))];
memset(control, 0, sizeof control);
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof control;
ssize_t n;
do {
n = recvmsg(sock, &msg, MSG_CMSG_CLOEXEC);
} while (n == -1 && errno == EINTR);
if (n <= 0) return -1;
if (msg.msg_flags & MSG_CTRUNC) {
errno = EMSGSIZE;
return -1;
}
for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c != NULL;
c = CMSG_NXTHDR(&msg, c)) {
if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_RIGHTS) {
if (c->cmsg_len != CMSG_LEN(sizeof(int))) {
errno = EPROTO;
return -1;
}
int fd;
memcpy(&fd, CMSG_DATA(c), sizeof fd);
return fd;
}
}
errno = EPROTO;
return -1;
}
int main(void)
{
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) == -1)
die("socketpair");
pid_t pid = fork();
if (pid == -1) die("fork");
if (pid == 0) {
close(sv[0]);
char tmpl[] = "/tmp/fdpassXXXXXX";
int fd = mkstemp(tmpl);
if (fd == -1) die("mkstemp");
unlink(tmpl);
if (write(fd, "secret-data", 11) != 11) die("write temp");
if (lseek(fd, 0, SEEK_SET) == -1) die("lseek");
if (send_fd(sv[1], fd) == -1) die("send_fd");
close(fd);
close(sv[1]);
_exit(0);
}
close(sv[1]);
struct ucred cred;
socklen_t clen = sizeof cred;
if (getsockopt(sv[0], SOL_SOCKET, SO_PEERCRED, &cred, &clen) == -1)
die("getsockopt SO_PEERCRED");
printf("peer pid=%ld uid=%ld gid=%ld\n",
(long)cred.pid, (long)cred.uid, (long)cred.gid);
if (cred.uid != getuid()) {
fprintf(stderr, "peer uid mismatch\n");
return EXIT_FAILURE;
}
int fd = recv_fd(sv[0]);
if (fd == -1) die("recv_fd");
int flags = fcntl(fd, F_GETFD);
if (flags == -1) die("fcntl F_GETFD");
printf("received fd=%d cloexec=%d\n", fd, (flags & FD_CLOEXEC) != 0);
char buf[32];
ssize_t n = read(fd, buf, sizeof buf - 1);
if (n < 0) die("read received fd");
buf[n] = '\0';
printf("content: %s\n", buf);
close(fd);
close(sv[0]);
int status;
if (waitpid(pid, &status, 0) == -1) die("waitpid");
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
fd_pass_cred.c -o fd_pass_cred
./fd_pass_cred
# peer pid=12345 uid=501 gid=20
# received fd=4 cloexec=1
# content: secret-data
关键点:
MSG_CMSG_CLOEXEC让收到的 fd 自动带FD_CLOEXEC,避免exec后泄漏给子进程;SO_PEERCRED在连接建立时记录,cred.uid是对端有效 UID;pid只用于展示/短期关联;cmsg_len必须精确等于CMSG_LEN(sizeof(int));多 fd 时用(cmsg_len - CMSG_LEN(0))/sizeof(int);- 接收方必须
close(fd),否则每次传 fd 都泄漏一个。
strace -f -e trace=sendmsg,recvmsg 会看到 sendmsg(..., [{cmsg_level=SOL_SOCKET, cmsg_type=SCM_RIGHTS, cmsg_data=[4]}], ...),接收方看到 recvmsg(..., MSG_CMSG_CLOEXEC) 与 [fd 4]。
2.17.16 程序三:MSG_PEEK、MSG_WAITALL 与 MSG_TRUNC
这个程序验证三个 flag 的真实语义:MSG_PEEK 不消费数据;MSG_WAITALL 在对端关闭时仍会短读;MSG_TRUNC 在数据报上返回真实长度。
/* msg_flags.c — PEEK / WAITALL / TRUNC 的边界 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
/* 1. UDP 数据报:MSG_TRUNC 返回真实长度 */
int u = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
if (u == -1) die("socket udp");
struct sockaddr_in a = {
.sin_family = AF_INET,
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
.sin_port = 0,
};
if (bind(u, (struct sockaddr *)&a, sizeof a) == -1) die("bind udp");
socklen_t alen = sizeof a;
if (getsockname(u, (struct sockaddr *)&a, &alen) == -1)
die("getsockname udp");
char big[100];
memset(big, 'D', sizeof big);
if (sendto(u, big, sizeof big, 0, (struct sockaddr *)&a, sizeof a)
!= (ssize_t)sizeof big)
die("sendto");
char small[16];
ssize_t n = recv(u, small, sizeof small, MSG_TRUNC);
printf("udp recv(MSG_TRUNC) buffer=16 -> %zd (real length)\n", n);
close(u);
/* 2. 流式:MSG_PEEK 不消费;MSG_WAITALL 在 EOF 时短读 */
int s[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, s) == -1)
die("socketpair stream");
pid_t pid = fork();
if (pid == -1) die("fork");
if (pid == 0) {
close(s[0]);
if (write(s[1], "abcdefghij", 10) != 10) _exit(1);
close(s[1]); /* 关闭写端,制造 EOF */
_exit(0);
}
close(s[1]);
char peek[5] = {0};
n = recv(s[0], peek, 4, MSG_PEEK);
printf("peek 4 -> %zd: %.4s\n", n, peek);
char first[5] = {0};
n = recv(s[0], first, 4, 0);
printf("recv 4 -> %zd: %.4s (same bytes)\n", n, first);
char rest[7] = {0};
n = recv(s[0], rest, 6, MSG_WAITALL);
printf("recv(MSG_WAITALL, 6) -> %zd: %.6s (short because EOF)\n", n, rest);
close(s[0]);
int status;
if (waitpid(pid, &status, 0) == -1) die("waitpid");
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
msg_flags.c -o msg_flags
./msg_flags
# dgram recv(MSG_TRUNC) buffer=16 -> 100 (real length)
# peek 4 -> 4: abcd
# recv 4 -> 4: abcd (same bytes)
# recv(MSG_WAITALL, 6) -> 2: ij (short because EOF)
关键点:
- 数据报 socket 保留边界;缓冲区不足时
MSG_TRUNC返回完整数据报长度,数据被丢弃。流式 socket 上MSG_TRUNC的语义不同,不要混用。 MSG_PEEK只复制,不推进读指针;下一次recv仍从同一位置开始。MSG_WAITALL不是「一定读满」:对端关闭、信号打断、出错都会提前返回。返回值可能小于请求长度,必须自己判断。
2.17.17 实验:ss、tcpdump 与 fd 传递
实验一:非阻塞 connect 的状态与超时
# 1. 正常连接
./nonblock_connect
strace -f -e trace=socket,connect,poll,getsockopt,accept \
./nonblock_connect 2>&1 | grep -E "connect|poll|SO_ERROR|accept" | head -20
# 预期:connect(...) = -1 EINPROGRESS,poll(...)=1,getsockopt(SO_ERROR)=0
# 2. 连接一个没人监听的端口:把程序里的端口改成 1(或写一个小客户端)
# 预期:connect 返回 ECONNREFUSED;非阻塞路径下 poll 可写,SO_ERROR=111
# 3. 观察内核连接状态
./nonblock_connect & pid=$!
ss -tanp | grep -E "127.0.0.1|State" | head
wait $pid
怎么读:strace 里 connect 返回 -1 EINPROGRESS 是预期;poll 的 revents=POLLOUT 只说明状态变化,getsockopt(SO_ERROR) 返回 0 才代表成功。ss 的 SYN-SENT/ESTABLISHED 可以对照时间线。
实验二:fd 传递、凭证与 RST
# 1. 观察 SCM_RIGHTS
strace -f -e trace=sendmsg,recvmsg,getsockopt ./fd_pass_cred 2>&1 | tail -20
# 预期:sendmsg 的 cmsg_data 里有 fd;recvmsg 返回 fd;MSG_CMSG_CLOEXEC 出现在 flags
# 2. 观察接收方的 fd 表
./fd_pass_cred & pid=$!
ls -l /proc/$pid/fd | head
wait $pid
# 3. 制造 ECONNRESET:服务端用 SO_LINGER{1,0} 关闭,客户端再 send
python3 - <<'PY'
import socket, struct, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 12399)); s.listen()
c = socket.create_connection(("127.0.0.1", 12399))
a, _ = s.accept()
a.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
a.close() # 发 RST
time.sleep(0.1)
try:
c.send(b"x" * 100)
c.send(b"y" * 100)
print("send returned (maybe buffered)")
except OSError as e:
print("send error:", e)
PY
# 预期:send error: [Errno 104] Connection reset by peer(或后续 recv 报 ECONNRESET)
怎么读:
SO_LINGER {1, 0}让close立即发 RST,而不是 FIN;客户端下一次send/recv会得到ECONNRESET。- 如果客户端只
send一次,数据可能只是进入发送缓冲区,错误在下一次调用才出现;要读到错误,继续send或recv。 MSG_NOSIGNAL让send返回EPIPE而不是触发SIGPIPE;ECONNRESET与EPIPE是两种不同的「连接坏了」。
ss -tanp看状态:SYN-SENT(连不上)、CLOSE-WAIT(应用没关)、TIME-WAIT(主动关闭方)。ss -lntp看监听队列:Recv-Q接近 backlog 说明accept太慢。tcpdump -nn -i any 'tcp port N'看 SYN/SYN-ACK/RST/FIN 与重传。strace -f -e trace=connect,sendmsg,recvmsg,shutdown看返回值与errno。cat /proc/net/tcp看内核四元组与tx_queue/rx_queue。bpftrace挂tcp_retransmit_skb、tcp_drop看丢包/重传。
2.17.18 陷阱清单:套接字编程的十七个现场
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 把 recv 返回 0 当成「暂时没数据」 | 0 是 EOF | 非阻塞无数据是 EAGAIN;0 表示对端关闭写方向 |
| 2 | 一次 send 以为发完 | 短写:缓冲区满/信号/非阻塞 | 循环发送,保存已发偏移 |
| 3 | 进程被 SIGPIPE 杀掉 | 对端关闭后继续写 | 忽略 SIGPIPE 或用 MSG_NOSIGNAL |
| 4 | SO_RCVTIMEO 超时判断成 ETIMEDOUT | Linux 返回 EAGAIN | 判断 EAGAIN/EWOULDBLOCK |
| 5 | connect 超时不可控 | SO_SNDTIMEO 不影响 connect | 非阻塞 connect + poll |
| 6 | poll(POLLOUT) 后直接发数据 | 失败也会可写 | 查 SO_ERROR |
| 7 | 非阻塞 connect 后 fd 仍非阻塞 | O_NONBLOCK 是文件描述状态 | fcntl 清掉,或全程非阻塞 |
| 8 | EINTR 后重新 connect | 连接仍在进行 | 继续 poll,不要重连 |
| 9 | 读到 ECONNRESET 后重试同一连接 | 对端已 RST | 关闭,重建连接 |
| 10 | shutdown(SHUT_RD) 后对端写触发 RST | 内核拒绝到达的数据 | 不要只关读方向;用协议表达关闭 |
| 11 | close 时有未读数据,对端丢响应 | 内核发 RST 而不是 FIN | 先读完,或 SO_LINGER(慎用) |
| 12 | SO_LINGER 卡住事件循环 | close 阻塞到数据发完 | 非阻塞程序不要用,或设短超时 |
| 13 | MSG_CTRUNC 后仍使用 fd | 控制信息被截断 | 检查 MSG_CTRUNC,关闭连接,不用任何 fd |
| 14 | 传 fd 后接收方 fd 泄漏 | 没 close 收到的每个 fd | 遍历 cmsg,逐个 close |
| 15 | SCM_RIGHTS fd 泄漏给 exec 子进程 | 没设 MSG_CMSG_CLOEXEC | 接收时加 MSG_CMSG_CLOEXEC |
| 16 | SO_PEERCRED 的 pid 被复用 | pid 是短期标识 | 用 uid/gid + 令牌,或 pidfd |
| 17 | EADDRINUSE 以为都是 TIME_WAIT | 可能是别的进程在监听 | ss -lntp 先看监听者,再看 TIME_WAIT |
| 18 | TCP_NODELAY 开了仍然慢 | 对端延迟 ACK/应用层攒包 | 用 tcpdump 看实际发送时间;配合 TCP_CORK/MSG_MORE |
| 19 | sendmsg 的 iovlen 超限 | IOV_MAX(通常 1024) | 分批发送或合并缓冲区 |
| 20 | recvfrom 的 addrlen 没初始化 | 值-结果参数被当成入参 | 调用前设成 sizeof(addr) |
accept4是 Linux 2.6.28 起;老内核只有accept,需要fcntl两次。MSG_CMSG_CLOEXEC是 Linux 2.6.23 起;老内核收到的 fd 需要手动fcntl(FD_CLOEXEC)。SO_PEERCRED是 Linux 特有;BSD 用LOCAL_PEERCRED/getpeereid,语义不同。MSG_ZEROCOPY是 Linux 4.14 起,且需要网卡/协议栈支持;不是所有 socket 都能用。SO_REUSEPORT是 Linux 3.9 起;老内核只有SO_REUSEADDR,多进程绑同一端口会失败。TCP_USER_TIMEOUT是 Linux 2.6.37 起;它控制已建立连接的重传超时,不控制connect。
2.17.19 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 解释拆包/粘包 | 能画出读缓冲区的状态 | 2.17.1 |
| 写出长度前缀解析器 | 能处理「一次读两条」与「一条分两次」 | 2.17.2 |
| 检查帧长度上限 | 在读负载之前拒绝超限 | 2.17.2 |
用 shutdown(SHUT_WR) 半关闭 | 能解释与 close 的区别 | 2.17.5 |
区分 SO_RCVTIMEO 与 ETIMEDOUT | 知道超时返回 EAGAIN | 2.17.4 |
用 SCM_RIGHTS 传 fd | 检查 MSG_CTRUNC 并关闭接收的 fd | 2.17.7 |
用 SO_PEERCRED 取对端身份 | 知道只对 UNIX socket 有效 | 2.17.8 |
排查 EADDRINUSE | 能区分监听进程与 TIME_WAIT | 2.17.9 |
Q1 一个长度前缀协议规定「4 字节大端长度 + payload」。客户端发了一条 10 字节的消息,服务端第一次 read 只返回 6 字节。服务端的解析器应该怎么处理?
Q2 服务端把 MAX_FRAME 设为 1 MiB,攻击者发来长度字段 0xFFFFFFFF 后不再发送数据。如果不检查长度会发生什么?
Q3 为什么 shutdown(SHUT_WR) 之后还能收到对端数据,而 close 之后不能?
Q4 recvmsg 收到了 SCM_RIGHTS,但 msg_flags 含 MSG_CTRUNC。此时 CMSG_FIRSTHDR 还能拿到 fd 吗?应该怎么处理?
答案
A1 把 6 字节存进读缓冲区,解析器看到「不足 4 字节头」或「头完整但 payload 不足 10 字节」,进入「等待更多数据」状态;下一次 read 补齐后继续解析。不能把 6 字节当错误,也不能假设下一次 read 从消息边界开始。
A2 若代码直接 malloc(len) 或 read_full(fd, buf, len),会尝试分配/读取 4 GiB:轻则 malloc 失败,重则 OOM killer 杀掉进程;攻击者只需发 4 字节就能让服务端拒绝服务。正确做法是先比较 len > MAX_FRAME,超限立即断开,不读负载。
A3 shutdown(SHUT_WR) 只关闭写方向(发 FIN),读方向仍打开,所以能继续读到对端数据与 EOF。close 释放 fd,当前进程不能再 read/write;如果还有其他引用(dup/fork),连接不会立即关闭,但这个 fd 已经无效。
A4 不能可靠地拿到。MSG_CTRUNC 表示控制缓冲区不足,内核可能已经丢弃了部分或全部 fd;即使 CMSG_FIRSTHDR 返回了头,cmsg_len 也可能不完整。正确做法是把控制缓冲区开大(CMSG_SPACE(sizeof(int) * N)),检查 MSG_CTRUNC 后直接关闭连接,不要使用任何收到的 fd。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 59 章 SOCKET:Internet Domain(p.982–1015,PDF p.1027–1060) | 2.17.1–2.17.6、2.17.11 | 完整:地址结构、socket/bind/connect/listen/accept、TCP/UDP 客户端服务端、getaddrinfo |
| K | 第 60 章 SOCKET:服务器设计(p.1016–1027,PDF p.1061–1072) | 2.17.2、2.17.5、2.17.13 | 完整:迭代/并发服务器、accept 队列、inetd、fd 传递动机 |
| K | 第 61 章 SOCKET:高级主题(p.1028–1057,PDF p.1073–1102) | 2.17.1、2.17.4–2.17.16 | 完整:61.1 部分读写(readn/writen)、61.2 shutdown、61.3 recv/send flags、61.4 sendfile/TCP_CORK、61.5 getpeername、61.9 选项、61.10 SO_REUSEADDR、61.11 accept 继承、61.13.2 sendmsg/recvmsg、61.13.3 SCM_RIGHTS、61.13.4 凭证 |
| C | 第 11 章 11.4–11.6 | 2.17.1–2.17.5 | 完整(套接字接口、Web 服务器、TINY) |
| N | 第 6 章 深度理解 TCP 连接建立过程 | 2.17.9 | 背景(EADDRINUSE、队列) |
| N | 第 7 章 一条 TCP 连接消耗多大内存 | 2.17.6 | 背景(缓冲区与内存) |
| N | 第 8 章 一台机器最多能支持多少条 TCP 连接 | 2.17.6、2.17.9 | 背景(fd、端口、SO_REUSEPORT) |
| W | 第 5 章 运输层 | 2.17.4–2.17.5 | 背景(FIN/RST、超时重传) |
| W | 第 6 章 应用层 | 2.17.1 | 背景(HTTP 报文边界) |
下一站:2.18 备选 I/O 把「一条连接一个线程/进程」换成事件驱动:
select/poll/epoll的语义差异、LT 与 ET 的写法、io_uring如何把系统调用也批量化。