Lecture 19: 文件系统与网络编程 I (File Systems; Network Programming Part I)
Lecture 19: 文件系统与网络编程 I (File Systems; Network Programming Part I)
讲义对应:CMU 15-213 Lecture 19 — File Systems / Network Programming (Part I)(素材:
F25-19-fs_netprog1.txt) 教材对应:CS:APP3e 第 11 章 11.1–11.4(网络编程:客户端-服务器模型、网络、IP 与域名、套接字接口) 关联 Lab:L7 Proxy Lab(Web 代理:套接字 + HTTP + 并发 + 缓存)
19.1 概述
本讲前半部分用 SFS(Shark File System,L8 的骨架文件系统)快速串起”文件系统如何把盘块抽象成文件”,后半部分进入网络编程。核心问题是:两个位于不同主机上的进程,如何通过一条”看不见的线”可靠地交换字节? 答案是 TCP/IP 协议族 + 套接字接口(sockets interface)——它把网络访问统一成文件描述符,于是上一讲(L18)学过的 Unix I/O 与 RIO 包可以原封不动地复用到网络上。本讲是 L20(并发服务器)与 Proxy Lab 的直接前提:Proxy Lab 的骨架代码就是这里的 open_listenfd / accept / rio_readlineb 循环。
19.2 核心概念与底层机制图解
19.2.1 文件系统速览(略,为 L8 铺垫)
文件系统管理磁盘块以提供文件抽象:磁盘表面划分为磁道(track),磁道划分为扇区(sector),文件系统以块(block)为单位管理。创建文件系统要做格式化:指定一个(或多个)超级块(super block)记录类型/大小/根目录/空闲块,其余块标记为空闲。目录本身是一种特殊文件,把字符串映射到文件(还可能映射到目录)。打开文件做三件事:找空闲 fd、分配打开文件表项(pos、权限)、把文件信息载入内存。删除文件分两步:移除映射 + 把块放回空闲链表(”像 free(),但要先回答:打开中的文件能被删除吗?”)。SFS 的特点:用 mmap 把整个”磁盘”文件映射进内存、块大小 512 字节、块 0 是超级块(其他引用 0 表示 NULL)、扁平目录结构。SFS 的难点不在代码量(平均约 200 行),而在识别临界区——临界区由共享变量/资源共享定义,可能是两个线程调用相同或不同的函数。
19.2.2 网络作为第三种 I/O 设备(Network as an I/O Device)
- 定义与目的:网络是主机上又一种 I/O 设备,但它比磁盘更深、更慢——数据要经过网卡 → 交换机 → 路由器 → 骨干网 → …… 才能到达对端。
- 直观解释:把网络想成”主机之间的一条 I/O 总线”。本地 I/O 总线连 CPU 与磁盘/显卡;而网络适配器(network adapter)把这条总线延伸到了全世界。访问
www.google.com就像访问一块”别人家的磁盘”,只是延迟高几个数量级、还可能丢数据。 - 底层机制图解:教材名言——“网络是一种 I/O 设备”。
+--------------------------------------+
| CPU chip |
| +--------+ +-------+ +---------+ |
| | register| | ALU | | MI | |
| | file | +-------+ +---------+ |
| +--------+ | |
+-------------------|-------------------+
| system bus
+-------+--------+
| I/O bridge |
+-------+--------+
| memory bus
+--------------------+--------------------+
| main memory (DRAM) |
+-----------------------------------------+
| I/O bus
+-------------+-------------+--------+--------+----------------+
| | | | |
+-----+-----+ +-----+-----+ +-----+------+ +------+------+ +------+------+
| disk | | graphics | | USB | | network | | Expansion |
| controller| | adapter | | controller | | adapter | | slots |
+-----+-----+ +-----+-----+ +-----+------+ +------+------+ +-------------+
| | | |
disk monitor mouse/kbd =======+======= <-- "第三种 I/O 设备"
| network | 通向外部的世界
+-----------+
- 与机器码/硬件的对应:应用进程通过系统调用陷入内核,内核的网络协议栈把数据交给网卡;网卡发完后通过中断(interrupt)通知 CPU 数据到达/发送完成。所以网络 I/O 与磁盘 I/O 在硬件层面是同一套模式:
DMA + 中断。
19.2.3 网络的分层与协议(Protocol Layering)
- 定义与目的:协议(protocol) 是一组规则,规定主机与路由器在跨网络传输数据时如何协作;它”抹平”了不同 LAN/WAN 之间的差异。
- 直观解释:寄国际快递。你只写”寄到巴黎”,剩下的交给邮政系统:本地区域中心 → 国家交换中心 → 目的国 → 收件人。每一层只关心自己的信封,不关心里面装什么。
- 底层机制图解(协议分层):
应用层 | HTTP / FTP / SMTP / SSH / DNS ... | 进程间报文
--------+--------------------------------------------------------+----------
传输层 | UDP (不可靠数据报) | TCP (可靠、面向连接、全双工字节流)| 端到端(端口)
--------+----------------------+---------------------------------+----------
网络层 | IP (Internet Protocol) —— 主机到主机、尽力而为的数据报 | 主机到主机(IP)
--------+--------------------------------------------------------+----------
链路层 | Ethernet / WiFi / Fibre Channel / T1 / DSL ... | 一跳(帧)
--------+--------------------------------------------------------+----------
物理层 | 双绞线 / 光纤 / 无线电 | 比特
封装(encapsulation):数据自上而下逐层加头,到对端再逐层剥头。
Host A Host B
+--------+ +--------+--------+ +--------+--------+--------+--------+
| data | ---> | FH1 | data+PH | ---> | FH2 | data+PH | ---> | data |
+--------+ +--------+--------+ +--------+--------+--------+--------+
应用数据 LAN1 帧 LAN2 帧
| Router 拆 FH1、装 FH2 |
PH = internet packet header (IP 头: 目的地址、大小…)
FH = LAN frame header (链路层帧头: MAC 地址…)
- 与机器码/硬件的对应:分层是软件实现的(内核协议栈 + 用户态库),只有最底层落到网卡固件。这也解释了”包可能走不同路径”:每一跳的路由器独立决策,IP 层不保证路径一致。
19.2.4 IP 地址、域名与 DNS
一个程序员眼中的 Internet 有三件事:32 位 IP 地址(如 128.2.203.179,127.0.0.1 永远是本机)、域名系统(DNS) 把人类可读的域名映射到 IP(www.cs.cmu.edu → 128.2.217.3)、连接让一个进程与另一主机上的进程通信。
- IPv4 vs IPv6:IPv4 于 1981 年规定,32 位地址;从 1990 年代起就知道地址不够用。IPv6 于 1996 年规定,128 位地址(
2001:0db8:0:0:0:0:cafe:1a7e),但采用极慢,因为要更换路由器(CMU 的网络完全不支持 IPv6!)。好消息是应用程序员基本不用在意——套接字 API 让同一份代码能无缝使用两者。 - 点分十进制(dotted decimal notation):32 位地址按字节写成十进制、用点分隔:
0x8002C2F2=128.2.194.242。 - DNS 映射的性质(用
nslookup可观察):
一对一的映射: whaleshark.ics.cs.cmu.edu -> 128.2.210.175
多名映射同一 IP: cs.mit.edu -> 18.25.0.23
eecs.mit.edu -> 18.25.0.23 (同一等价类)
反向查询: nslookup 18.25.0.23
-> 23.0.25.18.in-addr.arpa name = eecs.mit.edu.
多名映射多 IP: www.twitter.com -> 104.244.42.65
104.244.42.129
104.244.42.193
104.244.42.1 (每次顺序可能不同!)
有效但无地址: ics.cs.cmu.edu -> (No Address given)
本机固定: localhost -> 127.0.0.1 (环回地址 loopback)
概念上,DNS 是一个全球分布式数据库;每个主机条目(host entry) 定义了域名与 IP 的映射,数学上是一个域名与 IP 的等价类。
19.2.5 客户端-服务器模型与连接剖析
- 定义与目的:绝大多数网络应用基于客户端-服务器模型(client-server model):服务器进程管理某种资源,客户端发请求,服务器操纵资源产生响应。
- 直观解释:自动售货机。售货机(服务器)守着货物(资源);你(客户端)投币按钮(请求),它掉出商品(响应);没有请求就一直待机。
- 底层机制图解:
Client process Server process
| |
| 1. 客户端发请求 ------------------------------> |
| | 2. 服务器处理请求
| 3. 服务器发响应 <------------------------------ | (操纵资源)
| |
4. 客户端处理响应 [ Resource ]
注意:客户端与服务器都是"运行在主机上的进程",可同机也可异机。
连接(connection)的三个性质:点对点(point-to-point)——连接一对进程;全双工(full-duplex)——数据可同时双向流动;可靠(reliable)——源端发出的字节流最终按原顺序到达目的端。套接字(socket)是连接的端点,套接字地址是 IP:端口 对。端口是 16 位整数:临时端口(ephemeral port) 由客户端内核自动分配;知名端口(well-known port) 与服务绑定。
Connection socket pair
(128.2.194.242:51213 , 208.216.181.15:80)
\_____client______/ \______server______/
Client socket address 128.2.194.242 : 51213 <- 临时端口(内核分配)
Server socket address 208.216.181.15 : 80 <- 知名端口(Web)
内核靠"目的端口"把到达的包分派给正确的服务进程:
目的 = 128.2.194.242:80 --> Web server (port 80)
目的 = 128.2.194.242:7 --> Echo server (port 7)
常见知名端口:echo 7、ftp 21、ssh 22、smtp 25、http 80、https 443;映射表在 /etc/services。
19.2.6 字节序:网络字节序 vs 主机字节序
- 定义与目的:IP 地址与端口号在内存中一律以大端字节序(big-endian / network byte order)存放。这对任何在包头里跨机器传输的整数都成立。
- 直观解释:两个国家的人约定”先说高位”,但本国人习惯”先说低位”。打电话时必须按约定说,否则对方听到的是反的。
- 底层机制图解:
struct in_addr { uint32_t s_addr; /* network byte order (big-endian) */ };
值 128.2.194.242 = 0x8002C2F2
小端主机内存: 低地址 -> 高地址
+------+------+------+------+
| 0xF2 | 0xC2 | 0x02 | 0x80 | 把 0x8002C2F2 直接存进去(错!)
+------+------+------+------+
网络序内存(大端):
+------+------+------+------+
| 0x80 | 0x02 | 0xC2 | 0xF2 | htonl(0x8002C2F2) 之后(对)
+------+------+------+------+
- 与机器码/硬件的对应:x86-64 是小端机器,所以必须显式转换。四个转换函数(
arpa/inet.h):htonl/htons(host→network,用于 long/short)、ntohl/ntohs(network→host)。这是网络编程最常见的 bug 来源之一。
19.2.7 套接字地址结构与全部函数(Sockets Interface)
套接字接口是 1980 年代初随 Berkeley Unix 诞生的系统级函数组,与 Unix I/O 配合使用,如今在所有现代系统上都有。对内核而言套接字是通信端点;对应用而言套接字就是一个文件描述符——正因如此才能复用 read/write 与 RIO。套接字 I/O 与普通文件 I/O 的唯一区别只在于”如何打开描述符”。
套接字地址结构(ASCII 图):
┌──────────────────────────────────────────────────────────────────────────┐
│ 通用: struct sockaddr (只用于"占位",从不直接使用) │
│ +----------------+---------------+ │
│ | sa_family_t | char | 16 字节 │
│ | sa_family (2B) | sa_data[14] | │
│ +----------------+---------------+ │
└──────────────────────────────────────────────────────────────────────────┘
▲ 强制转换 (cast) 成这一种,供 socket API 的统一形参使用
│
┌──────────┴───────────────────────────┐ ┌────────────────────────────────┐
│ IPv4: struct sockaddr_in │ │ IPv6: struct sockaddr_in6 │
│ sin_family (2B) = AF_INET │ │ sin6_family (2B)=AF_INET6 │
│ sin_port (2B, network order)│ │ sin6_port (2B) │
│ struct in_addr sin_addr (4B) │ │ sin6_flowinfo (4B) │
│ └─ uint32_t s_addr (big-endian) │ │ struct in6_addr sin6_addr(16B)│
│ char sin_zero[8] (填充) │ │ uint32_t sin6_scope_id (4B) │
│ 共 16 字节 │ │ 共 28 字节 │
└──────────────────────────────────────┘ └────────────────────────────────┘
▲ ▲
└──────────────┬───────────────────────────┘
┌─────────────────────────┴────────────────────────────────────────────┐
│ struct sockaddr_storage —— "足够大以容纳任意协议地址"的通用容器 │
│ ss_family + __ss_padding —— 用它做 accept() 的缓冲区最安全 │
└──────────────────────────────────────────────────────────────────────┘
经典错误示例:struct sockaddr_in 是 IPv4 专用的;写协议无关代码时应使用 struct sockaddr_storage,否则一旦客户端用 IPv6 连接就会缓冲区溢出。
完整套接字函数速查表:
| 函数 | 作用 | 谁调用 | 关键参数 | 常见错误 |
|---|---|---|---|---|
getaddrinfo(node, service, hints, &res) | 主机名+服务名 → 地址链表(协议无关,线程安全) | 客户端+服务器 | hints.ai_socktype=SOCK_STREAM;服务器加 AI_PASSIVE | 忘记检查返回值;忘记 freeaddrinfo;忘记 memset(&hints,0,...) |
getnameinfo(sa, salen, host, hl, serv, sl, flags) | 套接字地址 → 主机名/服务名字符串(反向查询) | 服务器(accept 之后) | NI_NUMERICHOST/NI_NUMERICSERV 避免阻塞式反查 | 缓冲区长度搞错;默认 flags 会做反向 DNS,可能阻塞 |
freeaddrinfo(res) | 释放 getaddrinfo 返回的链表 | 客户端+服务器 | — | 内存泄漏 |
socket(domain, type, protocol) | 创建套接字,返回描述符 | 客户端+服务器 | AF_INET/AF_INET6,SOCK_STREAM,0 | 忘记检查 -1;类型与用途不符 |
connect(sockfd, addr, addrlen) | 客户端发起三次握手,建立连接 | 仅客户端 | 目标服务器地址 | 用 listenfd 去 connect;未处理 ECONNREFUSED |
bind(sockfd, addr, addrlen) | 把套接字绑定到本地地址/端口 | 仅服务器 | 端口须 htons() | 忘记 htons;端口被占(EADDRINUSE) |
listen(sockfd, backlog) | 把主动套接字转为监听套接字 | 仅服务器 | backlog(CS:APP 用 LISTENQ=1024) | 顺序颠倒(listen 在 bind 前) |
accept(listenfd, &addr, &addrlen) | 从已完成队列取一个连接,返回新的 connfd | 仅服务器 | addr 传 sockaddr_storage*,addrlen 是值-结果参数 | 用 listenfd 读写数据;addrlen 未初始化为缓冲区长度 |
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &1, sizeof(int)) | 允许立即重用处于 TIME_WAIT 的地址 | 仅服务器 | 必须在 bind 之前 | 忘记设置 → 重启服务器报 “Address already in use” |
close(fd) | 关闭描述符,发送 FIN | 客户端+服务器 | — | 忘记关闭 connfd → 描述符泄漏(长跑服务器致命) |
htonl/htons/ntohl/ntohs | 主机序 ↔ 网络序转换 | 客户端+服务器 | 16 位用 s,32 位用 l | 漏掉转换 → 端口/地址错乱 |
inet_pton/inet_ntop | 文本 ↔ 二进制 IP 地址(协议无关,可重入) | 客户端+服务器 | AF_INET/AF_INET6 | 再用已废弃的 inet_ntoa(不可重入) |
gethostbyname | 主机名 → IP(旧接口) | 不推荐 | — | 不可重入、非线程安全;已被 getaddrinfo 取代 |
rio_readlineb/rio_writen | 带缓冲的健壮读写(L18) | 客户端+服务器 | — | 在套接字上用标准 I/O(fgets/fputs)导致缓冲问题 |
客户端-服务器套接字函数调用流程图:
CLIENT SERVER
| |
| (等待连接请求) socket() <- 创建监听套接字
| setsockopt(SO_REUSEADDR)
| bind() <- 绑定知名端口
| listen() <- 转为监听套接字
| |
getaddrinfo() accept() <- 阻塞, 返回 connfd
socket()| <---- 三次握手 ---- |
connect()|------------->--------------------- |
| |
rio_writen() -------- HTTP 请求 --------------> rio_readlineb()
rio_readlineb() <----- HTTP 响应 ------------- rio_writen()
| |
close() -------- FIN ----------------> rio_readlineb() 返回 0 (EOF)
| |
| close(connfd) <- 只关 connfd!
| (回到 accept 等待下一个)
v v
注意: clientfd 一个进程一个 注意: listenfd 只创建一次且永不关闭
connfd 每次连接一个、用完就关
listenfd vs connfd——必须分清的两个描述符:
listenfd(监听描述符) | connfd(连接描述符) | |
|---|---|---|
| 创建次数 | 服务器生命周期内只创建一次 | 每个客户端连接创建一个 |
| 创建者 | open_listenfd(内部 socket+bind+listen) | accept 的返回值 |
| 作用 | 作为”总机”,只负责接收连接请求 | 作为”分机”,负责与这个客户端收发数据 |
| I/O | 绝不能对它 read/write | read/write 数据只能用它 |
| 关闭时机 | 服务器退出时 | 处理完该客户端立刻关闭 |
19.3 代码示例与底层机制分析
19.3.1 字节序演示:htonl 前后对比
/* byteorder.c —— 演示网络字节序(大端)与主机字节序的差异 */
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>
static void dump(const char *tag, uint32_t v)
{
unsigned char *p = (unsigned char *)&v;
printf("%-22s = 0x%08X 内存字节: %02x %02x %02x %02x\n",
tag, v, p[0], p[1], p[2], p[3]);
}
int main(void)
{
uint32_t one = 1;
printf("本机字节序: %s\n", (*(unsigned char *)&one == 1) ? "小端 (little-endian)"
: "大端 (big-endian)");
struct in_addr a; /* IPv4 地址结构 */
inet_pton(AF_INET, "128.2.194.242", &a); /* 文本 -> 二进制(网络序) */
dump("inet_pton 之后 s_addr", a.s_addr);
char buf[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &a, buf, sizeof buf); /* 二进制(网络序) -> 文本 */
printf("inet_ntop 还原 = %s\n", buf);
uint16_t port_host = 8080;
uint16_t port_net = htons(port_host); /* 16 位: host -> network */
printf("port host order = 0x%04X (%u)\n", port_host, port_host);
printf("port network order = 0x%04X (%u)\n", port_net, port_net);
printf("ntohs(htons(8080)) = %u\n", ntohs(port_net));
uint32_t ip_host = 0x8002C2F2u; /* 128.2.194.242 的数值 */
uint32_t ip_net = htonl(ip_host); /* 32 位: host -> network */
dump("htonl 之前 (主机序)", ip_host);
dump("htonl 之后 (网络序)", ip_net);
printf("主机序按字节打印 = %u.%u.%u.%u\n",
(ip_host >> 24) & 0xFF, (ip_host >> 16) & 0xFF,
(ip_host >> 8) & 0xFF, ip_host & 0xFF);
return 0;
}
【代码做什么?】 ① 取整数 1 看它的最低地址字节判断本机字节序;② 用 inet_pton 把点分十进制地址转成网络序二进制,并打印其内存字节;③ 演示 16 位 htons 与 32 位 htonl 的对称性(ntohs(htons(x)) == x);④ 打印 htonl 前后同一数值在内存中的字节排列差异。
【底层机制透视】 inet_pton 内部会把地址按大端写入 s_addr,因此在小端机器上 s_addr 的数值变成 0xF2C20280——这不是”值错了”,而是”同一个 32 位里字节的排列被有意反转”。htonl 的实现是字节交换(bswap),在 x86-64 上编译器会直接生成一条 bswap %eax;在本来就是大端的机器上则是空操作。这条规则对包头中的所有整数都成立,所以端口号也必须转换。
【实测验证】(本机 GCC 12.2.0 / x86-64 Linux,真实输出)
$ gcc -g -Wall -std=c11 byteorder.c -o byteorder && ./byteorder
本机字节序: 小端 (little-endian)
----------------------------------------------------------------
inet_pton 之后 s_addr = 0xF2C20280 内存字节: 80 02 c2 f2
inet_ntop 还原 = 128.2.194.242
----------------------------------------------------------------
port host order = 0x1F90 (8080)
port network order = 0x901F (36895)
ntohs(htons(8080)) = 8080
----------------------------------------------------------------
htonl 之前 (主机序) = 0x8002C2F2 内存字节: f2 c2 02 80
htonl 之后 (网络序) = 0xF2C20280 内存字节: 80 02 c2 f2
主机序按字节打印 = 128.2.194.242
注意 htonl 之前 的内存是 f2 c2 02 80(小端),之后 是 80 02 c2 f2(大端)——同一串字节恰好完全反序。
19.3.2 回显服务器 echoserveri.c
/* echoserveri.c - 迭代式(iterative) echo 服务器,CS:APP3e 图 11-16 风格 */
#include "csapp.h"
void echo(int connfd);
int main(int argc, char **argv)
{
int listenfd, connfd;
socklen_t clientlen;
struct sockaddr_storage clientaddr; /* 足以容纳任意协议地址 */
char client_hostname[MAXLINE], client_port[MAXLINE];
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(0);
}
listenfd = Open_listenfd(argv[1]); /* 只创建一次的监听描述符 */
printf("listening on port %s, listenfd = %d\n", argv[1], listenfd);
fflush(stdout);
while (1) {
clientlen = sizeof(struct sockaddr_storage);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Getnameinfo((SA *)&clientaddr, clientlen, client_hostname, MAXLINE,
client_port, MAXLINE, 0);
printf("Connected to (%s, %s) connfd = %d\n",
client_hostname, client_port, connfd);
fflush(stdout);
echo(connfd); /* 每次连接一个连接描述符 */
Close(connfd);
}
exit(0);
}
void echo(int connfd)
{
size_t n;
char buf[MAXLINE];
rio_t rio;
Rio_readinitb(&rio, connfd);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
printf("server received %d bytes\n", (int)n);
fflush(stdout);
Rio_writen(connfd, buf, n); /* 原样回显 */
}
}
【代码做什么?】 ① Open_listenfd 创建唯一的 listenfd;② 进入无限循环,Accept 阻塞等待连接,返回一个新的 connfd;③ 用 Getnameinfo 把客户端地址反查成主机名/端口并打印;④ 调用 echo 用 RIO 逐行读取并原样回写,直到读到 EOF(Rio_readlineb 返回 0);⑤ Close(connfd),回到 ②。
【底层机制透视】 Accept 是从内核的已完成连接队列中取出一个已握手完成的连接——真正的三次握手由内核完成,accept 只是”取号”。Rio_readinitb 在用户态分配 8192 字节缓冲(rio_t 结构),于是 Rio_readlineb 一次 read 就能喂饱多行,避免每字节一次系统调用。Rio_readlineb 返回 0 不是错误,而是对端 close 触发的 EOF(收到 FIN)——这是”服务器只处理一个客户端、处理完继续接受下一个”的迭代式(iterative)服务器的关键。
【内存布局图解】(服务器主循环的栈帧与内核对象)
服务器进程地址空间 (用户态) 内核
┌──────────────────────────────┐ ┌─────────────────────────────────┐
│ main 栈帧 │ │ 打开文件表 │
│ listenfd = 3 │───────►│ fd 3 ── 监听套接字 (端口 8139) │
│ connfd = 4 │───┐ │ fd 4 ── 连接套接字 (对端 50068) │
│ clientaddr (sockaddr_storage)│ │ └─────────────────────────────────┘
│ client_hostname[8192] │ │
│ client_port[8192] │ └───► 注意: fd 3 永不关闭、永不读写;
├──────────────────────────────┤ fd 4 每轮循环换一个、用完 close
│ echo 栈帧 │
│ buf[MAXLINE=8192] │ <- 栈上 8 KiB 缓冲,gcc -O1 会把它放在
│ rio: rio_fd=4, rio_cnt, │ %rsp+8208 处(见反汇编 leaq 8208(%rsp))
│ rio_buf[8192] │ <- rio_t 内部又有一个 8 KiB 缓冲
└──────────────────────────────┘
【与汇编 / 硬件的对应】 gcc -S -O1 生成的客户端主循环(真实片段,AT&T 语法):
call Open_clientfd # 返回 fd 在 %eax
movl %eax, %ebx # clientfd 存入 callee-saved 的 %ebx
.L4:
leaq 8208(%rsp), %rdi # buf 位于栈顶上方 8208 字节处
call strlen
movq %rax, %rdx # n = strlen(buf)
leaq 8208(%rsp), %rsi
movl %ebx, %edi # fd = clientfd
call Rio_writen
movl $8192, %edx
leaq 8208(%rsp), %rsi
movq %rsp, %rdi # rio_t 在栈顶
call Rio_readlineb
movq stdout(%rip), %rsi
leaq 8208(%rsp), %rdi
call Fputs
.L3:
movq stdin(%rip), %rdx
movl $8192, %esi
leaq 8208(%rsp), %rdi
call Fgets # 读不到行返回 NULL -> 退出循环
testq %rax, %rax
jne .L4
movl %ebx, %edi
call Close
注意 clientfd 存放在 %ebx——它必须跨 call 存活,所以编译器选了被调用者保存(callee-saved) 寄存器,这直接复用了 L5 学的调用约定。
19.3.3 回显客户端 echoclient.c
/* echoclient.c - echo 客户端,CS:APP3e 图 11-17 风格 */
#include "csapp.h"
int main(int argc, char **argv)
{
int clientfd;
char *host, *port, buf[MAXLINE];
rio_t rio;
if (argc != 3) {
fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
exit(0);
}
host = argv[1];
port = argv[2];
clientfd = Open_clientfd(host, port); /* getaddrinfo+socket+connect */
Rio_readinitb(&rio, clientfd);
printf("clientfd = %d\n", clientfd);
fflush(stdout);
while (Fgets(buf, MAXLINE, stdin) != NULL) {
Rio_writen(clientfd, buf, strlen(buf));
Rio_readlineb(&rio, buf, MAXLINE);
Fputs(buf, stdout);
}
Close(clientfd);
exit(0);
}
【代码做什么?】 ① Open_clientfd 一步完成 getaddrinfo + socket + connect;② 用 Fgets 从终端读一行;③ Rio_writen 发给服务器;④ Rio_readlineb 读回显;⑤ Fputs 打印到终端;⑥ 终端 EOF(Ctrl-D)时 Fgets 返回 NULL,关闭连接退出。
【底层机制透视】 客户端不需要 bind:内核在 connect 时自动分配一个临时端口并绑定本地 IP。客户端也不需要 listen/accept——它是主动方。Fgets 与 Rio_writen 混用是安全的,因为前者操作 FILE*(终端)、后者操作文件描述符(套接字);危险的是在同一个套接字上混用 fgets 和 rio_readlineb(L18 讲的缓冲冲突)。
【实测验证:两个终端真实跑通】 编译并运行(注意:csapp.c 用了 SA_RESTART、AI_ADDRCONFIG 等,需 -D_POSIX_C_SOURCE=200809L,否则 -std=c11 下会因特性宏屏蔽而报 struct addrinfo 不完整):
$ gcc -g -Wall -std=c11 -D_POSIX_C_SOURCE=200809L echoserveri.c csapp.c -o echoserveri -lpthread
$ gcc -g -Wall -std=c11 -D_POSIX_C_SOURCE=200809L echoclient.c csapp.c -o echoclient -lpthread
终端 A(终端 1):启动服务器
$ ./echoserveri 8139
listening on port 8139, listenfd = 3
Connected to (localhost, 50068) connfd = 4
server received 26 bytes
server received 17 bytes
Connected to (localhost, 50072) connfd = 4
server received 29 bytes
终端 B(终端 2)用 nc 连接(第 1 次会话)
$ printf 'This line is being echoed\nThis one is, too\n' | nc 127.0.0.1 8139
This line is being echoed
This one is, too
终端 B 用自己写的 echoclient 连接(第 2 次会话,新连接)
$ printf 'This one is a new connection\n' | ./echoclient 127.0.0.1 8139
clientfd = 3
This one is a new connection
这与讲义上的会话示例逐行对应:服务器打印 Connected to (...) 与 server received N bytes,客户端回显同样的行。注意 connfd 两次都是 4——第一个连接关闭后 fd 4 被释放,accept 立刻复用了它;这正是”connfd 是每连接消耗品”的直接证据。(括号里的客户端端口是内核分配的临时端口,每次运行都不同,此处为实测值。)
【实测验证:用 strace 看内核真实调用序列】
$ strace -f -e trace=socket,bind,listen,accept,close -o trace.log ./echoserveri 8139
(另一终端执行 printf 'hello\n' | nc 127.0.0.1 8139)
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(8139),
sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 1024) = 0
accept(3, {sa_family=AF_INET, sin_port=htons(56540),
sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 4
close(4) = 0
accept(3, 0x7ffd36d694d0, [128]) = ? ERESTARTSYS
三件事被证实:① open_listenfd 内部确实就是 socket→bind→listen,且 bind 的端口显示为 htons(8139)(网络序);② accept 返回 4,是从 listenfd=3 派生的新描述符;③ 处理完只 close(4),listenfd=3 依然活着并回到 accept 阻塞(ERESTARTSYS 是信号中断后重启,说明它确实在等)。
【实测验证:/proc 里的 fd 表】 保持一个连接打开时观察服务器进程:
$ ls -l /proc/$(pgrep -x echoserveri)/fd | grep socket
lrwx------ 1 runguoli mzhang 64 ... 3 -> socket:[3421151768] <- listenfd
lrwx------ 1 runguoli mzhang 64 ... 4 -> socket:[3421151769] <- connfd
lrwx------ 1 runguoli mzhang 64 ... 5 -> socket:[3421170446] <- (csapp.c 内部临时)
$ ss -tn | grep 8139
ESTAB 0 0 127.0.0.1:8139 127.0.0.1:59842
19.3.4 open_clientfd / open_listenfd(教材风格完整实现)
/* csapp.c 摘录:open_clientfd / open_listenfd 的教材完整实现 */
#include "csapp.h"
/* open_clientfd - 连接到 <hostname, port> 的服务器,返回可读写的套接字描述符。
* 可重入(reentrant)且协议无关(protocol-independent)。
* 出错返回 -2(getaddrinfo 失败)或 -1(其他错误,errno 已设置)。 */
int open_clientfd(char *hostname, char *port) {
int clientfd, rc;
struct addrinfo hints, *listp, *p;
/* Get a list of potential server addresses */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM; /* Open a connection */
hints.ai_flags = AI_NUMERICSERV; /* ... using a numeric port arg. */
hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */
if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n",
hostname, port, gai_strerror(rc));
return -2;
}
/* Walk the list for one that we can successfully connect to */
for (p = listp; p; p = p->ai_next) {
/* Create a socket descriptor */
if ((clientfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) < 0)
continue; /* Socket failed, try the next */
/* Connect to the server */
if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1)
break; /* Success */
if (close(clientfd) < 0) { /* Connect failed, try another */
fprintf(stderr, "open_clientfd: close failed: %s\n", strerror(errno));
return -1;
}
}
/* Clean up */
freeaddrinfo(listp);
if (!p) /* All connects failed */
return -1;
else /* The last connect succeeded */
return clientfd;
}
/* open_listenfd - 在 port 上打开并返回一个监听套接字。
* 同样可重入且协议无关。出错返回 -2 或 -1。 */
int open_listenfd(char *port)
{
struct addrinfo hints, *listp, *p;
int listenfd, rc, optval=1;
/* Get a list of potential server addresses */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM; /* Accept connections */
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */
hints.ai_flags |= AI_NUMERICSERV; /* ... using port number */
if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo failed (port %s): %s\n",
port, gai_strerror(rc));
return -2;
}
/* Walk the list for one that we can bind to */
for (p = listp; p; p = p->ai_next) {
/* Create a socket descriptor */
if ((listenfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) < 0)
continue; /* Socket failed, try the next */
/* Eliminates "Address already in use" error from bind */
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
(const void *)&optval, sizeof(int));
/* Bind the descriptor to the address */
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
break; /* Success */
if (close(listenfd) < 0) { /* Bind failed, try the next */
fprintf(stderr, "open_listenfd close failed: %s\n", strerror(errno));
return -1;
}
}
/* Clean up */
freeaddrinfo(listp);
if (!p) /* No address worked */
return -1;
/* Make it a listening socket ready to accept connection requests */
if (listen(listenfd, LISTENQ) < 0) {
close(listenfd);
return -1;
}
return listenfd;
}
【代码做什么?】 两个函数都是同一个”三件套模板“:① memset(&hints,0,...) 清零并设置 ai_socktype/ai_flags;② getaddrinfo 拿到候选地址链表;③ for (p = listp; p; p = p->ai_next) 逐个尝试,失败就 continue 试下一个。
【底层机制透视】 为什么要遍历链表?因为 getaddrinfo 对同一个名字可能返回多条记录(IPv4/IPv6、多个 IP),只有逐个尝试才知道哪条真正可达。我实测 ./gai localhost 8139 的输出就是证据:
getaddrinfo("localhost", "8139") 返回的链表:
[0] family=AF_INET socktype=SOCK_STREAM protocol=6 addrlen=16 addr=127.0.0.1:8139
[1] family=AF_INET socktype=SOCK_STREAM protocol=6 addrlen=16 addr=127.0.0.1:8139
getnameinfo(第一个地址, flags=0) = "localhost" (反向 DNS)
AI_PASSIVE 让 getaddrinfo(NULL, port, ...) 返回通配地址(0.0.0.0 / ::)供 bind 使用——这正是服务器能接受任意网卡上连接的原因。SO_REUSEADDR 解决的是 TIME_WAIT 问题:服务器重启时旧连接仍处于 TIME_WAIT,没有它 bind 会返回 EADDRINUSE。
关键区别:open_clientfd 的循环里调 connect,open_listenfd 的循环里调 bind,两者都以 freeaddrinfo(listp) 收尾(否则每次调用泄漏一条链表——长跑服务器上就是慢性内存泄漏)。另外 open_listenfd 把 listen 放在循环外面:只有 bind 成功后 p 才非空,循环结束意味着”绑定已完成”,此时才 listen。
19.3.5 HTTP 协议概览(为 L20 与 Proxy Lab 铺垫)
HTTP 请求格式:<method> <uri> <version>\r\n<headers>\r\n\r\n
GET /hub/index.html HTTP/1.0\r\n <- 请求行: 方法 URI 版本
Host: www.cmu.edu\r\n <- 头部: 名字: 值
User-Agent: Mozilla/5.0 ...\r\n
Connection: close\r\n
Proxy-Connection: close\r\n
\r\n <- 空行: 请求结束 (CRLF, 即 \r\n)
HTTP 响应格式:<version> <status-code> <status-message>\r\n<headers>\r\n\r\n<body>
HTTP/1.0 200 OK\r\n <- 状态行
Server: Tiny Web Server\r\n
Content-length: 120\r\n
Content-type: text/html\r\n
\r\n <- 空行: 头部结束
<html>...\r\n <- 响应体 (可为二进制!)
常见方法 GET / POST;常见状态码 200 OK、301 Moved Permanently、403 Forbidden、404 Not Found、501 Not Implemented;常见头部 Host、User-Agent、Content-Length、Content-Type、Connection、Proxy-Connection。注意每一行都以 \r\n 结束,整个请求/头部以空行 \r\n 结束——这是 Proxy Lab 解析逻辑的全部依据。
我用 Proxy Lab 附带的 CS:APP Tiny 服务器做了真实验证(gcc -g -Wall -std=c11 -D_POSIX_C_SOURCE=200809L tiny.c csapp.c -o tiny,./tiny 8139):
$ printf 'GET /home.html HTTP/1.0\r\nHost: localhost\r\n\r\n' | nc 127.0.0.1 8139
HTTP/1.0 200 OK
Server: Tiny Web Server
Content-length: 120
Content-type: text/html
<html>
<head><title>test</title></head>
<body>
<img align="middle" src="godzilla.gif">
Dave O'Hallaron
</body>
</html>
19.4 实验关联:L7 Proxy Lab
Proxy Lab 要求写一个缓存 Web 代理:浏览器把请求发给代理,代理转发给真正的服务器,再把响应回送给浏览器。三个阶段——Part I 顺序代理(40 分)、Part II 并发(15 分)、Part III 缓存(15 分,共 70 分)。
本讲内容直接对应 Part I 的骨架:
int listenfd = open_listenfd(argv[1]); // 命令行指定的监听端口
while (1) {
int connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
doit(connfd); // 读请求 -> 解析 -> 转发 -> 回写
Close(connfd);
}
doit 内部用的正是本讲的工具:rio_readlineb 读请求行与头部、open_clientfd(host, port) 连上游服务器、rio_writen 回写响应。
Part I 的三个关键坑(都源于本讲的知识点):
- URI 有两种形式。浏览器发给代理的是绝对 URL(
GET http://www.cmu.edu/hub/index.html HTTP/1.1),代理转发给服务器时必须改写成相对路径(GET /hub/index.html HTTP/1.0)。我用真实 Tiny 服务器验证了这个差异:$ printf 'GET http://localhost:8139/home.html HTTP/1.1\r\nHost: localhost:8139\r\n\r\n' \| nc 127.0.0.1 8139 HTTP/1.0 404 Not found ... 404: Not found <p>Tiny couldn't find this file: .http://localhost:8139/home.html <- 把整个 URL 当文件名了! $ printf 'GET /home.html HTTP/1.0\r\nHost: localhost:8139\r\n\r\n' \| nc 127.0.0.1 8139 HTTP/1.0 200 OK <- 正确所以解析器必须同时处理
http://全 URL 与相对路径:从全 URL 里切出 hostname(可选端口,如http://www.cmu.edu:8080/...要连 8080 而不是默认 80)与 path+query。 - 必须转发
Host头,并总是加上Connection: close与Proxy-Connection: close(代理每个请求新开一条连接即可)。虽然 HTTP/1.0 规范不强制Host,但虚拟主机服务器不认没有Host的请求。 - 一律转发为 HTTP/1.0,即使原请求是 HTTP/1.1。
其他必须记住的坑(writeup 明确列出):
csapp.c的错误处理函数在代理里不能照用——服务器一旦开始accept就不该退出,unix_error那种exit(0)会让代理崩掉。- 必须忽略
SIGPIPE并优雅处理EPIPE——客户端提前关闭时write会触发SIGPIPE,默认动作是终止进程;read也可能返回-1且errno == ECONNRESET,同样不能因此退出。 - 网上内容是二进制(图片/视频),必须用
rio_readnb/rio_writen这类字节流函数,绝不能用strlen或按行处理 body。 - Part II 推荐每连接一线程且置为 detached(避免内存泄漏);
open_clientfd/open_listenfd基于getaddrinfo,是线程安全的(对比:gethostbyname不是)。 - Part III 缓存:
MAX_CACHE_SIZE = 1 MiB、MAX_OBJECT_SIZE = 100 KiB,只统计对象本身的字节;淘汰近似 LRU(读和写都算”使用”)。同步要求多个读者可同时读、写者独占——一把大互斥锁不可接受(为 L22–L23 的读者-写者问题埋下伏笔)。
调试工具链:./driver.sh(自动评分)、CS:APP Tiny 服务器(driver 用它取页面)、telnet/nc 手动构造请求(nc -l 12345 当”假服务器”可看清代理实际发出的请求)、curl -v --proxy http://localhost:15214 http://localhost:15213/home.html(打印完整代理交互)、Firefox(测试前关掉浏览器自己的缓存)。端口用 ./port-for-user.pl <userID> 生成,不要随便挑。
19.5 常见错误与调试技巧
- 忘记
htons/htonl:服务器”以为”自己在 8080,内核实际把端口解释成字节翻转后的36895(0x901F 反过来)。现象是连接被拒绝(Connection refused),日志里ntohs(sin_port)打印出莫名其妙的数字。调试:ss -tlnp \| grep <pid>看内核真实监听端口;用inet_ntop/ntohs把地址”翻译”回可读形式打印;用strace -e trace=bind ./server <port>直接看bind的实参——本讲实测:$ ./badport # sin_port = 8900 而漏了 htons 程序想绑 8900,内核实际分配的端口 = 50210 (0xC422) $ ./badserver & # 服务器以为自己在 8900 server: "I am on port 8900" $ nc 127.0.0.1 8900 Ncat: Connection refused. <- 客户端连"服务器以为的端口"失败 $ nc 127.0.0.1 50210 hi <- 连内核真实端口才成功 listenfd/connfd混用:在监听套接字上read得到errno = 107 (ENOTCONN);在连接套接字上accept得到errno = 22 (EINVAL)。调试:在每次socket/accept之后立刻printf("listenfd=%d connfd=%d\n", ...),并对照/proc/<pid>/fd与ss -tn;gdb -p <pid>后p listenfd/p connfd。- 忘记
close(connfd):长跑服务器会描述符泄漏,最终accept返回EMFILE(Too many open files),服务器彻底停止服务。调试:ls -l /proc/<pid>/fd \| wc -l观察增长;valgrind --track-fds=yes ./server <port>;lsof -p <pid> \| grep socket \| wc -l。 bind报 “Address already in use”:上次运行留下的连接处于TIME_WAIT。调试:ss -tn state time-wait \| grep <port>;修复方式是setsockopt(..., SO_REUSEADDR, ...)且在bind之前调用(open_listenfd已经替你做了)。- 在套接字上用标准 I/O(
fgets/fputs/fprintf):FILE*会在用户态缓冲,与直接read抢同一批字节,造成丢行/粘包;且fgets遇到二进制数据(含\0)会截断。调试:strace -e trace=read,write -f ./server观察read返回的字节数与你的printf是否对得上;统一改用 RIO(rio_readlineb读文本行 /rio_readnb读二进制)。 - 不检查
rio_writen返回值 / 不处理SIGPIPE:客户端提前Ctrl-C,服务器在下一次write时直接被信号杀死,日志停在半句话。调试:signal(SIGPIPE, SIG_IGN);检查write返回EPIPE/ECONNRESET时只关闭该connfd并继续循环。 - 用
struct sockaddr_in接accept的地址:IPv6 客户端连接时会写坏栈上内存(sockaddr_in6有 28 字节)。调试:改用struct sockaddr_storage+clientlen = sizeof(struct sockaddr_storage);用valgrind或-fsanitize=address编译能立刻抓到。 getaddrinfo返回值用errno检查:getaddrinfo失败时返回的是自己的错误码(要用gai_strerror(rc)),不设置errno,用perror会打印出误导性的 “Success”。调试:fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc))。
19.6 关键要点
- 网络是一种 I/O 设备,而且是存储层次中最深、最慢的一层——应用程序员看到的是”套接字就是文件描述符”,于是 L18 的 Unix I/O 与 RIO 可以无缝复用。
- 分层是互联网可扩展的根本:物理层 → 链路层(以太网/WiFi)→ 网络层(IP,不可靠数据报)→ 传输层(TCP 可靠字节流 / UDP 不可靠数据报)→ 应用层(HTTP/FTP);每层只依赖下一层提供的抽象,封装时逐层加头。
- 主机 = 32 位 IPv4 地址(或 128 位 IPv6)+ 16 位端口;
IP:port唯一标识一条连接的端点,内核靠目的端口把包分派给正确的服务进程。 - 包头里的一切整数都是大端(network byte order),x86-64 是小端,所以必须
htonl/htons/ntohl/ntohs——这是网络编程最常见的 bug 来源。 listenfd只创建一次、永不读写;connfd每个连接一个、用完即关。这条区分是理解所有服务器代码(迭代式、并发式、预线程式)的钥匙。open_clientfd/open_listenfd把getaddrinfo+socket+connect/bind/listen的样板封装起来,并且因为基于getaddrinfo而线程安全——Proxy Lab 与后续所有服务器代码都直接用它们。
19.7 思考题(带答案)
题 1(推演题:字节序) 某同学写了一个服务器,用 struct sockaddr_in 时写成 sin_port = 8000;(漏了 htons),其余代码完全正确。请推演:(a) 客户端执行 nc 127.0.0.1 8000 会发生什么?(b) 客户端要在哪个端口上才能连上?(c) 如果客户端用 ./echoclient 127.0.0.1 8000,会打印什么错误?(d) 如果这台机器是大端机器,还会有 bug 吗?
答:(a) 连不上,nc 报 Connection refused——内核把 8000 = 0x1F40 当成已是网络序,在小端机上解释为 ntohs(0x1F40) = 0x401F = 16415。(b) 16415 端口。(c) connect 返回 -1 且 errno = ECONNREFUSED;因 close(clientfd) 成功、p 走到 NULL,函数返回 -1;若用 Open_clientfd 包装则打印类似 Open_clientfd error: Connection refused 后 exit(0)。(d) 不会——大端机上 htons 是恒等操作,主机序恰好等于网络序。这正是该类 bug 在 x86 上必现、在别处却”看起来没事”的原因。实测(bind 8900 漏 htons):
程序想绑 8900,内核实际分配的端口 = 50210 (0xC422)
$ nc 127.0.0.1 8900 -> Ncat: Connection refused.
$ nc 127.0.0.1 50210 -> hi (成功)
题 2(推演题:listenfd / connfd) 下面这段”服务器”能工作吗?如果 accept 返回的 connfd 在某一轮恰好也是 3(与 listenfd 相同),会发生什么?
int listenfd = open_listenfd(port);
while (1) {
int connfd = accept(listenfd, NULL, NULL);
rio_readinitb(&rio, listenfd); /* ← 本行可疑 */
while (rio_readlineb(&rio, buf, MAXLINE) != 0)
rio_writen(listenfd, buf, strlen(buf)); /* ← 本行可疑 */
}
答:不能工作。① rio_readinitb(&rio, listenfd) 让 RIO 去读监听套接字,其上没有数据流,read 失败返回 errno = 107 (ENOTCONN);实测:
[错1] read(listenfd, ...) = -1, errno = 107 (Transport endpoint is not connected)
[错2] accept(connfd, ...) = -1, errno = 22 (Invalid argument)
[对 ] read(connfd, ...) = 5, errno = 0 (Success)
② rio_writen(listenfd, ...) 同样失败。③ 更严重的是 accept 的返回值被完全忽略:每个客户端都得不到服务,且 connfd 永不 close——每连接泄漏一个描述符,最终撞上 EMFILE。至于”connfd 恰好等于 listenfd = 3“:同一进程内不可能,fd 3 已被占用,accept 只能返回下一个空闲号(通常是 4)。真正危险的场景是把 listenfd 传给了本该接收 connfd 的函数——编译器不会报错(都是 int),这正是”类型系统帮不了你”的陷阱,只能靠命名规范与日志(每次 accept 后打印两个值)自查。
题 3(计算题:fd 与缓存上界) Proxy Lab 的最大并发连接数 $T = 64$,每个连接缓冲区按 MAX_OBJECT_SIZE 计。请计算:(a) 代理用于 Web 对象的上界内存是多少 MB?(b) 如果缓存里已有 $N$ 个恰好 10 KiB 的对象,$N$ 最大是多少?(c) 若每个连接还额外占 3 个文件描述符(clientfd、connfd、日志),加上默认 ulimit -n = 1024,还有多少余量?
答:(a) 上界 $= \text{MAX\CACHE\_SIZE} + T \times \text{MAX\_OBJECT\_SIZE} = 1\ \text{MiB} + 64 \times 100\ \text{KiB} = 1024\ \text{KiB} + 6400\ \text{KiB} = 7424\ \text{KiB} \approx \mathbf{7.25\ MiB}$。(b) 缓存只统计对象本身字节,$1024 / 10 = 102.4$,故 $N{\max} = \mathbf{102}$(第 103 个超出 1 MiB,须先按近似 LRU 淘汰一个)。(c) $64 \times 3 = 192$,$1024 - 192 = 832$,余量 832 个;但 connfd 若忘记关闭会持续泄漏,再大的余量终将耗尽——“余量”不是不 close 的理由。
题 4(”直观但错误的想法”) 有同学认为:”既然 accept 返回的 connfd 就是用来收发数据的,那我能不能跳过 accept 这一步,直接对 listenfd 用 rio_readlineb?反正 listenfd 也是个套接字描述符。” 这个想法错在哪?如果这个逻辑成立,服务器会有什么后果?
答:错在把”端点工厂”当成了”端点”。listen 之后 listenfd 的语义已变为”监听套接字”——内核只允许对它 accept。它背后是两个握手队列,而不是一条字节流,所以”用 listenfd 读数据”在内核里没有对应对象;实测证据即 read(listenfd, ...) = -1, errno = 107 (ENOTCONN)。若这个逻辑成立,后果是多条连接的数据混在同一条”流”里无法区分:你失去了 TCP “每条连接一个独立字节流”的核心抽象,任何”一个客户端一个处理单元”的并发模型(迭代 / 每连接一线程 / 预线程)都无法实现。accept 的本质是从已完成队列认领一条连接、把它物化为新描述符,使应用能按连接隔离状态——这是服务器设计的基石。