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

1.11 网络编程与协议基础

本章回答

两个进程隔着网络,凭什么能像读写文件一样交换字节?socket、bind、listen、accept、connect 各自把连接推进到哪一步?HTTP 请求在 TCP 字节流里怎样切出边界?

读完你会

用 getaddrinfo 写出协议无关的客户端;实现一个 fork 并发 echo 服务器与一个最小 HTTP 服务器;解释三次握手、backlog、TIME_WAIT、MSS 与分片;在 TCP/UDP 之间做出有理由的选择。

1.10 把 fd 统一成了字节流的句柄。socket 是这套抽象的一个特例:read/write 的语义不变,短读、短写、EINTR、EAGAIN 一条都不用改;变的是对端——字节不再来自磁盘,而来自另一台机器上的进程。

网络编程的难点不在 API 数量,而在边界:TCP 是字节流,不保留消息边界;连接是四元组,不是「一个 socket」;accept 返回的是新 fd;close 不等于对方立刻收到;超时、重传、分片由内核处理,应用只能看到「慢」和「断」。这一章先把协议分层与 socket 生命周期讲清楚,再落到两个能跑的服务器上。

协议部分以谢希仁《计算机网络》第 1–6 章为骨架:分层、IP、TCP/UDP、应用层;实现部分以 CSAPP 第 11 章和 Kerrisk 第 58 章为骨架。所有代码以 Linux 6.8、IPv4 为例,IPv6 的差异在 getaddrinfo 处点出。

1.11.1 客户端-服务器模型与协议分层

网络应用的基本形态是客户端发起请求,服务器循环处理连接。一次 HTTP 访问可以拆成:

  1. 客户端解析域名(DNS),得到服务器 IP;
  2. 客户端 socket + connect,内核发 SYN,三次握手建立 TCP 连接;
  3. 客户端 write 请求字节;服务器 read 解析;
  4. 服务器 write 响应;客户端 read 解析;
  5. 一方 close,四次挥手(或 RST)。

协议分层把这个过程切成独立的层,每层只关心自己的首部:

应用层   HTTP 请求 "GET /index.html HTTP/1.1\r\nHost: ...\r\n\r\n"
         │
         ▼
运输层   ┌──────────────┬────────────────────────────────────┐
         │ TCP 首部 20B │ HTTP 数据 ...                       │
         └──────────────┴────────────────────────────────────┘
         │
         ▼
网络层   ┌────────────┬──────────────┬───────────────────────┐
         │ IP 首部 20B│ TCP 首部 20B │ HTTP 数据 ...          │
         └────────────┴──────────────┴───────────────────────┘
         │
         ▼
链路层   ┌──────────────┬────────────┬──────────┬────────────┐
         │ 以太网首部14B│ IP 包 ...  │ 填充 ... │ FCS 4B     │
         └──────────────┴────────────┴──────────┴────────────┘

每一层在发送端加首部(封装),在接收端逐层剥掉(分用)。应用层只看到 HTTP 字节,运输层只看到 TCP 段,网络层只看到 IP 包。分层的好处是替换一层不影响其他层:HTTP/2 仍跑在 TCP 上;QUIC 把可靠传输挪到 UDP 之上;IPv6 替换 IPv4 时 TCP 几乎不变。

层数据单元地址典型协议
应用层报文(message)URL/域名HTTP、DNS、SSH
运输层段(segment)/数据报端口TCP、UDP、QUIC
网络层包(packet)IP 地址IPv4、IPv6、ICMP
链路层帧(frame)MAC 地址Ethernet、Wi-Fi
物理层比特—双绞线、光纤、无线电

「IP 地址 + 端口」唯一标识一台主机上的一个服务端点;「源 IP:源端口 + 目的 IP:目的端口 + 协议」唯一标识一条连接。这就是四元组。服务器的监听 socket 只绑定「本地 IP:端口」,不绑定对端;accept 返回的连接 socket 才带完整四元组。

1.11.2 IP、端口与字节序

IPv4 地址是 32 位,写成点分十进制;IPv6 是 128 位,写成冒号十六进制。端口是 16 位,0–65535:

范围用途
0保留;bind 时表示「让内核挑一个临时端口」
1–1023知名端口,绑定通常需要特权(或 CAP_NET_BIND_SERVICE)
1024–49151注册端口(HTTP 8080、MySQL 3306)
49152–65535临时端口(ephemeral),客户端 connect 时内核分配

网络字节序是大端(big-endian),主机字节序取决于 CPU(x86-64 是小端)。所以端口和 32 位地址在写入 sockaddr_in 前必须转换:

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);              /* 16 位:host → network */
addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 32 位:0.0.0.0 */

htons/htonl 只处理整数;IP 地址字符串用 inet_pton 转换,不要手写 sscanf("%d.%d.%d.%d")(它不检查范围,也不支持 IPv6)。

更好地,用 getaddrinfo 把「名字 + 服务」解析成 addrinfo 链表,协议无关:

/* resolve.c:把主机名/服务解析为地址列表 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>

int main(int argc, char **argv)
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s <host> <service>\n", argv[0]);
        return 2;
    }

    struct addrinfo hints;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_UNSPEC;          /* IPv4 或 IPv6 */
    hints.ai_socktype = SOCK_STREAM;

    struct addrinfo *res;
    int rc = getaddrinfo(argv[1], argv[2], &hints, &res);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return 1;
    }

    char host[NI_MAXHOST], serv[NI_MAXSERV];
    for (struct addrinfo *p = res; p != NULL; p = p->ai_next) {
        if (getnameinfo(p->ai_addr, p->ai_addrlen, host, sizeof(host),
                        serv, sizeof(serv),
                        NI_NUMERICHOST | NI_NUMERICSERV) != 0)
            continue;
        printf("family=%s type=%s host=%s port=%s\n",
               p->ai_family == AF_INET ? "AF_INET" :
               p->ai_family == AF_INET6 ? "AF_INET6" : "other",
               p->ai_socktype == SOCK_STREAM ? "STREAM" : "DGRAM",
               host, serv);
    }

    freeaddrinfo(res);
    return 0;
}

getaddrinfo 的价值在于:调用者不用判断 IPv4/IPv6、不用手写 sockaddr 结构、不用查 /etc/services。服务器端用 AI_PASSIVE 加 node=NULL 得到通配地址;客户端直接传主机名。返回的链表要逐个尝试 socket+connect,直到一个成功。

点分十进制只是表示法;子网划分、CIDR 聚合、路由查找与 DNS 的层级解析不在本章展开,分别见 4.3 网络层 与 4.7 应用层。

1.11.3 socket API 与 TCP 生命周期

TCP 服务器的主干是四步:

socket() → bind() → listen() → accept() → read/write → close()

客户端是:

socket() → connect() → read/write → close()
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 queue),服务器回 SYN+ACK;客户端回 ACK 后,连接移入全连接队列(accept queue),等待 accept 取走。listen(fd, backlog) 的 backlog 和 net.core.somaxconn 共同决定全连接队列长度;ss -lnt 的 Recv-Q 是当前排队数,Send-Q 是 backlog 上限。队列满时,Linux 默认丢弃或忽略 ACK,客户端会重传——表现为「连接慢」而不是「连接被拒」。

accept 返回一个新的 fd,它绑定到这条连接的四元组;监听 fd 继续留在 listen 状态接收新连接。新 fd 不继承监听 fd 的 O_NONBLOCK(Linux 上如此),需要非阻塞时用 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。

几个常见设置:

选项作用什么时候用
SO_REUSEADDR允许绑定处于 TIME_WAIT 的地址服务器重启时必开
SO_REUSEPORT多个 socket 绑定同一端口,内核分流多进程/多线程 accept
TCP_NODELAY关闭 Nagle 合并交互式/低延迟协议
SO_RCVBUF/SO_SNDBUF调整内核缓冲区高带宽长肥管道
SO_LINGER控制 close 时是否等待/RST需要精确控制关闭语义时

TIME_WAIT 出现在主动关闭方,持续 2×MSL(Linux 约 60 秒)。它有两个作用:保证最后一个 ACK 丢失时还能重发;让旧连接的迷路报文在网络中消散。服务器主动关闭大量短连接时会积累 TIME_WAIT,SO_REUSEADDR 解决的是「重启时 bind 失败」,不是「TIME_WAIT 占端口」本身。

为什么连接需要四元组

监听 socket 只知道「本地 8080 端口」,它无法区分两个客户端。accept 返回的连接 socket 把「客户端 IP:端口 + 服务器 IP:端口」固定下来,内核用四元组查连接表,才知道这个字节属于哪条连接。所以同一台服务器可以同时服务成千上万条连接,靠的不是多个端口,而是四元组的不同组合。

1.11.4 一个 fork 并发的 echo 服务器

echo 服务器把收到的字节原样写回。它足够简单,能把 socket 生命周期、EINTR、SIGCHLD 回收、并发模型一次串起来。

/* echo_server.c:fork 并发的 TCP echo;cc ... -o echo_server */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <arpa/inet.h>
#include <errno.h>
#include <signal.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 reap(int signo)
{
    (void)signo;
    int saved = errno;
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;
    errno = saved;
}

static int write_all(int fd, const char *buf, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, buf, n);
        if (w < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        if (w == 0) {
            errno = EIO;
            return -1;
        }
        buf += (size_t)w;
        n -= (size_t)w;
    }
    return 0;
}

static void serve(int cfd)
{
    char buf[4096];
    for (;;) {
        ssize_t n = read(cfd, buf, sizeof(buf));
        if (n < 0) {
            if (errno == EINTR)
                continue;
            break;
        }
        if (n == 0)
            break;                       /* 对端关闭 */
        if (write_all(cfd, buf, (size_t)n) < 0)
            break;
    }
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        return 2;
    }
    int port = atoi(argv[1]);
    if (port <= 0 || port > 65535) {
        fprintf(stderr, "bad port\n");
        return 2;
    }

    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd < 0) {
        perror("socket");
        return 1;
    }

    int on = 1;
    if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
        perror("SO_REUSEADDR");

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons((uint16_t)port);

    if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        close(lfd);
        return 1;
    }
    if (listen(lfd, 128) < 0) {
        perror("listen");
        close(lfd);
        return 1;
    }

    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = reap;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NODEFER;
    if (sigaction(SIGCHLD, &sa, NULL) < 0)
        perror("sigaction");

    printf("echo server listening on port %d\n", port);
    for (;;) {
        int cfd = accept(lfd, NULL, NULL);
        if (cfd < 0) {
            if (errno == EINTR)
                continue;
            perror("accept");
            break;
        }

        pid_t pid = fork();
        if (pid < 0) {
            perror("fork");
            close(cfd);
            continue;
        }
        if (pid == 0) {
            close(lfd);                  /* 子进程不监听 */
            serve(cfd);
            close(cfd);
            _exit(0);
        }
        close(cfd);                      /* 父进程只保留监听 fd */
    }

    close(lfd);
    return 0;
}

关键点:

  • 父进程 close(cfd) 后,子进程仍持有这条连接;fd 是引用计数,关闭一个副本不影响另一个。
  • 子进程 close(lfd),否则监听 fd 会泄漏到子进程,服务器重启时可能 bind 失败。
  • SIGCHLD 处理函数循环 waitpid(WNOHANG);标准信号不排队,只回收一个会留下僵尸。
  • SA_RESTART 让 accept 被信号打断后自动重启;即使如此,代码仍处理 EINTR,因为并非所有调用都会重启。
  • 子进程用 _exit,避免刷新从父进程继承的 stdio 缓冲区。

这个模型的并发上限是进程数。连接数上千时,fork 的页表复制和上下文切换成本变高;1.12 会给出线程池与 epoll 两个替代方案。

1.11.5 HTTP 与一个最小 Web 服务器

HTTP/1.1 的请求是文本:

GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
User-Agent: curl/8.5.0\r\n
\r\n

响应也是文本头 + 可选 body:

HTTP/1.1 200 OK\r\n
Content-Length: 13\r\n
Content-Type: text/plain\r\n
Connection: close\r\n
\r\n
hello, world\n

三件事决定了 HTTP 服务器的最小实现:

  1. 请求边界:头和 body 之间用空行(\r\n\r\n)分隔;GET 通常没有 body,POST 需要 Content-Length 或 chunked。
  2. 响应边界:有 Content-Length 时按长度读;没有时以连接关闭为界(HTTP/1.0 风格)。持久连接(keep-alive)必须有明确长度。
  3. URI 到文件:URL 解码、去掉查询串、防止 .. 越界、目录补 index.html。

下面的服务器在收到一个请求后返回当前目录下的文件,然后关闭连接。它只处理 GET,够用来观察协议:

/* http_server.c:最小静态文件服务器;cc ... -o http_server */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>

static int write_all(int fd, const char *buf, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, buf, n);
        if (w < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        if (w == 0) {
            errno = EIO;
            return -1;
        }
        buf += (size_t)w;
        n -= (size_t)w;
    }
    return 0;
}

static void send_error(int cfd, int code, const char *reason)
{
    char body[128];
    int n = snprintf(body, sizeof(body), "%d %s\n", code, reason);
    char head[256];
    int h = snprintf(head, sizeof(head),
                     "HTTP/1.1 %d %s\r\nContent-Length: %d\r\n"
                     "Content-Type: text/plain\r\nConnection: close\r\n\r\n",
                     code, reason, n);
    if (h > 0 && (size_t)h < sizeof(head)) {
        (void)write_all(cfd, head, (size_t)h);
        (void)write_all(cfd, body, (size_t)n);
    }
}

static void handle(int cfd)
{
    char req[4096];
    size_t used = 0;

    while (used < sizeof(req) - 1) {
        ssize_t n = read(cfd, req + used, sizeof(req) - 1 - used);
        if (n < 0) {
            if (errno == EINTR)
                continue;
            return;
        }
        if (n == 0)
            break;
        used += (size_t)n;
        req[used] = '\0';
        if (strstr(req, "\r\n\r\n") != NULL)
            break;
    }
    if (used == 0)
        return;

    char method[8], uri[512], version[16];
    if (sscanf(req, "%7s %511s %15s", method, uri, version) != 3) {
        send_error(cfd, 400, "Bad Request");
        return;
    }
    if (strcmp(method, "GET") != 0) {
        send_error(cfd, 405, "Method Not Allowed");
        return;
    }
    if (uri[0] != '/' || strstr(uri, "..") != NULL) {
        send_error(cfd, 403, "Forbidden");
        return;
    }

    const char *rel = strcmp(uri, "/") == 0 ? "/index.html" : uri;
    char path[1024];
    int pn = snprintf(path, sizeof(path), ".%s", rel);
    if (pn <= 0 || (size_t)pn >= sizeof(path)) {
        send_error(cfd, 414, "URI Too Long");
        return;
    }

    struct stat st;
    if (stat(path, &st) < 0 || !S_ISREG(st.st_mode)) {
        send_error(cfd, 404, "Not Found");
        return;
    }

    int fd = open(path, O_RDONLY);
    if (fd < 0) {
        send_error(cfd, 403, "Forbidden");
        return;
    }

    const char *ctype = strstr(path, ".html") ? "text/html" :
                        strstr(path, ".css")  ? "text/css" :
                        strstr(path, ".js")   ? "application/javascript" :
                                                "application/octet-stream";
    char head[512];
    int h = snprintf(head, sizeof(head),
                     "HTTP/1.1 200 OK\r\nContent-Length: %lld\r\n"
                     "Content-Type: %s\r\nConnection: close\r\n\r\n",
                     (long long)st.st_size, ctype);
    if (h <= 0 || (size_t)h >= sizeof(head) ||
        write_all(cfd, head, (size_t)h) < 0) {
        close(fd);
        return;
    }

    char buf[8192];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n < 0) {
            if (errno == EINTR)
                continue;
            break;
        }
        if (n == 0)
            break;
        if (write_all(cfd, buf, (size_t)n) < 0)
            break;
    }
    close(fd);
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        return 2;
    }
    int port = atoi(argv[1]);
    if (port <= 0 || port > 65535) {
        fprintf(stderr, "bad port\n");
        return 2;
    }

    signal(SIGPIPE, SIG_IGN);            /* 客户端提前关闭时不要杀死服务器 */

    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if (lfd < 0) {
        perror("socket");
        return 1;
    }

    int on = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons((uint16_t)port);

    if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        close(lfd);
        return 1;
    }
    if (listen(lfd, 128) < 0) {
        perror("listen");
        close(lfd);
        return 1;
    }

    printf("http server on http://127.0.0.1:%d/\n", port);
    for (;;) {
        int cfd = accept(lfd, NULL, NULL);
        if (cfd < 0) {
            if (errno == EINTR)
                continue;
            perror("accept");
            break;
        }
        handle(cfd);
        close(cfd);
    }

    close(lfd);
    return 0;
}

这个服务器故意保持串行:一次只处理一个连接。它足以用 curl 验证协议,但不能做生产服务——慢客户端会阻塞所有请求。并发化有两条路:每连接一个线程/进程,或事件驱动 + 非阻塞 fd(1.12)。

Content-Length 是最重要的响应头。没有它,客户端只能读到连接关闭才知道 body 结束,无法复用连接;有它,客户端能精确切分响应,也为后续 keep-alive 打基础。POST 请求则要看请求的 Content-Length,否则读多读少都会错位。

MIME 类型与 CGI:静态与动态内容的分界

静态服务器要根据扩展名给出 Content-Type,否则浏览器会猜错或下载:

扩展名MIME说明
.html/.htmtext/html; charset=utf-8必须带 charset,否则中文乱码
.csstext/css
.jstext/javascript现代标准;旧 application/javascript 也可
.jsonapplication/json
.png/.jpg/.webpimage/png 等二进制;不要加 charset
.svgimage/svg+xml可被脚本执行,注意 CSP
.pdfapplication/pdf
未知application/octet-stream浏览器下载

CGI 是最早的动态内容协议:服务器 fork 子进程,把请求信息放进环境变量,把 body 写到 stdin,子进程的 stdout 就是响应。

/* cgi_echo.c — 最小 CGI;服务器把它当 /cgi-bin/echo 执行 */
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    const char *qs = getenv("QUERY_STRING");
    const char *len = getenv("CONTENT_LENGTH");
    printf("Content-Type: text/plain; charset=utf-8\r\n\r\n");
    printf("QUERY_STRING=%s\n", qs ? qs : "");
    printf("CONTENT_LENGTH=%s\n", len ? len : "");
    return 0;
}

服务器侧关键步骤:pipe → fork → 子进程 dup2(pipe[1], 1)、execve;父进程写 body、读 stdout、加 Content-Length。CGI 每个请求一个进程,慢且难管;现代用 FastCGI/uWSGI/反向代理,但「环境变量 + stdin/stdout」的模型仍是理解网关的基础。

MIME/CGI 的四个坑
  1. Content-Type 与 Content-Length 后必须空行;少一个 \r\n 浏览器会把头当 body。
  2. CGI 的 QUERY_STRING 未解码:%20/+ 要自己 urldecode,且要防注入。
  3. CONTENT_LENGTH 不可信:要限制最大值,边读边计数,防止内存耗尽。
  4. CGI 已过时:新项目不要用;但读老代码/理解网关时仍要懂。

1.11.6 字节流边界、MTU 与 TCP/UDP 选择

TCP 提供的是有序字节流,不是消息流。一次 write(1000) 可能被拆成多个段,接收端一次 read 可能拿到 300、700,也可能两次合在一起。应用必须自己定边界:

方案做法适用
长度前缀先发 4 字节大端长度,再发 body二进制协议、RPC
分隔符以 \n/\0 结尾文本协议、Redis RESP
固定长度每条消息长度固定定长记录
关闭连接读到 EOF 为止一次性响应(HTTP/1.0)

HTTP 头用 \r\n\r\n 分隔,body 用 Content-Length 或 chunked;这是「分隔符 + 长度」的组合。自己设计协议时,不要假设「一次 read 等于一条消息」。

MTU 与分片

以太网 MTU 通常 1500 字节。IPv4 首部 20 字节、TCP 首部 20 字节,所以 TCP 单段最大载荷 MSS = 1460;IPv6 首部 40 字节,MSS = 1440。超过 MTU 的 IP 包会被分片(IPv4 路由器可分片,IPv6 通常由源端分片),接收端重组;分片丢失会导致整个包重传,性能很差。

ip link show | grep mtu
# mtu 1500
ping -M do -s 1472 192.0.2.1     # 1472 + 8(ICMP) + 20(IP) = 1500
ss -tin dst 192.0.2.1            # 看 mss、rtt、cwnd

TCP 会做路径 MTU 发现(PMTUD),根据 ICMP「需要分片」消息调整 MSS;如果 ICMP 被防火墙丢弃,会出现「小包能通、大包卡住」的黑洞。这时要在应用层限制消息大小,或配置 MSS clamping。

TCP 还是 UDP

维度TCPUDP
连接面向连接,三次握手无连接,直接 sendto
可靠性确认、重传、有序不保证到达/顺序/去重
边界字节流,无消息边界保留数据报边界
拥塞控制有(cwnd、慢启动)无,应用自己控制
首部20 字节起8 字节
典型用途HTTP、SSH、数据库DNS、视频、游戏、QUIC 底层

UDP 的 sendto 成功只表示「交给了本机网络栈」,不代表对端收到。需要可靠性就在应用层实现序号、确认、重传,或者直接用 QUIC。UDP 也可以 connect,之后用 send/recv,好处是内核会记录对端并回传 ICMP 错误,但仍然是不可靠数据报。

关于网络编程的六个误解
  1. 「TCP 一次 read 就是一条消息」——TCP 是字节流,消息边界要应用层自己定义。
  2. 「accept 返回的还是监听 socket」——返回新 fd,监听 fd 继续接收新连接;新 fd 关闭不影响监听。
  3. 「close 会立刻断开,对端马上收到 FIN」——默认情况下内核先发送已排队数据,再发 FIN;SO_LINGER 设为 0 才会发 RST 并丢弃数据。
  4. 「端口写完 htons 就万事大吉」——htons 只转 16 位端口;IP 地址要用 inet_pton/getaddrinfo,不要手写解析。
  5. 「send/write 返回成功就代表对方收到了」——只代表数据进了本机内核缓冲区;对端是否收到要等应用层确认。
  6. 「SO_REUSEADDR 能解决所有端口占用」——它主要解决 TIME_WAIT 状态的 bind;端口被另一个进程监听时仍然会 EADDRINUSE。

1.11.7 排障:从 socket 到报文

网络问题先分层定位,不要一上来就抓包:

# 1. 本机监听与连接状态
ss -lntp
ss -tanp | grep :8080
ss -s

# 2. 域名与路由
getent hosts example.com
ip route get 93.184.216.34

# 3. 连接与协议
curl -v --max-time 3 http://127.0.0.1:8080/
nc -vz 127.0.0.1 8080

# 4. 报文
tcpdump -i any -nn -c 20 'tcp port 8080'

ss 的 State 列能直接看出问题:大量 SYN-SENT 说明对端不回 SYN+ACK(防火墙/路由/端口未监听);大量 CLOSE-WAIT 说明应用没有 close;大量 TIME-WAIT 说明本机主动关闭了大量短连接。Recv-Q/Send-Q 在监听 socket 上分别表示 accept 队列当前长度和 backlog 上限,在连接 socket 上表示接收/发送缓冲区中未处理的字节数。

strace -f -e trace=network 能看到 socket/bind/listen/accept/connect/sendto/recvfrom 的参数与返回值;curl -v 能看到 DNS、连接、TLS、HTTP 的每一步。两者结合,通常能在几分钟内定位「连不上」「连上了没响应」「响应被截断」三类问题。

1.11.8 getaddrinfo 全字段:hints、flags 与错误码

1.11.2 给了 getaddrinfo 的最小用法。真正写客户端/服务器时,难点在 hints 里哪几个字段由调用者填、ai_flags 的每个位改变什么、失败时 EAI_* 该怎么区分。这一节把这些逐项列全。

struct addrinfo 有七个字段,但只有四个是输入:

字段方向含义常见取值
ai_flags输入行为开关AI_PASSIVE、AI_ADDRCONFIG…
ai_family输入地址族AF_UNSPEC、AF_INET、AF_INET6
ai_socktype输入套接字类型SOCK_STREAM、SOCK_DGRAM
ai_protocol输入协议0(由内核按 type 选)、IPPROTO_TCP
ai_addrlen输出ai_addr 的长度sizeof(struct sockaddr_in)
ai_addr输出指向 sockaddr 的指针直接传给 bind/connect
ai_canonname输出规范主机名仅第一个结果、且设了 AI_CANONNAME
ai_next输出下一条结果链表,需 freeaddrinfo

ai_flags 的每一位都有明确的适用面:

flag作用什么时候用坑
AI_PASSIVEnode == NULL 时返回通配地址(0.0.0.0/::)服务器 bind 前客户端误用会连到 0.0.0.0
AI_CANONNAME第一个结果填 ai_canonname需要显示规范名只有第一个结果有
AI_NUMERICHOST禁止 DNS,只接受数字地址禁止解析、减少延迟传域名返回 EAI_NONAME
AI_NUMERICSERV服务必须是数字端口避免查 /etc/services传 "http" 返回 EAI_NONAME
AI_ADDRCONFIG只返回本机已配置的地址族双栈客户端挑可达族容器只有 loopback 时可能什么都不返回
AI_V4MAPPEDIPv6 查询无结果时返回 ::ffff:a.b.c.d只想要 IPv6 socket 的双栈程序需与 AF_INET6 同用
AI_ALL与 AI_V4MAPPED 一起返回全部映射地址需要完整候选列表结果数量翻倍
AI_IDN(glibc)国际化域名转 punycode中文域名需要 libidn,行为依版本

失败时的返回值不是 errno,而是 EAI_* 码。errno 只在 EAI_SYSTEM 时有意义:

返回码含义常见原因处理
EAI_AGAIN临时失败DNS 超时、服务器不响应退避重试
EAI_NONAME名字/服务不存在域名拼错、AI_NUMERICHOST 传了域名报错,不要重试
EAI_SERVICE服务与 socktype 不匹配"domain" 配 SOCK_STREAM检查服务名
EAI_SOCKTYPE不支持该 socktype传了 SOCK_RAW 给解析器改 type
EAI_FAMILY不支持该地址族AF_UNIX 配主机名改 family
EAI_BADFLAGSflag 组合非法AI_PASSIVE 与 AI_CANONNAME 冲突去掉一个
EAI_MEMORY内存不足结果过多降级/报错
EAI_SYSTEM系统调用失败看 errno按 errno 处理
EAI_OVERFLOWgetnameinfo 缓冲区太小主机名超长加大缓冲

服务器端的标准写法是「AI_PASSIVE + node = NULL + 逐个候选 bind」,客户端是「AF_UNSPEC + 逐个候选 connect」:

/* 服务器:绑定所有地址族;IPv6 双栈 socket 通常能同时收 IPv4 */
struct addrinfo hints = {0}, *res, *p;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
int rc = getaddrinfo(NULL, "8080", &hints, &res);
if (rc != 0) { fprintf(stderr, "%s\n", gai_strerror(rc)); return -1; }

int lfd = -1;
for (p = res; p != NULL; p = p->ai_next) {
    lfd = socket(p->ai_family, p->ai_socktype | SOCK_CLOEXEC, p->ai_protocol);
    if (lfd == -1) continue;
    int on = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);
    if (p->ai_family == AF_INET6)          /* 关掉 IPv6-only,才能收 IPv4 映射地址 */
        setsockopt(lfd, IPPROTO_IPV6, IPV6_V6ONLY, &(int){0}, sizeof(int));
    if (bind(lfd, p->ai_addr, p->ai_addrlen) == 0 && listen(lfd, 128) == 0)
        break;
    close(lfd);
    lfd = -1;
}
freeaddrinfo(res);

两个容易忽略的事实:

  1. getaddrinfo 可能阻塞数秒。它没有超时参数,DNS 查询的超时由 /etc/resolv.conf 的 timeout/attempts 与 options 决定。对延迟敏感的程序应使用异步解析器(getaddrinfo_a、c-ares、libuv 的线程池),或在独立线程里解析。
  2. AI_ADDRCONFIG 在容器里会骗人。它检查的是「本机是否配置了该地址族」,容器通常只有 lo 与一个 IPv4 地址;若没有全局 IPv6 地址,AF_INET6 的结果会被过滤掉,curl -6 直接失败。排查时先用 getaddrinfo 打印不带该 flag 的结果对照。

1.11.9 socket 选项:SO_REUSEADDR、SO_REUSEPORT 与超时

socket 的默认行为对大多数服务都不合适:重启时 bind 失败、关闭时数据被丢弃、Nagle 增加延迟。这些都要用 setsockopt 调整。选项分两层:SOL_SOCKET 的通用选项与 IPPROTO_TCP 的协议选项。

选项层取值作用典型场景
SO_REUSEADDRSOL_SOCKETint 0/1允许 bind 处于 TIME_WAIT 的地址服务器重启必开
SO_REUSEPORTSOL_SOCKETint 0/1多个 socket 绑同一端口,内核分流多进程 accept
SO_LINGERSOL_SOCKETstruct lingerclose 时等多久 / 是否发 RST精确控制关闭
SO_RCVBUF/SO_SNDBUFSOL_SOCKETint 字节内核缓冲区大小高带宽长肥管道
SO_RCVTIMEO/SO_SNDTIMEOSOL_SOCKETstruct timeval阻塞读写的超时简单客户端
SO_KEEPALIVESOL_SOCKETint 0/1空闲时发探测包长连接保活
TCP_NODELAYIPPROTO_TCPint 0/1关闭 Nagle交互式协议
TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNTIPPROTO_TCPint 秒/次保活参数缩短默认 2 小时
TCP_FASTOPENIPPROTO_TCPint 队列长度TFO,省一个 RTT短连接服务
TCP_DEFER_ACCEPTIPPROTO_TCPint 秒有数据才唤醒 accept减少空连接开销

SO_REUSEADDR 到底解决什么

TIME_WAIT 出现在主动关闭方,Linux 持续 60 秒(2 × MSL,net.ipv4.tcp_fin_timeout 只影响 FIN_WAIT_2)。服务器重启时,旧连接处于 TIME_WAIT,端口仍被占用,bind 返回 EADDRINUSE。SO_REUSEADDR 允许绑定这个地址——但必须在 bind 之前设置,且只对「地址相同、状态为 TIME_WAIT」的情况生效;另一个进程正在 LISTEN 同一端口时,它救不了你。

int on = 1;
if (setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on) == -1)
    perror("SO_REUSEADDR");           /* ENOPROTOOPT/EINVAL 少见,但不要静默忽略 */

SO_REUSEPORT 是另一回事:它允许多个 socket(通常属于不同进程/线程)绑定完全相同的地址和端口,内核用四元组哈希把新连接分给其中一个。它适合「多进程各自 accept」的 prefork 模型,但有两个约束:所有 socket 都必须设置该选项(且同一 uid),并且只在 bind 时检查一次。它带来的是负载分布,不是「连接迁移」——一条连接建立后就固定在某个 socket 上。

SO_LINGER 与关闭语义

struct linger lg = { .l_onoff = 1, .l_linger = 0 };
setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof lg);
close(fd);       /* 立刻发 RST,丢弃发送缓冲区里未发出的数据 */
  • l_onoff = 0:默认行为,close 立即返回,内核后台发送剩余数据并走四次挥手。
  • l_onoff = 1, l_linger = 0:close 发 RST,丢弃未发数据。用于「明确要中断」的场景,但会让对端收到 ECONNRESET,且可能丢数据。
  • l_onoff = 1, l_linger > 0:close 阻塞,直到数据发完或超时;超时后返回 EWOULDBLOCK,连接状态不确定。

TCP_NODELAY 与延迟

Nagle 算法把「小包」攒到收到 ACK 或凑够 MSS 再发,减少小包数量;它和延迟 ACK(对端最多等 40 ms 再确认)叠加,会让「请求-响应」型协议出现 40 ms 级的固定延迟。交互式协议(SSH、Redis、游戏、RPC)通常设 TCP_NODELAY;大块传输(文件下载)保持默认更省带宽。没有一种设置适合所有流量,按连接类型决定。

1.11.10 HTTP 报文边界与 TINY 的解剖

HTTP/1.1 的报文是「文本头 + 可选 body」,但边界规则有四种,实现时必须都考虑:

场景边界解析方式
有 Content-Length定长读到指定字节数
Transfer-Encoding: chunked分块读十六进制块长 + 数据 + CRLF,块长 0 结束
无长度、非 chunked连接关闭读到 EOF(HTTP/1.0 风格)
无 body 的状态立即结束HEAD、204、304 没有 body,即使有 Content-Length

请求侧同理:请求行 + 头 + 空行;有 body 时看 Content-Length 或 chunked。头部的结束标志是 \r\n\r\n,不是 \n\n;虽然很多服务器宽容地接受 \n,但代理和中间件不一定。

chunked 的实际字节流:

HTTP/1.1 200 OK\r\n
Transfer-Encoding: chunked\r\n
\r\n
5\r\n
hello\r\n
6\r\n
 world\r\n
0\r\n
\r\n

块长是十六进制,块数据后必须跟 \r\n;最后一块长度为 0,后面可跟 trailer 头,以空行结束。解析时不要用 atoi(它按十进制),要用 strtoul(s, NULL, 16)。

TINY 的结构与它的局限

CSAPP 第 11.6 节的 TINY 是一个约 250 行的迭代式 HTTP/1.0 服务器,结构如下:

函数职责关键点
mainopen_listenfd + 循环 accept一次只服务一个连接
doit读请求行、调 read_requesthdrs、parse_uri、serve_static只支持 GET
read_requesthdrs读到空行,丢弃所有头不解析 Host/Content-Length
parse_uri拆分 URI 与 CGI 参数不做 URL 解码
serve_staticstat + open + mmap + rio_writen用文件大小做 Content-Length

它故意省略的东西,恰好是生产服务器的主要工作:

  • 没有 keep-alive:每个请求一个连接,响应后关闭;Connection: close 是隐含的。
  • 没有并发:慢客户端会阻塞所有后续连接。
  • 没有路径规范化:parse_uri 只做字符串拼接,GET /../etc/passwd 可能逃出文档根目录。生产实现必须 realpath 后检查前缀,或全程 openat + O_NOFOLLOW。
  • 没有超时:慢速loris(slowloris)可以用极慢的头发送耗尽连接。
  • 不做 URL 解码:%2e%2e%2f 这类编码绕过路径检查。
关于 HTTP 解析的四个误解
  1. 「URL 里的 .. 检查一次就够」——编码(%2e%2e)、双重编码、反斜杠(Windows)、符号链接都能绕过字符串检查。正确做法是解析后 realpath 并验证仍在根目录下。
  2. 「Content-Length 一定可信」——它可能为负、超大、与实际不符;要设上限(如 8 MiB),并与实际读取量核对。
  3. 「响应头可以随便拼用户输入」——把 URI 或参数直接写进头会引入 CRLF 注入(response splitting)。头字段必须过滤 \r/\n。
  4. 「HTTP/1.1 默认短连接」——HTTP/1.1 默认持久连接,除非显式 Connection: close;HTTP/1.0 才默认关闭。

1.11.11 并发模型:进程、线程、事件循环与 io_uring

一个服务器要同时服务多个连接,只有四种基本模型。它们的差别不在「谁更快」,而在阻塞在哪里、状态放在哪里、失败如何隔离。

模型代表每连接成本优点缺点
每连接一进程TINY、Apache prefork页表 + 独立地址空间(MB 级)隔离最好,崩溃不扩散fork 贵,连接数受限
每连接一线程Apache worker、传统 Java栈 8 MiB(可调小)+ TCB编程简单,共享内存方便锁竞争、上下文切换、栈内存
事件循环(就绪通知)nginx、Redis、Node.js一个 fd + 用户态缓冲(KB 级)十万级连接、无锁回调复杂度、阻塞调用会卡住全循环
异步 I/O(完成通知)io_uring、Windows IOCP提交/完成队列项真正异步、批量提交内核版本要求、编程模型新

「每连接一线程」的真正瓶颈不是线程创建,而是每个线程的栈与调度器里的可运行线程数。把栈调到 64 KiB、用线程池复用,可以撑到几千连接;但一旦每个请求都要等网络,线程大部分时间在睡眠,内存与切换开销仍然存在。

select、poll、epoll 的分界

接口时间复杂度连接上限触发方式关键限制
selectO(n) 每次全量拷贝FD_SETSIZE(通常 1024)水平fd 集每次要重建
pollO(n) 每次全量扫描受内存/fd 限制水平返回后仍要遍历全部
epollO(1) 取就绪链表受 RLIMIT_NOFILE水平/边沿只能用于 Linux;dup 后行为有坑

epoll 快在内核维护了就绪链表:epoll_wait 只返回就绪的 fd,不需要每次把全部 fd 拷进内核再扫描。但「快」有条件:连接大多是空闲的、事件稀疏。若所有连接都持续活跃,epoll_wait 每次都要返回大量事件,用户态处理成本仍在。

水平触发(level-triggered, LT)是默认:只要缓冲区还有数据,下一次 epoll_wait 还会报告。边沿触发(edge-triggered, ET)只在状态变化时报告一次,必须一次读到 EAGAIN,否则剩余数据不会再触发事件。ET 减少了事件数量,但要求 fd 非阻塞、读循环写对;写侧还要自己管理「没写完的部分」,等 EPOLLOUT。

为什么 epoll 要配合非阻塞 fd

ET 模式下,最后一次 read 必须返回 EAGAIN 才能确认「读干净了」。如果 fd 是阻塞的,这个 read 会永远等下去,整个事件循环卡死。所以 ET + 非阻塞是绑定的组合,不是风格选择。

1.11.12 完整程序:边沿触发的 epoll echo 服务器

这个服务器把 1.11.11 的四个要点落到代码:非阻塞 fd、边沿触发、一次读到 EAGAIN、写侧自己缓冲未发完的数据。它只有一个线程,却能同时持有上万条空闲连接。

/* epoll_echo.c:单线程、边沿触发、非阻塞的 echo 服务器(Linux 2.6.17+) */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_EVENTS 256
#define OUT_SIZE   (64 * 1024)

struct conn {
    int fd;
    int peer_closed;                 /* 读到 EOF,但还有数据没写完 */
    size_t out_len, out_off;
    char out[OUT_SIZE];
};

static void close_conn(int ep, struct conn *c)
{
    epoll_ctl(ep, EPOLL_CTL_DEL, c->fd, NULL);
    close(c->fd);
    free(c);
}

/* 把连接关心的事件写回 epoll:始终读,有积压才写 */
static int arm(int ep, struct conn *c)
{
    struct epoll_event ev;
    memset(&ev, 0, sizeof ev);
    ev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
    if (c->out_off < c->out_len)
        ev.events |= EPOLLOUT;
    ev.data.ptr = c;
    return epoll_ctl(ep, EPOLL_CTL_MOD, c->fd, &ev);
}

static void handle_read(int ep, struct conn *c)
{
    char buf[8192];
    for (;;) {                       /* 边沿触发必须读到 EAGAIN */
        ssize_t n = read(c->fd, buf, sizeof buf);
        if (n > 0) {
            if (c->out_len + (size_t)n > OUT_SIZE) {
                close_conn(ep, c);   /* 慢客户端:输出积压超过上限,断开 */
                return;
            }
            memcpy(c->out + c->out_len, buf, (size_t)n);
            c->out_len += (size_t)n;
        } else if (n == 0) {
            c->peer_closed = 1;
            break;
        } else if (errno == EINTR) {
            continue;
        } else if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break;
        } else {
            close_conn(ep, c);
            return;
        }
    }
    if (arm(ep, c) == -1)
        close_conn(ep, c);
    else if (c->peer_closed && c->out_off == c->out_len)
        close_conn(ep, c);           /* 没有待写数据,可以关了 */
}

static void handle_write(int ep, struct conn *c)
{
    while (c->out_off < c->out_len) {
        ssize_t n = write(c->fd, c->out + c->out_off, c->out_len - c->out_off);
        if (n > 0) {
            c->out_off += (size_t)n;
        } else if (n == -1 && errno == EINTR) {
            continue;
        } else if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
            break;
        } else {
            close_conn(ep, c);
            return;
        }
    }
    if (c->out_off == c->out_len) {  /* 写空:复位,重新只关心可读 */
        c->out_off = c->out_len = 0;
        if (c->peer_closed) {
            close_conn(ep, c);
            return;
        }
    }
    if (arm(ep, c) == -1)
        close_conn(ep, c);
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        return 2;
    }
    int port = atoi(argv[1]);
    if (port <= 0 || port > 65535) {
        fprintf(stderr, "bad port\n");
        return 2;
    }

    signal(SIGPIPE, SIG_IGN);

    int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
    if (lfd == -1) {
        perror("socket");
        return 1;
    }
    int on = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons((uint16_t)port);
    if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) == -1) {
        perror("bind");
        return 1;
    }
    if (listen(lfd, 128) == -1) {
        perror("listen");
        return 1;
    }

    int ep = epoll_create1(EPOLL_CLOEXEC);
    if (ep == -1) {
        perror("epoll_create1");
        return 1;
    }

    struct epoll_event ev;
    memset(&ev, 0, sizeof ev);
    ev.events = EPOLLIN | EPOLLET;
    ev.data.ptr = NULL;              /* NULL 表示监听 fd */
    if (epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev) == -1) {
        perror("epoll_ctl");
        return 1;
    }

    struct epoll_event evs[MAX_EVENTS];
    printf("epoll echo server on port %d\n", port);

    for (;;) {
        int n = epoll_wait(ep, evs, MAX_EVENTS, -1);
        if (n == -1) {
            if (errno == EINTR)
                continue;
            perror("epoll_wait");
            break;
        }
        for (int i = 0; i < n; i++) {
            struct conn *c = evs[i].data.ptr;
            if (c == NULL) {         /* 监听 fd:一次把新连接全部取完 */
                for (;;) {
                    int fd = accept4(lfd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
                    if (fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                            break;
                        if (errno == EINTR)
                            continue;
                        if (errno == EMFILE || errno == ENFILE) {
                            fprintf(stderr, "fd exhausted\n");
                            break;
                        }
                        perror("accept4");
                        break;
                    }
                    struct conn *nc = calloc(1, sizeof *nc);
                    if (nc == NULL) {
                        close(fd);
                        continue;
                    }
                    nc->fd = fd;
                    if (arm(ep, nc) == -1) {
                        close(fd);
                        free(nc);
                    }
                }
                continue;
            }

            uint32_t e = evs[i].events;
            if (e & EPOLLERR) {
                close_conn(ep, c);
                continue;
            }
            if (e & EPOLLOUT)
                handle_write(ep, c);
            else if (e & (EPOLLIN | EPOLLRDHUP | EPOLLHUP))
                handle_read(ep, c);
        }
    }
    close(ep);
    close(lfd);
    return 0;
}

关键行:

  • accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC) 一次把新连接设成非阻塞并带 CLOEXEC,避免 fork/exec 泄漏。
  • 监听 fd 也用 EPOLLET:必须在一次事件里 accept 到 EAGAIN;handle_read 同理,读不到 EAGAIN 就会丢事件。
  • 读到的数据先追加到 c->out,再由 handle_write 发送;EAGAIN 时保留剩余部分并注册 EPOLLOUT。
  • 对端关闭后不立刻 close,等输出缓冲写完再关;OUT_SIZE 上限则是最朴素的背压:慢客户端不能无限占内存。

这个程序没有超时:空闲连接会一直占用内存。生产实现要给每个连接记录最后活跃时间,用 timerfd 或时间轮定期清理。

1.11.13 完整程序:手写 HTTP 客户端

curl 隐藏了字节流切分。这个客户端把「发请求 → 读响应 → 按 Content-Length 截断」完整走一遍,重点在短读与响应边界。

/* http_client.c:手写 HTTP/1.1 GET;处理短读、短写与两种响应边界 */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <unistd.h>

#define MAX_RESP (1 << 20)          /* 演示用:响应上限 1 MiB */

static int send_all(int fd, const char *buf, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, buf, n);      /* socket 上 write == send */
        if (w > 0) {
            buf += w;
            n -= (size_t)w;
        } else if (w == -1 && errno == EINTR) {
            continue;                       /* 信号打断:重试 */
        } else {
            return -1;
        }
    }
    return 0;
}

/* 读到 EOF 或缓冲区满;返回总字节数,-1 表示出错 */
static ssize_t read_all(int fd, char *buf, size_t cap)
{
    size_t used = 0;
    while (used < cap - 1) {
        ssize_t n = read(fd, buf + used, cap - 1 - used);
        if (n > 0) {
            used += (size_t)n;
        } else if (n == 0) {
            break;                          /* 对端关闭:HTTP/1.0 的 body 边界 */
        } else if (errno == EINTR) {
            continue;
        } else {
            return -1;
        }
    }
    buf[used] = '\0';
    return (ssize_t)used;
}

int main(int argc, char **argv)
{
    if (argc < 2 || argc > 4) {
        fprintf(stderr, "usage: %s <host> [port] [path]\n", argv[0]);
        return 2;
    }
    const char *host = argv[1];
    const char *port = argc > 2 ? argv[2] : "80";
    const char *path = argc > 3 ? argv[3] : "/";

    struct addrinfo hints, *res = NULL;
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    int rc = getaddrinfo(host, port, &hints, &res);
    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return 1;
    }

    int fd = -1;
    for (struct addrinfo *p = res; p != NULL; p = p->ai_next) {
        fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
        if (fd == -1)
            continue;
        if (connect(fd, p->ai_addr, p->ai_addrlen) == 0)
            break;                          /* 成功 */
        close(fd);
        fd = -1;                            /* 换下一个候选地址 */
    }
    freeaddrinfo(res);
    if (fd == -1) {
        perror("connect");
        return 1;
    }

    char req[1024];
    int n = snprintf(req, sizeof req,
                     "GET %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: ch11-client\r\n"
                     "Accept: */*\r\nConnection: close\r\n\r\n", path, host);
    if (n <= 0 || (size_t)n >= sizeof req || send_all(fd, req, (size_t)n) == -1) {
        fprintf(stderr, "send failed\n");
        close(fd);
        return 1;
    }

    char *resp = malloc(MAX_RESP);
    if (resp == NULL) {
        close(fd);
        return 1;
    }
    ssize_t total = read_all(fd, resp, MAX_RESP);
    close(fd);
    if (total <= 0) {
        fprintf(stderr, "no response\n");
        free(resp);
        return 1;
    }

    char *head_end = strstr(resp, "\r\n\r\n");
    if (head_end == NULL) {
        fprintf(stderr, "malformed response (%zd bytes)\n", total);
        free(resp);
        return 1;
    }
    *head_end = '\0';                       /* 头部单独成串 */
    char *body = head_end + 4;
    size_t body_len = (size_t)total - (size_t)(body - resp);

    printf("--- status line ---\n%s\n", strtok(resp, "\r\n"));
    printf("--- headers ---\n");
    for (char *line = strtok(NULL, "\r\n"); line != NULL; line = strtok(NULL, "\r\n")) {
        printf("%s\n", line);
        if (strncasecmp(line, "Content-Length:", 15) == 0) {
            long cl = strtol(line + 15, NULL, 10);
            if (cl >= 0 && (size_t)cl <= body_len)
                body_len = (size_t)cl;      /* 有长度就按长度截断 */
        }
    }
    printf("--- body (%zu bytes) ---\n", body_len);
    fwrite(body, 1, body_len, stdout);
    if (body_len == 0 || body[body_len - 1] != '\n')
        putchar('\n');

    free(resp);
    return 0;
}

要点:

  • getaddrinfo 返回多个候选时逐个 connect,任何一个成功就停;这是 IPv4/IPv6 双栈客户端的标准写法。
  • send_all 处理短写与 EINTR;read_all 读到 EOF 或缓冲区满——HTTP/1.0 与 Connection: close 的响应以 EOF 为界。
  • 头部用 strstr(resp, "\r\n\r\n") 定位;body 从空行之后开始。若头里有 Content-Length,按它截断(可能小于已读到的字节数)。
  • strtok 会改写原缓冲区(把分隔符换成 \0);这里只用于打印,真实协议解析应改成不修改数据的扫描。

对 chunked 响应,这个客户端只打印原始块(因为 Transfer-Encoding 时没有 Content-Length,body_len 会保留全部字节)。要真正解码,按 1.11.10 的规则循环读块长、数据与 CRLF。

1.11.14 完整程序:getaddrinfo 标志浏览器

同一个主机名在不同 ai_flags 下会得到不同结果。这个小工具把 AI_PASSIVE、AI_NUMERICHOST、AI_CANONNAME、AI_ADDRCONFIG、AI_V4MAPPED 的效果直接打印出来,便于对照 1.11.8 的表。

/* getaddr.c:把主机名/服务解析成地址列表,并演示 getaddrinfo 的主要 flags */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

static void usage(const char *prog)
{
    fprintf(stderr,
            "usage: %s [-p] [-n] [-4|-6] [-c] [-a] [-m] <host|-> <service>\n"
            "  -p  AI_PASSIVE:host 传 - 时返回通配地址(服务器 bind 用)\n"
            "  -n  AI_NUMERICHOST|AI_NUMERICSERV:禁止 DNS/服务名查询\n"
            "  -4  AF_INET    -6  AF_INET6\n"
            "  -c  AI_CANONNAME:返回规范主机名\n"
            "  -a  AI_ADDRCONFIG:只返回本机已配置的地址族\n"
            "  -m  AI_V4MAPPED|AI_ALL:IPv6 查询时把 IPv4 映射进来\n", prog);
}

int main(int argc, char **argv)
{
    struct addrinfo hints;
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    int opt;
    while ((opt = getopt(argc, argv, "pn46cam")) != -1) {
        switch (opt) {
        case 'p': hints.ai_flags |= AI_PASSIVE; break;
        case 'n': hints.ai_flags |= AI_NUMERICHOST | AI_NUMERICSERV; break;
        case '4': hints.ai_family = AF_INET; break;
        case '6': hints.ai_family = AF_INET6; break;
        case 'c': hints.ai_flags |= AI_CANONNAME; break;
        case 'a': hints.ai_flags |= AI_ADDRCONFIG; break;
        case 'm': hints.ai_flags |= AI_V4MAPPED | AI_ALL; break;
        default: usage(argv[0]); return 2;
        }
    }
    if (argc - optind != 2) {
        usage(argv[0]);
        return 2;
    }

    const char *host = strcmp(argv[optind], "-") == 0 ? NULL : argv[optind];
    const char *serv = argv[optind + 1];

    struct addrinfo *res = NULL;
    int rc = getaddrinfo(host, serv, &hints, &res);
    if (rc != 0) {
        /* gai_strerror 的返回值是静态字符串;EAI_SYSTEM 时还要看 errno */
        fprintf(stderr, "getaddrinfo: %s (EAI=%d)%s\n",
                gai_strerror(rc), rc, rc == EAI_SYSTEM ? strerror(errno) : "");
        return 1;
    }

    printf("hints: family=%s socktype=%s flags=0x%x\n",
           hints.ai_family == AF_INET ? "AF_INET" :
           hints.ai_family == AF_INET6 ? "AF_INET6" : "AF_UNSPEC",
           hints.ai_socktype == SOCK_STREAM ? "SOCK_STREAM" : "SOCK_DGRAM",
           hints.ai_flags);

    int i = 0;
    for (struct addrinfo *p = res; p != NULL; p = p->ai_next) {
        char hbuf[NI_MAXHOST] = "-", sbuf[NI_MAXSERV] = "-";
        if (getnameinfo(p->ai_addr, p->ai_addrlen, hbuf, sizeof hbuf,
                        sbuf, sizeof sbuf,
                        NI_NUMERICHOST | NI_NUMERICSERV) != 0) {
            strcpy(hbuf, "?");
            strcpy(sbuf, "?");
        }
        printf("[%d] family=%-7s socktype=%-2d proto=%-2d addr=%s port=%s canon=%s\n",
               i++,
               p->ai_family == AF_INET ? "AF_INET" :
               p->ai_family == AF_INET6 ? "AF_INET6" : "other",
               p->ai_socktype, p->ai_protocol, hbuf, sbuf,
               p->ai_canonname ? p->ai_canonname : "-");
    }
    freeaddrinfo(res);                    /* 必须释放整条链表 */
    return 0;
}

三次典型调用:

./getaddr -c example.com https     # 规范名 + 全部 A/AAAA
./getaddr -p - 8080                # 服务器通配地址::: 与 0.0.0.0
./getaddr -n example.com 80        # AI_NUMERICHOST 拒绝域名 → EAI_NONAME

注意 EAI_* 的数值在不同 libc 上不同(glibc 的 EAI_NONAME 是 -2,macOS 是 8),程序里不要比较数字,用宏与 gai_strerror。

1.11.15 实验一:用 ss、tcpdump、strace 看连接生命周期

目标:把「三次握手 → 数据传输 → 四次挥手 → TIME_WAIT」与 SO_REUSEADDR 的作用变成可见证据。

# 终端 1:启动 echo 服务器
./echo_server 9000

# 终端 2:看监听状态与队列
ss -lntp '( sport = :9000 )'
# State  Recv-Q Send-Q Local Address:Port  Peer Address:Port
# LISTEN 0      128    0.0.0.0:9000        0.0.0.0:*

# 建立一条连接并观察状态
curl -s http://127.0.0.1:9000/ >/dev/null &
ss -tan '( sport = :9000 or dport = :9000 )'

ss 的列要这样读:监听 socket 的 Recv-Q 是 accept 队列里已完成握手的连接数,Send-Q 是 backlog 上限;连接 socket 的 Recv-Q/Send-Q 是接收/发送缓冲区中尚未被应用读走的字节数。大量 CLOSE-WAIT 说明应用没 close;大量 TIME-WAIT 说明本机主动关闭了大量短连接。

# 抓 20 个包看握手与挥手
tcpdump -i lo -nn -c 20 'tcp port 9000'
# IP 127.0.0.1.51234 > 127.0.0.1.9000: Flags [S], seq ...
# IP 127.0.0.1.9000 > 127.0.0.1.51234: Flags [S.], ack ...
# IP 127.0.0.1.51234 > 127.0.0.1.9000: Flags [.], ack ...
# ... Flags [F.] / [F.] / [.] 四次挥手

[S] 是 SYN,[S.] 是 SYN+ACK,[.] 是纯 ACK,[F.] 是 FIN+ACK。抓包时把 -i lo 换成实际网卡,-nn 禁止反解,避免 DNS 干扰时序。

# 用 strace 看服务器侧的 socket 调用序列
strace -f -e trace=network -s 64 ./echo_server 9001

会看到 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)、setsockopt(SO_REUSEADDR)、bind、listen、accept4、read、write、close。strace 把「库函数」还原成「系统调用」,是确认 SO_REUSEADDR 是否真的设置成功的最快方式。

验证 SO_REUSEADDR:

./echo_server 9002 &        # 启动
kill %1                     # 杀掉,留下 TIME_WAIT
./echo_server 9002          # 没有 SO_REUSEADDR 时 bind 会 EADDRINUSE

把 setsockopt(SO_REUSEADDR) 注释掉再编译,第二次启动会失败;加回去就成功。这是「服务器重启失败」最常见的原因。

1.11.16 实验二:backlog 溢出与 epoll 的观测方式

目标:看见 accept 队列的长度、溢出计数器,以及 epoll 服务器在大量空闲连接下的系统调用特征。

# 终端 1:listen(1) 后睡 15 秒再 accept,制造队列溢出
python3 - <<'PY'
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 9003))
s.listen(1)                  # backlog = 1
time.sleep(15)
PY

# 终端 2:并发发起 50 条连接
python3 - <<'PY'
import socket
cs = []
for i in range(50):
    try:
        cs.append(socket.create_connection(("127.0.0.1", 9003), timeout=1))
    except OSError as e:
        print(i, e)
print("connected", len(cs))
PY

# 终端 3:看队列与溢出计数
ss -lnt '( sport = :9003 )'          # Recv-Q 会接近 Send-Q
nstat -az | grep -i -E 'ListenOverflows|ListenDrops'

TcpExtListenOverflows 表示 accept 队列满导致丢弃的次数,TcpExtListenDrops 包含 SYN 队列溢出。两者增长说明 backlog 太小或 accept 太慢;调大 listen 的 backlog 与 net.core.somaxconn 只是缓冲,根本办法是让 accept 循环不被业务阻塞。

epoll 的观测:对 1.11.12 的服务器开 500 条空闲连接,再用 strace -c 统计 5 秒内的系统调用:

./epoll_echo 9004 &
strace -c -p $! &            # 需要 ptrace 权限;容器里可能要 --cap-add SYS_PTRACE
python3 - <<'PY'
import socket, time
cs = [socket.create_connection(("127.0.0.1", 9004)) for _ in range(500)]
time.sleep(5)
PY

预期:epoll_wait 的调用次数与「有事件发生的批次数」同阶,而不是 500 的倍数,服务器 CPU 接近 0。换成 poll,每次都要把 500 个 fd 拷进内核并线性扫描;连接到万级时,这个固定成本就是 C10K 问题的核心。

1.11.17 十三条网络编程陷阱

#现象原因修复
1服务重启 bind: Address already in use旧连接处于 TIME_WAIT开 SO_REUSEADDR,且在 bind 前设置
2accept 返回 EMFILE 后空转fd 耗尽,循环继续预留救急 fd;限流;accept 后立即关闭
3客户端收到 RST服务器 SO_LINGER=0 或对已关闭连接写检查关闭语义;忽略 SIGPIPE/用 MSG_NOSIGNAL
4请求被截断一次 read 当成完整请求按 \r\n\r\n 与 Content-Length 循环读
5响应尾部丢失对端关闭前没读完读到 EOF 或按长度读满
6大响应卡住没处理短写/EAGAIN写循环 + EPOLLOUT 缓冲
7curl 正常、浏览器卡住缺 Content-Length 或 keep-alive 边界明确长度或 Connection: close
8路径穿越读到 /etc/passwdURI 未规范化realpath + 前缀校验,或 openat/O_NOFOLLOW
9中文 URL 404未做百分号解码解码后再映射文件;注意 %2e%2e
10IPv6 环境连不上只监听了 0.0.0.0AF_UNSPEC + AI_PASSIVE,或双栈 ::
11连接数一多就慢select/poll 线性扫描epoll + 非阻塞 + 边沿触发
12空闲连接突然消失NAT/防火墙超时应用层心跳;调小 TCP_KEEPIDLE
13send 返回成功但对方没收到只写进本机发送缓冲区应用层 ACK;SO_LINGER 只影响关闭
三个关于「连接已建立」的误解
  1. 「connect 成功就代表服务可用」——只代表 TCP 握手完成;应用可能还没 accept,或已经过载。健康检查要发真实请求。
  2. 「close 后对端立刻知道」——FIN 只表示「我没有数据要发了」;对端可能还有数据在路上。半关闭要用 shutdown(fd, SHUT_WR)。
  3. 「SO_REUSEPORT 能接管已有连接」——它只影响新连接的分配;已有连接固定在原 socket 上,进程崩溃后连接随之消失。

1.11.18 本章出口检查

检查项达标线没达标去哪
画出封装/分用能标出每层首部与 MTU/MSS1.11.1、1.11.6
解释四元组能说出监听 socket 与连接 socket 的区别1.11.3
用 getaddrinfo能写出协议无关的解析循环1.11.2
实现 echo 服务器能处理 EINTR、SIGCHLD、fd 关闭1.11.4
解释 HTTP 边界能说出 Content-Length 与空行的作用1.11.5
选择 TCP/UDP能按可靠性/边界/延迟给理由1.11.6
即时自测 · 1.11

Q1 listen(fd, 5) 的 5 表示什么?如果 10 个客户端同时完成三次握手,第 6 个会怎样?

Q2 为什么 accept 要返回一个新的 fd?直接用监听 fd read 会怎样?

Q3 一个 HTTP 响应没有 Content-Length,客户端怎样知道 body 结束?这对 keep-alive 有什么影响?

Q4 设计一个「上传文件」的 TCP 协议,怎样在字节流里划分消息边界?至少给出两种方案并比较。

答案

A1 5 是 accept 队列(已完成三次握手、等待 accept)的建议上限,实际值受 net.core.somaxconn 限制。队列满时,新完成握手的连接可能被丢弃或忽略 ACK,客户端会重传;第 6 个不会立刻得到服务,表现为连接延迟或超时,而不是明确的「拒绝」。

A2 监听 fd 只绑定本地地址,无法区分不同客户端;accept 返回的新 fd 绑定完整四元组,并拥有独立的连接状态。直接对监听 fd read 会返回 ENOTCONN/EINVAL,因为它不是已连接 socket。

A3 没有 Content-Length 时,HTTP/1.1 用 chunked 编码分块,最后一块长度为 0;HTTP/1.0 则以连接关闭(EOF)为 body 结束。以关闭为界意味着连接不能复用,keep-alive 必须要有明确长度或 chunked。

A4 方案一:4 字节大端长度前缀 + body,接收端先读满 4 字节,再读指定长度;边界清晰、支持二进制,但需要防超大长度。方案二:以 \n 分隔的文本行,接收端循环读到换行;实现简单,但 body 不能含分隔符,且要限制单行长度。大文件通常用「长度前缀 + 分块 + 校验和」,并在应用层做超时与重试。

母本对应

母本章节本书落点程度
C(CSAPP)第 11 章 网络编程(11.1–11.7)1.11.1–1.11.17完整:客户端-服务器、IP/端口、socket、HTTP、TINY、并发
W(谢希仁)第 1–3 章 概述、物理层、数据链路层1.11.1、1.11.6背景:分层、封装、MTU
W(谢希仁)第 4–6 章 网络层、运输层、应用层1.11.1–1.11.6完整:IP、TCP/UDP、HTTP/DNS
K(Kerrisk)第 58 章 SOCKET:TCP/IP 网络基础1.11.2–1.11.14精选:getaddrinfo、socket 生命周期、SO_REUSEADDR
K(Kerrisk)第 59–61 章 套接字高级主题1.11.6、1.11.11背景:select/poll/epoll,详见 1.12 与 2.18

CSAPP 第 11 章与谢希仁第 1–3 章小节清单

状态说明:已覆盖=有独立 H2/H3 展开;合并=并入相邻小节;背景=只给上下文;淘汰=不适用。谢希仁为章级目录,页码取章首页。

母本·小节页码状态本书落点
C 11.1 客户端-服务器编程模型677已覆盖1.11.1
C 11.2 网络678合并1.11.1、1.11.6;LAN/以太网/路由器见 4.1–4.3
C 11.3 全球 IP 因特网681已覆盖1.11.2、1.11.8;DNS 层级见 4.7.5–4.7.6
C 11.4 套接字接口687已覆盖1.11.3、1.11.9、1.11.12–1.11.14
C 11.5 Web 服务器700已覆盖1.11.5、1.11.5.1(MIME/CGI)、1.11.10
C 11.6 综合:TINY Web 服务器706已覆盖1.11.5.1(CGI)、1.11.10、1.11.15
C 11.7 小结713合并1.11.17(十三条陷阱)
W 第 1 章 概述1背景1.11.1(分层、封装)
W 第 2 章 物理层41背景1.11.6(MTU 的物理来源,不展开编码)
W 第 3 章 数据链路层70背景1.11.6(帧、MTU;PPP/CSMA 不展开)

下一站:一个服务器要同时处理上万条连接,fork 一个进程显然太重。线程、锁、条件变量、线程池与事件循环,怎样在共享内存上安全地并发?进入 1.12 并发编程。