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.16 套接字基础

本章回答

一个 socket 也是文件描述符,那它和普通文件差在哪?bind/listen/accept 每一步在内核里各做了什么?为什么 accept 返回的新 fd 不继承 O_NONBLOCK?TCP 与 UDP 在编程接口上到底哪些地方不同?

读完你会

写出一个能处理 EINTR、短读短写、SIGPIPE 的最小 TCP echo 服务端与配套客户端;用 getaddrinfo 写出同时支持 IPv4/IPv6 的连接代码;解释 SO_REUSEADDR、backlog、TIME_WAIT 的关系;用 socketpair 做本机双向通信。

2.14 与 2.15 讲的是「同一台机器上的进程通信」。这一章把通信推到网络上:两个进程不再共享内核里的管道或共享内存,只能通过协议交换字节。编程接口仍然是 fd——socket() 返回一个文件描述符,read/write 照样能用——但语义变了:读写可能短、可能阻塞、可能被信号打断,对端可能在任何时刻消失。

TCP 提供「可靠、有序、双向字节流」,UDP 提供「不可靠、有边界的数据报」。两者在内核里是两套协议栈,在 API 上只差几个参数和几个函数。真正难的不是记住 socket/bind/listen/accept,而是理解 TCP 的字节流没有消息边界:你写两次 100 字节,对端可能一次读到 200 字节,也可能分十次读到 20 字节。这个事实决定了 2.17 的长度前缀协议,也决定了为什么「一次 read 就是一条消息」的代码在压测时必然出错。

这一章先打地基:地址结构、连接建立、最小可用的 TCP 服务端与客户端、UDP 数据报、UNIX domain socket。所有例子都用 getaddrinfo 而不是 inet_addr,因为它们要能在 IPv6-only 的环境里跑起来。

2.16.1 socket 是 fd,但不是一个「文件」

定义 2.16.1 · 套接字(socket)

套接字是内核里的一个通信端点,由 (domain, type, protocol) 三元组创建:

  • domain(地址族):AF_INET(IPv4)、AF_INET6、AF_UNIX(本机);
  • type:SOCK_STREAM(字节流,TCP/UNIX stream)、SOCK_DGRAM(数据报,UDP)、SOCK_SEQPACKET(有边界的有序报文,UNIX 支持)、SOCK_RAW;
  • protocol:通常填 0,由前两者决定。

socket() 返回一个 fd,进入进程的 fd 表,表项指向 struct file,后者指向 struct socket 与 struct sock。所以 read/write/close/fcntl/poll 都能用,但 lseek 会返回 ESPIPE。

「socket 也是 fd」带来三个直接好处:

  1. 统一的事件循环:epoll 同时监听 socket、管道、timerfd、signalfd(2.18);
  2. 统一的继承规则:fork 后子进程共享同一个 socket(偏移概念不适用,但连接状态共享),exec 时受 FD_CLOEXEC 控制;
  3. 统一的权限模型:/proc/<pid>/fd 能看到 socket,close 是唯一的释放方式。

差异也要记牢:

维度普通文件socket
lseek支持ESPIPE
read 返回 0EOF(文件末尾)对端关闭写方向(收到 FIN)
write 失败ENOSPC、EDQUOTEPIPE(对端关闭)+ SIGPIPE
短读/短写少见(普通文件)常态
并发多进程写同一文件需锁内核保证字节流顺序,但消息边界要自己做
O_NONBLOCK对普通文件基本无效决定 accept/connect/read 是否阻塞

SOCK_CLOEXEC 与 SOCK_NONBLOCK 可以在 socket() 的 type 里直接指定(Linux 2.6.27+),避免「创建后再 fcntl」的竞态:

int fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);

2.16.2 地址结构:sockaddr 家族

所有 socket 地址结构都以 sa_family_t sa_family 开头,系统调用用 struct sockaddr * 加长度来传递,内核根据 family 决定怎么解释后面的字节。这种「通用头 + 具体结构」的设计让 bind/connect 能同时接受 IPv4、IPv6、UNIX 地址。

#include <netinet/in.h>
#include <sys/un.h>

struct sockaddr_in {              /* IPv4 */
    sa_family_t    sin_family;    /* AF_INET */
    in_port_t      sin_port;      /* 网络字节序 */
    struct in_addr sin_addr;      /* 网络字节序 */
    /* 8 字节 padding,历史遗留 */
};

struct sockaddr_in6 {             /* IPv6 */
    sa_family_t     sin6_family;  /* AF_INET6 */
    in_port_t       sin6_port;
    uint32_t        sin6_flowinfo;
    struct in6_addr sin6_addr;
    uint32_t        sin6_scope_id; /* 链路本地地址的接口索引 */
};

struct sockaddr_un {              /* UNIX domain */
    sa_family_t sun_family;       /* AF_UNIX */
    char        sun_path[108];    /* 路径或抽象名字 */
};

两个必须遵守的规则:

  1. 端口与地址用网络字节序。sin_port = htons(8080),sin_addr 用 inet_pton 或 INADDR_ANY。直接把主机字节序的整数塞进去,在 x86 上会变成 0x1F90 → 0x901F(端口 36919),而且在小端机器上「看起来能跑」的代码到大端机器就错了。
  2. 传长度。bind(fd, (struct sockaddr *)&addr, sizeof addr),不是 sizeof(struct sockaddr)。内核用长度判断地址是否完整、是否需要截断。

IPv4 地址的两种写法:

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = htonl(INADDR_ANY);          /* 0.0.0.0,所有网卡 */

/* 或者用文本转换,失败返回 0 */
if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) {
    fprintf(stderr, "bad address\n");
}

inet_ntop 是反方向,输出用 INET_ADDRSTRLEN(16)或 INET6_ADDRSTRLEN(46)大小的缓冲区。不要用 inet_ntoa:它返回静态缓冲区,不可重入。

关于地址结构的四个误解
  1. 「sin_port 直接赋值端口号」——必须 htons。字节序错了端口就变了,且只有跨架构才暴露。
  2. 「sizeof(struct sockaddr) 可以当长度」——它只有 16 字节,比 sockaddr_in 小;IPv6 的 sockaddr_in6 是 28 字节。必须传具体结构的大小。
  3. 「sockaddr_un 的路径随便填」——sun_path 只有 108 字节,必须 strncpy 并保证结尾有 \0(或使用抽象命名空间时不加 \0)。
  4. 「INADDR_ANY 是 127.0.0.1」——是 0.0.0.0,绑定所有网卡。只监听本机要用 127.0.0.1。

2.16.3 bind、listen 与两个连接队列

服务端的三步:socket → bind → listen。

  • bind 把本地地址(IP + 端口)绑定到 socket。端口填 0 表示让内核分配临时端口,之后用 getsockname 取回。
  • listen 把 socket 从「主动」转为「被动」,第二个参数是 backlog:已完成三次握手、等待 accept 的连接队列上限。
TCP 三次握手与两个连接队列 SYN 进入半连接队列,ACK 后移入 accept 队列,accept() 取走。 CLIENTSERVERTCP 三次握手与连接队列SYN seq=xSYN+ACK seq=y, ack=x+1ACK seq=x+1, ack=y+1SYN queue半连接:收到 SYN,已回 SYN-ACKaccept queue全连接:三次握手完成,等待 accept收到 ACKbacklog = min(somaxconn, listen(fd, n))队列满:默认丢弃 / 忽略 ACKSYN 队列长度由 tcp_max_syn_backlog 控制;accept 队列由 listen backlog 控制;ss -lnt 的 Recv-Q 是后者当前长度
读图:上半部分是报文时序,下半部分是服务端内核的两个队列。注意:listen() 的 backlog 只约束 accept 队列;SYN flood 打的是 SYN 队列(tcp_syncookies 是另一条防线)。ss -lnt 的 Recv-Q 就是 accept 队列里等待被取走的连接数。

读图:客户端发 SYN,服务端回 SYN+ACK,这条连接先进入 SYN 队列(半连接队列);客户端回 ACK 后,连接移入 accept 队列(全连接队列),等待应用调用 accept 取走。两个队列各有上限:SYN 队列受 net.ipv4.tcp_max_syn_backlog 约束,accept 队列受 listen(fd, backlog) 与 net.core.somaxconn 的较小值约束。ss -lnt 的 Recv-Q 是当前 accept 队列长度,Send-Q 是 backlog 上限。

两个队列溢出时的行为不同:

  • SYN 队列满:新的 SYN 被丢弃(或启用 syncookie),客户端重传;
  • accept 队列满:服务端通常忽略客户端的 ACK,让客户端重传,表现为「连接建立慢」而不是「立即拒绝」。

这就是为什么「accept 慢」会导致客户端超时,而不是收到 ECONNREFUSED。ECONNREFUSED 只在端口没人监听、或防火墙回 RST 时出现。

int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1) {
    perror("setsockopt SO_REUSEADDR");
}
if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
    perror("bind");
}
if (listen(lfd, 128) == -1) {
    perror("listen");
}

SO_REUSEADDR 的作用是允许 bind 一个处于 TIME_WAIT 的本地地址(以及在某些情况下绑定通配地址与具体地址的组合)。服务端几乎总要开它,否则重启时会因为旧连接的 TIME_WAIT 而 EADDRINUSE。它不是「允许多个进程绑同一端口」——那是 SO_REUSEPORT 的语义(2.17)。

2.16.4 accept 与 connect:阻塞、非阻塞与 EINPROGRESS

accept 从 accept 队列取一条已完成握手的连接,返回一个新的 fd。这个新 fd 与监听 fd 是两个不同的 socket:

  • 监听 fd 继续 accept 新连接;
  • 新 fd 代表这条连接,读写都在它上面;
  • 新 fd 不继承监听 fd 的 O_NONBLOCK(Linux 行为)。监听 fd 是非阻塞的,accept 返回的连接默认仍是阻塞的。要非阻塞,用 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。
struct sockaddr_storage peer;
socklen_t peer_len = sizeof peer;
int cfd = accept4(lfd, (struct sockaddr *)&peer, &peer_len,
                  SOCK_CLOEXEC | SOCK_NONBLOCK);
if (cfd == -1) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        /* 非阻塞监听 fd:当前没有新连接 */
    } else if (errno == EINTR) {
        /* 被信号打断,重试 */
    } else if (errno == EMFILE || errno == ENFILE) {
        /* fd 耗尽:常见于 fd 泄漏或连接风暴 */
    } else {
        perror("accept4");
    }
}

EMFILE 是服务端的经典故障:进程 fd 用完了,accept 返回 EMFILE,但连接仍在队列里,客户端看起来「连上了但不响应」。正确做法是预留一个空闲 fd,遇到 EMFILE 时关掉它、accept 一条连接、立刻 close,给日志留出空间,再重新打开预留 fd。

connect 的行为分两种:

  • 阻塞 socket:connect 等三次握手完成才返回;失败返回 -1,errno 为 ECONNREFUSED、ETIMEDOUT、ENETUNREACH 等;
  • 非阻塞 socket:connect 通常立刻返回 -1、errno = EINPROGRESS,连接在后台继续。用 poll 等 POLLOUT,再 getsockopt(SOL_SOCKET, SO_ERROR) 取结果。
int rc = connect(fd, res->ai_addr, res->ai_addrlen);
if (rc == -1 && errno == EINPROGRESS) {
    struct pollfd pfd = { .fd = fd, .events = POLLOUT };
    if (poll(&pfd, 1, 5000) <= 0) { /* 超时或出错 */ }
    int err = 0;
    socklen_t len = sizeof err;
    if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == -1 || err != 0) {
        /* 连接失败,err 是原因 */
    }
}

一个容易忽略的点:阻塞 connect 被信号打断时返回 EINTR,此时连接可能仍在后台进行,不能直接重试 connect(会得到 EALREADY)。正确做法是改用 poll + SO_ERROR 确认状态,或者干脆用非阻塞 connect。

2.16.5 最小 TCP 服务端

下面的服务端是「完整但最小」的:getaddrinfo 解析、SO_REUSEADDR、accept 循环、短读短写、EINTR、SIGPIPE 全部处理。它一次只服务一条连接(迭代型),够用来验证协议;并发版本在 2.17 与 2.18。

/* tcp_echo_server.c:最小的迭代型 TCP echo 服务端 */
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define PORT "12345"
#define BACKLOG 16
#define BUF_SIZE 4096

static volatile sig_atomic_t stop;

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

static void on_sigint(int sig)
{
    (void)sig;
    stop = 1;
}

int main(void)
{
    /* Ctrl-C 只置标志,不直接退出,便于关闭监听 fd */
    struct sigaction sa = {0};
    sa.sa_handler = on_sigint;
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGINT, &sa, NULL) == -1)
        die("sigaction SIGINT");

    /* 写已关闭的连接会触发 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;      /* NULL host → 通配地址 */

    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("listening on port %s\n", PORT);
    fflush(stdout);

    char buf[BUF_SIZE];
    while (!stop) {
        struct sockaddr_storage peer;
        socklen_t peer_len = sizeof peer;
        int cfd = accept(lfd, (struct sockaddr *)&peer, &peer_len);
        if (cfd == -1) {
            if (errno == EINTR)
                continue;             /* 信号打断,回到循环看 stop */
            perror("accept");
            break;
        }

        char host[NI_MAXHOST], serv[NI_MAXSERV];
        if (getnameinfo((struct sockaddr *)&peer, peer_len,
                        host, sizeof host, serv, sizeof serv,
                        NI_NUMERICHOST | NI_NUMERICSERV) == 0)
            printf("connection from %s:%s\n", host, serv);

        for (;;) {
            ssize_t n = read(cfd, buf, sizeof buf);
            if (n == 0)
                break;                /* 对端关闭写方向 */
            if (n == -1) {
                if (errno == EINTR)
                    continue;
                perror("read");
                break;
            }

            ssize_t off = 0;
            while (off < n) {
                ssize_t w = write(cfd, buf + off, (size_t)(n - off));
                if (w == -1) {
                    if (errno == EINTR)
                        continue;
                    perror("write");
                    break;
                }
                off += w;
            }
            if (off < n)
                break;                /* 写失败,放弃这条连接 */
        }

        if (close(cfd) == -1)
            perror("close cfd");
        printf("connection closed\n");
    }

    if (close(lfd) == -1)
        perror("close lfd");
    printf("server stopped\n");
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tcp_echo_server.c -o tcp_echo_server
./tcp_echo_server

另开一个终端:

printf 'hello\nworld\n' | nc 127.0.0.1 12345
hello
world

服务端输出:

listening on port 12345
connection from 127.0.0.1:52344
connection closed

关键行:

  • sigaction(SIGINT, ...) 不设 SA_RESTART,让阻塞的 accept 返回 EINTR,循环才能检查 stop;
  • SO_REUSEADDR 在 bind 之前设置,解决重启时的 EADDRINUSE;
  • getaddrinfo 返回的列表要遍历,直到某个地址 bind 成功;freeaddrinfo 不能漏;
  • 读循环里 n == 0 是对端 close/shutdown(SHUT_WR),不是错误;
  • 写循环处理短写,off < n 表示失败,直接关连接。

配套客户端

/* tcp_echo_client.c:连接服务端,发送一行,读回 echo */
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    const char *host = argc > 1 ? argv[1] : "127.0.0.1";
    const char *port = argc > 2 ? argv[2] : "12345";
    const char *msg = argc > 3 ? argv[3] : "hello from client\n";

    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");

    size_t len = strlen(msg);
    size_t off = 0;
    while (off < len) {
        ssize_t n = write(fd, msg + off, len - off);
        if (n == -1) {
            if (errno == EINTR)
                continue;
            die("write");
        }
        off += (size_t)n;
    }

    /* 告诉服务端「我写完了」,但仍可以读 */
    if (shutdown(fd, SHUT_WR) == -1)
        die("shutdown");

    char buf[1024];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof buf);
        if (n == 0)
            break;
        if (n == -1) {
            if (errno == EINTR)
                continue;
            die("read");
        }
        if (write(STDOUT_FILENO, buf, (size_t)n) == -1)
            die("write stdout");
    }

    if (close(fd) == -1)
        die("close");
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tcp_echo_client.c -o tcp_echo_client
./tcp_echo_client 127.0.0.1 12345 "ping"

客户端用 shutdown(SHUT_WR) 发送 FIN,服务端读到 EOF 后关闭连接,客户端再读到 EOF 退出。如果客户端直接 close(fd),它就不能再读回 echo 了——close 会同时关闭读写两个方向。这是 2.17 优雅关闭的起点。

2.16.6 名字与地址:getaddrinfo 与 getnameinfo

gethostbyname 是 IPv4 时代的接口,不可重入、不支持 IPv6、不能指定服务类型。现代代码用 getaddrinfo:

struct addrinfo hints = {0};
hints.ai_family = AF_UNSPEC;         /* IPv4 或 IPv6 都行 */
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;         /* 给 bind 用:NULL host → 通配地址 */

struct addrinfo *res;
int rc = getaddrinfo(NULL, "8080", &hints, &res);
if (rc != 0) {
    fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
    return EXIT_FAILURE;
}
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
    /* 依次尝试 ai->ai_family / ai_socktype / ai_protocol */
}
freeaddrinfo(res);

getaddrinfo 的三个返回值层次要分清:

  • 返回 0:成功,res 是链表;
  • 返回 EAI_SYSTEM:系统错误,原因在 errno;
  • 其他 EAI_*:解析错误,用 gai_strerror(rc) 转文字。

AI_ADDRCONFIG 只返回本机配置了对应地址族的记录(IPv6-only 环境不会返回 A 记录);AI_NUMERICHOST 禁止 DNS 查询;AI_CANONNAME 要求返回规范主机名。服务端用 AI_PASSIVE,客户端不要用。

反向转换用 getnameinfo:

char host[NI_MAXHOST], serv[NI_MAXSERV];
int rc = getnameinfo((struct sockaddr *)&peer, peer_len,
                     host, sizeof host, serv, sizeof serv,
                     NI_NUMERICHOST | NI_NUMERICSERV);

NI_NUMERICHOST 禁止反查 DNS——日志里打印对端地址时应该加上,否则每个连接都要等一次 DNS,慢且可能被伪造的 PTR 记录影响。

2.16.7 UDP:有边界、不保证到达

UDP 的接口只有四个函数:sendto、recvfrom、以及可选的 connect/send/recv。与 TCP 的根本差异:

维度TCPUDP
连接三次握手,面向连接无连接,每个数据报独立
边界字节流,无边界保留发送方的数据报边界
可靠性确认、重传、排序不保证到达、不保证顺序、可能重复
流量控制滑动窗口、拥塞控制无
read 语义可能短读,返回 0 是 EOF一次返回一个完整数据报;缓冲区小则截断
典型用途HTTP、SSH、数据库DNS、日志、音视频、服务发现
/* udp_echo_server.c:UDP echo 服务端 */
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define PORT "12346"
#define BUF_SIZE 2048

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main(void)
{
    struct addrinfo hints = {0};
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_DGRAM;
    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 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 (bind(fd, ai->ai_addr, ai->ai_addrlen) == 0)
            break;
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    if (fd == -1)
        die("bind");

    printf("udp echo server on %s\n", PORT);
    fflush(stdout);

    char buf[BUF_SIZE];
    for (;;) {
        struct sockaddr_storage peer;
        socklen_t peer_len = sizeof peer;
        ssize_t n = recvfrom(fd, buf, sizeof buf, 0,
                             (struct sockaddr *)&peer, &peer_len);
        if (n == -1) {
            if (errno == EINTR)
                continue;
            perror("recvfrom");
            break;
        }
        printf("datagram %zd bytes\n", n);
        if (sendto(fd, buf, (size_t)n, 0,
                   (struct sockaddr *)&peer, peer_len) != n) {
            perror("sendto");
        }
    }

    close(fd);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g udp_echo_server.c -o udp_echo_server
./udp_echo_server
# 另一个终端:
printf 'first' | nc -u -w1 127.0.0.1 12346
first

连续发送两个数据报(比如用 nc 两次,或自己写客户端调两次 sendto),服务端会 recvfrom 两次,每次一个完整数据报;即使它们在网络上被合并成一个 IP 分片、或在同一个缓冲区里到达,UDP 层也会把边界还原。TCP 没有这个保证——这是「UDP 适合消息、TCP 适合流」的接口层原因。

recvfrom 的缓冲区不够大时,数据报被截断,多余部分丢弃,返回值是缓冲区大小(除非用 MSG_TRUNC 拿到真实长度)。TCP 没有这个问题:没读完的字节留在内核缓冲区,下次 read 继续。

2.16.8 UNIX domain socket:本机的高效通道

AF_UNIX 不经过网络协议栈,数据在本机内核里复制,没有 IP/TCP 头、没有校验和、没有拥塞控制。它比 TCP 回环快,而且可以用文件系统权限控制访问。

struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/demo.sock", sizeof addr.sun_path - 1);
/* sun_path 只有 108 字节,strncpy 后要保证结尾有 \0 */

int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
unlink("/tmp/demo.sock");                 /* 清理上次残留 */
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) == -1)
    perror("bind");
if (listen(fd, 16) == -1)
    perror("listen");

三种地址形式:

  1. 路径名:绑定后在文件系统里创建一个 socket 文件,权限由 chmod/umask 控制;进程退出后文件残留,需要 unlink。
  2. 抽象命名空间(Linux):sun_path[0] == '\0',名字是 sun_path + 1 开始的字节串,不受文件系统权限约束,进程退出自动消失。适合不需要权限控制的内部服务。
  3. 匿名:socketpair() 创建一对已连接的 socket,不绑定名字。

socketpair 是最常用的本机双向通道:

int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) == -1) {
    perror("socketpair");
    exit(EXIT_FAILURE);
}
/* sv[0] 与 sv[1] 双向可读写;fork 后父子各持一端 */

它比「两条 pipe」简洁,且支持 SOCK_SEQPACKET(保留消息边界)。SCM_RIGHTS(传递 fd)也依赖 UNIX domain socket,见 2.17。

关于 UNIX socket 的三个误解
  1. 「UNIX socket 文件可以用 open 打开」——不能。它是 socket 类型的特殊文件,只能用 socket + connect 访问。
  2. 「抽象命名空间也要 unlink」——不需要,进程退出自动释放;路径名 socket 才需要。
  3. 「UNIX socket 比 TCP 慢」——本机通信通常更快:没有协议栈封装、没有校验和、没有握手(socketpair 直接连好)。但它是本机的,不能跨主机。

2.16.9 close、shutdown 与 TIME_WAIT

close(fd) 减少 fd 的引用计数;最后一个引用关闭时,内核向对端发 FIN。shutdown(fd, how) 只关闭一个方向:

调用效果
shutdown(fd, SHUT_WR)发送 FIN,本端不能再写,但仍可读
shutdown(fd, SHUT_RD)本端 read 返回 0;到达数据被 ACK 后丢弃(不主动发 RST)
shutdown(fd, SHUT_RDWR)两个方向都关闭,类似 close 但不释放 fd
close(fd)释放 fd;最后一个引用时关闭连接

shutdown(SHUT_WR) 是「优雅关闭」的关键:它让对端读到 EOF,但自己还能读完对端剩下的数据。HTTP/1.1 的 Connection: close、gRPC 的 GOAWAY 都依赖半关闭。

TIME_WAIT 出现在主动关闭的一方:发送最后一个 ACK 后,连接在 TIME_WAIT 停留 2MSL(Linux 固定 60 秒),目的是让重传的 FIN 能被正确响应,并让旧连接的重复报文在网络中消失。

这带来两个现象:

  • 服务端主动关闭大量短连接时,ss -tan 里出现大量 TIME_WAIT,占用本地端口;
  • 服务端重启时 bind 返回 EADDRINUSE,因为旧连接的 TIME_WAIT 还占着四元组。

SO_REUSEADDR 解决第二个问题;第一个问题要靠「让客户端先关」或连接复用(HTTP keep-alive)。调 net.ipv4.tcp_tw_reuse 要谨慎:它只对主动发起连接的 socket 生效,且依赖时间戳,跨 NAT 场景可能出问题。

2.16.10 socket():三个参数与一张 errno 表

这一节回答:socket() 的 domain、type、protocol 各自在内核里决定什么,哪些错误在「创建」阶段就会返回,而不是等到 bind/connect。

#include <sys/socket.h>

int socket(int domain, int type, int protocol);

type 的低 4 位是「套接字类型」,高位是「创建标志」。Linux 的 UAPI 把这两类值设计成可以直接按位或:

值数值(Linux UAPI)含义备注
SOCK_STREAM1有序、可靠、双向字节流TCP、UNIX stream
SOCK_DGRAM2保留边界的数据报UDP、UNIX datagram
SOCK_RAW3原始套接字,绕过传输层需要 CAP_NET_RAW
SOCK_RDM4可靠数据报Linux 基本未实现,常返回 ESOCKTNOSUPPORT
SOCK_SEQPACKET5有序、可靠、保留边界AF_UNIX 支持;AF_INET 不支持
SOCK_NONBLOCKO_NONBLOCK新 fd 置非阻塞与 fcntl 设置等价
SOCK_CLOEXECO_CLOEXEC新 fd 置 FD_CLOEXEC避免 fork+exec 泄漏

protocol 通常填 0,让内核按 (domain, type) 选默认协议;也可以显式指定:

domaintypeprotocol实际协议
AF_INET/AF_INET6SOCK_STREAM0 或 IPPROTO_TCPTCP
AF_INET/AF_INET6SOCK_DGRAM0 或 IPPROTO_UDPUDP
AF_INET/AF_INET6SOCK_STREAMIPPROTO_MPTCP(262)MPTCP(Linux 5.6+)
AF_INETSOCK_RAWIPPROTO_ICMP/IPPROTO_RAW原始 IP/ICMP
AF_UNIX任意已支持类型0UNIX domain
AF_NETLINKSOCK_RAW/SOCK_DGRAMNETLINK_ROUTE 等内核-用户态消息

一个常见误解是「SOCK_STREAM 一定是 TCP」。AF_UNIX + SOCK_STREAM 是本机字节流,没有 IP 头、没有握手、没有拥塞控制;AF_VSOCK + SOCK_STREAM 又是另一套实现。类型只描述语义,协议由 domain 决定。

创建阶段的 errno

errno触发条件排查
EAFNOSUPPORTdomain 未注册(如未加载 af_key)检查 family 拼写、内核模块
EPROTONOSUPPORT该 family 不支持此 protocol如 AF_INET + SOCK_STREAM + IPPROTO_UDP
ESOCKTNOSUPPORT该 family 不支持此 type如 AF_INET + SOCK_SEQPACKET
EINVALtype 含未知位,或 type/protocol 组合非法检查是否把自定义标志塞进 type
EACCES/EPERM创建 SOCK_RAW 缺少 CAP_NET_RAW,或被 seccomp/LSM 拒绝capsh --print、容器 --cap-add=NET_RAW
EMFILE进程 fd 达到 RLIMIT_NOFILEls /proc/{pid}/fd 计数
ENFILE系统级文件表满file-nr、fs.file-max
ENOBUFS/ENOMEM内核内存不足查 dmesg、cgroup 内存限制

SOCK_CLOEXEC 的价值不在「少一次 fcntl」,而在原子性。下面这段代码在多线程程序里有竞态:

int fd = socket(AF_INET, SOCK_STREAM, 0);   /* 此时 fd 没有 CLOEXEC */
/* 另一个线程 fork+exec:子进程继承了 fd */
fcntl(fd, F_SETFD, FD_CLOEXEC);             /* 已经晚了 */

用 socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0) 把「创建」与「设置」合成一个原子操作,exec 之前不存在泄漏窗口。accept4、pipe2、dup3、eventfd 的 EFD_CLOEXEC 都是同一动机。

内核路径:从 socket() 到 struct sock

/* net/socket.c,Linux 6.8(节选,保留错误处理结构) */
int __sys_socket(int family, int type, int protocol)
{
    int flags = type & ~SOCK_TYPE_MASK;
    struct socket *sock;

    if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))
        flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;

    int retval = sock_create(family, type, protocol, &sock);
    if (retval < 0)
        return retval;

    return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

调用链是:

socket(2)
  └─ __sys_socket()                  net/socket.c
       ├─ sock_create()              → __sock_create()
       │    ├─ net_families[family]  → struct net_proto_family
       │    └─ pf->create()          → inet_create() / unix_create() / ...
       │         ├─ sk_alloc()       分配 struct sock(slab)
       │         └─ sock_init_data() 绑定 socket 与 sock
       └─ sock_map_fd()              → sock_alloc_file() → fd_install()

struct socket 是「面向 VFS 的壳」,struct sock 是「协议栈的对象」。同一个连接在两套结构里各有一份状态,字段分工如下:

结构关键字段谁维护何时变
struct socketstate(SS_UNCONNECTED/SS_CONNECTING/SS_CONNECTED)协议层connect/accept/shutdown
type、flags创建时不变
ops(struct proto_ops)family创建时按 family 赋值
sk创建时指向协议对象
filesock_alloc_file创建时
struct socksk_state(TCP_ESTABLISHED/TCP_CLOSE/…)TCP 状态机收发包、超时、关闭
sk_receive_queue软中断/协议层数据到达、应用读取
sk_write_queue发送路径应用写入、ACK 释放
sk_error_queueICMP/错误路径MSG_ERRQUEUE 读取
sk_rcvbuf/sk_sndbufsetsockopt/自动调优SO_RCVBUF/SO_SNDBUF
sk_rcvtimeo/sk_sndtimeoSO_RCVTIMEO/SO_SNDTIMEO每次 setsockopt
sk_wq等待队列poll/epoll 注册的回调挂在这里

sk_receive_queue 里挂的是 sk_buff(skb),每个 skb 指向一段内核缓冲区。应用 read 时,tcp_recvmsg_locked 从队列里摘 skb、skb_copy_datagram_msg 拷到用户缓冲区,然后 tcp_cleanup_rbuf 更新窗口并可能发 ACK。「socket 是 fd」只说明它有一个 struct file,数据既不在 struct file 里,也不在页缓存里,而在协议栈的 skb 队列里——这是 socket 与普通文件最根本的结构差异。

2.16.11 bind() 与 listen():地址冲突矩阵与两个队列

这一节回答:bind 到底在检查什么,为什么 SO_REUSEADDR 有时救不了 EADDRINUSE,listen 的 backlog 最终由谁截断。

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);

bind 没有 flags。它的全部语义由「地址内容 + 地址长度 + socket 选项 + 当前内核里的绑定表」决定。

地址长度必须按 family 给

地址结构大小addrlen 该传传错的后果
IPv4sizeof(struct sockaddr_in) = 1616小于 16 常返回 EINVAL
IPv6sizeof(struct sockaddr_in6) = 2828少传会丢 scope_id
UNIX 路径名offsetof(struct sockaddr_un, sun_path) + strlen(path) + 1实际长度多传的尾部字节被当成名字的一部分
UNIX 抽象名offsetof(...) + 1 + namelen实际长度名字里可以含 \0,不能 strlen
通用容器sizeof(struct sockaddr_storage) = 128由具体 family 决定仅用于接收,不用于 bind

bind 的 addrlen 不是「缓冲区大小」,而是「地址有多少字节有效」。IPv4 的 sockaddr_in 里有 8 字节 sin_zero 填充,习惯上 memset 清零,但内核并不依赖它。

端口 0 与临时端口

sin_port = 0 表示「让内核选一个」。内核在 inet_csk_get_port 里从 net.ipv4.ip_local_port_range(默认 32768 60999)选端口,避开已有绑定,并尽量让同一四元组的端口可复用。绑定后用 getsockname 取回:

struct sockaddr_in addr = { .sin_family = AF_INET,
                            .sin_port = 0,
                            .sin_addr.s_addr = htonl(INADDR_ANY) };
if (bind(fd, (struct sockaddr *)&addr, sizeof addr) == -1)
    perror("bind");

socklen_t len = sizeof addr;
if (getsockname(fd, (struct sockaddr *)&addr, &len) == -1)
    perror("getsockname");
printf("ephemeral port = %d\n", (int)ntohs(addr.sin_port));

临时端口范围可以通过 sysctl net.ipv4.ip_local_port_range="10240 65000" 调整,但扩大范围会压缩「客户端连接可用的源端口」,在百万连接场景要一起算(见母本 N 第 8 章的端口账)。

特权端口

端口小于 1024 需要 CAP_NET_BIND_SERVICE(root 默认有)。Linux 5.7+ 提供 net.ipv4.ip_unprivileged_port_start,把它设为 0 就允许非特权进程绑定任意端口;容器里常见 --sysctl net.ipv4.ip_unprivileged_port_start=0。

EADDRINUSE 的四种来源

现场ss 看到什么原因处理
已有进程在 LISTEN 同一地址ss -lntp 有进程真正的端口占用换端口或停旧进程
旧连接在 TIME_WAITss -tan state time-wait四元组仍被占SO_REUSEADDR 或等 60 秒
通配地址与具体地址冲突一个 0.0.0.0:80,一个 127.0.0.1:80绑定表允许部分重叠,但要看选项两边都设 SO_REUSEADDR,或只绑一个
IPv6 双栈占用 IPv4ss -lnt 看到 *:80(v6)IPV6_V6ONLY=0 时 :: 同时占 IPv4设 IPV6_V6ONLY=1 或改用同一 family

SO_REUSEADDR 的确切语义

SO_REUSEADDR 在 Linux 上做三件事(TCP):

  1. 允许 bind 一个处于 TIME_WAIT 的本地地址;
  2. 允许在同一端口上绑定不同的本地地址(一个通配、一个具体),前提是冲突的 socket 都设了 SO_REUSEADDR;
  3. 对 UDP 允许部分多播/广播场景的重复绑定。

它不允许两个都处于 LISTEN 的 socket 绑定完全相同的 (地址, 端口);那仍然返回 EADDRINUSE。很多「开了 SO_REUSEADDR 还是起不来」的现场,是因为旧进程还活着,而不是 TIME_WAIT。

int one = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1)
    perror("setsockopt SO_REUSEADDR");
/* 必须在 bind 之前设置;bind 之后再设对本次绑定无效 */

SO_REUSEPORT:真正的多进程同端口

SO_REUSEPORT(Linux 3.9+)允许多个处于 LISTEN 的 socket绑定完全相同的地址与端口,内核在 bind 时把它们放进同一个 reuseport 组,收到 SYN 时按四元组哈希选一个 socket。使用条件:

  • 所有 socket 都设置 SO_REUSEPORT,family/type/protocol 相同,绑定完全相同的地址(不能一个 0.0.0.0、一个 127.0.0.1),且有效 UID 相同;主要支持 TCP/UDP,UNIX domain 不支持。
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);

内核用 reuseport_select_sock 选 socket,默认按四元组哈希;可以用 SO_ATTACH_REUSEPORT_CBPF/SO_ATTACH_REUSEPORT_EBPF 自定义选择逻辑,用 SO_INCOMING_CPU 做 CPU 亲和。Nginx 的 reuseport、Envoy 的 SO_REUSEPORT 多 worker 都依赖它。

选项两个 LISTEN 同地址绑定 TIME_WAIT分流典型用途
无EADDRINUSEEADDRINUSE——
SO_REUSEADDREADDRINUSE允许—服务重启
SO_REUSEPORT允许允许四元组哈希多 worker 监听

listen() 与 backlog 的真实上限

if (listen(lfd, 128) == -1)
    perror("listen");

backlog 不是「总连接数」,而是 accept 队列(全连接队列)的最大长度。内核在 __sys_listen 里先做一次截断:

/* net/socket.c,Linux 6.8(节选) */
int __sys_listen(int fd, int backlog)
{
    struct socket *sock;
    int somaxconn;

    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    if (sock) {
        somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
        if ((unsigned int)backlog > somaxconn)
            backlog = somaxconn;
        err = security_socket_listen(sock, backlog);
        if (!err)
            err = sock->ops->listen(sock, backlog);
        ...
    }
}

所以实际上限是 min(backlog, net.core.somaxconn)。somaxconn 在 Linux 5.4 之前的默认值是 128,之后是 4096;systemd 和一些发行版会把它调到 4096 或更高。listen(fd, -1) 会被转成无符号大数,再被截到 somaxconn——不是 0,也不是「无限制」。

ss -lnt 的两列正好对应两个队列:

State   Recv-Q  Send-Q  Local Address:Port
LISTEN  0       128     0.0.0.0:12345
  • Recv-Q:当前 accept 队列里已完成握手、等待 accept 的连接数;
  • Send-Q:sk_max_ack_backlog,即实际生效的 backlog 上限。

当 Recv-Q 接近 Send-Q 时,新连接开始被丢弃。证据在 /proc/net/netstat:

nstat -az TcpExtListenOverflows TcpExtListenDrops
# TcpExtListenOverflows     12     0.0
# TcpExtListenDrops         12     0.0

ListenOverflows 表示 accept 队列满导致 ACK 被丢弃的次数,ListenDrops 是所有 listen 阶段丢包的总和。两者持续增长,说明应用 accept 太慢或 backlog 太小。

SYN 队列与 syncookie

三次握手的第一个阶段,连接处于 SYN 队列(半连接队列),由 net.ipv4.tcp_max_syn_backlog 限制。SYN 队列满时:

  • net.ipv4.tcp_syncookies=1(默认)时,内核可以不算半连接,直接把状态编码进 SYN-ACK 的序列号(syncookie),客户端 ACK 回来再重建请求;
  • tcp_syncookies=0 时,新的 SYN 被丢弃,客户端重传。

syncookie 的代价是:SYN-ACK 里不能携带某些 TCP 选项(如窗口缩放、SACK 的部分信息),握手完成后的性能可能受影响。观测:

nstat -az TcpExtSyncookiesSent TcpExtSyncookiesRecv TcpExtSyncookiesFailed

net.ipv4.tcp_abort_on_overflow 决定 accept 队列满时的行为:默认 0,丢弃 ACK 让客户端重传(表现为连接慢);设为 1 则直接回 RST(客户端看到 ECONNRESET)。生产环境一般保持 0,先把 accept 循环和 backlog 修好。

2.16.12 accept4() 与 connect():全 flags、全 errno

这一节回答:accept 返回的新 fd 继承什么、不继承什么,connect 在阻塞/非阻塞/被信号打断三种情况下分别把连接留在什么状态。

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

accept4 的 flags

accept4 只接受两个标志,其他位返回 EINVAL:

flag作用不用的后果
SOCK_CLOEXEC新 fd 原子设置 FD_CLOEXEC多线程 fork+exec 时泄漏连接
SOCK_NONBLOCK新 fd 原子设置 O_NONBLOCK需要额外 fcntl,有竞态

accept 本身没有 flags 参数;accept4 是 Linux 2.6.28+ 的扩展,glibc 在 _GNU_SOURCE 下声明。可移植代码可以 accept + fcntl,但要接受竞态窗口。

新 fd 继承什么

属性是否继承说明
O_NONBLOCK不继承Linux 上新 fd 默认阻塞;监听 fd 非阻塞只影响 accept 本身
O_ASYNC不继承需要重新 fcntl(F_SETFL)
FD_CLOEXEC不继承accept 返回的 fd 默认没有 CLOEXEC
SO_RCVBUF/SO_SNDBUFLinux 通常继承sk_clone_lock 复制缓冲区大小,受 rmem_max/wmem_max 限制
TCP_NODELAYLinux 上继承但不要依赖,显式设置更稳
SO_LINGER不保证各协议实现不同
SO_REUSEADDR/SO_REUSEPORT无意义只影响 bind,新连接已绑定

O_NONBLOCK 不继承是最容易踩的一条:监听 fd 设了非阻塞,accept 在没连接时返回 EAGAIN,但 accept 返回的 cfd 仍然是阻塞的;在事件循环里直接 read(cfd) 会卡住整个线程。正确写法是 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。

accept 的 errno

errno含义处理
EAGAIN/EWOULDBLOCK非阻塞监听 fd,当前无已完成连接回去等 poll/epoll
EINTR被信号打断重试;不要当成「没有连接」
ECONNABORTED连接在 accept 前被对端/RST 中止记录并继续
EMFILE进程 fd 耗尽预留 fd 技巧(见下)
ENFILE系统文件表耗尽降载、查泄漏
ENOBUFS/ENOMEM内存不足降载
EINVALsocket 不是监听态,或 flags 非法检查 listen 是否成功
EBADF/ENOTSOCKfd 无效/不是 socket检查生命周期
EOPNOTSUPP该协议不支持 accept如 UDP

EMFILE 的经典处理是「预留一个 fd」:进程启动时 open("/dev/null") 留一个空位;accept 返回 EMFILE 时,close 预留 fd、accept 一条连接、立刻 close,再重新打开预留 fd。这样至少能把连接从队列里取出来并回一个错误,而不是让队列一直满着:

static int reserve_fd = -1;

void init_reserve(void) { reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC); }

int accept_or_recover(int lfd)
{
    int cfd = accept4(lfd, NULL, NULL, SOCK_CLOEXEC | SOCK_NONBLOCK);
    if (cfd != -1 || errno != EMFILE)
        return cfd;

    close(reserve_fd);                       /* 腾出一个 fd 名额 */
    cfd = accept(lfd, NULL, NULL);           /* 取出连接,避免队列继续满 */
    if (cfd != -1)
        close(cfd);                          /* 关闭,客户端得到错误 */
    reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC);
    errno = EMFILE;
    return -1;
}

connect 的状态机

connect 没有 flags。它的行为由 socket 的 O_NONBLOCK 和当前状态决定:

场景返回值errno连接状态
阻塞,握手成功0—ESTABLISHED
阻塞,被拒绝-1ECONNREFUSEDCLOSED
阻塞,超时-1ETIMEDOUTCLOSED
阻塞,被信号打断-1EINTR可能仍在 SYN_SENT
非阻塞,立即成功(本机/已缓存路由)0—ESTABLISHED
非阻塞,进行中-1EINPROGRESSSYN_SENT
非阻塞,立即失败-1ECONNREFUSED 等CLOSED
已连接再 connect-1EISCONN不变
非阻塞连接未完成再 connect-1EALREADY不变

connect 的完整 errno:

errno触发
EAFNOSUPPORT地址 family 与 socket 不符
EADDRNOTAVAIL源地址不可用(绑定了一个不属于本机的 IP)
ECONNREFUSED端口无监听,收到 RST
ETIMEDOUTSYN 重传耗尽(tcp_syn_retries)
ENETUNREACH/EHOSTUNREACH无路由/主机不可达(可能来自 ICMP)
EACCES广播地址未设 SO_BROADCAST,或被防火墙拒绝
EPERM防火墙 REJECT 或 LSM
EINPROGRESS非阻塞连接已启动
EALREADY上一次非阻塞连接还没完成
EISCONN已连接
EINTR阻塞 connect 被信号打断

EINTR 的处理是本章最容易写错的地方。阻塞 connect 被信号打断时,内核并没有取消三次握手;连接可能仍在后台进行。此时直接重试 connect 会得到 EALREADY,正确做法是:

int rc = connect(fd, addr, addrlen);
if (rc == -1 && errno == EINTR) {
    /* 不要直接 goto retry_connect */
    struct pollfd pfd = { .fd = fd, .events = POLLOUT };
    int pr = poll(&pfd, 1, 5000);
    if (pr == 0) { /* 超时 */ }
    else if (pr > 0) {
        int err = 0;
        socklen_t len = sizeof err;
        if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) == -1 || err != 0) {
            /* 连接失败,err 是原因 */
        }
    }
}

poll 返回 POLLOUT 只表示「有事件」,连接可能成功也可能失败;必须用 SO_ERROR 取结果。getsockopt(SO_ERROR) 会读取并清除 sk_err,所以只查一次,别在多个线程里重复查。

内核路径:connect 如何发出第一个 SYN

inet_stream_connect(net/ipv4/af_inet.c)把 sock->state 从 SS_UNCONNECTED 推到 SS_CONNECTING,调用 sk->sk_prot->connect(TCP 是 tcp_v4_connect),返回 EINPROGRESS。 tcp_v4_connect 选源地址、查路由、填 struct sock 的四元组,然后 tcp_connect 构造 SYN、tcp_transmit_skb 交给 IP 层。SYN 发出后 socket 进入 TCP_SYN_SENT;收到 SYN+ACK 后,软中断里的 tcp_rcv_state_process 把它推进到 TCP_ESTABLISHED 并唤醒等待者。非阻塞 connect 返回 EINPROGRESS 时,握手已经在进行,不是「还没开始」。

2.16.13 sockaddr 家族全表:不止 IPv4 与 IPv6

这一节回答:sockaddr 到底有多少种,sockaddr_storage 为什么是 128 字节,IPv6 的 scope_id 与 flowinfo 在什么场景下非填不可。

socket() 的 domain 与 sockaddr 的 sa_family 是同一套编号。每个 family 注册一个 struct net_proto_family,其中 create 指向该 family 的套接字构造函数:

family值sockaddr 具体结构典型 create用途
AF_UNIX1sockaddr_ununix_create本机进程通信
AF_INET2sockaddr_ininet_createIPv4
AF_INET610sockaddr_in6inet6_createIPv6
AF_NETLINK16sockaddr_nlnetlink_create路由、审计、uevent
AF_PACKET17sockaddr_llpacket_create链路层抓包/注入
AF_ALG38sockaddr_algalg_create内核 crypto 接口
AF_VSOCK40sockaddr_vmvsock_create虚拟机与宿主通信
AF_TIPC30sockaddr_tipctipc_create集群内寻址
AF_CAN29sockaddr_cancan_createCAN 总线
AF_BLUETOOTH31sockaddr_hci 等bt_sock_create蓝牙
AF_XDP44sockaddr_xdpxsk_create高性能收包

AF_MPTCP 没有独立的地址族;MPTCP 复用 AF_INET/AF_INET6,靠 socket() 的 protocol = IPPROTO_MPTCP 选择。类似地,AF_INET6 在 IPV6_V6ONLY=0 时可以接收 IPv4 连接,地址用 IPv4-mapped 形式 ::ffff:127.0.0.1 表示。

通用头与容器

所有具体地址结构都以 sa_family_t 开头:

struct sockaddr 本身只有 16 字节,装不下 IPv6(28 字节)或 UNIX 路径(110 字节)。所以系统调用接受的是 struct sockaddr * 加一个独立的长度参数,内核按 sa_family 解释后面的字节。接收地址时用 struct sockaddr_storage:

struct sockaddr_storage {
    sa_family_t ss_family;       /* 偏移 0,与 sockaddr 对齐 */
    char        __ss_padding[118];
    unsigned long __ss_align;    /* 强制 8/16 字节对齐 */
};

它的大小是 128 字节,足够容纳所有 family,并且对齐满足 sockaddr_in6/sockaddr_un 的要求。把它强转成 struct sockaddr * 传给 accept/recvfrom,再按 ss_family 转回具体结构,是唯一可移植的写法。

IPv4:sockaddr_in

struct sockaddr_in {
    sa_family_t    sin_family;  /* AF_INET */
    in_port_t      sin_port;    /* 网络字节序 */
    struct in_addr sin_addr;    /* 网络字节序 */
    unsigned char  sin_zero[8]; /* 填充,习惯清零 */
};

sin_addr.s_addr 常用 INADDR_ANY(0.0.0.0,所有网卡)、INADDR_LOOPBACK(127.0.0.1)、INADDR_BROADCAST 或某个具体网卡地址;广播需要 SO_BROADCAST。

IPv6:sockaddr_in6 的三个额外字段

struct sockaddr_in6 {
    sa_family_t     sin6_family;   /* AF_INET6 */
    in_port_t       sin6_port;     /* 网络字节序 */
    uint32_t        sin6_flowinfo; /* 流标签,通常 0 */
    struct in6_addr sin6_addr;     /* 16 字节 */
    uint32_t        sin6_scope_id; /* 链路本地地址的接口索引 */
};
  • sin6_flowinfo:IPv6 流标签(traffic class + flow label),普通应用填 0;内核在 IPV6_FLOWINFO_SEND 等选项下才使用。
  • sin6_scope_id:链路本地地址(fe80::/10)必须指定接口,否则 connect/bind 返回 EINVAL 或 ENETUNREACH。值可以用 if_nametoindex("eth0") 取。
  • IPv4-mapped 地址(::ffff:192.0.2.1)在双栈 socket 上代表 IPv4 对端;getnameinfo 会把它格式化成 IPv4 文本。
struct sockaddr_in6 a6 = {0};
a6.sin6_family = AF_INET6;
a6.sin6_port = htons(8080);
if (inet_pton(AF_INET6, "fe80::1", &a6.sin6_addr) != 1)
    perror("inet_pton");
a6.sin6_scope_id = if_nametoindex("eth0");   /* 链路本地必须 */

UNIX domain:变长地址

struct sockaddr_un {
    sa_family_t sun_family;  /* AF_UNIX */
    char        sun_path[108];
};

sun_path 是变长的:

  • 路径名:以 \0 结尾,addrlen = offsetof(struct sockaddr_un, sun_path) + strlen(path) + 1;
  • 抽象名:sun_path[0] == '\0',名字从 sun_path + 1 开始,长度由 addrlen 决定,可以包含 \0;
  • 匿名:socketpair() 创建,不绑定名字。

bind 的 addrlen 如果只等于 sizeof(sa_family_t),Linux 会做自动抽象绑定(autobind):内核生成一个唯一名字,之后可以用 getsockname 取回。这个特性在需要「临时本机地址」时有用,但不可移植。

这些 family 的共同点是:地址结构不止 (host, port) 两元组,所以 getaddrinfo 只覆盖 INET/INET6;其他 family 要自己填结构。getaddrinfo 的 AI_NUMERICHOST 也只对 INET/INET6 有意义。

getsockname/getpeername 的长度是「值-结果」

struct sockaddr_storage ss;
socklen_t len = sizeof ss;
if (getsockname(fd, (struct sockaddr *)&ss, &len) == -1)
    perror("getsockname");
/* 返回后 len 是实际写入的字节数,可能小于 sizeof ss */

传入时 len 是缓冲区容量,返回时是实际地址长度。如果缓冲区太小,内核会截断并返回实际长度;调用方应检查 len > sizeof ss 并用 ss_family 判断类型。getpeername 在连接被对端关闭后仍可能返回旧地址;判断连接状态要用 SO_ERROR 或 read 的返回值,而不是 getpeername。

2.16.14 getaddrinfo()/getnameinfo():全 flags 与解析路径

这一节回答:getaddrinfo 的每个 ai_flags 到底改变哪一步,为什么同一个域名在不同机器上解析出不同顺序的地址,EAI_* 与 errno 该看哪个。

#include <netdb.h>

int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints, struct addrinfo **res);
void freeaddrinfo(struct addrinfo *res);
const char *gai_strerror(int errcode);

int getnameinfo(const struct sockaddr *addr, socklen_t addrlen,
                char *host, socklen_t hostlen,
                char *serv, socklen_t servlen, int flags);

struct addrinfo 的字段:

字段谁填含义
ai_flags调用方(hints)行为标志,见下表
ai_family调用方AF_UNSPEC/AF_INET/AF_INET6
ai_socktype调用方0 表示不限,返回可能同时含 stream/dgram
ai_protocol调用方0 表示由 socktype 决定
ai_addrlen返回地址长度
ai_addr返回指向具体 sockaddr
ai_canonname返回仅第一个节点,且需要 AI_CANONNAME
ai_next返回链表下一项

ai_flags 全表

flag值(glibc)作用常见误用
AI_PASSIVE0x0001node == NULL 时返回通配地址(0.0.0.0/::)客户端误用,连到 0.0.0.0
AI_CANONNAME0x0002返回规范主机名到 ai_canonname以为每个节点都有
AI_NUMERICHOST0x0004禁止 DNS,node 必须是数字地址传域名得到 EAI_NONAME
AI_V4MAPPED0x0008仅 AF_INET6:无 AAAA 时返回 IPv4-mapped与 AI_ALL 混用前没设 family
AI_ALL0x0010与 AI_V4MAPPED 同用:返回 AAAA + A单独用无效
AI_ADDRCONFIG0x0020只返回本机已配置的地址族只有 loopback 的容器里查不到
AI_NUMERICSERV0x0400service 必须是数字端口传 "http" 得到 EAI_NONAME

AI_ADDRCONFIG 的坑值得单独说:它检查的是「本机是否有该 family 的非 loopback 地址」。一个只有 lo 的容器里,即使内核支持 IPv6,getaddrinfo("example.com", ..., AF_UNSPEC|AI_ADDRCONFIG) 也可能只返回 A 记录;反过来,一个配了全局 IPv6 但没有 IPv4 默认路由的机器,可能只返回 AAAA。调试时先把 AI_ADDRCONFIG 去掉,确认是不是它导致的差异。

返回值:0 与 EAI_*

getaddrinfo 成功返回 0,失败返回非 0 的 EAI_*(不是 errno,也不会设置 errno,除非返回 EAI_SYSTEM)。

返回值含义排查
EAI_AGAIN临时失败,DNS 服务器超时/拒绝重试;查 /etc/resolv.conf
EAI_BADFLAGSai_flags 含非法位检查是否把自定义值塞进去
EAI_FAIL不可恢复的解析失败DNS 返回 SERVFAIL
EAI_FAMILYai_family 不支持拼写错误
EAI_MEMORY内存不足降载
EAI_NONAME名字或服务不存在;或 AI_NUMERICHOST/AI_NUMERICSERV 下不是数字检查输入
EAI_SERVICE服务名不支持该 socktype如 "http" + SOCK_DGRAM
EAI_SOCKTYPEai_socktype 不支持拼写错误
EAI_SYSTEM系统错误,原因在 errno看 errno(如 ENOENT)
EAI_OVERFLOW输出缓冲区不足只在 getnameinfo
EAI_NODATA有名字无地址(已废弃,glibc 可能不返回)用 EAI_NONAME 处理
EAI_ADDRFAMILY地址族不匹配(已废弃)用 EAI_FAMILY 处理

错误处理的标准写法:

int rc = getaddrinfo(host, serv, &hints, &res);
if (rc != 0) {
    if (rc == EAI_SYSTEM)
        fprintf(stderr, "getaddrinfo: %s\n", strerror(errno));
    else
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
    return -1;
}
freeaddrinfo(res);

解析路径:glibc、NSS 与 DNS

getaddrinfo 不是一个简单的 DNS 客户端,它经过 NSS(Name Service Switch):

getaddrinfo()
  └─ glibc: gaih_inet()
       ├─ 读 /etc/nsswitch.conf 的 hosts: 行
       │    hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname
       ├─ files   → /etc/hosts
       ├─ dns     → libnss_dns → /etc/resolv.conf → DNS 服务器
       ├─ myhostname → systemd 提供的本机名
       └─ 其他    → nss-mdns、sssd、winbind、nscd 缓存

/etc/hosts 优先于 DNS,这是本地覆盖域名最常用的手段。nscd(name service cache daemon)或 systemd-resolved 会在中间加一层缓存;systemd-resolved 把 /etc/resolv.conf 指向 127.0.0.53,实际查询走本地 socket。容器里如果 /etc/resolv.conf 指向 127.0.0.11,那是 Docker 的内嵌 DNS。

DNS 查询的细节会影响行为:

  • A 与 AAAA 并行,search/ndots 决定短名先拼后缀还是先当 FQDN,options timeout:5 attempts:2 决定 EAI_AGAIN 的出现时机。
  • TCP 回退:响应被截断(TC 位)时,解析器改用 TCP 53 重查;防火墙只放 UDP 53 时,大响应会失败。
  • /etc/gai.conf:控制 RFC 6724 的地址排序(precedence/label),影响「IPv6 优先还是 IPv4 优先」。默认规则通常优先全局 IPv6,再 IPv4,最后 IPv4-mapped。
  • HOSTALIASES:用户级别名文件,只对非 setuid 程序生效。

getaddrinfo 的返回顺序不是稳定契约。应用应该遍历链表逐个尝试 connect,而不是假设第一个一定最快。Happy Eyeballs(RFC 8305)就是在这个链表上做「IPv6 先试、300ms 后 IPv4 并行」的算法。

getnameinfo 的 flags

flag作用注意
NI_NUMERICHOST不做反向 DNS,直接输出数字地址日志里必加,避免每个连接等 DNS
NI_NUMERICSERV输出数字端口,不查 /etc/services日志里必加
NI_NOFQDN本地主机只输出短名只对本地地址有意义
NI_NAMEREQD反查不到名字就返回 EAI_NONAME需要「必须有 PTR」时用
NI_DGRAM按 UDP 查服务名SOCK_DGRAM 场景
NI_NUMERICSCOPEIPv6 scope 用数字而非接口名日志解析方便

反向解析的安全风险:PTR 记录由地址所有者控制,不能作为身份认证。日志里用 NI_NUMERICHOST,需要展示主机名时再单独做「正向确认」(查到的名字再正向解析回同一地址)。

2.16.15 UDP 与 UNIX domain 的边界细节

这一节回答:UDP 的 recvfrom 返回 0 到底是不是 EOF,sendto 的 EMSGSIZE 从哪里来,UNIX domain 的抽象名字为什么不能用 strlen。

UDP 的四个系统调用与 flags

#include <sys/socket.h>

ssize_t sendto(int fd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest, socklen_t addrlen);
ssize_t recvfrom(int fd, void *buf, size_t len, int flags,
                 struct sockaddr *src, socklen_t *addrlen);
ssize_t sendmsg(int fd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int fd, struct msghdr *msg, int flags);
flag用于语义
MSG_DONTWAITsend/recv本次调用非阻塞,不改 fd 状态
MSG_PEEKrecv看一眼数据但不从队列移除
MSG_TRUNCrecv返回数据报的真实长度,即使缓冲区不够
MSG_WAITALLrecv对 UDP 无效(数据报要么整条、要么截断)
MSG_ERRQUEUErecv读错误队列(ICMP 错误、IP_RECVERR)
MSG_CMSG_CLOEXECrecvmsg收到的 SCM_RIGHTS fd 自动设 CLOEXEC
MSG_NOSIGNALsend(TCP)不产生 SIGPIPE;UDP 无此问题

recvfrom 的返回值有三种,含义完全不同:

  • > 0:收到一个数据报,长度是返回值;
  • 0:收到一个零长度数据报(合法),不是 EOF;
  • -1:错误,看 errno。

UDP 没有「连接关闭」的概念,所以 recvfrom 永远不会用 0 表示 EOF。把 0 当 EOF 退出循环,会在对方发空数据报时误关连接。

UDP 的 errno

errno触发说明
EAGAIN/EWOULDBLOCK非阻塞,接收队列空/发送缓冲区满等 poll
EINTR被信号打断重试
EMSGSIZE数据报超过路径 MTU 且设了 IP_MTU_DISCOVER=IP_PMTUDISC_DO,或超过 65507分片或减小消息
ECONNREFUSEDconnect 过的 UDP 收到 ICMP port unreachable异步错误,下一次 send/recv 才返回
EACCES发广播未设 SO_BROADCASTsetsockopt(SO_BROADCAST)
EDESTADDRREQ未 connect 且 sendto 没给目的地址补地址
EISCONN已 connect 又用 sendto 指定目的地址用 send 或先断开
ENOBUFS/ENOMEM内核缓冲区不足降载;查 SndbufErrors
EPERM防火墙/LSM 拒绝查 iptables -j REJECT

UDP 的最大负载:IPv4 是 65507 字节(65535 − 20 IP 头 − 8 UDP 头),IPv6 是 65527 字节(65535 − 8 UDP 头)。超过这个长度 sendto 返回 EMSGSIZE;小于等于这个长度但在路径 MTU 以下,可能被 IP 分片。

截断、丢包与 SO_RXQ_OVFL

recvfrom(fd, buf, 1024, 0, ...) 收到 2000 字节的数据报时,返回 1024,剩余 976 字节被丢弃。要区分「数据报就是 1024 字节」与「被截断」,用 recvmsg + MSG_TRUNC:

char buf[1024];
struct iovec iov = { .iov_base = buf, .iov_len = sizeof buf };
struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1 };
ssize_t n = recvmsg(fd, &msg, MSG_TRUNC);
if (n > (ssize_t)sizeof buf)
    fprintf(stderr, "truncated: real=%zd, kept=%zu\n", n, sizeof buf);

接收缓冲区满时,UDP 直接丢包,不通知发送方。丢包计数在 /proc/net/snmp 的 Udp: 行:

grep '^Udp:' /proc/net/snmp
# Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors InCsumErrors

RcvbufErrors 增长说明应用读得太慢或 SO_RCVBUF 太小;Linux 3.16+ 可用 SO_RXQ_OVFL 在辅助数据里附带丢包计数。

UDP 的 connect

对 UDP 调用 connect 不发送任何报文,只做两件事:

  1. 记录默认目的地址,之后可以用 send/write;
  2. 让内核把该目的地址的 ICMP 错误(port unreachable 等)作为 ECONNREFUSED 返回给应用。

UDP 的 connect 可以多次调用改目的地址;也可以用一个 AF_UNSPEC 的 connect 断开。它不会产生 EINPROGRESS,因为不涉及握手。

UNIX domain 的抽象名字与 socketpair

抽象命名空间的名字不是文件系统路径,长度由 addrlen 决定:

struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
const char name[] = "demo.worker";       /* 不含结尾 \0 */
addr.sun_path[0] = '\0';                 /* 抽象命名空间标志 */
memcpy(addr.sun_path + 1, name, sizeof name - 1);
socklen_t len = offsetof(struct sockaddr_un, sun_path) + 1 + sizeof name - 1;
bind(fd, (struct sockaddr *)&addr, len);

规则:

  • 名字从 sun_path + 1 开始,可以包含 \0,所以不能用 strlen;
  • addrlen 必须精确,多一个字节就多一个 \0,名字就变了;
  • 不需要 unlink,进程退出自动释放;
  • 不受文件系统权限约束,但受 SO_PASSCRED/SO_PEERCRED 的身份检查保护。

路径名 socket 则在文件系统里创建一个 S_IFSOCK 文件,权限由 umask 与 chmod 决定。服务端启动时先 unlink 旧文件,但这里有竞态:两个实例同时 unlink + bind,后一个会把前一个的 socket 文件删掉。稳妥做法是用 flock 锁文件,或让 systemd 的 socket activation 保证单实例。

socketpair 创建一对已连接的匿名 socket:

int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0, sv) == -1)
    perror("socketpair");
type语义典型用途
SOCK_STREAM字节流,和 TCP 一样没有边界父子进程双向管道
SOCK_DGRAM保留边界,可靠(本机不丢)简单消息传递
SOCK_SEQPACKET保留边界 + 有序 + 可靠需要消息边界的本机 RPC

socketpair 的 type 也接受 SOCK_CLOEXEC/SOCK_NONBLOCK;它比「两条 pipe」少一次 pipe,而且天然双向。SCM_RIGHTS 传 fd 必须用 UNIX domain socket(socketpair 也可以),因为只有 UNIX domain 的 sendmsg 实现了文件描述符传递。

SCM_RIGHTS 一次最多传 SCM_MAX_FD(253)个 fd;接收方拿到的每个 fd 都是 dup 出来的新描述符,必须自己 close。SO_PASSCRED 打开后,接收方还能收到 SCM_CREDENTIALS(发送方的 pid/uid/gid),这是本机服务做认证的另一种手段,细节见 2.17.7。

2.16.16 内核路径:从 write 到网卡,从网卡到 read

这一节回答:一次 write(sockfd, ...) 在内核里经过哪些函数、哪些队列,最后怎么变成网线上的电信号;反向路径又怎么把包送到 read 的缓冲区。

发送路径

write(2)
  └─ vfs_write()                    fs/read_write.c
       └─ sock_write_iter()         net/socket.c
            └─ sock_sendmsg()       net/socket.c
                 └─ inet_sendmsg()  net/ipv4/af_inet.c
                      └─ tcp_sendmsg()          net/ipv4/tcp.c
                           └─ tcp_sendmsg_locked()
                                ├─ tcp_stream_alloc_skb()  分配 skb
                                ├─ skb_add_data_nocache()  用户数据拷进 skb
                                └─ tcp_push()
                                     └─ __tcp_push_pending_frames()
                                          └─ tcp_write_xmit()  net/ipv4/tcp_output.c
                                               ├─ tcp_transmit_skb()  填 TCP 头
                                               └─ ip_queue_xmit()     填 IP 头
                                                    └─ ip_local_out()
                                                         └─ dst_output()
                                                              └─ ip_output()
                                                                   └─ dev_queue_xmit()  net/core/dev.c
                                                                        ├─ qdisc enqueue
                                                                        └─ sch_direct_xmit()
                                                                             └─ dev_hard_start_xmit()
                                                                                  └─ ndo_start_xmit()  驱动

这条路径上有三个「可能停住」的点:

  1. 发送缓冲区满:tcp_sendmsg_locked 发现 sk_wmem_queued 超过 sk_sndbuf,阻塞 socket 睡眠等 ACK 释放空间,非阻塞返回 EAGAIN;
  2. 拥塞窗口/接收窗口不足:数据留在 sk_write_queue,tcp_write_xmit 只发窗口允许的部分;
  3. qdisc 队列满:dev_queue_xmit 返回 NET_XMIT_DROP,包被丢弃,TCP 靠重传恢复。

tcp_sendmsg_locked 的关键动作(Linux 6.8,节选):

/* net/ipv4/tcp.c */
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
    ...
    while (msg_data_left(msg)) {
        ...
        skb = tcp_stream_alloc_skb(sk, sk->sk_allocation, ...);
        ...
        copy = min_t(int, msg_data_left(msg), skb_availroom(skb));
        err = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
        ...
        tcp_push(sk, flags, mss_now, tp->nonagle, size_goal);
    }
    ...
}

skb_add_data_nocache 把用户态数据拷到 skb 的线性区或分片页;tcp_push 决定是否立刻发(受 Nagle、TCP_CORK、MSG_MORE 影响)。write 返回成功只表示数据进了发送缓冲区,不表示对端收到——这是 TCP 与「写到文件」最大的语义差异之一。

接收路径

网卡 DMA → RX ring
  └─ 硬中断 → napi_schedule()
       └─ 软中断 net_rx_action()       net/core/dev.c
            └─ napi_poll()            驱动 poll
                 └─ napi_gro_receive()
                      └─ netif_receive_skb()
                           └─ ip_rcv()             net/ipv4/ip_input.c
                                └─ ip_local_deliver()
                                     └─ tcp_v4_rcv()          net/ipv4/tcp_ipv4.c
                                          └─ tcp_rcv_established()  net/ipv4/tcp_input.c
                                               ├─ tcp_data_queue()  挂到 sk_receive_queue
                                               └─ sk_data_ready()   sock_def_readable()
                                                    └─ wake_up_interruptible_sync_poll()
                                                         └─ 唤醒 epoll/read 等待者

应用侧:

read(2)
  └─ sock_read_iter()
       └─ sock_recvmsg()
            └─ inet_recvmsg()
                 └─ tcp_recvmsg()
                      └─ tcp_recvmsg_locked()
                           ├─ skb_copy_datagram_iter()  从 skb 拷到用户缓冲区
                           └─ tcp_cleanup_rbuf()        更新窗口,可能发 ACK

关键点:

  • 数据到达由软中断完成,read 只负责从已排队的 skb 拷贝;sk_data_ready 唤醒 sk_wq,epoll 通过 poll_wait 挂同一个队列;tcp_cleanup_rbuf 在应用读走后更新窗口,不读则窗口降到 0;SO_RCVLOWAT 控制唤醒阈值(默认 1)。

2.16.17 三个完整程序:地址枚举、accept 防护、UDP 边界

这一节给三个可以直接编译的程序。它们分别验证:getaddrinfo 的返回结构、EMFILE 下的 accept 恢复、UDP 的截断与零长度数据报。

程序一:枚举 getaddrinfo 的全部结果

/* addr_dump.c:打印 getaddrinfo 返回的每个地址 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>

static void die_gai(const char *what, int rc)
{
    fprintf(stderr, "%s: %s\n", what, gai_strerror(rc));
    exit(EXIT_FAILURE);
}

int main(int argc, char **argv)
{
    if (argc < 3) {
        fprintf(stderr, "usage: %s host service [passive|numeric|addrconfig]\n",
                argv[0]);
        return EXIT_FAILURE;
    }

    int flags = AI_CANONNAME;
    if (argc > 3 && strcmp(argv[3], "passive") == 0)
        flags |= AI_PASSIVE;
    if (argc > 3 && strcmp(argv[3], "numeric") == 0)
        flags |= AI_NUMERICHOST | AI_NUMERICSERV;
    if (argc > 3 && strcmp(argv[3], "addrconfig") == 0)
        flags |= AI_ADDRCONFIG;

    struct addrinfo hints = {0};
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = flags;

    struct addrinfo *res = NULL;
    int rc = getaddrinfo(argv[1], argv[2], &hints, &res);
    if (rc != 0)
        die_gai("getaddrinfo", rc);

    int i = 0;
    for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next, i++) {
        char host[NI_MAXHOST] = "?";
        char serv[NI_MAXSERV] = "?";
        if (getnameinfo(ai->ai_addr, ai->ai_addrlen,
                        host, sizeof host, serv, sizeof serv,
                        NI_NUMERICHOST | NI_NUMERICSERV) != 0) {
            strcpy(host, "?");
            strcpy(serv, "?");
        }
        printf("%d family=%d socktype=%d proto=%d addrlen=%u %s:%s canon=%s\n",
               i, ai->ai_family, ai->ai_socktype, ai->ai_protocol,
               (unsigned)ai->ai_addrlen, host, serv,
               ai->ai_canonname ? ai->ai_canonname : "-");
    }

    freeaddrinfo(res);
    return i > 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g addr_dump.c -o addr_dump
./addr_dump example.com https
./addr_dump 127.0.0.1 8080 numeric
./addr_dump example.com 443 addrconfig

程序二:EMFILE 下仍能 accept 的服务端

/* tcp_guard_server.c:4 字节长度前缀 echo,处理 EMFILE/短读/超时 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.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 "12349"
#define BACKLOG 8
#define MAX_FRAME (64 * 1024)

static volatile sig_atomic_t stop;
static int reserve_fd = -1;

static void on_sigint(int sig) { (void)sig; stop = 1; }

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}
static int read_full(int fd, void *buf, size_t n)
{
    size_t off = 0;
    char *p = buf;
    while (off < n) {
        ssize_t r = read(fd, p + off, n - off);
        if (r == -1) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        if (r == 0)
            return off == 0 ? 0 : -1;   /* 边界 EOF / 中途截断 */
        off += (size_t)r;
    }
    return 1;
}
static int write_full(int fd, const void *buf, size_t n)
{
    size_t off = 0;
    const char *p = buf;
    while (off < n) {
        ssize_t w = send(fd, p + off, n - off, MSG_NOSIGNAL);
        if (w == -1) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        off += (size_t)w;
    }
    return 0;
}

static void handle(int cfd)
{
    struct timeval tv = { .tv_sec = 3, .tv_usec = 0 };
    setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);

    for (;;) {
        uint32_t netlen;
        int rc = read_full(cfd, &netlen, sizeof netlen);
        if (rc == 0)
            return;
        if (rc == -1) {
            perror("read header");
            return;
        }
        uint32_t len = ntohl(netlen);
        if (len > MAX_FRAME) {
            fprintf(stderr, "frame too large: %u\n", len);
            return;
        }
        unsigned char *frame = malloc(4 + (size_t)len);
        if (frame == NULL)
            return;
        memcpy(frame, &netlen, 4);
        if (read_full(cfd, frame + 4, len) != 1) {
            free(frame);
            return;
        }
        if (write_full(cfd, frame, 4 + (size_t)len) == -1) {
            free(frame);
            return;
        }
        free(frame);
    }
}

int main(void)
{
    struct sigaction sa = {0};
    sa.sa_handler = on_sigint;
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGINT, &sa, NULL) == -1)
        die("sigaction");

    reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC);
    if (reserve_fd == -1)
        die("open reserve");

    int lfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
    if (lfd == -1)
        die("socket");
    int one = 1;
    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) == -1)
        die("SO_REUSEADDR");

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_port = htons(12349);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1)
        die("bind");
    if (listen(lfd, BACKLOG) == -1)
        die("listen");

    printf("guard server on %s\n", PORT);
    fflush(stdout);

    while (!stop) {
        struct sockaddr_storage peer;
        socklen_t plen = sizeof peer;
        int cfd = accept4(lfd, (struct sockaddr *)&peer, &plen, SOCK_CLOEXEC);
        if (cfd == -1) {
            if (errno == EINTR)
                continue;
            if (errno == EMFILE) {
                close(reserve_fd);
                cfd = accept(lfd, NULL, NULL);
                if (cfd != -1)
                    close(cfd);
                reserve_fd = open("/dev/null", O_RDONLY | O_CLOEXEC);
                fprintf(stderr, "EMFILE: dropped one connection\n");
                continue;
            }
            perror("accept4");
            break;
        }

        handle(cfd);
        close(cfd);
    }

    close(lfd);
    close(reserve_fd);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tcp_guard_server.c -o tcp_guard_server
./tcp_guard_server
# 另一个终端:先发一个 5 字节帧,再发一个 70000 字节的超限长度头
printf '\x00\x00\x00\x05hello' | nc 127.0.0.1 12349
printf '\x00\x01\x11\x70' | nc -q1 127.0.0.1 12349

预期:第一个连接回 hello;第二个连接被服务端断开并打印 frame too large: 70000。ulimit -n 8 后并发连接可观察到 EMFILE 分支。

程序三:UDP 的零长度、截断与真实长度

/* udp_boundary.c:发送三个数据报,观察 recvmsg 的返回值与 MSG_TRUNC */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.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>

#define BIG 2000

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main(void)
{
    int rfd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
    if (rfd == -1)
        die("socket");

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    addr.sin_port = 0;
    if (bind(rfd, (struct sockaddr *)&addr, sizeof addr) == -1)
        die("bind");

    socklen_t alen = sizeof addr;
    if (getsockname(rfd, (struct sockaddr *)&addr, &alen) == -1)
        die("getsockname");

    pid_t pid = fork();
    if (pid == -1)
        die("fork");

    if (pid == 0) {
        int sfd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0);
        if (sfd == -1)
            die("socket sender");
        unsigned char big[BIG];
        memset(big, 'x', sizeof big);
        if (sendto(sfd, "", 0, 0, (struct sockaddr *)&addr, sizeof addr) < 0)
            die("send zero");
        if (sendto(sfd, big, sizeof big, 0, (struct sockaddr *)&addr, sizeof addr) < 0)
            die("send big");
        if (sendto(sfd, "ten bytes!", 10, 0, (struct sockaddr *)&addr, sizeof addr) < 0)
            die("send ten");
        close(sfd);
        _exit(0);
    }

    char buf[1024];
    for (int i = 0; i < 3; i++) {
        struct iovec iov = { .iov_base = buf, .iov_len = sizeof buf };
        struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1 };
        ssize_t n = recvmsg(rfd, &msg, MSG_TRUNC);
        if (n == -1) {
            perror("recvmsg");
            break;
        }
        if (n == 0)
            printf("datagram %d: zero-length\n", i + 1);
        else if (n > (ssize_t)sizeof buf)
            printf("datagram %d: real=%zd kept=%zu (truncated)\n",
                   i + 1, n, sizeof buf);
        else
            printf("datagram %d: len=%zd\n", i + 1, n);
    }

    close(rfd);
    int status;
    if (waitpid(pid, &status, 0) == -1)
        die("waitpid");
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g udp_boundary.c -o udp_boundary
./udp_boundary
datagram 1: zero-length
datagram 2: real=2000 kept=1024 (truncated)
datagram 3: len=10

关键行:recvmsg(..., MSG_TRUNC) 返回真实长度 2000,但只往 buf 里放了 1024 字节;如果不带 MSG_TRUNC,返回值就是 1024,无法区分「本来 1024」和「被截断」。

2.16.18 实验:accept 队列溢出与握手抓包

实验一:把 accept 队列压满

用 Python 起一个「只 listen、不 accept」的服务端,队列长度设为 1:

python3 - <<'PY'
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 12345))
s.listen(1)
print("listening, sleeping 30s", flush=True)
time.sleep(30)
PY

另开终端,发起 5 条连接并保持:

for i in $(seq 1 5); do
  (exec 3<>/dev/tcp/127.0.0.1/12345; sleep 10) &
done
ss -lnt 'sport = :12345'
nstat -az TcpExtListenOverflows TcpExtListenDrops

预期输出:

State   Recv-Q  Send-Q  Local Address:Port
LISTEN  1       1       127.0.0.1:12345
TcpExtListenOverflows     3     0.0
TcpExtListenDrops         3     0.0

Recv-Q=1 表示 accept 队列已满(backlog=1),Send-Q=1 是上限;ListenOverflows 增加说明后续 ACK 被丢弃。此时客户端不会立刻收到 ECONNREFUSED,而是连接建立变慢或超时——这正是「服务端 accept 慢」与「端口没开」的区别。

实验二:用 strace 与 tcpdump 看握手和关闭

三个终端分别执行:

strace -f -e trace=network -o /tmp/tcp.log ./tcp_echo_server
sudo tcpdump -i lo -nn -vv 'tcp port 12345' -w /tmp/tcp.pcap
printf 'hello\n' | nc 127.0.0.1 12345
ss -tanp 'sport = :12345 or dport = :12345'

预期 tcpdump 文本(省略窗口与校验和):

IP 127.0.0.1.53124 > 127.0.0.1.12345: Flags [S], seq ...
IP 127.0.0.1.12345 > 127.0.0.1.53124: Flags [S.], seq ... ack ...
IP 127.0.0.1.53124 > 127.0.0.1.12345: Flags [.], ack ...
IP 127.0.0.1.12345 > 127.0.0.1.53124: Flags [P.], length 6
IP 127.0.0.1.53124 > 127.0.0.1.12345: Flags [F.], seq ...
IP 127.0.0.1.12345 > 127.0.0.1.53124: Flags [F.], seq ... ack ...

读法:

  • [S]/[S.]/[.] 是三次握手;
  • [P.] 是带数据的包,length 6 对应 hello\n;
  • [F.] 是 FIN,两个方向各一次,最后 [.] 是最后一个 ACK;
  • 若看到 [R] 而不是 [F.],说明有一方在未读完数据时 close,或 SO_LINGER 设了 0。

2.16.19 陷阱清单:十二个现场

2.16 的十二个工程陷阱
  1. 「shutdown(SHUT_RD) 会让对端收到 RST」——Linux 的 shutdown(SHUT_RD) 只是让本端 read 返回 0,之后到达的数据被 ACK 后丢弃;只有在本端接收队列还有未读数据时 close,才容易触发 RST。原 2.16.9 表中「对端继续写会收到 RST」的说法不准确,以此处为准。
  2. 「recvfrom 返回 0 是 EOF」——UDP 的 0 是零长度数据报;UDP 没有 EOF。TCP 的 0 才是对端关闭写方向。
  3. 「accept 会继承监听 fd 的 O_NONBLOCK」——不会。必须用 accept4(..., SOCK_NONBLOCK) 或再 fcntl。
  4. 「设了 SO_REUSEADDR 就能起两个同端口实例」——不能。两个 LISTEN 同地址仍需 SO_REUSEPORT。
  5. 「SO_REUSEPORT 可以随便绑」——要求同 UID、同 family/type/protocol、同地址;否则 EADDRINUSE/EINVAL。
  6. 「listen(fd, 0) 表示禁止连接」——backlog 0 不是开关,内核实现可能仍允许一条;限流要在应用层做。
  7. 「getaddrinfo 的第一个结果一定最快」——顺序由 RFC 6724 与 gai.conf 决定,应用应遍历尝试。
  8. 「AI_PASSIVE 对客户端也适用」——客户端传了非 NULL host 时 AI_PASSIVE 基本无效;它只影响 node == NULL 的通配地址。
  9. 「AI_ADDRCONFIG 在容器里没问题」——只有 loopback 的容器可能查不到 IPv6/IPv4;调试时先去掉。
  10. 「inet_ntoa 可以多线程用」——它返回静态缓冲区;用 inet_ntop。
  11. 「sizeof(struct sockaddr) 可以当 addrlen」——只有 16 字节,IPv6 需要 28;必须传具体结构大小。
  12. 「close 会立刻发 FIN」——只要还有 dup/fork 的引用,连接就保持;shutdown 影响所有引用,close 只减引用计数。

2.16.20 本章出口检查

检查项达标线没达标去哪
说出 socket 与普通文件的三个差异lseek、EOF、短读2.16.1
正确填充 sockaddr_in端口/地址都用网络字节序2.16.2
解释 backlog 与两个队列能说出 ss -lnt 的 Recv-Q2.16.3
用 accept4 设置 CLOEXEC/NONBLOCK知道新 fd 不继承 O_NONBLOCK2.16.4
写出处理短读短写的 echo代码能编译并通过 nc 测试2.16.5
说出 SOCK_NONBLOCK/SOCK_CLOEXEC 的原子性能在 socket/accept4 里用对2.16.10
解释 SO_REUSEPORT 的四个条件同 UID、同地址、同 family/type2.16.11
用 getaddrinfo 支持 IPv4/IPv6能遍历结果并 freeaddrinfo2.16.6
区分 TCP 与 UDP 的边界语义能说出 UDP 截断行为2.16.7
解释 SO_REUSEADDR 与 TIME_WAIT知道它不解决 SO_REUSEPORT 的问题2.16.9
即时自测 · 2.16

Q1 服务端 bind 返回 EADDRINUSE,但 ss -lnt 看不到同端口监听。最可能的原因是什么?怎么解决?

Q2 accept 返回的新 fd 是非阻塞的吗?如果监听 fd 设了 O_NONBLOCK 呢?

Q3 客户端 connect 返回 EINTR,可以直接重试 connect 吗?为什么?

Q4 UDP 服务端 recvfrom 的缓冲区是 1024 字节,客户端发了 2000 字节的数据报。recvfrom 返回什么?剩下的 976 字节去哪了?

Q5 为什么 shutdown(SHUT_WR) 之后还能 read?close 之后呢?

答案

A1 旧连接的 TIME_WAIT 占着四元组,ss -lnt 只显示 LISTEN,不显示 TIME_WAIT(用 ss -tan 才看得到)。解决:setsockopt(SO_REUSEADDR) 后再 bind,或等 2MSL(Linux 60 秒)。

A2 不是。Linux 上 accept 返回的新 fd 不继承监听 fd 的 O_NONBLOCK,新 fd 默认阻塞。监听 fd 是非阻塞的,只影响 accept 本身(没有连接时返回 EAGAIN)。要新 fd 非阻塞,用 accept4(..., SOCK_NONBLOCK),或之后再 fcntl。

A3 不能直接重试。阻塞 connect 被信号打断时,三次握手可能仍在后台进行;立即重试会得到 EALREADY。应改用 poll(POLLOUT) + getsockopt(SO_ERROR) 确认结果,或从一开始就用非阻塞 connect。

A4 返回 1024(缓冲区大小),数据报被截断,剩余 976 字节被丢弃,不会留在内核里等下次读。要检测截断,用 recvmsg 并检查 MSG_TRUNC 标志;要收完整数据报,缓冲区要足够大(UDP 最大 65507 字节)。

A5 shutdown(SHUT_WR) 只关闭写方向,读方向仍打开,所以能继续 read 直到对端关闭。close 释放 fd,之后不能再 read/write;如果还有其他 fd 引用同一 socket(dup/fork),连接不会立即关闭,但当前 fd 已经无效。

母本对应

母本章节本书落点程度
K第 56 章 SOCKET:介绍2.16.1–2.16.4完整(socket/bind/listen/accept)
K第 57 章 SOCKET:UNIX DOMAIN2.16.8完整(sockaddr_un、socketpair、抽象命名空间)
K第 58 章 SOCKET:TCP/IP 网络基础2.16.2、2.16.7完整(地址、端口、字节序、TCP/UDP)
K第 59 章 SOCKET:Internet Domain2.16.5–2.16.7完整(getaddrinfo、TCP/UDP 示例)
C第 11 章 网络编程2.16.1–2.16.7完整(客户端-服务器模型、套接字接口)
W第 5 章 运输层2.16.3、2.16.7背景(TCP/UDP 语义、端口)
W第 6 章 应用层2.16.6背景(DNS 与名字解析)
N第 6 章 深度理解 TCP 连接建立过程2.16.3背景(SYN 队列、accept 队列、backlog)
K第 60 章 SOCKET:服务器设计2.16.3、2.16.11、2.17.1.1背景(backlog/SO_REUSEADDR;服务器模型见 2.17.1.1)
K第 61 章 SOCKET:高级主题2.16.9、2.16.15完整(shutdown、选项、辅助数据)
N第 3 章 内核如何与用户进程协作2.16.16背景(等待队列、唤醒路径)
N第 8 章 一台机器最多能支持多少条 TCP 连接2.16.4背景(fd 限制、EMFILE)

下一站:2.17 套接字高级与网络编程 把 echo 服务端变成能承载真实协议的服务器:长度前缀分帧、部分读写、超时、心跳、优雅关闭,以及用 SCM_RIGHTS 在进程间传递 fd。