yuqi-zheng

POSIX Sockets API:使用技巧与常见陷阱


POSIX sockets API(也叫 Berkeley 或 BSD sockets)是 Linux 上能用的最底层的网络接口。它也布满了微妙的失败模式。除非你有充分的理由直接在这一层工作,否则类似 libuv、libevent 或 ASIO 的跨平台库会替你处理掉大部分问题。

本文记录你如果直接对着裸 API 写代码时会遇到的那些坑,以及每个的正确处理方式。


抑制 SIGPIPE

对一个远端已关闭的 TCP socket 写入,会让内核把 SIGPIPE 投递给进程。SIGPIPE 的默认行为是终止进程。在多线程程序中,信号处理器是进程全局的,极易出现竞态。

Linux 与大多数 BSD(OpenBSD、FreeBSD、NetBSD)

在每次 send 调用上传 MSG_NOSIGNAL

ssize_t n = send(fd, msg, len, MSG_NOSIGNAL);

调用返回 -1errno = EPIPE,而不会引发信号。

macOS

MSG_NOSIGNAL 在 macOS 上不可用。改用 SO_NOSIGPIPE socket 选项:

int opt = 1;
if (setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &opt, sizeof(opt)) == -1) {
    perror("setsockopt");
    return -1;
}

可移植的兜底

在进程范围内忽略 SIGPIPE

signal(SIGPIPE, SIG_IGN);

到处能用,但很粗暴 —— 它会抑制进程中所有文件描述符的 SIGPIPE,包括到子进程的管道。


处理 EINTR

任何阻塞系统调用都可能被信号打断,返回 -1errno = EINTR。是否自动重启取决于安装信号处理器时是否设置了 SA_RESTART,以及具体的系统调用。Linux 上的 epoll_wait 无论是否设 SA_RESTART 都会返回 EINTR

遇到 EINTR 要检查并重试:

retry:
    ssize_t n = recv(fd, buf, len, 0);
    if (n == -1) {
        if (errno == EINTR) goto retry;
        perror("recv");
        return -1;
    }

同样的模式适用于 sendconnectacceptepoll_wait 和其他任何阻塞调用。


异步主机名解析

getaddrinfo 是 POSIX 的主机名解析函数。它是同步的,等 DNS 回包时可能阻塞数秒,不能直接用在事件循环里。

如果你的代码只需要处理 IP 字面量,把 AI_NUMERICHOST 传入以让调用非阻塞:

struct addrinfo hints = { .ai_flags = AI_NUMERICHOST };
getaddrinfo("192.0.2.1", "80", &hints, &res);

在异步上下文中做真正的 DNS 解析,用专门的库:

  • c-ares:使用广泛,能与任何事件循环集成
  • getdns:带 DNSSEC 感知,更高层的 API

避免使用 getaddrinfo_a(glibc 的异步变体)—— 它内部使用线程和信号,难以与 epoll 集成。


非阻塞 connect

第 1 步:创建非阻塞 socket

int fd = socket(res->ai_family,
                res->ai_socktype | SOCK_NONBLOCK | SOCK_CLOEXEC,
                res->ai_protocol);
if (fd == -1) { perror("socket"); return -1; }

SOCK_NONBLOCK 在创建时就把 socket 设为非阻塞,免去后续 fcntl 调用。SOCK_CLOEXEC 防止 fd 在 fork/exec 后泄漏到子进程。

第 2 步:调用 connect

retry:
    int rc = connect(fd, res->ai_addr, res->ai_addrlen);
    if (rc == -1 && errno != EINPROGRESS) {
        if (errno == EINTR) goto retry;
        perror("connect");
        return -1;
    }
    // rc == 0:立即连接成功(比如环回)
    // errno == EINPROGRESS:连接进行中

第 3 步:检测完成

当事件循环报告这个 fd 可写(EPOLLOUT)或出错(EPOLLERREPOLLHUP),检查结果:

int err;
socklen_t errlen = sizeof(err);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &errlen) == -1) {
    perror("getsockopt"); return -1;
}
if (err != 0) {
    errno = err;
    perror("connect"); return -1;
}
// 连接已建立

非阻塞 accept

准备

int lfd = socket(AF_INET6, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
listen(lfd, 128);

accept 循环

epoll 报告监听 socket 可读时,把它排空:

for (;;) {
    int fd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
    if (fd == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
        if (errno == EINTR || errno == ECONNABORTED) continue;
        perror("accept4"); return -1;
    }
    // 处理 fd
}

accept4 是 Linux 扩展,原子地在被 accept 的 socket 上设置标志,避免 acceptfcntl 之间的竞态。ECONNABORTED 发生在客户端在 accept 返回前重置连接时,可以安全忽略。


Nagle 算法与延迟 ACK 的交互

TCP 的 Nagle 算法会把小写入缓冲起来再一起发送,减少包数量。TCP 的延迟 ACK 机制在收到数据后最多等 40 ms 才确认,以便应用能把响应搭在一起。两者叠加会让交互式请求-响应模式出现最长 40 ms 的延迟 —— 尤其当服务器把请求的最后一个字节放在单独的 write 调用里时。

禁用 Nagle(TCP_NODELAY

对 RPC 式或低延迟协议:

int opt = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

注意:如果你的应用在紧循环里用单字节 payload 调 write,关掉 Nagle 会让网络被微小包淹没。请改用 writevsendmmsg 批量写。

禁用延迟 ACK(TCP_QUICKACK

对”发请求、收大响应”的客户端:

int opt = 1;
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &opt, sizeof(opt));

TCP_QUICKACK 是 Linux 扩展,而且不是粘性的 —— 内核可能重置它。如果需要行为一致,在每次 recv 之后再设一次。


实现低延迟

内核旁路(kernel bypass)

想要最低的延迟(个位数微秒),完全绕开内核网络栈:

  • DPDK:通用的内核旁路
  • OpenOnload(Solarflare):用 LD_PRELOAD shim 做透明加速
  • Mellanox VMA:面向 Mellanox/NVIDIA 网卡,类似 OpenOnload
  • Exablaze:基于 FPGA,亚微秒延迟

忙轮询(仍走内核路径)

Linux 3.11 添加了 SO_BUSY_POLL,在 socket 调用内部轮询网卡驱动,而不是等中断:

int usecs = 10000;
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &usecs, sizeof(usecs));

用户态忙轮询

想在不启用内核旁路的前提下把内核路径延迟压到最低,在用户态自旋:

retry:
    ssize_t n = recv(fd, buf, len, MSG_DONTWAIT);
    if (n == -1) {
        if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)
            goto retry;
        perror("recv"); return -1;
    }

这会烧掉一个 CPU 核,但能消除中断和调度带来的延迟。

关闭中断合并

网卡固件默认会为提升吞吐而把中断批处理。对延迟敏感的应用,关掉它:

ethtool -C eth0 adaptive-rx off rx-usecs 0 rx-frames 0

包时间戳

Linux 通过 SO_TIMESTAMPING 支持对收发包的硬件和软件时间戳。应用可以用它们来测量单向延迟(需要同步的参考时钟)、检测调度抖动、或审计网络路径。完整 API 见 Linux 内核文档中关于网络时间戳的部分。