Lecture 19: 文件系统与网络编程 I (File Systems; Network Programming Part I)

目录 · ← l18 · l20 →

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 与域名、套接字接口) 关联 LabL7 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.179127.0.0.1 永远是本机)、域名系统(DNS) 把人类可读的域名映射到 IP(www.cs.cmu.edu128.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 7ftp 21ssh 22smtp 25http 80https 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_INET6SOCK_STREAM0忘记检查 -1;类型与用途不符
connect(sockfd, addr, addrlen)客户端发起三次握手,建立连接仅客户端目标服务器地址listenfdconnect;未处理 ECONNREFUSED
bind(sockfd, addr, addrlen)把套接字绑定到本地地址/端口仅服务器端口须 htons()忘记 htons;端口被占(EADDRINUSE
listen(sockfd, backlog)把主动套接字转为监听套接字仅服务器backlog(CS:APP 用 LISTENQ=1024顺序颠倒(listenbind 前)
accept(listenfd, &addr, &addrlen)从已完成队列取一个连接,返回新的 connfd仅服务器addrsockaddr_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+listenaccept 的返回值
作用作为”总机”,只负责接收连接请求作为”分机”,负责与这个客户端收发数据
I/O绝不能对它 read/writeread/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 逐行读取并原样回写,直到读到 EOFRio_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——它是主动方。FgetsRio_writen 混用是安全的,因为前者操作 FILE*(终端)、后者操作文件描述符(套接字);危险的是在同一个套接字上混用 fgetsrio_readlineb(L18 讲的缓冲冲突)。

【实测验证:两个终端真实跑通】 编译并运行(注意:csapp.c 用了 SA_RESTARTAI_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 内部确实就是 socketbindlisten,且 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_PASSIVEgetaddrinfo(NULL, port, ...) 返回通配地址0.0.0.0 / ::)供 bind 使用——这正是服务器能接受任意网卡上连接的原因。SO_REUSEADDR 解决的是 TIME_WAIT 问题:服务器重启时旧连接仍处于 TIME_WAIT,没有它 bind 会返回 EADDRINUSE

关键区别open_clientfd 的循环里调 connectopen_listenfd 的循环里调 bind两者都以 freeaddrinfo(listp) 收尾(否则每次调用泄漏一条链表——长跑服务器上就是慢性内存泄漏)。另外 open_listenfdlisten 放在循环外面:只有 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 OK301 Moved Permanently403 Forbidden404 Not Found501 Not Implemented;常见头部 HostUser-AgentContent-LengthContent-TypeConnectionProxy-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 的三个关键坑(都源于本讲的知识点)

  1. URI 有两种形式。浏览器发给代理的是绝对 URLGET 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

  2. 必须转发 Host,并总是加上 Connection: closeProxy-Connection: close(代理每个请求新开一条连接即可)。虽然 HTTP/1.0 规范不强制 Host,但虚拟主机服务器不认没有 Host 的请求。
  3. 一律转发为 HTTP/1.0,即使原请求是 HTTP/1.1。

其他必须记住的坑(writeup 明确列出):

  • csapp.c 的错误处理函数在代理里不能照用——服务器一旦开始 accept 就不该退出,unix_error 那种 exit(0) 会让代理崩掉。
  • 必须忽略 SIGPIPE 并优雅处理 EPIPE——客户端提前关闭时 write 会触发 SIGPIPE,默认动作是终止进程read 也可能返回 -1errno == ECONNRESET,同样不能因此退出。
  • 网上内容是二进制(图片/视频),必须用 rio_readnb/rio_writen 这类字节流函数,绝不能用 strlen 或按行处理 body。
  • Part II 推荐每连接一线程且置为 detached(避免内存泄漏);open_clientfd/open_listenfd 基于 getaddrinfo是线程安全的(对比:gethostbyname 不是)。
  • Part III 缓存:MAX_CACHE_SIZE = 1 MiBMAX_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>/fdss -tngdb -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/fprintfFILE* 会在用户态缓冲,与直接 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_inaccept 的地址: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_listenfdgetaddrinfo + 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) 连不上,ncConnection refused——内核把 8000 = 0x1F40 当成已是网络序,在小端机上解释为 ntohs(0x1F40) = 0x401F = 16415。(b) 16415 端口。(c) connect 返回 -1errno = ECONNREFUSED;因 close(clientfd) 成功、p 走到 NULL,函数返回 -1;若用 Open_clientfd 包装则打印类似 Open_clientfd error: Connection refusedexit(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 这一步,直接对 listenfdrio_readlineb?反正 listenfd 也是个套接字描述符。” 这个想法错在哪?如果这个逻辑成立,服务器会有什么后果?

:错在把”端点工厂”当成了”端点”listen 之后 listenfd 的语义已变为”监听套接字”——内核只允许对它 accept。它背后是两个握手队列,而不是一条字节流,所以”用 listenfd 读数据”在内核里没有对应对象;实测证据即 read(listenfd, ...) = -1, errno = 107 (ENOTCONN)。若这个逻辑成立,后果是多条连接的数据混在同一条”流”里无法区分:你失去了 TCP “每条连接一个独立字节流”的核心抽象,任何”一个客户端一个处理单元”的并发模型(迭代 / 每连接一线程 / 预线程)都无法实现。accept 的本质是从已完成队列认领一条连接、把它物化为新描述符,使应用能按连接隔离状态——这是服务器设计的基石。