Lecture 20: 网络编程 II 与 Web 服务器 (Network Programming II and Web Servers)
Lecture 20: 网络编程 II 与 Web 服务器 (Network Programming II and Web Servers)
讲义对应:CMU 15-213 Lecture 20 — Network Programming (Part II)(素材:
F25-20-netprog2.txt;习题课F25-rec11_slides.txt) 教材对应:CS:APP3e 第 11 章 11.4–11.6(HTTP 事务、HTTP 报文、Tiny Web 服务器、CGI 动态内容) 关联 Lab:L7 Proxy Lab(迭代式 → 并发式 → 缓存式代理)
20.1 概述
上一讲(Lecture 19)把 socket 抽象成”文件描述符”,学会了 open_listenfd/accept/open_clientfd 这条建立 TCP 连接的通路。但连接只是管道,管道里流的是什么?本讲回答这个问题:流的是一种文本协议——超文本传输协议(HyperText Transfer Protocol, HTTP)。我们先把 HTTP 报文格式精确到字节(每个 \r\n 都算数),再剖析教材里那个 200 行却五脏俱全的 Tiny Web 服务器,最后引入 代理(proxy)——它同时是服务器又是客户端,是 Proxy Lab 的全部内容。这也是 Lecture 21–23(并发与同步)的直接动机:一旦代理要同时服务多个客户端并共享缓存,就必须面对线程与读者-写者锁。
20.2 核心概念与底层机制图解
20.2.1 Web 基础与 Web 内容(Web Basics and MIME Content)
- 定义与目的:Web 客户端(浏览器)与 Web 服务器(Apache、nginx,或教材的 Tiny)通过 HTTP 通信。客户端与服务器建立 TCP 连接 → 客户端请求内容 → 服务器回送内容 → 最终关闭连接。当前版本已是 HTTP/2.0,但 HTTP/1.1(RFC 2616)仍被广泛使用,也是本课程与 Proxy Lab 的工作对象。
- 直观解释(”它是什么?”):把 Web 想成点菜。HTTP 就是”服务员与厨房的对话规范”:先说点哪道菜(请求行),再说特殊要求(请求头),最后用一声”就这些”(空行)结束点单;厨房先回一句”好嘞/没了”(状态行),附上菜品说明(响应头),再端上菜(响应体)。内容(content)就是一串字节,其身份由 MIME 类型标签说明。
- MIME 类型(Multipurpose Internet Mail Extensions):HTTP 响应体是二进制安全的,服务器必须告诉客户端这串字节是什么。常见类型:
text/html、text/plain、image/gif、image/png、image/jpeg;完整清单见 IANA 的 media-types 列表。关键工程含义:响应体可能是任意二进制,因此绝不能用strlen/strcpy处理它,必须按字节数(Content-Length)读写——这正是 Proxy Lab 反复强调”内容不全是 ASCII”的原因。 - 静态与动态内容:静态内容(static content)是磁盘上的文件,请求标识”要哪个文件”;动态内容(dynamic content)是服务器当场运行一个程序产生的输出,请求标识”运行哪个可执行文件”。讲义约定:可执行程序放在
cgi-bin目录下。 - 与机器码/硬件的对应:HTTP 的”文本协议”只是约定,落到内核仍是
write()写入 TCP 发送缓冲区、由网卡 DMA 发出;TCP 负责切分成报文段、重传丢失段、按序重组。HTTP 能只用文本,是因为 TCP 已提供可靠字节流。
20.2.2 URL 与代理的位置(URL and the Proxy)
- URL(Uniform Resource Locator)结构:
http://<host>:<port>/<path>?<query>。以http://www.cmu.edu:80/index.html为例:客户端用前缀http://www.cmu.edu:80推断协议(HTTP)、服务器位置(www.cmu.edu)、端口(80);服务器用后缀/index.html判断静态还是动态、定位文件系统上的文件,其中初始/表示内容的”家目录”。 - 最小后缀是
/:服务器会把它扩展为配置好的默认文件名(一般约定index.html;教材 handout 的 Tiny 配置为home.html)。 - 代理(proxy)的位置与作用:代理是客户端与源服务器(origin server)之间的中间人:对客户端它像服务器,对服务器它像客户端。它的价值在于”请求与响应都从它手里过”:能缓存(caching)、记日志(logging)、匿名化(anonymization)、过滤(filtering)。经典场景:客户端 A 与 B 同处便宜的本地网,A 先取
foo.html并缓存;B 再要同一文件时代理直接从内存返回,省掉一次昂贵的跨广域网往返。 - 底层机制图解:见 20.2.4 代理数据流图。代理在 TCP 层面终止一条连接并另起一条,因此必须自己拼装下一跳的请求报文。
20.2.3 HTTP 事务与报文格式(HTTP Transaction and Message Format)
- 定义与目的:一次 HTTP 事务 = 建立 TCP 连接 → 客户端发请求 → 服务器发响应 → 关闭连接。
Connection: close表示一次事务后关闭;持久连接(persistent connection) 则复用同一条 TCP 连接发多次请求/响应,省去反复三次握手。 - 直观解释(”它是什么?”):HTTP 报文像电报:一行一行写,每行以
\r\n(0x0D 0x0A)结束;一个空行(单独一个\r\n)表示”正文开始”。这就是为什么 Proxy Lab 的解析器核心动作只有一句:一直读行,直到读到内容恰为"\r\n"的那一行。 - 底层机制图解(字节级报文格式,真实抓取的请求/响应):
请求报文 = 请求行 + 零或多个请求头 + 空行 + 可选请求体
=== HTTP REQUEST, 89 bytes, real capture ===
offset hex bytes (16 per row) |ASCII | meaning
0000 47 45 54 20 2f 69 6e 64 65 78 2e 68 74 6d 6c 20 |GET /index.html | <-- REQUEST LINE
0016 48 54 54 50 2f 31 2e 31 0d 0a 48 6f 73 74 3a 20 |HTTP/1.1..Host: |
0032 31 32 37 2e 30 2e 30 2e 31 3a 31 38 32 31 33 0d |127.0.0.1:18213.|
0048 0a 55 73 65 72 2d 41 67 65 6e 74 3a 20 63 75 72 |.User-Agent: cur|
0064 6c 2f 37 2e 37 36 2e 31 0d 0a 41 63 63 65 70 74 |l/7.76.1..Accept|
0080 3a 20 2a 2f 2a 0d 0a 0d 0a |: */*.... |
(headers end, body starts at offset 89)
关键: 0d 0a 依次出现在偏移 24(请求行末), 47(Host 末), 72(User-Agent 末),
85(Accept 末); 紧随其后的 87 处那个"孤立"的 0d 0a 就是终止全部头部的空行。
请求行内 "GET /index.html HTTP/1.1" 三个字段以 0x20(空格) 分隔。
响应报文 = 状态行 + 零或多个响应头 + 空行 + 响应体
=== HTTP RESPONSE, 228 bytes, real capture ===
offset hex bytes (16 per row) |ASCII | meaning
0000 48 54 54 50 2f 31 2e 30 20 32 30 30 20 4f 4b 0d |HTTP/1.0 200 OK.| <-- STATUS LINE
0016 0a 53 65 72 76 65 72 3a 20 54 69 6e 79 2d 4c 69 |.Server: Tiny-Li|
0032 74 65 20 57 65 62 20 53 65 72 76 65 72 0d 0a 43 |te Web Server..C|
0048 6f 6e 74 65 6e 74 2d 6c 65 6e 67 74 68 3a 20 31 |ontent-length: 1|
0064 33 33 0d 0a 43 6f 6e 74 65 6e 74 2d 74 79 70 65 |33..Content-type|
0080 3a 20 74 65 78 74 2f 68 74 6d 6c 0d 0a 0d 0a 3c |: text/html....<|
0096 68 74 6d 6c 3e 0a 3c 68 65 61 64 3e 3c 74 69 74 |html>.<head><tit| <-- BODY begins
0112 6c 65 3e 43 53 41 50 50 20 54 69 6e 79 20 54 65 |le>CSAPP Tiny Te|
0128 73 74 3c 2f 74 69 74 6c 65 3e 3c 2f 68 65 61 64 |st</title></head|
0144 3e 0a 3c 62 6f 64 79 3e 0a 3c 68 31 3e 48 65 6c |>.<body>.<h1>Hel|
0160 6c 6f 20 66 72 6f 6d 20 54 69 6e 79 2d 4c 69 74 |lo from Tiny-Lit|
0176 65 3c 2f 68 31 3e 0a 3c 70 3e 4c 65 63 74 75 72 |e</h1>.<p>Lectur|
0192 65 20 32 30 20 64 65 6d 6f 20 70 61 67 65 2e 3c |e 20 demo page.<|
0208 2f 70 3e 0a 3c 2f 62 6f 64 79 3e 0a 3c 2f 68 74 |/p>.</body>.</ht|
0224 6d 6c 3e 0a |ml>. |
(headers end, body starts at offset 95; 正文恰为 133 字节, 二进制安全)
- 请求行
<method> <uri> <version>:方法可为GET/POST/OPTIONS/HEAD/PUT/DELETE/TRACE;<uri>对代理是全 URL,对源服务器是 URL 后缀(这是代理与服务器解析逻辑的第一道分水岭);版本为HTTP/1.0或HTTP/1.1。注意 URL 是 URI 的一个子类。 - 常见请求头:
Host(HTTP/1.1 必需,用于虚拟主机)、User-Agent(客户端标识)、Connection、Proxy-Connection(是否保持连接)、Content-Length、Content-Type、Cookie。 - 响应行
<version> <status-code> <status-message>,状态码分五类:
| 类别 | 含义 | 本课程常见实例 |
|---|---|---|
1xx | 信息 | 继续处理 |
2xx | 成功 | 200 OK |
3xx | 重定向 | 301 Moved Permanently、302 Found |
4xx | 客户端错误 | 400 Bad Request、403 Forbidden、404 Not Found |
5xx | 服务器错误 | 500 Internal Server Error、501 Not Implemented、502 Bad Gateway、505 HTTP Version Not Supported |
常见响应头:Content-Length、Content-Type、Date、Last-Modified、Server、Connection。
- 与机器码/硬件的对应:
rio_readlineb的实现是逐字节调用rio_read直到见到\n,所以解析 HTTP 头是 O(报文长度) 次函数调用——这正是它比大块read“慢”却仍被选用的原因:正确性优先,且头只有几十到几百字节;响应体才用rio_readn/mmap批量搬运。
20.2.4 代理的数据流与缓存结构(Proxy Data Flow and Cache Structure)
- 定义与目的:代理必须同时扮演两个角色:对客户端是服务器(
open_listenfd+accept),对源服务器是客户端(open_clientfd+ 转发)。缓存则是”命中即免去一次网络往返”的性能机制。 - 直观解释(”它是什么?”):代理像图书馆的借阅台。你要某本书(对象),管理员先翻近旁的书架(缓存):有就直接给你(快、免费),没有就打电话去总馆调(慢、贵),书到手再顺手在书架上放一本。书架面积有限(
MAX_CACHE_SIZE = 1 MiB),太厚的书不收藏(MAX_OBJECT_SIZE = 100 KiB),书架满了就淘汰最久没人翻的那本(LRU)。 - 底层机制图解(数据流 + 缓存结构):
客户端A ---+
客户端B ---+--> listenfd --> accept --> 每连接一线程 (detach)
客户端C ---+ |
v
handle(connfd) --> cache_lookup(key = "host:port/path")
|
+-------------------+-------------------+
| 命中 (HIT) | 未命中 (MISS)
v v
仅取读锁读出对象并回送 open_clientfd(host, port)
(多个读者可并发) |
v
转发 "GET path HTTP/1.0" + Host/User-Agent/
Connection: close/Proxy-Connection: close
|
v
读响应头 --> 原样转给客户端 --> 按 Content-Length 精确读体
|
边转发边累积到本地 buf
|
len <= MAX_OBJECT_SIZE(100 KiB)?
|
v
cache_store(): 取写锁, 插入 LRU 头, 超限则淘汰尾
+----------------------------------------------------------------------------+
| cache_t -- 全进程唯一实例, 由 pthread_rwlock_t 保护 |
| |
| pthread_rwlock_t rw; /* readers share | writer exclusive */ |
| size_t cur_size; /* sum of object bytes, metadata NOT counted */ |
| size_t max_size; /* MAX_CACHE_SIZE = 1 MiB */ |
| obj_t *lru_head; --MRU--> [A] <-> [C] <-> [B] --LRU--> NULL |
| |
| evict(): while (cur_size + new_len > max_size) drop lru_tail; O(1) |
+----------------------------------------------------------------------------+
- 为什么必须是读者-写者锁而不是互斥锁:缓存读多写少。用互斥锁(mutex)时,多个线程即使只读互不相同的对象也必须排队,吞吐被串行化;读者-写者锁允许任意多个读者并发,只有写者独占。Proxy Lab 明确规定”用一把大独占锁保护缓存是不可接受的方案”,并要求多线程必须能同时读缓存。
- 与机器码/硬件的对应:
pthread_create最终调用clone()创建共享地址空间的执行流;读者-写者锁的读者计数是pthread_rwlock_t内的原子计数器,更新它编译成lock xadd(带lock前缀的原子读-改-写),由缓存一致性协议(MESI)保证跨核可见。
20.3 代码示例与底层机制分析
20.3.1 简化版 Tiny Web 服务器(支持静态 GET)
以下是自包含的完整 Tiny 精简版(内联了 RIO 包,不需要 csapp.c),支持 GET 静态文件、目录追加默认页、200/403/404/501,并用 mmap 发送文件体。
/* tiny_srv.c — 简化版 Tiny Web 服务器:gcc -g -Wall -Wextra -std=gnu11 tiny_srv.c -o tiny_srv
* 运行: cd docroot && ./tiny_srv 18213 (以 docroot 为工作目录)
* 验证: curl -v http://127.0.0.1:18213/index.html */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <netdb.h>
#define MAXLINE 8192
#define MAXBUF (MAXLINE * 2)
#define LISTENQ 1024
typedef struct sockaddr SA;
/* ---------- RIO: 缓冲读一行(rio_readlineb) + 无缓冲写满(rio_writen) ---------- */
typedef struct { int rio_fd; ssize_t rio_cnt; char *rio_bufptr; char rio_buf[MAXLINE]; } rio_t;
ssize_t rio_readn(int fd, void *usrbuf, size_t n) { /* 读满 n 字节,仅 EOF 才短读 */
size_t nleft = n; ssize_t nread; char *bufp = usrbuf;
while (nleft > 0) {
if ((nread = read(fd, bufp, nleft)) < 0) {
if (errno == EINTR) nread = 0; else return -1;
} else if (nread == 0) break; /* EOF */
nleft -= nread; bufp += nread;
}
return (ssize_t)(n - nleft);
}
static ssize_t rio_writen(int fd, const void *usrbuf, size_t n) { /* 绝不短写 */
size_t nleft = n; ssize_t nwritten; const char *bufp = usrbuf;
while (nleft > 0) {
if ((nwritten = write(fd, bufp, nleft)) <= 0) {
if (errno == EINTR) nwritten = 0; else return -1;
}
nleft -= nwritten; bufp += nwritten;
}
return (ssize_t)n;
}
static void rio_readinitb(rio_t *rp, int fd)
{ rp->rio_fd = fd; rp->rio_cnt = 0; rp->rio_bufptr = rp->rio_buf; }
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n) { /* 内部缓冲读 */
int cnt;
while (rp->rio_cnt <= 0) { /* 缓冲空则填满 */
rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
if (rp->rio_cnt < 0) { if (errno != EINTR) return -1; }
else if (rp->rio_cnt == 0) return 0; /* EOF */
else rp->rio_bufptr = rp->rio_buf;
}
cnt = (rp->rio_cnt < (ssize_t)n) ? (int)rp->rio_cnt : (int)n;
memcpy(usrbuf, rp->rio_bufptr, cnt);
rp->rio_bufptr += cnt; rp->rio_cnt -= cnt;
return cnt;
}
static ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) {
ssize_t n, rc; char c, *bufp = usrbuf;
for (n = 1; n < (ssize_t)maxlen; n++) {
if ((rc = rio_read(rp, &c, 1)) == 1) { *bufp++ = c; if (c == '\n') break; }
else if (rc == 0) { if (n == 1) return 0; else break; }
else return -1;
}
*bufp = '\0';
return n;
}
static void unix_error(const char *m){ fprintf(stderr,"%s: %s\n",m,strerror(errno)); exit(1); }
static int Open_listenfd(const char *port) { /* getaddrinfo 版: 协议无关+线程安全 */
struct addrinfo hints, *listp, *p; int listenfd, optval = 1;
memset(&hints, 0, sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG | AI_NUMERICSERV;
if (getaddrinfo(NULL, port, &hints, &listp) != 0) unix_error("getaddrinfo");
for (p = listp; p; p = p->ai_next) {
if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(int));
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0) break;
close(listenfd);
}
freeaddrinfo(listp);
if (!p) return -1;
if (listen(listenfd, LISTENQ) < 0) { close(listenfd); return -1; }
return listenfd;
}
/* ------------------------------- Tiny 主体 -------------------------------- */
static void serve_static(int fd, const char *filename, int filesize) {
int srcfd; char *srcp, filetype[MAXLINE], buf[MAXBUF];
if (strstr(filename, ".html")) strcpy(filetype, "text/html");
else if (strstr(filename, ".gif")) strcpy(filetype, "image/gif");
else if (strstr(filename, ".png")) strcpy(filetype, "image/png");
else if (strstr(filename, ".jpg")) strcpy(filetype, "image/jpeg");
else strcpy(filetype, "text/plain");
sprintf(buf, "HTTP/1.0 200 OK\r\n"); rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny-Lite Web Server\r\n"); rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n", filesize); rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: %s\r\n\r\n", filetype); rio_writen(fd, buf, strlen(buf));
srcfd = open(filename, O_RDONLY, 0);
if (srcfd < 0) return;
srcp = mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0); /* 文件页按需装入 */
close(srcfd); /* 映射后即可关闭 fd */
if (srcp != MAP_FAILED) { rio_writen(fd, srcp, filesize); munmap(srcp, filesize); }
}
static void clienterror(int fd, const char *cause, const char *errnum,
const char *shortmsg, const char *longmsg) {
char buf[MAXLINE], body[MAXLINE];
sprintf(body, "<html><title>Tiny Error</title>");
sprintf(body + strlen(body), "<body bgcolor=\"ffffff\">\r\n");
sprintf(body + strlen(body), "%s: %s\r\n", errnum, shortmsg);
sprintf(body + strlen(body), "<p>%s: %s\r\n", longmsg, cause);
sprintf(body + strlen(body), "<hr><em>The Tiny Web server</em>\r\n");
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg); rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n"); rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n\r\n",(int)strlen(body)); rio_writen(fd, buf, strlen(buf));
rio_writen(fd, body, strlen(body));
}
static void doit(int fd) {
int is_static; struct stat sbuf;
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE]; rio_t rio;
rio_readinitb(&rio, fd);
if (!rio_readlineb(&rio, buf, MAXLINE)) return; /* 客户端未发请求就关闭 */
printf("[server] request line: %s", buf);
sscanf(buf, "%s %s %s", method, uri, version); /* 解析请求行 */
if (strcasecmp(method, "GET")) { /* 非 GET -> 501 */
clienterror(fd, method, "501", "Not Implemented", "Tiny does not implement this method");
return;
}
while (strcmp(buf, "\r\n")) { /* 读头直到空行 */
if (!rio_readlineb(&rio, buf, MAXLINE)) break;
printf("[server] header: %s", buf);
}
is_static = (strstr(uri, "cgi-bin") == NULL); /* cgi-bin -> 动态 */
strcpy(filename, "."); strcat(filename, uri); /* URI 映射到 "./"+uri */
if (uri[strlen(uri) - 1] == '/') strcat(filename, "index.html"); /* 目录默认页 */
if (!is_static) {
clienterror(fd, filename, "501", "Not Implemented", "CGI not shown in this demo");
return;
}
if (stat(filename, &sbuf) < 0) { /* 不存在 -> 404 */
clienterror(fd, filename, "404", "Not found", "Tiny couldn't find this file");
return;
}
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) { /* 非常规文件/不可读 -> 403 */
clienterror(fd, filename, "403", "Forbidden", "Tiny couldn't read the file");
return;
}
serve_static(fd, filename, (int)sbuf.st_size);
}
int main(int argc, char **argv) {
int listenfd, connfd; char hostname[MAXLINE], port[MAXLINE];
socklen_t clientlen; struct sockaddr_storage clientaddr;
if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(1); }
listenfd = Open_listenfd(argv[1]);
if (listenfd < 0) unix_error("Open_listenfd");
printf("[server] listening on port %s\n", argv[1]); fflush(stdout);
while (1) { /* 迭代式: 一次一个连接 */
clientlen = sizeof(clientaddr);
connfd = accept(listenfd, (SA *)&clientaddr, &clientlen);
if (connfd < 0) continue;
getnameinfo((SA *)&clientaddr, clientlen, hostname, MAXLINE, port, MAXLINE, 0);
printf("[server] accepted connection from (%s, %s)\n", hostname, port); fflush(stdout);
doit(connfd);
close(connfd);
}
return 0;
}
【代码做什么?】
main用Open_listenfd建立监听描述符(getaddrinfo→socket→setsockopt(SO_REUSEADDR)→bind→listen),随后进入无限循环。accept阻塞等待连接请求,返回已连接描述符connfd(每次accept都是新的 fd,只服务这一个客户端)。doit用rio_readlineb读请求行,sscanf拆成method/uri/version;非GET立即clienterror(…, "501", …)。- 循环读头直到读到
"\r\n"——这是 HTTP 解析的锚点。注意rio_readlineb把\r\n一起返回,所以比较strcmp(buf, "\r\n") == 0成立。 parse_uri的简化版:URI 含cgi-bin即动态;否则把"."+uri作为文件名,若 URI 以/结尾则追加默认页。stat判断文件存在性(失败 →404)、S_ISREG判断是否普通文件、S_IRUSR判断用户可读位(失败 →403)。serve_static先写 4 行响应头(顺序无关,但空行必须在最后),再open+mmap+rio_writen发文件体。
【底层机制透视】
- RIO 两层设计的理由:
rio_read一次read填满 8 KB 内部缓冲,之后rio_readlineb逐字节取用——系统调用次数从 O(行数) 降到 O(缓冲区数),同时保留了”读到\n为止”的行语义。注意rio_readn(无缓冲)与rio_readlineb(有缓冲)绝不能混用在同一个 fd 上,否则缓冲区的预读字节会被无缓冲读”跳过”。 mmap优于malloc+read之处:malloc+rio_readn需要”内核页缓存 → 用户缓冲区 → socket 缓冲区”两次拷贝;mmap让文件页直接映射进进程地址空间,rio_writen时直接从页缓存拷到 socket 缓冲区,省掉一次拷贝和一次显式分配。缺点是对小文件而言mmap/munmap的建页表开销可能反超收益——Tiny 服务的就是小页面,mmap属于”优雅但不一定最快”;而 Proxy Lab 的响应体是流式的,只能用缓冲区累积。close(srcfd)早于munmap:映射建立后,文件描述符的使命就结束了;mmap持有的是对 inode 的引用,即使文件被unlink,映射依然有效直到munmap。
【内存布局 / 数据结构图解】
进程虚拟地址空间 (x86-64, 文件 133 字节, 真实地址来自 strace 输出)
0x7f7c85ed3000 +---------------------------+ <- mmap(NULL,133,PROT_READ,
| page holding file content | MAP_PRIVATE,fd,0)
| "<html>...\n" (133 bytes) | 返回地址页对齐; 页内其余为填充
+---------------------------+
0x7ffff... +---------------------------+ 高地址
| argv / envp / 辅助向量 |
+---------------------------+
| rio_t rio | rio_buf[8192] 在栈上
| .rio_cnt / .rio_bufptr | rio_cnt 记录缓冲区内剩余字节数
| buf[MAXLINE] = 8192 B | 请求行/头部就地解析
| filetype[MAXLINE] |
| buf[MAXBUF] = 16384 B | serve_static 拼响应头
+---------------------------+
堆 (低地址) | (本程序几乎不用堆) |
+---------------------------+
| .data / .bss | 低地址
+---------------------------+
注意: 栈向下增长, 且 rio_t 结构体中 8 KB 的 rio_buf 是"值"而非指针,
因此每层函数调用都在栈上真实占用 8 KB 栈空间。
【与汇编 / 硬件的对应】(gcc -O1 -S 真实产物,rio_writen 的核心循环)
rio_writen:
pushq %r14 ; movq %rdx,%r13 ; testq %rdx,%rdx ; je .L23 # n==0 直接返回
movl %edi, %r12d # fd -> %r12d
movq %rsi, %rbp # buf -> %rbp (循环指针)
movq %rdx, %rbx # n -> %rbx (剩余字节)
.L26:
movq %rbx, %rdx # 第 3 参数 nleft
movq %rbp, %rsi # 第 2 参数 bufp
movl %r12d, %edi # 第 1 参数 fd
call write # 系统调用
testq %rax, %rax ; jg .L24
call __errno_location ; cmpl $4,(%rax) ; jne .L27 # errno==4(EINTR)? 重试 : 报错
.L24:
addq %rax, %rbp # bufp += nwritten
subq %rax, %rbx # nleft -= nwritten
je .L23 # nleft==0 退出
jmp .L26
这段汇编体现了三条契约:%rdi/%rsi/%rdx 依次是 fd/buf/n(x86-64 整数参数寄存器顺序),返回值在 %rax;EINTR(值为 4)必须重试;循环变量放在被调用者保存寄存器(%rbx/%rbp/%r12)中,因为 write 会破坏调用者保存寄存器。
【实测验证】(真实运行结果,工作目录 docroot,服务端口 18213)
$ curl -sv http://127.0.0.1:18213/index.html 2>&1
> GET /index.html HTTP/1.1
> Host: 127.0.0.1:18213
> User-Agent: curl/7.76.1
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: Tiny-Lite Web Server
< Content-length: 133
< Content-type: text/html
<
{ [133 bytes data]
<html>
<head><title>CSAPP Tiny Test</title></head>
<body>
<h1>Hello from Tiny-Lite</h1>
<p>Lecture 20 demo page.</p>
</body>
</html>
* Closing connection 0
服务器端 strace 记录的真实系统调用序列(验证了”先写头、再 mmap、最后写体”的顺序):
read(4, "GET /index.html HTTP/1.1\r\nHost: "..., 8192) = 89
write(4, "HTTP/1.0 200 OK\r\n", 17) = 17
write(4, "Server: Tiny-Lite Web Server\r\n", 30) = 30
write(4, "Content-length: 133\r\n", 21) = 21
write(4, "Content-type: text/html\r\n\r\n", 27) = 27
openat(AT_FDCWD, "./index.html", O_RDONLY) = 6
mmap(NULL, 133, PROT_READ, MAP_PRIVATE, 6, 0) = 0x7f7c85ed3000
write(4, "<html>\n<head><title>CSAPP Tiny T"..., 133) = 133
munmap(0x7f7c85ed3000, 133) = 0
404 分支同样实测通过(curl 得到 HTTP/1.0 404 Not found,Content-length: 152,错误页正文明确含 ./nope.html)。注意 Content-Length 与 Content-type 的顺序与讲义不同但完全合法——头部顺序无语义,只有空行的位置有意义。
20.3.2 最小可用代理(转发 GET http://host:port/path)
/* miniproxy.c — 最小可用代理: gcc -g -Wall -Wextra -std=gnu11 miniproxy.c -o miniproxy
* 运行: ./miniproxy 18214
* 验证: curl -x http://127.0.0.1:18214 http://127.0.0.1:18213/index.html */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#define MAXLINE 8192
#define LISTENQ 1024
typedef struct sockaddr SA;
/* 逐字节读到 '\r\n':头部长度不可预知,这样最稳 */
static ssize_t read_until_crlf(int fd, char *buf, size_t max) {
size_t i = 0; char c; ssize_t n;
while (i + 1 < max) {
if ((n = read(fd, &c, 1)) <= 0) return n == 0 ? (ssize_t)i : -1;
buf[i++] = c;
if (i >= 2 && buf[i-2] == '\r' && buf[i-1] == '\n') break;
}
buf[i] = '\0';
return (ssize_t)i;
}
static ssize_t writen(int fd, const char *buf, size_t n) {
size_t left = n; ssize_t w;
while (left > 0) {
if ((w = write(fd, buf, left)) <= 0) {
if (errno == EINTR) continue;
return -1; /* 常见: EPIPE —— 客户端提前断开 */
}
left -= (size_t)w; buf += w;
}
return (ssize_t)n;
}
static ssize_t readn(int fd, char *buf, size_t n) { /* 严格读满 n 字节 */
size_t left = n; ssize_t r;
while (left > 0) {
if ((r = read(fd, buf, left)) <= 0) {
if (r < 0 && errno == EINTR) continue;
return r < 0 ? -1 : (ssize_t)(n - left);
}
left -= (size_t)r; buf += r;
}
return (ssize_t)n;
}
static void clienterror(int fd, const char *num, const char *msg, const char *longmsg) {
char buf[MAXLINE], body[MAXLINE];
snprintf(body, sizeof(body),
"<html><title>Proxy Error</title>\r\n%s: %s\r\n<p>%s\r\n", num, msg, longmsg);
snprintf(buf, sizeof(buf), "HTTP/1.0 %s %s\r\n", num, msg); writen(fd, buf, strlen(buf));
snprintf(buf, sizeof(buf), "Content-type: text/html\r\n"); writen(fd, buf, strlen(buf));
snprintf(buf, sizeof(buf), "Content-length: %d\r\n\r\n", (int)strlen(body));
writen(fd, buf, strlen(buf));
writen(fd, body, strlen(body));
}
static int open_clientfd(const char *host, const char *port) {
struct addrinfo hints, *listp, *p; int fd;
memset(&hints, 0, sizeof(hints));
hints.ai_socktype = SOCK_STREAM; hints.ai_flags = AI_NUMERICSERV;
if (getaddrinfo(host, port, &hints, &listp) != 0) return -1;
for (p = listp; p; p = p->ai_next) {
if ((fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue;
if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break;
close(fd);
}
freeaddrinfo(listp);
return p ? fd : -1;
}
/* 从 "http://host[:port]/path" 拆出 host / port / path(含查询串) */
static int parse_url(const char *url, char *host, char *port, char *path) {
const char *p = url, *slash, *colon; size_t hl, pl;
if (strncasecmp(p, "http://", 7) != 0) return -1;
p += 7; slash = strchr(p, '/');
if (!slash) return -1;
colon = memchr(p, ':', (size_t)(slash - p));
if (colon) {
hl = (size_t)(colon - p); pl = (size_t)(slash - colon - 1);
if (hl == 0 || hl >= MAXLINE || pl == 0 || pl >= 16) return -1;
memcpy(host, p, hl); host[hl] = '\0';
memcpy(port, colon + 1, pl); port[pl] = '\0';
} else {
hl = (size_t)(slash - p);
if (hl == 0 || hl >= MAXLINE) return -1;
memcpy(host, p, hl); host[hl] = '\0';
strcpy(port, "80"); /* 缺省端口 80 */
}
snprintf(path, MAXLINE, "%s", slash); /* 保留 ?query */
return 0;
}
static void handle(int connfd) {
char buf[MAXLINE], method[MAXLINE], url[MAXLINE], version[MAXLINE];
char host[MAXLINE], port[16], path[MAXLINE], server_buf[MAXLINE * 4];
int serverfd, bad_line = 0; ssize_t n;
long content_length = -1; int saw_cl = 0;
n = read_until_crlf(connfd, buf, MAXLINE); /* 1. 请求行 */
if (n <= 0) return;
if (sscanf(buf, "%s %s %s", method, url, version) != 3) bad_line = 1;
/* 2. 无论如何先把客户端请求头读干净(直到空行)!
* 若带着未读数据 close(), 内核发送 RST 而非 FIN, 客户端读不到错误响应。 */
while ((n = read_until_crlf(connfd, buf, MAXLINE)) > 0) {
if (strcmp(buf, "\r\n") == 0) break;
if (strncasecmp(buf, "Content-Length:", 15) == 0) {
content_length = strtol(buf + 15, NULL, 10); saw_cl = 1;
}
}
if (saw_cl && content_length > 0) { /* 有 body 一并读掉 */
char sink[MAXLINE]; long left = content_length;
while (left > 0) {
size_t chunk = left < MAXLINE ? (size_t)left : MAXLINE;
if (readn(connfd, sink, chunk) <= 0) break;
left -= (long)chunk;
}
}
if (bad_line) { clienterror(connfd,"400","Bad Request","Malformed request line"); return; }
if (strcasecmp(method, "GET")) {
clienterror(connfd, "501", "Not Implemented", "Proxy only implements GET"); return; }
if (strcasecmp(version,"HTTP/1.0") && strcasecmp(version,"HTTP/1.1")) {
clienterror(connfd,"505","HTTP Version Not Supported","Only HTTP/1.0 and 1.1"); return; }
if (url[0] == '/') { /* 相对路径 = 源服务器请求, Proxy Lab 要求 400 */
clienterror(connfd,"400","Bad Request","Origin-server requests not supported"); return; }
if (parse_url(url, host, port, path) < 0) {
clienterror(connfd,"400","Bad Request","Proxy expects absolute URL"); return; }
/* 3. 连接源服务器(必须在读完请求头之后) */
if ((serverfd = open_clientfd(host, port)) < 0) {
clienterror(connfd,"502","Bad Gateway","Could not connect to origin server"); return; }
/* 4. 转发: 降级为 HTTP/1.0, 补 Host/Connection/Proxy-Connection */
snprintf(server_buf, sizeof(server_buf),
"GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: miniproxy/1.0\r\n"
"Connection: close\r\nProxy-Connection: close\r\n\r\n", path, host);
if (writen(serverfd, server_buf, strlen(server_buf)) < 0) { close(serverfd); return; }
/* 5. 转发响应头, 同时记下 Content-Length */
content_length = -1; saw_cl = 0;
while ((n = read_until_crlf(serverfd, buf, MAXLINE)) > 0) {
if (writen(connfd, buf, (size_t)n) < 0) { close(serverfd); return; }
if (strncasecmp(buf, "Content-Length:", 15) == 0) {
content_length = strtol(buf + 15, NULL, 10); saw_cl = 1;
}
if (strcmp(buf, "\r\n") == 0) break;
}
/* 6. 转发响应体: 有长度就"恰好"读这么多, 否则读到 EOF */
if (saw_cl && content_length >= 0) {
char body[MAXLINE]; long left = content_length;
while (left > 0) {
size_t chunk = left < MAXLINE ? (size_t)left : MAXLINE;
ssize_t got = readn(serverfd, body, chunk);
if (got <= 0) break;
if (writen(connfd, body, (size_t)got) < 0) break;
left -= got;
}
printf("[proxy] relayed %ld bytes of body (Content-Length exact)\n", content_length - left);
} else {
while ((n = read(serverfd, buf, MAXLINE)) > 0)
if (writen(connfd, buf, (size_t)n) < 0) break;
}
close(serverfd);
}
int main(int argc, char **argv) {
int listenfd, connfd, optval = 1;
struct addrinfo hints, *listp, *p;
struct sockaddr_storage clientaddr; socklen_t clientlen;
if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(1); }
signal(SIGPIPE, SIG_IGN); /* 写已关闭 socket 不要杀死进程 */
memset(&hints, 0, sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG | AI_NUMERICSERV;
if (getaddrinfo(NULL, argv[1], &hints, &listp) != 0) { perror("getaddrinfo"); exit(1); }
for (p = listp; p; p = p->ai_next) {
if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(int));
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0) break;
close(listenfd);
}
freeaddrinfo(listp);
if (!p || listen(listenfd, LISTENQ) < 0) { perror("listen"); exit(1); }
printf("[proxy] listening on port %s\n", argv[1]); fflush(stdout);
while (1) { /* 迭代式: 一次一个客户端 */
clientlen = sizeof(clientaddr);
if ((connfd = accept(listenfd, (SA *)&clientaddr, &clientlen)) < 0) continue;
handle(connfd);
close(connfd);
}
return 0;
}
【代码做什么?】
- 第 1 步读请求行并
sscanf拆字段,但先不判断合法性——因为必须先读完请求头(第 2 步)才能关闭连接。 - 第 2 步把客户端头部读到空行为止,顺带记下
Content-Length并把请求体一并读走。 - 校验:方法非
GET→501;版本非 1.0/1.1 →505;URI 以/开头(源服务器请求)→400;URL 拆分失败 →400。 open_clientfd连源服务器,失败 →502。- 重新拼装请求:路径只取后缀、版本降级为 HTTP/1.0、补
Host/User-Agent/Connection: close/Proxy-Connection: close。 - 边读边转发响应头,记录
Content-Length;再按长度精确读满响应体转发。
【底层机制透视】
- 为什么”先读干净再报错”:TCP 的
close()只在接收缓冲区已空时才发 FIN;若有未读数据,内核改发 RST。客户端收到 RST 会丢弃尚未交付的数据,于是”错误响应”根本读不到,curl只报Connection reset by peer——最容易被误判成逻辑 bug 的现象。 Content-Length必须精确:多读一个字节,那个字节就是下一条响应的开头,持久连接上会直接串包;少读则客户端永远等不到剩余字节而挂住。讲义与本 lab 都把它列为头号 bug 来源。因此这里用readn(循环读满)而不是read。SIGPIPE必须忽略:向已关闭的 socket 写入会触发SIGPIPE,默认动作是终止进程——对一个长跑服务器是灾难。忽略后write返回 -1/EPIPE,交给代码优雅处理。
【内存布局 / 数据结构图解】
代理进程一次事务的缓冲区占用(全部在栈上,默认 8 MB 栈足够)
+---------------------------+ 连接源服务器前 connfd 必须已读空
| buf[MAXLINE] 8 KB | 读请求行/头部/响应头(逐行复用同一块)
| method[MAXLINE] 8 KB | 请求行三字段
| url[MAXLINE] 8 KB |
| version[MAXLINE] 8 KB |
| host[MAXLINE] 8 KB | parse_url 输出: "127.0.0.1"
| port[16] 16 B | parse_url 输出: "18213"(缺省 "80")
| path[MAXLINE] 8 KB | "/some/path.html"(含 ?query)
| server_buf[4*MAXLINE] 32 KB | 重组的转发请求(Host/UA/Connection 必填)
| body[MAXLINE] 8 KB | Content-Length 精确转发的中转块
+---------------------------+
缓存版再加: obj_t* -> malloc(len) 以及全局 cache_t(堆/数据段,加锁保护)
【与汇编 / 硬件的对应】:signal(SIGPIPE, SIG_IGN) 修改内核中该进程的信号处置表;accept 在无连接时让进程进入可中断睡眠,由网卡中断唤醒完成三次握手;dup2(动态内容用)在 fd 表中做一次指针复制。
【实测验证】(真实运行结果:Tiny 在 18213,代理在 18214)
$ curl -sv -x http://127.0.0.1:18214 http://127.0.0.1:18213/index.html
* Trying 127.0.0.1:18214...
* Connected to 127.0.0.1 (127.0.0.1) port 18214 (#0)
> GET http://127.0.0.1:18213/index.html HTTP/1.1 <- 客户端对代理说"全 URL"
> Host: 127.0.0.1:18213
> User-Agent: curl/7.76.1
> Accept: */*
> Proxy-Connection: Keep-Alive
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK <- 代理把源服务器响应原样送回
< Server: Tiny-Lite Web Server
< Content-length: 133
< Content-type: text/html
<
{ [133 bytes data]
* Closing connection 0
用一个假源服务器(Python 原始 socket)抓下代理实际发出的字节,验证”降级为 HTTP/1.0 + 补 Host”:
b'GET /some/path.html HTTP/1.0\r\nHost: 127.0.0.1\r\nUser-Agent: miniproxy/1.0\r\n'
b'Connection: close\r\nProxy-Connection: close\r\n\r\n'
代理自身日志(证明按长度精确转发):
[proxy] client request line: GET http://127.0.0.1:18213/hello.txt HTTP/1.1
[proxy] forwarding:
GET /hello.txt HTTP/1.0
Host: 127.0.0.1
User-Agent: miniproxy/1.0
Connection: close
Proxy-Connection: close
[proxy] relayed 29 bytes of body (Content-Length exact)
四条错误路径的原始响应字节(全部实测通过):
b'HTTP/1.0 501 Not Implemented\r\n...\r\n501: Not Implemented\r\n<p>Proxy only implements GET\r\n'
b'HTTP/1.0 400 Bad Request\r\n...\r\n400: Bad Request\r\n<p>Origin-server requests not supported\r\n'
b'HTTP/1.0 505 HTTP Version Not Supported\r\n...\r\n505: HTTP Version Not Supported\r\n<p>Only HTTP/1.0 and 1.1\r\n'
b'HTTP/1.0 502 Bad Gateway\r\n...\r\n502: Bad Gateway\r\n<p>Could not connect to origin server\r\n'
20.3.3 服务动态内容:CGI 四问
讲义用四个问题概括 CGI(Common Gateway Interface):客户端如何把参数传给服务器?服务器如何把参数传给子进程?其他请求信息如何传递?服务器如何捕获子进程的输出? 答案分别是:
- 参数附加在 URI 上:
http://add.com/cgi-bin/adder?15213&18213;参数表以?开始、以&分隔,空格用+或%20表示。 - 服务器通过环境变量传递:
?之后的整串放进QUERY_STRING(上例为"15213&18213");另有SERVER_PORT、REQUEST_METHOD等。 - 子进程用
getenv("QUERY_STRING")读取,adder.c用strchr(buf,'&')切开参数再atoi。 - 服务器用
dup2把子进程的 stdout 接到客户端 socket:
void serve_dynamic(int fd, char *filename, char *cgiargs) {
char buf[MAXLINE], *emptylist[] = { NULL };
sprintf(buf, "HTTP/1.0 200 OK\r\n"); Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny Web Server\r\n"); Rio_writen(fd, buf, strlen(buf));
if (Fork() == 0) { /* 子进程 */
setenv("QUERY_STRING", cgiargs, 1); /* 第 3 参数 1 = 覆盖已有值 */
Dup2(fd, STDOUT_FILENO); /* stdout 重定向到客户端连接 */
Execve(filename, emptylist, environ); /* 执行 CGI 程序 */
}
Wait(NULL); /* 父进程回收子进程,避免僵尸 */
}
关键点:只有 CGI 子进程知道内容的类型和长度,所以 Content-length/Content-type 必须由 CGI 程序自己打印(真实会话里 Connection: close、Content-length: 117、Content-type: text/html 都来自 CGI,而状态行与 Server: 来自 Tiny)。CGI 因”每请求都 fork+exec“代价高,已被 fastCGI、Apache 模块、Servlet 等取代。
20.4 实验关联:L7 Proxy Lab 分阶段实现路线图
Proxy Lab 占课程成绩 4%,评分 70 分 = BasicCorrectness 40 + Concurrency 15 + Cache 15,用 ./driver.sh 自动评分。先把迭代式做对,再加并发,最后加缓存——顺序不可颠倒,否则 bug 定位成本会指数上升。
| 阶段 | 目标与关键动作 | 测试方法 |
|---|---|---|
| 阶段 0:跑通基线 | 编译 handout 的 tiny,用 ./free-port.sh 或 port-for-user.pl <userID> 取端口;同时运行 Tiny 与代理 | curl -v --proxy http://localhost:<p+1> http://localhost:<p>/home.html |
| 阶段 I:迭代式代理 | open_listenfd + accept + 解析全 URL 请求行;转发 GET path HTTP/1.0;必须补 Host/User-Agent/Connection: close/Proxy-Connection: close;按 Content-Length 精确转发响应体;实现 400/501/505/502 | ① nc -l 12345 当假服务器,用浏览器/curl 请求它,肉眼检查代理发出的每一个头;② telnet <proxy> <port> 手工发 GET http://localhost:<p>/home.html HTTP/1.0;③ curl -x 取普通页与二进制页(如 godzilla.gif)并 cmp 比对 |
| 阶段 II:并发代理 | pthread_create 每连接一线程,并 pthread_detach 避免内存泄漏;不共享可变状态是关键(所有缓冲区放线程栈上);用 open_clientfd(基于 getaddrinfo,线程安全)而非过时的 gethostbyname | ① 同时开多个 curl 或用 driver.sh 的并发用例;② valgrind --leak-check=full 确认无线程栈/描述符泄漏;③ 用 awk 按线程 ID 拆分日志 |
| 阶段 III:缓存代理 | 全局有界缓存:MAX_CACHE_SIZE = 1 MiB、MAX_OBJECT_SIZE = 100 KiB(只计对象字节);pthread_rwlock_t 保护;LRU 淘汰;每对象带时间戳;边转发边累积,超限即丢弃 | ① 同一对象请求两次,第二次日志须显示”命中且未连源服务器”;② 用 nop-server.py 与 driver.sh 跑 Cache 用例;③ 验证”读也更新 LRU”:反复读 A 再插入 C,被淘汰的应是 B |
本 lab 的六大坑(讲义与 recitation 反复强调):
Content-Length多读/少读——第一号 bug。- 用标准 I/O(
fprintf/fgets)操作 socket——stdio 缓冲与 fd 语义冲突,必须用 RIO。 - 照抄
csapp.c的错误处理——unix_error会exit(1),而代理一旦开始服务就不允许退出,必须自己写”记录并继续”的错误处理。 - 忽略
SIGPIPE与ECONNRESET——进程会被信号杀死或误判为致命错误。 - 假定内容是 ASCII——图像/视频是二进制,只能用按字节数的 I/O。
- 转发时把浏览器加的
Connection: Keep-Alive原样透传——必须改写为close,否则服务器保持连接而客户端在等 FIN。
调试工具链:telnet/nc 手工构造报文、curl -v 看完整事务、pxydrive.py 复现并 trace 事务(generate/serve/fetch/trace/check)、gdb 多窗口调试(./port-for-user.pl 取独占端口)。
20.5 常见错误与调试技巧
Content-Length与实际正文不符:客户端挂住或读到垃圾。调试:curl -v看Content-length,再curl -s ... \| wc -c数实际字节;strace -e trace=write核对写入总量。- 忘记用空行结束头部:服务器一直等更多头部直到超时。调试:
nc -l 12345收下代理发出的原始字节,用xxd确认末尾是0d 0a 0d 0a。 rio_readn与rio_readlineb混用同一 fd:预读进缓冲的字节被无缓冲读跳过,表现为”随机丢数据”。调试:gdb -tui断在rio_read,p rp->rio_cnt、p rp->rio_bufptr。- 向已关闭 socket 写入导致进程消失:
SIGPIPE。调试:gdb里info signal SIGPIPE;或signal(SIGPIPE, SIG_IGN)后观察errno == EPIPE。 - 带着未读数据
close():客户端报Connection reset by peer。调试:tcpdump -i lo -nn port <p> -X看是否发出RST;strace -f -e trace=close,read确认close前是否还有可读数据。 - 缓冲区溢出:把 8 KB 的
buf当无限容量拼接 URI。调试:valgrind查堆越界;栈越界须用gcc -fsanitize=address -g。 - 线程未 detach 导致内存泄漏:长跑后 RSS 持续上涨。调试:
valgrind --leak-check=full --show-leak-kinds=all。 - 缓存被单把互斥锁串行化:并发吞吐不达标。调试:
perf stat -e cache-misses,context-switches对比 RW 锁与 mutex 的上下文切换次数。
20.6 关键要点
- HTTP 的一切解析都围绕
\r\n:每行以\r\n结束,一个单独的空行标志头部结束;记住这一条,Proxy Lab 解析器就有了一半。 - 代理的身份是双重的:对客户端是服务器(
listenfd/accept),对源服务器是客户端(open_clientfd/connect);它终止一条连接、另起一条。 - Tiny 的价值在于骨架而非性能:
main/doit/clienterror/read_requesthdrs/parse_uri/serve_static/serve_dynamic这七个函数是 Web 服务器的最小完备集合。 - 正文长度必须精确:
Content-Length多读一字节就会破坏下一条响应,这既是代理头号 bug 来源,也是持久连接能工作的前提。 - 缓存必须并发友好:全局、有界(1 MiB / 100 KiB)、LRU、带时间戳,且用读者-写者锁而非互斥锁——读多写少,读者必须能并发。
- 长跑进程的错误处理原则:忽略
SIGPIPE、容忍ECONNRESET、绝不exit、绝不泄漏 fd 与内存。
20.7 思考题(带答案)
题 1(推演题):代理按 Content-Length: 100 读取响应体时多读了 50 字节,会发生什么?如果少读 50 字节呢?为什么”读多”比”读少”更危险?
答:多读的 50 字节是源服务器为下一次请求准备的下一条响应的开头(在持久连接下)。它们会被代理当作本次响应体转发给客户端,于是客户端拿到的正文前面正确、尾部混入垃圾;更糟的是代理随后转发”下一条响应头”时会从被截断的中间位置开始,导致整条连接上的报文全面错位,且客户端可能把
HTTP/1.0 200之类的文本当普通数据吞掉而不报错——错误隐蔽。 少读 50 字节时,客户端还在等剩余 50 字节(因为头里声明了 100),于是挂住直到超时,服务器也可能一直等待客户端读完而不关闭连接。现象明显、容易定位。 因此”读多”造成静默的数据损坏(对应 recitation 里proxy-corrupt的报错:'F' (hex 0x46) != 'G' (hex 0x47)),”读少”造成可观测的挂起。工程上宁可挂起也不要静默损坏。
题 2(推演题):为什么 Proxy Lab 明确要求缓存用读者-写者锁而不是一把互斥锁?请从”缓存访问的读写比例”和”临界区长度”两个角度推演,并说明 LRU 与该锁之间为什么存在张力。
答:缓存是典型的读多写少结构(同一热对象被反复请求,只有首次未命中才写)。用互斥锁时,任意两个线程即使只读互不相同的对象也必须排队,吞吐量退化为串行;而读者-写者锁允许任意多个读者并发,只有写者(插入/淘汰)独占,因此并发读的扩展性显著更好——这正是 lab 写明的”多个线程必须能同时读缓存”的要求。 第二个角度是临界区长度:读缓存只是查表并复制数据指针(微秒级),而”未命中走网络”这一段绝不能持锁——否则一个慢服务器会阻塞全部读者。所以正确做法是:读锁内完成查找与引用计数增加,立刻放锁,再在锁外发送数据。 张力在于 LRU:严格 LRU 要求”读操作也更新链表中对象的位置(把命中的对象移到 MRU 头)”,这本质上是写操作,会破坏”多读者并发”的前提。这正是 lab 允许”不必严格 LRU,只要接近”的原因:可以只让插入时更新顺序,读命中时仅更新时间戳(时间戳更新用原子写或放宽精度),从而让读者路径保持真正只读。这也是”不用严格 LRU 换取并发度”的经典工程权衡。
题 3(计算题):Tiny 用 mmap 而非 malloc+rio_readn 发送一个 133 字节文件。若一次 mmap/munmap 的系统调用与缺页开销合计约 5 μs,而 malloc+read(两次拷贝)对 133 字节开销约 1.5 μs,那么文件要多大时 mmap 才开始占优?本 lab 的缓存对象上限(100 KiB)下应选哪种?
答:设文件大小为 $F$ 字节。
mmap路径的开销近似 $5\ \mu s + c \cdot F$($c$ 为单字节搬运代价),malloc+read路径近似 $1.5\ \mu s + 2c \cdot F$(多一次拷贝)。令两者相等:$5 + cF = 1.5 + 2cF$,得 $cF = 3.5\ \mu s$。 关键在 $c$:内存带宽约 10 GB/s 时 $c \approx 0.1\ ns/B = 10^{-4}\ \mu s/B$,于是 $F \approx 3.5/10^{-4} = 35000$ 字节 ≈ 35 KB。也就是说 35 KB 以上的文件mmap才划算,小文件用mmap反而更慢——这与 Tiny 的行为一致:它服务的是几十字节到几 KB 的小页面,mmap其实是”优雅但不一定最快”的选择。 在 Proxy Lab 中,响应体是流式的(必须先读来才能知道长度并决定是否入缓存),无法直接mmap源服务器 socket 上的数据,因此只能用”累积缓冲区”方案;MAX_CACHE_SIZE + T × MAX_OBJECT_SIZE才是内存上界($T$ 为并发连接数)。
题 4(”直观但错误的想法”错在哪):有同学认为”代理只要把 connfd 里读到的字节原样写进 serverfd、再把 serverfd 读到的字节原样写回 connfd,就实现了代理,根本不需要解析 HTTP”。这个想法错在哪?
答:错在三处。① 必须改写请求行:客户端发的是
GET http://host:port/path HTTP/1.1(全 URL),而源服务器期望的是GET /path HTTP/1.0(后缀),照抄会让服务器返回400;同时版本必须降级为 HTTP/1.0。② 必须增删头部:Host在 HTTP/1.0 下不强制但虚拟主机必需;浏览器的Connection: Keep-Alive/Proxy-Connection: Keep-Alive必须改写为close,否则连接语义与代理”一次事务一条连接”的假设冲突;Proxy-Connection是代理专用头,不应转发给源服务器。③ 必须先读完请求头再连接源服务器:若边读边转发,代理会在解析出Host之前就试图connect,而它此刻还不知道目标是谁。此外还有端口缺省补 80、Content-Length精确转发、错误码映射(502/400/501/505)等——代理的全部工作量恰恰集中在”解析并重写头部”这一层,这也正是 Proxy Lab 要考的东西。