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 访问可以拆成:
- 客户端解析域名(DNS),得到服务器 IP;
- 客户端
socket+connect,内核发 SYN,三次握手建立 TCP 连接; - 客户端
write请求字节;服务器read解析; - 服务器
write响应;客户端read解析; - 一方
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()
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 服务器的最小实现:
- 请求边界:头和 body 之间用空行(
\r\n\r\n)分隔;GET 通常没有 body,POST 需要Content-Length或 chunked。 - 响应边界:有
Content-Length时按长度读;没有时以连接关闭为界(HTTP/1.0 风格)。持久连接(keep-alive)必须有明确长度。 - 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/.htm | text/html; charset=utf-8 | 必须带 charset,否则中文乱码 |
.css | text/css | |
.js | text/javascript | 现代标准;旧 application/javascript 也可 |
.json | application/json | |
.png/.jpg/.webp | image/png 等 | 二进制;不要加 charset |
.svg | image/svg+xml | 可被脚本执行,注意 CSP |
.pdf | application/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」的模型仍是理解网关的基础。
Content-Type与Content-Length后必须空行;少一个\r\n浏览器会把头当 body。- CGI 的
QUERY_STRING未解码:%20/+要自己 urldecode,且要防注入。 CONTENT_LENGTH不可信:要限制最大值,边读边计数,防止内存耗尽。- 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
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接,三次握手 | 无连接,直接 sendto |
| 可靠性 | 确认、重传、有序 | 不保证到达/顺序/去重 |
| 边界 | 字节流,无消息边界 | 保留数据报边界 |
| 拥塞控制 | 有(cwnd、慢启动) | 无,应用自己控制 |
| 首部 | 20 字节起 | 8 字节 |
| 典型用途 | HTTP、SSH、数据库 | DNS、视频、游戏、QUIC 底层 |
UDP 的 sendto 成功只表示「交给了本机网络栈」,不代表对端收到。需要可靠性就在应用层实现序号、确认、重传,或者直接用 QUIC。UDP 也可以 connect,之后用 send/recv,好处是内核会记录对端并回传 ICMP 错误,但仍然是不可靠数据报。
- 「TCP 一次
read就是一条消息」——TCP 是字节流,消息边界要应用层自己定义。 - 「
accept返回的还是监听 socket」——返回新 fd,监听 fd 继续接收新连接;新 fd 关闭不影响监听。 - 「
close会立刻断开,对端马上收到 FIN」——默认情况下内核先发送已排队数据,再发 FIN;SO_LINGER设为 0 才会发 RST 并丢弃数据。 - 「端口写完
htons就万事大吉」——htons只转 16 位端口;IP 地址要用inet_pton/getaddrinfo,不要手写解析。 - 「
send/write返回成功就代表对方收到了」——只代表数据进了本机内核缓冲区;对端是否收到要等应用层确认。 - 「
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_PASSIVE | node == 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_V4MAPPED | IPv6 查询无结果时返回 ::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_BADFLAGS | flag 组合非法 | AI_PASSIVE 与 AI_CANONNAME 冲突 | 去掉一个 |
EAI_MEMORY | 内存不足 | 结果过多 | 降级/报错 |
EAI_SYSTEM | 系统调用失败 | 看 errno | 按 errno 处理 |
EAI_OVERFLOW | getnameinfo 缓冲区太小 | 主机名超长 | 加大缓冲 |
服务器端的标准写法是「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);
两个容易忽略的事实:
getaddrinfo可能阻塞数秒。它没有超时参数,DNS 查询的超时由/etc/resolv.conf的timeout/attempts与options决定。对延迟敏感的程序应使用异步解析器(getaddrinfo_a、c-ares、libuv 的线程池),或在独立线程里解析。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_REUSEADDR | SOL_SOCKET | int 0/1 | 允许 bind 处于 TIME_WAIT 的地址 | 服务器重启必开 |
SO_REUSEPORT | SOL_SOCKET | int 0/1 | 多个 socket 绑同一端口,内核分流 | 多进程 accept |
SO_LINGER | SOL_SOCKET | struct linger | close 时等多久 / 是否发 RST | 精确控制关闭 |
SO_RCVBUF/SO_SNDBUF | SOL_SOCKET | int 字节 | 内核缓冲区大小 | 高带宽长肥管道 |
SO_RCVTIMEO/SO_SNDTIMEO | SOL_SOCKET | struct timeval | 阻塞读写的超时 | 简单客户端 |
SO_KEEPALIVE | SOL_SOCKET | int 0/1 | 空闲时发探测包 | 长连接保活 |
TCP_NODELAY | IPPROTO_TCP | int 0/1 | 关闭 Nagle | 交互式协议 |
TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT | IPPROTO_TCP | int 秒/次 | 保活参数 | 缩短默认 2 小时 |
TCP_FASTOPEN | IPPROTO_TCP | int 队列长度 | TFO,省一个 RTT | 短连接服务 |
TCP_DEFER_ACCEPT | IPPROTO_TCP | int 秒 | 有数据才唤醒 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 服务器,结构如下:
| 函数 | 职责 | 关键点 |
|---|---|---|
main | open_listenfd + 循环 accept | 一次只服务一个连接 |
doit | 读请求行、调 read_requesthdrs、parse_uri、serve_static | 只支持 GET |
read_requesthdrs | 读到空行,丢弃所有头 | 不解析 Host/Content-Length |
parse_uri | 拆分 URI 与 CGI 参数 | 不做 URL 解码 |
serve_static | stat + 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这类编码绕过路径检查。
- 「URL 里的
..检查一次就够」——编码(%2e%2e)、双重编码、反斜杠(Windows)、符号链接都能绕过字符串检查。正确做法是解析后realpath并验证仍在根目录下。 - 「
Content-Length一定可信」——它可能为负、超大、与实际不符;要设上限(如 8 MiB),并与实际读取量核对。 - 「响应头可以随便拼用户输入」——把 URI 或参数直接写进头会引入 CRLF 注入(response splitting)。头字段必须过滤
\r/\n。 - 「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 的分界
| 接口 | 时间复杂度 | 连接上限 | 触发方式 | 关键限制 |
|---|---|---|---|---|
select | O(n) 每次全量拷贝 | FD_SETSIZE(通常 1024) | 水平 | fd 集每次要重建 |
poll | O(n) 每次全量扫描 | 受内存/fd 限制 | 水平 | 返回后仍要遍历全部 |
epoll | O(1) 取就绪链表 | 受 RLIMIT_NOFILE | 水平/边沿 | 只能用于 Linux;dup 后行为有坑 |
epoll 快在内核维护了就绪链表:epoll_wait 只返回就绪的 fd,不需要每次把全部 fd 拷进内核再扫描。但「快」有条件:连接大多是空闲的、事件稀疏。若所有连接都持续活跃,epoll_wait 每次都要返回大量事件,用户态处理成本仍在。
水平触发(level-triggered, LT)是默认:只要缓冲区还有数据,下一次 epoll_wait 还会报告。边沿触发(edge-triggered, ET)只在状态变化时报告一次,必须一次读到 EAGAIN,否则剩余数据不会再触发事件。ET 减少了事件数量,但要求 fd 非阻塞、读循环写对;写侧还要自己管理「没写完的部分」,等 EPOLLOUT。
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 前设置 |
| 2 | accept 返回 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 缓冲 |
| 7 | curl 正常、浏览器卡住 | 缺 Content-Length 或 keep-alive 边界 | 明确长度或 Connection: close |
| 8 | 路径穿越读到 /etc/passwd | URI 未规范化 | realpath + 前缀校验,或 openat/O_NOFOLLOW |
| 9 | 中文 URL 404 | 未做百分号解码 | 解码后再映射文件;注意 %2e%2e |
| 10 | IPv6 环境连不上 | 只监听了 0.0.0.0 | AF_UNSPEC + AI_PASSIVE,或双栈 :: |
| 11 | 连接数一多就慢 | select/poll 线性扫描 | epoll + 非阻塞 + 边沿触发 |
| 12 | 空闲连接突然消失 | NAT/防火墙超时 | 应用层心跳;调小 TCP_KEEPIDLE |
| 13 | send 返回成功但对方没收到 | 只写进本机发送缓冲区 | 应用层 ACK;SO_LINGER 只影响关闭 |
- 「
connect成功就代表服务可用」——只代表 TCP 握手完成;应用可能还没accept,或已经过载。健康检查要发真实请求。 - 「
close后对端立刻知道」——FIN 只表示「我没有数据要发了」;对端可能还有数据在路上。半关闭要用shutdown(fd, SHUT_WR)。 - 「
SO_REUSEPORT能接管已有连接」——它只影响新连接的分配;已有连接固定在原 socket 上,进程崩溃后连接随之消失。
1.11.18 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出封装/分用 | 能标出每层首部与 MTU/MSS | 1.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 |
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 并发编程。