Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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]解析简单、可预分配、二进制安全需要知道最大长度,防 DoSgRPC、自定义 RPC
分隔符payload\n 或 \r\n可读、流式内容不能含分隔符,需转义;单条长度不定HTTP/1 头、Redis、SMTP
定长固定 N 字节最简单、可定长索引浪费带宽,灵活性差二进制协议、传感器

长度前缀是最通用的选择。协议格式:

+----------------+--------------------------+
| len: 4 字节     | payload: len 字节         |
| 网络字节序      | 任意二进制                |
+----------------+--------------------------+

四个必须定的规则:

  1. 长度是否包含头部:本文约定「只算 payload」,头部固定 4 字节;
  2. 字节序:网络字节序(大端),用 htonl/ntohl;
  3. 最大长度:接收端必须在分配内存前检查,否则一个 0xFFFFFFFF 就能让服务端 OOM;
  4. 长度为 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最简单、无锁一个慢客户端阻塞所有人低并发、教学、健康检查
每连接 forkaccept 后 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) {}  /* 回收僵尸 */
}
并发服务器的四个坑
  1. 父进程不关 cfd:连接 fd 泄漏,最终 EMFILE;子进程不关 lfd,监听 fd 永远不释放。
  2. SIGCHLD 默认忽略但僵尸仍存在:要 waitpid(-1, WNOHANG) 循环回收,或 SA_NOCLDWAIT。
  3. accept 的惊群:多进程阻塞在同一 lfd 上,新连接唤醒所有进程;Linux 用 EPOLLEXCLUSIVE/SO_REUSEPORT 缓解。
  4. 每连接线程不设栈/上限:默认 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

三个设计点:

  1. MAX_FRAME 检查在分配/读取之前。长度字段来自对端,不能信。len > MAX_FRAME 直接断开,不读负载——继续读等于帮攻击者消耗带宽。
  2. 头部与负载一起写回。分两次 write 会让对端可能先收到头部、后收到负载(中间还夹着其他连接的数据),虽然 TCP 顺序不变,但多一次系统调用与一次潜在的 Nagle 延迟。
  3. read_full 区分「边界处 EOF」与「中途 EOF」。前者是正常关闭(返回 0),后者是截断(返回 -1)。如果把两者都当正常关闭,日志里会丢掉「对端发到一半崩了」的线索。
关于分帧的四个误解
  1. 「一次 read 就是一条消息」——只在消息小于 MSS 且没有并发时偶尔成立。压测、跨公网、TLS 下都会拆/粘。
  2. 「recv 返回 0 表示没有数据」——返回 0 是 EOF,对端关闭了写方向;「暂时没数据」是非阻塞下的 EAGAIN。
  3. 「长度前缀可以随便定最大长度」——接收端必须在读取前检查上限。malloc(ntohl(len)) 是远程 OOM 的经典入口。
  4. 「分隔符协议不用转义」——内容里出现分隔符就会提前截断。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)才能发现「进程还在但线程池堵死」的情况。

关于超时的三个误解
  1. 「SO_RCVTIMEO 超时返回 ETIMEDOUT」——Linux 返回 EAGAIN/EWOULDBLOCK。判断超时要看这个,不是 ETIMEDOUT。
  2. 「设了 TCP_KEEPALIVE 就能发现死连接」——只能发现链路层/对端内核不响应;对端进程崩溃但内核还在(比如容器 paused)时,keepalive 仍会成功。
  3. 「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

四个必查点:

  1. msg_controllen 在发送前要设为 CMSG_SPACE(sizeof(int)),不是 sizeof control(后者可能更大,内核会拒绝或传输多余字节)。
  2. 接收方检查 MSG_CTRUNC:控制缓冲区不够大时,fd 会被截断丢弃;不检查就会误以为「没有控制信息」。
  3. 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说明
sendssize_t send(int fd, const void *buf, size_t len, int flags)已发送字节数(可能短写)EAGAIN、EINTR、EPIPE、ECONNRESET、ENOTCONN、EMSGSIZE、ENOBUFS已连接 socket;flags 见下表
recvssize_t recv(int fd, void *buf, size_t len, int flags)字节数;0 表示 EOFEAGAIN、EINTR、ECONNRESET、ENOTCONN0 不是「暂时没数据」
sendtossize_t sendto(int fd, const void *buf, size_t len, int flags, const struct sockaddr *dst, socklen_t dlen)字节数同上 + EAFNOSUPPORT、EINVAL数据报 socket 必须给地址
recvfromssize_t recvfrom(int fd, void *buf, size_t len, int flags, struct sockaddr *src, socklen_t *slen)字节数同上 + EINVALslen 是值-结果参数,调用前必须初始化
sendmsgssize_t sendmsg(int fd, const struct msghdr *msg, int flags)字节数同上 + EINVAL、EMSGSIZE可带辅助数据、分散聚合
recvmsgssize_t recvmsg(int fd, struct msghdr *msg, int flags)字节数同上 + EINVAL可收辅助数据;msg_flags 返回截断标志
sendmmsgint sendmmsg(int fd, struct mmsghdr *vec, unsigned int vlen, int flags)已发送的消息数同 sendmsgLinux 3.0;批量发送数据报
recvmmsgint recvmmsg(int fd, struct mmsghdr *vec, unsigned int vlen, int flags, struct timespec *timeout)收到的消息数同 recvmsg + ETIMEDOUTLinux 2.6.33;批量接收
shutdownint shutdown(int fd, int how)0EBADF、ENOTCONN、EINVALSHUT_RD/SHUT_WR/SHUT_RDWR;不影响 fd 本身
getsocknameint getsockname(int fd, struct sockaddr *addr, socklen_t *len)0EBADF、EFAULT、ENOTSOCK取本地地址;len 值-结果
getpeernameint getpeername(int fd, struct sockaddr *addr, socklen_t *len)0EBADF、EFAULT、ENOTCONN、ENOTSOCK取对端地址;连接关闭后可能返回旧值

send/recv 与 write/read 在已连接 socket 上等价,区别只有 flags。send 的短写条件比管道更多:发送缓冲区不足、被信号打断、非阻塞、异步错误,都可能只发一部分。永远不要假设一次 send 发完。

flags 位表

flag适用语义常见误用
MSG_DONTWAITsend/recv本次调用非阻塞以为设置了它就能替代 O_NONBLOCK(只影响本次)
MSG_NOSIGNALsend不产生 SIGPIPE,返回 EPIPE忘了它,进程被 SIGPIPE 杀掉
MSG_MOREsend提示后续还有数据,内核可攒包最后一段忘了去掉,数据被延迟
MSG_PEEKrecv偷看数据但不消费以为 PEEK 后数据就没了
MSG_WAITALLrecv尽量等满 len信号/对端关闭/出错时仍会短读
MSG_TRUNCrecv返回数据报的真实长度(即使缓冲区小)用在流式 socket 上语义不同
MSG_CTRUNCrecvmsg 输出控制信息被截断不检查就使用 fd,导致泄漏/错误
MSG_OOBsend/recv带外数据(TCP 紧急模式)现代协议不应依赖;TCP 只支持 1 字节
MSG_ERRQUEUErecvmsg读错误队列(IP_RECVERR)需要先开 IP_RECVERR
MSG_CMSG_CLOEXECrecvmsg收到的 SCM_RIGHTS fd 设 FD_CLOEXEC不加则 fd 会泄漏给 exec 的子进程
MSG_ZEROCOPYsend零拷贝发送(Linux 4.14)需要 SO_ZEROCOPY 与完成通知,不是免费加速

errno 速查:连接类错误

errno典型来源含义处理
EAGAIN/EWOULDBLOCK非阻塞 send/recv现在不能收发;SO_RCVTIMEO 超时也返回它等 poll/epoll,不要当致命错误
EINTR任意阻塞调用被信号打断重试;注意已传输字节数
EPIPEsend对端已关闭读方向关闭连接;配合 MSG_NOSIGNAL 避免 SIGPIPE
ECONNRESETrecv/send对端发了 RST(崩溃、SO_LINGER 0、半开连接)关闭连接;不要重试同一连接
ENOTCONNsend/recv/getpeernamesocket 未连接检查 connect/accept 流程
ESHUTDOWNsend本端已 shutdown(SHUT_WR)不能再写;读方向可能仍可用
ETIMEDOUTconnect/sendTCP 重传超时或连接超时关闭;重连要退避
ECONNREFUSEDconnect对端端口没有监听(收到 RST)检查服务是否启动/端口是否正确
EHOSTUNREACH/ENETUNREACHconnect/send路由不可达检查路由/防火墙;通常不可重试
EADDRINUSEbind地址/端口已被占用查监听进程与 TIME_WAIT,见 2.17.9
EADDRNOTAVAILbind地址不属于本机检查 IP 是否配置在网卡上
EINPROGRESS非阻塞 connect连接正在进行poll(POLLOUT) 后查 SO_ERROR
EALREADY非阻塞 connect 重试上一次连接还没完成继续等,不要重复发起
EISCONNconnect 重试已经连接成功视为成功,继续 I/O
EMSGSIZEsend/sendto数据报超过上限分片或改用流式协议
ENOBUFSsend内核缓冲区/内存不足退避重试;检查是否被压垮

结构体

struct msghdr 是 sendmsg/recvmsg 的核心:

字段类型方向含义
msg_namevoid *入/出数据报的对端地址;流式 socket 可为 NULL
msg_namelensocklen_t入/出地址缓冲区长度;recvmsg 返回实际长度
msg_iovstruct iovec *入分散/聚合缓冲区数组
msg_iovlensize_t入数组元素个数;上限 IOV_MAX(通常 1024)
msg_controlvoid *入/出辅助数据缓冲区
msg_controllensize_t入/出辅助数据缓冲区长度;recvmsg 返回实际长度
msg_flagsint出MSG_CTRUNC/MSG_TRUNC/MSG_EOR 等

struct cmsghdr 描述一条辅助数据:

字段类型含义
cmsg_lensize_t整条控制消息长度,含头部;用 CMSG_LEN() 计算
cmsg_levelint协议层,如 SOL_SOCKET
cmsg_typeint类型,如 SCM_RIGHTS
数据紧跟头部用 CMSG_DATA() 取指针;必须按 CMSG_ALIGN 对齐

struct ucred(Linux):

字段类型含义
pidpid_t对端进程 ID;可能被回收复用
uiduid_t对端真实/有效 UID(SO_PEERCRED 取有效 UID)
gidgid_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
首次调用,进行中-1EINPROGRESSpoll(POLLOUT)
再次调用,仍在进行-1EALREADY继续 poll
再次调用,已完成-1EISCONN视为成功
poll 返回可写——查 SO_ERROR
SO_ERROR != 0—错误码关闭,按错误类型重试/放弃

三个细节:

  1. poll(POLLOUT) 可写不等于连接成功。连接失败时 socket 也会变成「可写」(错误状态可写),必须 getsockopt(SO_ERROR) 取具体错误。SO_ERROR 读取后会被清零,所以只读一次。
  2. EINTR 不表示连接失败。信号打断 poll 后,连接仍在进行;重算剩余超时再 poll,不要重新 connect。
  3. connect 失败后 socket 状态不确定。除 EINPROGRESS/EALREADY/EISCONN 外,其余错误都应 close 后重建 socket;不要在同一 fd 上反复 connect。

TCP_USER_TIMEOUT(毫秒)控制的是已建立连接的重传超时,不是连接建立超时;连接建立超时由 tcp_syn_retries 决定。两者不要混。

关于非阻塞 connect 的四个误解
  1. 「poll(POLLOUT) 返回就说明连上了」——失败也会可写。必须查 SO_ERROR。
  2. 「SO_SNDTIMEO 能控制 connect 超时」——Linux 上不能。用非阻塞 connect + poll。
  3. 「connect 返回 EINTR 要重新 connect」——不要。连接仍在进行,重新 connect 会得到 EALREADY 或打乱状态。
  4. 「非阻塞 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_RIGHTS fd 都是一次引用,泄漏会耗尽 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 的连接级凭证已经够用。

关于辅助数据的五个误解
  1. 「msg_controllen 填 sizeof(control) 就行」——发送时应填 CMSG_SPACE(len);填大了可能被内核拒绝,填小了会截断。
  2. 「cmsg_len 用 CMSG_SPACE」——cmsg_len 用 CMSG_LEN,CMSG_SPACE 只用于缓冲区大小。
  3. 「收到的 fd 编号和发送方一样」——不保证。fd 编号是进程本地的。
  4. 「传完 fd 后发送方可以立刻关,接收方就读不到了」——可以关;内核持有引用,接收方仍能读。
  5. 「SO_PEERCRED 的 pid 可以当唯一身份」——pid 会复用。用 uid/gid + 应用层令牌,或用 pidfd。

2.17.13 内核路径:从 send 到 sk_buff,从 recvmsg 到 scm

这一节回答:send 的数据在哪一层被复制?accept 返回的新 socket 从哪来?SCM_RIGHTS 的 fd 在内核里怎么挂到消息上?

TCP 发送与接收

用户动作内核路径关键数据结构
send/writesock_sendmsg → tcp_sendmsg → tcp_sendmsg_lockedstruct sock、struct sk_buff
攒包/Nagletcp_push → tcp_nagle_test/tcp_corktp->nonagle、TCP_NAGLE_OFF
接收tcp_recvmsg → skb_copy_datagram_msgsk_receive_queue、sk_buff
acceptinet_csk_accept → inet_csk_wait_for_connecticsk_accept_queue、request_sock
关闭tcp_close/tcp_shutdownTCP_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_RIGHTSunix_stream_sendmsg → scm_send → scm_fp_copystruct scm_fp_list、struct scm_cookie
recvmsgunix_stream_recvmsg → scm_recv → scm_detach_fdsscm_cookie、接收方 files_struct
SO_PEERCREDunix_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 只是减少引用计数,不影响接收方。

套接字排障的观测点
  1. ss -tanp:看 ESTABLISHED/TIME-WAIT/CLOSE-WAIT 与队列。
  2. ss -lntp:看监听队列溢出(Recv-Q/Send-Q)。
  3. tcpdump -i any -nn 'tcp port 8080':看 SYN/FIN/RST 与重传。
  4. strace -f -e trace=connect,sendmsg,recvmsg,shutdown:看用户态调用与返回值。
  5. cat /proc/net/tcp:看内核视角的四元组与状态。
  6. 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 是两种不同的「连接坏了」。
连接问题的排查顺序
  1. ss -tanp 看状态:SYN-SENT(连不上)、CLOSE-WAIT(应用没关)、TIME-WAIT(主动关闭方)。
  2. ss -lntp 看监听队列:Recv-Q 接近 backlog 说明 accept 太慢。
  3. tcpdump -nn -i any 'tcp port N' 看 SYN/SYN-ACK/RST/FIN 与重传。
  4. strace -f -e trace=connect,sendmsg,recvmsg,shutdown 看返回值与 errno。
  5. cat /proc/net/tcp 看内核四元组与 tx_queue/rx_queue。
  6. bpftrace 挂 tcp_retransmit_skb、tcp_drop 看丢包/重传。

2.17.18 陷阱清单:套接字编程的十七个现场

#现象原因修复
1把 recv 返回 0 当成「暂时没数据」0 是 EOF非阻塞无数据是 EAGAIN;0 表示对端关闭写方向
2一次 send 以为发完短写:缓冲区满/信号/非阻塞循环发送,保存已发偏移
3进程被 SIGPIPE 杀掉对端关闭后继续写忽略 SIGPIPE 或用 MSG_NOSIGNAL
4SO_RCVTIMEO 超时判断成 ETIMEDOUTLinux 返回 EAGAIN判断 EAGAIN/EWOULDBLOCK
5connect 超时不可控SO_SNDTIMEO 不影响 connect非阻塞 connect + poll
6poll(POLLOUT) 后直接发数据失败也会可写查 SO_ERROR
7非阻塞 connect 后 fd 仍非阻塞O_NONBLOCK 是文件描述状态fcntl 清掉,或全程非阻塞
8EINTR 后重新 connect连接仍在进行继续 poll,不要重连
9读到 ECONNRESET 后重试同一连接对端已 RST关闭,重建连接
10shutdown(SHUT_RD) 后对端写触发 RST内核拒绝到达的数据不要只关读方向;用协议表达关闭
11close 时有未读数据,对端丢响应内核发 RST 而不是 FIN先读完,或 SO_LINGER(慎用)
12SO_LINGER 卡住事件循环close 阻塞到数据发完非阻塞程序不要用,或设短超时
13MSG_CTRUNC 后仍使用 fd控制信息被截断检查 MSG_CTRUNC,关闭连接,不用任何 fd
14传 fd 后接收方 fd 泄漏没 close 收到的每个 fd遍历 cmsg,逐个 close
15SCM_RIGHTS fd 泄漏给 exec 子进程没设 MSG_CMSG_CLOEXEC接收时加 MSG_CMSG_CLOEXEC
16SO_PEERCRED 的 pid 被复用pid 是短期标识用 uid/gid + 令牌,或 pidfd
17EADDRINUSE 以为都是 TIME_WAIT可能是别的进程在监听ss -lntp 先看监听者,再看 TIME_WAIT
18TCP_NODELAY 开了仍然慢对端延迟 ACK/应用层攒包用 tcpdump 看实际发送时间;配合 TCP_CORK/MSG_MORE
19sendmsg 的 iovlen 超限IOV_MAX(通常 1024)分批发送或合并缓冲区
20recvfrom 的 addrlen 没初始化值-结果参数被当成入参调用前设成 sizeof(addr)
版本与平台差异
  1. accept4 是 Linux 2.6.28 起;老内核只有 accept,需要 fcntl 两次。
  2. MSG_CMSG_CLOEXEC 是 Linux 2.6.23 起;老内核收到的 fd 需要手动 fcntl(FD_CLOEXEC)。
  3. SO_PEERCRED 是 Linux 特有;BSD 用 LOCAL_PEERCRED/getpeereid,语义不同。
  4. MSG_ZEROCOPY 是 Linux 4.14 起,且需要网卡/协议栈支持;不是所有 socket 都能用。
  5. SO_REUSEPORT 是 Linux 3.9 起;老内核只有 SO_REUSEADDR,多进程绑同一端口会失败。
  6. 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知道超时返回 EAGAIN2.17.4
用 SCM_RIGHTS 传 fd检查 MSG_CTRUNC 并关闭接收的 fd2.17.7
用 SO_PEERCRED 取对端身份知道只对 UNIX socket 有效2.17.8
排查 EADDRINUSE能区分监听进程与 TIME_WAIT2.17.9
即时自测 · 2.17

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.62.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 如何把系统调用也批量化。