Lecture 20: 网络编程 II 与 Web 服务器 (Network Programming II and Web Servers)

目录 · ← l19 · l21 →

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 动态内容) 关联 LabL7 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/htmltext/plainimage/gifimage/pngimage/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.0HTTP/1.1。注意 URL 是 URI 的一个子类。
  • 常见请求头HostHTTP/1.1 必需,用于虚拟主机)、User-Agent(客户端标识)、ConnectionProxy-Connection(是否保持连接)、Content-LengthContent-TypeCookie
  • 响应行 <version> <status-code> <status-message>,状态码分五类:
类别含义本课程常见实例
1xx信息继续处理
2xx成功200 OK
3xx重定向301 Moved Permanently302 Found
4xx客户端错误400 Bad Request403 Forbidden404 Not Found
5xx服务器错误500 Internal Server Error501 Not Implemented502 Bad Gateway505 HTTP Version Not Supported

常见响应头:Content-LengthContent-TypeDateLast-ModifiedServerConnection

  • 与机器码/硬件的对应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;
}

【代码做什么?】

  1. mainOpen_listenfd 建立监听描述符(getaddrinfosocketsetsockopt(SO_REUSEADDR)bindlisten),随后进入无限循环。
  2. accept 阻塞等待连接请求,返回已连接描述符 connfd(每次 accept 都是新的 fd,只服务这一个客户端)。
  3. doitrio_readlineb请求行sscanf 拆成 method/uri/version;非 GET 立即 clienterror(…, "501", …)
  4. 循环读头直到读到 "\r\n"——这是 HTTP 解析的锚点。注意 rio_readlineb\r\n 一起返回,所以比较 strcmp(buf, "\r\n") == 0 成立。
  5. parse_uri 的简化版:URI 含 cgi-bin 即动态;否则把 "."+uri 作为文件名,若 URI 以 / 结尾则追加默认页。
  6. stat 判断文件存在性(失败 → 404)、S_ISREG 判断是否普通文件、S_IRUSR 判断用户可读位(失败 → 403)。
  7. 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 整数参数寄存器顺序),返回值在 %raxEINTR(值为 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 foundContent-length: 152,错误页正文明确含 ./nope.html)。注意 Content-LengthContent-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. 第 1 步读请求行并 sscanf 拆字段,但先不判断合法性——因为必须先读完请求头(第 2 步)才能关闭连接。
  2. 第 2 步把客户端头部读到空行为止,顺带记下 Content-Length 并把请求体一并读走。
  3. 校验:方法非 GET501;版本非 1.0/1.1 → 505;URI 以 / 开头(源服务器请求)→ 400;URL 拆分失败 → 400
  4. open_clientfd 连源服务器,失败 → 502
  5. 重新拼装请求:路径只取后缀、版本降级为 HTTP/1.0、补 Host/User-Agent/Connection: close/Proxy-Connection: close
  6. 边读边转发响应头,记录 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):客户端如何把参数传给服务器?服务器如何把参数传给子进程?其他请求信息如何传递?服务器如何捕获子进程的输出? 答案分别是:

  1. 参数附加在 URI 上http://add.com/cgi-bin/adder?15213&18213;参数表以 ? 开始、以 & 分隔,空格用 +%20 表示。
  2. 服务器通过环境变量传递? 之后的整串放进 QUERY_STRING(上例为 "15213&18213");另有 SERVER_PORTREQUEST_METHOD 等。
  3. 子进程用 getenv("QUERY_STRING") 读取adder.cstrchr(buf,'&') 切开参数再 atoi
  4. 服务器用 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: closeContent-length: 117Content-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.shport-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/502nc -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 MiBMAX_OBJECT_SIZE = 100 KiB(只计对象字节);pthread_rwlock_t 保护;LRU 淘汰;每对象带时间戳;边转发边累积,超限即丢弃① 同一对象请求两次,第二次日志须显示”命中且未连源服务器”;② 用 nop-server.pydriver.sh 跑 Cache 用例;③ 验证”读也更新 LRU”:反复读 A 再插入 C,被淘汰的应是 B

本 lab 的六大坑(讲义与 recitation 反复强调):

  1. Content-Length 多读/少读——第一号 bug。
  2. 用标准 I/O(fprintf/fgets)操作 socket——stdio 缓冲与 fd 语义冲突,必须用 RIO。
  3. 照抄 csapp.c 的错误处理——unix_errorexit(1),而代理一旦开始服务就不允许退出,必须自己写”记录并继续”的错误处理。
  4. 忽略 SIGPIPEECONNRESET——进程会被信号杀死或误判为致命错误。
  5. 假定内容是 ASCII——图像/视频是二进制,只能用按字节数的 I/O。
  6. 转发时把浏览器加的 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 -vContent-length,再 curl -s ... \| wc -c 数实际字节;strace -e trace=write 核对写入总量。
  • 忘记用空行结束头部:服务器一直等更多头部直到超时。调试nc -l 12345 收下代理发出的原始字节,用 xxd 确认末尾是 0d 0a 0d 0a
  • rio_readnrio_readlineb 混用同一 fd:预读进缓冲的字节被无缓冲读跳过,表现为”随机丢数据”。调试gdb -tui 断在 rio_readp rp->rio_cntp rp->rio_bufptr
  • 向已关闭 socket 写入导致进程消失SIGPIPE调试gdbinfo signal SIGPIPE;或 signal(SIGPIPE, SIG_IGN) 后观察 errno == EPIPE
  • 带着未读数据 close():客户端报 Connection reset by peer调试tcpdump -i lo -nn port <p> -X 看是否发出 RSTstrace -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 要考的东西。