网络知识总库

Book Notes · 06 / 14 · 网络编程

UNIX网络编程 卷1
套接字联网 API 精读笔记

Stevens、Fenner 与 Rudoff 把套接字写成一门可以亲手复现的工艺:先用 TCP 回射服务器立住主线,再把连接状态、IO 复用、UDP、名字解析、非阻塞和并发模型一层层叠上去。本笔记按这条主线做原创转述,不贴大段源码,只把每个调用背后的协议行为与工程陷阱讲清楚。

作者 W. Richard Stevens / Bill Fenner / Andrew M. Rudoff难度 编程进阶建议用时 三至四周笔记形式 深读档 · 主线八章

全书骨架:一条回射服务器走完全书

卷 1 的第 3 版仍以「套接字联网 API」为副标题。书很厚,但真正的骨架只有一条:先写出能工作的 TCP 回射,再问它在阻塞、复用、数据报、双栈、非阻塞和多进程下分别会怎样坏掉。后面每一章都是对同一条服务器的一次改装。

① socket / bind / listen→ ② accept 与 TCP 状态→ ③ select · poll · epoll→ ④ UDP 数据报→ ⑤ getaddrinfo→ ⑥ 非阻塞五模型→ ⑦ fork · 线程 · 预创建
FIG.0 — 回射主线:每个新机制都是同一台服务器的一次改装
API 是协议的投影

connect、accept、shutdown 不是孤立系统调用,它们分别对应握手、被动打开和半关闭。离开 TCP 状态机谈套接字,会把症状当根因。

字节流必须自己组帧

TCP 交付的是字节,不是「一次 write 对应一次 read」。回射按行、HTTP 按消息、RPC 按长度前缀——组帧是应用层的责任。

一种模型解决一类瓶颈

阻塞适合教学,复用适合连接多而工作轻,进程/线程适合隔离,非阻塞适合把等待从内核睡眠改成事件驱动。没有万能服务器。

陷阱比接口更值钱

SIGPIPE、TIME_WAIT、半关闭与 SO_REUSEADDR 是线上事故的常客。读完书应能解释它们为什么必须存在,而不是急着关掉。

第 3 版作者在 Stevens 之后补了 IPv6、更新后的名字转换和若干平台差异, 但教学顺序没变:先让回射跑起来,再让它在每一种 IO 与并发模型里摔一次。 本笔记按这条主线收成八章,不按原书三十余章逐页映射。 UNIX 域套接字、SCTP、路由套接字等支线,需要时回原书即可, 它们不改变「调用是状态机的投影」这一判断。 读法建议:每改装一次回射,就到站内排障与 TCP 精读里对一次状态机, 不要只在 API 手册里打转。 第八章的四条陷阱应能在每一次改装后重新勾一遍。 能讲清 SIGPIPE、TIME_WAIT、半关闭和 SO_REUSEADDR, 比多背十个套接字选项更接近这本书的目标。 选项很多,真正反复咬人的就是这四条。 把它们讲给别人听,比对着手册默写函数原型记得更牢。

01

套接字基础与 TCP 回射服务器

套接字是操作系统交给应用的通信端点:对应用它是一个文件描述符,对协议栈它是一份五元组状态。回射服务器把这条抽象钉死——客户端写一行,服务器原样写回,直到读到文件结束。

先分清三个身份

地址族AF_INET / AF_INET6 / AF_UNIX 决定地址怎么写,不决定可不可靠 套接字类型SOCK_STREAM 对应面向连接的字节流,SOCK_DGRAM 对应保留边界的数据报 协议通常让内核按族与类型配对;流式套接字在网际族上就是 TCP

地址结构要经过一次「通用化」:应用按sockaddr_in 或sockaddr_in6 填字段,调用时转成sockaddr * 加长度。网络字节序用htons /htonl 写入,用ntohs /ntohl 读出——漏一次就会在跨端通信里表现为端口对不上或地址全零。

五个调用撑起被动打开

服务器侧的最短路径是:socket 拿描述符,bind 钉死本地地址与端口,listen 把套接字改成被动, accept 取出已完成握手的连接。listen 的 backlog 描述的是「还没被 accept 取走的已完成连接」容量量级,不是「允许同时在线的客户数」。客户端只需要socket 加connect :内核替它选临时端口,并完成主动打开。

socket→ bind→ listen→ accept 阻塞‖ 客户 socket→ connect · 三次握手
FIG.1 — 被动打开与主动打开在 accept / connect 处汇合

回射为什么能当全书主线

回射把「读」和「写」绑在同一条连接上,又足够短,便于观察半关闭、缓冲与组帧。典型客户端从标准输入读一行,write 到套接字,再read 服务器的回声;服务器循环read 并原样write,读到 0 表示对端关闭了写端。书后每一章几乎都是给这对函数换引擎:先换成 select,再换成 UDP,再换成线程或进程池。

并发回射的最小形态是:父进程只负责accept,fork 出子进程处理已连接套接字;父进程关掉已连接描述符,子进程关掉监听描述符。描述符是引用计数——只有最后一个close 才会向对端发出 FIN。忘了关,连接会一直占着,TIME_WAIT 也会落在错误的一端。

读写封装比调用本身更重要

一次read 返回的字节数可以少于请求值,一次write 也可能只送出一部分。面向流的程序要把「读满 n 字节 / 写完 n 字节」写成循环,再在之上做按行或按消息的组帧。把read 的返回值直接当「一条应用消息」,是回射示例里最容易养成、也最容易在真实协议里爆炸的习惯。

书里把这类循环收成成对的封装:写路径循环到计数归零或出错;读路径要么读满指定长度,要么读到分隔符。回射按行,HTTP 按头部空行再按Content-Length 或分块,二进制 RPC 常在前面加网络字节序的长度。组帧策略可以换,循环本身不能省。被信号中断时,慢系统调用可能返回EINTR ,封装里要决定是重启还是交给上层——这是「能跑的示例」和「能上线的库」之间的另一条缝。

客户端按行回射会先在本机卡住。

最朴素的客户是:从标准输入读一行,写到套接字,再阻塞读回声。若把标准输入重定向成一个文件,写可能比网络快出一个数量级:客户把整个文件塞进套接字发送缓冲后才去读,而服务器的回声已经在路上甚至已经挤满接收缓冲。更糟的是,客户在「还没读完回声」时就因输入结束而 close,把未读数据变成复位。正确的客户必须同时盯输入和套接字:输入结束只关写端,读路径继续,直到服务器的 FIN 到来。这正是下一章半关闭、以及再下一章 select 版本客户的伏笔。

对照练习可以小到只改一处: 把回射从「按行」改成「四字节长度前缀 + 载荷」, 立刻会暴露所有把 read 返回值当消息的地方。 再故意让客户一次 write 半个前缀, 服务器必须停在「还差两字节」的状态,而不是提前解析。 这套练习不需要新 API,却能把第一章的结论钉死。

write n 字节≠ read 得到 n 字节→ 应用组帧→ 一行 / 长度前缀 / 状态机
FIG.1b — 套接字交付字节,消息边界是应用合同
易混淆:「服务器回了一行」不等于「内核交付了一行」。TCP 是字节流,消息边界必须由应用自己加——长度前缀、分隔符或应用层状态机,三者选一,不能指望套接字替你记住。父进程若忘了关掉已连接描述符,子进程 close 后引用计数仍大于零,FIN 发不出去,服务器会以为客户还在。
02

TCP 连接管理与状态机

套接字调用之所以有时阻塞、有时立刻失败、有时返回「还在进行」,是因为它们卡在 TCP 状态机的不同位置。这一章把connect、accept、close 和shutdown 重新翻译成状态迁移。

握手如何映射到调用

客户端connect 发出 SYN,自己进入SYN_SENT ;服务器内核完成三次握手后,把连接挂进已完成队列,accept 只是把这份状态交给应用。accept 返回时,连接往往已经是ESTABLISHED ——应用以为自己「接受了连接」,其实内核早就替它握完了手。backlog 溢出时,新的 SYN 可能被丢弃,客户表现为超时而不是立刻被拒绝,排障时容易误判成网络故障。

CLOSED→ SYN_SENT / LISTEN→ ESTABLISHED→ FIN_WAIT / CLOSE_WAIT→ TIME_WAIT / CLOSED
FIG.2 — 主动打开、被动打开与关闭在状态机上的交汇

关闭是两个方向的事

TCP 连接是全双工的。一方close 后,本端不再读写,并向对端送 FIN;对端读到 0,再决定何时关掉自己的写端。常见的四报文挥手来自「两边分别关闭」。若对端仍在发送,本端已经close,内核会对后续数据回 RST,本端再写就会撞上SIGPIPE。需要「我说完了,但我还想听」时,应当用shutdown(fd,SHUT_WR) 只关写端,而不是直接close。

TIME_WAIT 不是惩罚

主动关闭的一方会进入TIME_WAIT,停留约两个最大报文段寿命。它做两件事:让 Delayed 的旧重复报文自然过期,避免被新连接误收;让最后的 ACK 有机会重传,以免对端卡在LAST_ACK。客户端短连接会在本机堆积大量TIME_WAIT ,这通常说明它是主动关闭方,而不是服务器写错了。用SO_LINGER 把未发送数据丢弃并送 RST 来「消灭」TIME_WAIT ,等于拆掉这两层保护。

同时打开与同时关闭 会经过 SYN_RCVD 或 CLOSING 这类少见路径。

两端同时 connect、或同时主动关闭,并不是考题游戏: 它们解释了为何套接字必须用四元组(再加上协议)而不是「谁先听」来标识一条连接, 也解释了为何服务器重启后立刻 bind 同一端口会失败—— 旧连接的控制块还在 TIME_WAIT 里占着本地地址。

两级队列:还没握完的,和已经握完的

内核为监听套接字维护的不只是一个数字。尚未完成三次握手的 SYN 在不完整队列里,已经ESTABLISHED、等着accept 的在已完成队列里。backlog 历史上混用过这两个概念,不同内核计数方式也不一样;应用能确定的只有:已完成队列满时,新客户往往表现为超时或重传,而不是立刻收到 RST。于是「服务没响应」可能是应用卡死,也可能是accept 太慢、队列先溢了。排障时要同时看应用是否还在accept 、以及系统给出的溢出计数,而不是只 ping 一下端口。

保活(SO_KEEPALIVE )是另一件常被误用的状态工具。它探测的是「对端是否还承认这条连接」,默认周期以小时计,救不了应用层的假死。真正要检测半开连接或空闲回收,应在应用协议里做心跳,或把保活时间调到自己能说清的量级,并接受中间盒对空闲连接的砍伐往往比内核保活更早。

排障时把本机连接表的状态列和抓包时间轴对着看。 客户停在 SYN_SENT,问题在去路或过滤; 停在 ESTABLISHED 却不再有数据,问题在应用或窗口; 服务器堆 CLOSE_WAIT,是本端应用没调用 close; 堆 FIN_WAIT_2,是对端没完成被动关闭。 状态名本身就是一张诊断表,不必先怀疑网卡。

易混淆:看到本机堆满 TIME_WAIT 就去改内核等待时间,是把保护机制当故障。先问「谁在主动关闭、连接是否本该这么短、能否复用长连接」;只有主动关闭方才会进入该状态。客户端短连接堆满 TIME_WAIT 是常见的健康现象,不是服务器泄漏。
03

IO 复用:select、poll 与现代 epoll 注记

一个进程往往要同时盯监听套接字、多条已连接套接字,以及标准输入。阻塞在任意一个 read 上,其他描述符就会饿死。复用把「等哪一个可读可写」从多次阻塞调用收成一次等待。

select:最老、也最能暴露代价

select 接收三组描述符集合(读、写、异常)和一个可选超时。返回后集合被改成「就绪的那些」,所以每次进入内核前必须重填。它的硬伤几乎都写在接口上:描述符用位图表示,容量受FD_SETSIZE 一类编译期上限约束;内核要扫描整张位图;应用还要自己记住「最大描述符加一」。异常集合在 TCP 里主要对应带外数据,日常服务器很少用对。

poll:换掉位图,不换模型

poll 用pollfd 数组代替位图,不再受那道编译期上限,事件掩码也更清楚(普通可读、挂起、错误分开)。代价模型没变:每次调用仍要把整表拷进内核,内核仍可能做线性扫描。它适合中等规模的描述符集合,也是理解后续接口的台阶——你开始按「描述符 + 关心的事件」思考,而不是按三个位图思考。

阻塞 read 一条连接→ 其他连接饿死‖ select / poll 等待集合→ 只处理就绪描述符
FIG.3 — 复用解决的是「同时等多个描述符」,不是「让单次读写更快」

就绪条件要比「有数据」更细

套接字可读,可能是有数据、对端 FIN(读返回 0)、或出错待取。套接字可写,只表示发送缓冲有空间,不保证一次能写完,更不保证对端还活着。监听套接字可读,表示已完成队列里有连接可accept。把这些条件混成「select 返回就可以 read」,会在半关闭和重置连接上反复踩坑。

和信号一起等:pselect 的位置。

服务器几乎总要同时等描述符和信号(子进程退出、优雅退出)。先检查「有没有信号」,再进入select ,中间存在窗口:信号来得稍早,就会在睡眠里被错过,直到下一次超时。pselect 把信号掩码和等待做成一次原子切换——进入等待时放开掩码,返回时再盖上。自己用「全局标志 + select」模拟,必须能讲清这个窗口,否则关机信号会变成「再等一个超时」。

时代注记:epoll 改的是扩展方式

UNP 第 3 版成书时,Linux epoll 还年轻,书中更多讨论 select、poll 以及当时的 /dev/poll、BSD kqueue。今天的注记只需记住 epoll 相对前两者的结构变化:用epoll_ctl 把兴趣集合留在内核,epoll_wait 只取回就绪列表,避免每次整表拷贝;水平触发近似 poll 的语义,边缘触发要求应用把数据一次抽干,漏一次就会沉寂。它没有取消「你仍要处理半关闭、错误和部分写」,只是让上万连接时等待本身不再先把 CPU 吃光。

水平触发可以当成「poll 的规模化」:缓冲里还有数据,就会一直报告可读。边缘触发是「从无到有才通知一次」,漏抽就会静默到下一次边缘。和边缘触发配套的,几乎总是非阻塞描述符,以及「读到 EAGAIN 为止」的循环。把阻塞套接字丢进边缘触发,是把两种等待模型叠罗汉,排障时最难讲清「它为什么不读了」。

select三集合 + 每次重填;容量受编译期上限;接口最老,教材价值最高 poll描述符数组,无那道上限;等待模型与 select 同类 epoll LT兴趣集留在内核;有数据就持续通知,语义最接近 poll epoll ET只在状态变化时通知;必须非阻塞并把缓冲抽干
易混淆:select 返回后若忘记重填 fd_set,下一轮等待的集合是被内核改过的残留,表现为「有的连接再也等不到」。这是教学代码里最高频的复用 bug,与性能无关。另一高频错误是把 maxfd 写成最大描述符本身,而不是「最大描述符加一」。
04

UDP 编程:保留边界,放弃会话

把回射服务器改成 UDP 之后,五个调用里的listen /accept /connect 不再是必选项。数据报自己带着对端地址,也自己带着消息边界——同时把可靠性、顺序和拥塞控制一并卸掉。

接口从流变成报文

UDP 服务器通常socket +bind,然后循环recvfrom /sendto。每次接收都得到一份完整数据报(或因接收缓冲不够被截断)以及发送方地址,回程必须显式写回这个地址。没有「连接」对象,也就没有按连接 fork 的自然单位;多数 UDP 服务是迭代式的,一个进程依次处理报文。

边界一次 recvfrom 对应对端一次 sendto,不会像 TCP 那样粘包 丢失发送成功只表示本机交出了数据报,不表示对端收到 截断应用缓冲小于数据报时,多余部分被丢弃,且不一定报错 连接型 UDPconnect 只在本端记下默认对端,此后可用 send/recv,并能收到异步错误

为什么还要给 UDP 做 connect

未连接的 UDP 套接字写往一个无人监听的端口,ICMP「端口不可达」回来时,内核很难把它匹配到「哪一次 sendto」——错误常常被默默丢掉。对套接字做一次connect 后,内核知道唯一对端,后续的异步错误可以在下一次读写时以错误码形式交给应用。它并不在网上建立会话,只是在本机绑死默认目的地,并能过滤不是这个对端发来的报文。

并发与校验和的现实约束

UDP 并发通常按「一个请求一份数据」来拆,而不是按连接拆:要么同进程串行处理,要么把请求丢进队列由工作线程领取。注意:同一 UDP 套接字被多线程同时recvfrom 时,报文不会被复制给每个人,只会给其中一个。校验和在 IPv4 上曾经可以关掉,在 IPv6 上是强制的;应用层若再自己做完整性检查,要先搞清自己信任哪一层。

UDP 没有发送窗口,内核发送缓冲满时sendto 会阻塞或在非阻塞下失败,对端慢或丢失不会反过来压住发送方。于是「能发出去」和「对方处理得过来」是两件事。广播、组播在 API 上看起来只是换一个目的地址,但套接字选项(是否允许广播、加入哪个组、出接口选哪张网卡)会先于报文内容决定你看不看得到包。回射练习里先把单播做稳,再打开这些选项,否则排障时分不清是地址写错还是网卡没加入组。

同一端口上的 TCP 与 UDP

端口号在 TCP 与 UDP 上是两套名字空间。一个进程可以在 7 号端口同时提供 TCP 回射和 UDP 回射,彼此不冲突。这解释了为什么「端口被占用」必须连协议一起说。反过来,想用同一个 UDP 套接字既做服务器又做客户,要小心默认目的地:connect 过之后,来自其他地址的报文会被滤掉。需要多对端时,保持未连接并用sendto /recvfrom 更干净。

超时必须自己做。 recvfrom 可以配套接字接收超时,或放到 select 里等可读。 没有超时的 UDP 客户会在丢包后永远睡死,看起来像服务器没开。 重试则要带报文标识,以免迟到的旧回答被当成新回答—— 这已经踏进「在应用层重造一小截可靠传输」,正是许多 RPC overlay 的起点。

TCP:listen · accept · 字节流‖ UDP:bind · recvfrom→ 报文边界在→ 可靠性不在
FIG.4 — UDP 回射不是「去掉握手的 TCP」,而是换了一种交付合同
易混淆:在回射里给每条 UDP 请求 fork 一个子进程,看起来对称于 TCP 并发服务器,其实是反模式——没有连接可继承,进程创建成本会被小报文淹没,错误路径也更难绑回原来的客户。
05

名字与地址转换

程序里写死点分十进制,只能做实验。真实服务要在主机名、服务名、IPv4 与 IPv6 之间来回翻译,而且必须线程安全。这一章的现代答案几乎收束成一对调用:getaddrinfo 与getnameinfo。

旧接口为什么退场

gethostbyname /gethostbyaddr 返回静态内存,线程之间互相覆盖;它们以 IPv4 为中心,对双栈不友好。数字地址转换里的inet_addr /inet_ntoa 同样带有静态缓冲或无法区分错误与广播地址的老问题。今天应把它们看成历史兼容层:知道它们存在,新代码不要再写。

getaddrinfo 返回的是一份菜单

getaddrinfo 吃主机名和服务名(或数字端口),吐出一条addrinfo 链表。每一节都带好了族、类型、协议和已经填好的 sockaddr。调用方按链表逐个socket +connect (客户)或socket +bind (服务),失败就试下一个。标志位里最常用的是:AI_PASSIVE 表示绑本地通配地址,AI_NUMERICHOST 禁止触发 DNS,AI_V4MAPPED 一类选项处理双栈映射——具体组合要以目标平台手册为准,笔记只保留「链表必须走完」这一条硬规则。

主机名 + 服务名→ getaddrinfo→ addrinfo 链表→ 逐个 socket→ connect 或 bind 成功即止
FIG.5 — 名字解析给出候选,真正选出地址的是后续系统调用

反向转换与呈现

getnameinfo 把已填好的sockaddr 变回可打印的主机与服务字符串,可要求「只给数字、不要查 PTR」,避免一次失败的反向 DNS 把日志路径堵住。inet_ntop /inet_pton 则只处理数值地址,不管端口,也不查名字。写日志、写配置、写访问控制名单时,这三层不要混用。

双栈服务器的最小礼貌

只监听 IPv4 或只监听 IPv6,都会在另一半客户那里表现为「端口没开」。常见做法是:对AF_UNSPEC 拿到的每个族分别bind ,或在支持的平台上用 IPv6 套接字并关掉IPV6_V6ONLY 以接收映射地址。无论哪条路,都不要假设链表第一项就是「最好的」——第一项只是解析器的排序结果,连通性要靠实际connect 验证。

名字解析本身也可能阻塞在一个线程里很久:DNS 超时、搜索域逐个拼接、不可达的解析器。事件驱动服务器若在循环里直接调用getaddrinfo ,等于把所有连接的复用锁在一次解析上。要么把解析丢到工作线程,要么用平台提供的异步解析,要么对数字地址走AI_NUMERICHOST 短路。书把解析器放在独立一章,就是为了阻止「套接字已经非阻塞了,名字还在同步查询」这种阴阳架构。

服务名、端口与协议不一致 时,解析成功仍可能 connect 失败。

getaddrinfo 的服务名可以是十进制端口,也可以是/etc/services 里的名字。指定SOCK_STREAM 还是SOCK_DGRAM 会过滤链表;两端若一个要流、一个要报,会在「解析成功但 connect 失败」处汇合。调试时把gai_strerror 的返回、链表每一节的族和类型打出来,比只看第一个sockaddr 的十六进制有用得多。

旧接口gethostbyname 一类返回静态内存,线程不安全,以 IPv4 为中心 数值转换inet_pton / inet_ntop 只管地址,不查 DNS、不含端口 正向解析getaddrinfo 给出带 sockaddr 的链表,按节尝试 反向呈现getnameinfo 可要求纯数字,避免 PTR 拖死日志
易混淆:把 getaddrinfo 的第一条结果直接当唯一地址,等于主动放弃故障转移。IPv6 不可达、某个任播节点静默、服务名对应多种套接字类型,都会让「只试一次」的客户显得像网络坏了。
06

五种 IO 模型与非阻塞

复用解决「等谁」,非阻塞解决「等的时候进程睡不睡」。UNP 把 UNIX 上常见的等待方式收成五种模型,用来给后续的服务器设计当词汇表,而不是给它们排座次。

五种模型只回答一个问题

阻塞 IO调用直到数据拷进用户缓冲才返回;实现简单,一个描述符就能卡住整个线程 非阻塞 IO内核若还没准备好,立即返回 EWOULDBLOCK / EAGAIN;应用自己重试或改去等事件 IO 复用先在 select / poll 上睡,就绪后再做一次(通常仍是阻塞的)读写 信号驱动内核在描述符就绪时发 SIGIO;信号处理里不宜做复杂逻辑,现代服务器很少当主路径 异步 IO投递读写请求后立即返回,完成时再通知;真正的「拷贝也在后台」,与非阻塞不是同一件事

关键对比:非阻塞是「现在不行就告诉我」;异步是「你去做,做完再叫我」。很多自称异步的框架,内核路径其实是「非阻塞 + 复用」。

非阻塞 connect 的正确姿势

把套接字设为非阻塞后再connect,握手来不及完成时返回EINPROGRESS。应用把该描述符放入写集合等待;就绪后必须用 getsockopt(...,SO_ERROR,...) 取出真正的结果——可写只表示「有下文」,可能是成功,也可能是复位或超时。把「select 说可写」直接当成连接建立,会把失败连接送进业务逻辑。

部分写与事件循环

非阻塞write 写满发送缓冲就会短写。事件循环必须记住「还剩哪些字节没送出」,等套接字再次可写再续。只对可读做复用、假设每次write 都能一次送完,会在回声变大、窗口变小或对端变慢时丢失数据。读路径同样如此:边缘触发下尤其要把缓冲抽到EAGAIN 为止。

每个连接因此不再是「一个描述符」,而是一份应用自己维护的对象:接收拼帧缓冲、发送未完成队列、当前该等读还是等写、半关闭进行到哪一步。书后半本的事件驱动服务器,本质上就是给这份对象写状态机。谁把状态机写回全局变量或静态缓冲,谁就会在第二个客户进来时踩掉第一个客户的半行。

非阻塞 accept 与惊群的交界

监听套接字也可以是非阻塞的:accept 在已完成队列为空时返回EWOULDBLOCK ,而不是睡过去。多个进程同时盯同一条监听套接字时,水平触发会让大家都看到「可读」,只有一个accept 成功,其余得到错误——这就是复用接口上的惊群。把它当成「偶发的 EAGAIN」处理好即可;当成致命错误去重启进程,会把正常竞争变成事故。

五种模型可以画在同一张时间轴上: 应用发出读请求的时刻、内核把数据拷进用户缓冲的时刻、应用被通知的时刻。 阻塞模型里这三个点黏在一次调用里; 非阻塞把「还没好」提前返回; 复用把「等」从读写调用里抽出来; 异步才把拷贝本身也从调用线程拿开。 分清这三根针,就不会再把「epoll 服务器」随口叫成「异步 IO」。

阻塞:睡在 read→ 复用:睡在 select→ 非阻塞:立即返回→ 事件循环续写未完成缓冲
FIG.6 — 从「调用阻塞」到「自己管理未完成 IO」
易混淆:非阻塞不会让链路变快,它只是把等待从内核睡眠改成应用自己调度。在没有事件循环的程序里盲目 O_NONBLOCK,得到的是空转轮询,CPU 占用会先于吞吐量上涨。
07

并发模型:fork、线程与预创建

回射服务器一旦要同时服务多个客户,就要在「一个进程一条连接」「一池进程抢连接」「一个进程管所有事件」之间做选择。UNP 后半本把这些设计并列出来,强调的是权衡,不是冠军。

每客户一子进程

accept 之后fork ,子进程继承已连接套接字,父进程继续听。地址空间隔离好,一个客户的崩溃不容易带走整个服务;代价是进程创建与上下文切换。父进程必须处理SIGCHLD 并循环 waitpid,否则退出的子进程会变成僵尸,耗尽进程表。这是书中最早给出、也最适合用来讲描述符引用计数的模型。

预创建进程与预创建线程

高峰到来再fork 往往来不及。预创建(prefork / prethread)在启动时就做好一池工作者,大家在accept 上竞争或通过一把锁串行化accept。旧内核上无锁的多进程accept 会惊群:一个连接到来,所有睡眠者被唤醒,只有一个得到连接。现代内核多半已经削弱这种现象,但「谁来 accept、锁放哪、连接如何分发」仍然是设计问题。线程池则共享地址空间,数据交换便宜,错误隔离更弱,可重入与信号安全变成日常功课。

accept + fork→ prefork 进程池→ 每客户一线程→ 预创建线程池→ 单进程事件驱动
FIG.7 — 五种常见服务器形态,从隔离走向共享、从阻塞走向事件

单进程事件驱动

一个进程用复用接口盯所有描述符,自己维护每条连接的缓冲与状态机。没有按客户的栈与页表,内存效率高,也没有锁。代价是:任何阻塞调用(磁盘、DNS、忘记设非阻塞的 write)都会让所有客户停摆;状态机写不好就会变成「回调里再回调」。它与线程池并不互斥——许多产品用事件循环收包,把重活丢给工作线程。

选择模型的三把尺

看连接数、看每连接工作量、看故障域。连接多而每次只是搬几个缓冲,事件驱动更合适;每连接要跑阻塞式业务或本地计算,进程/线程更自然;需要把客户之间的内存错误隔开,进程胜于线程。书里用同一套回射基准把它们摆在一起,是为了让你看见:性能数字会随操作系统与负载翻转,接口复杂度却是写下去就还不清的债。

预创建还要决定池有多大、要不要按负载伸缩、空闲工作者睡在accept 上还是睡在自己的任务队列上。池太小,连接在已完成队列里排队;池太大,缓存被跑冷,上下文切换把 CPU 吃光。锁若保护accept ,要把持锁时间减到一次调用;锁若保护共享业务表,就要承认线程模型已经不再「无共享」。这些不是附录,而是把第五章的回射服务器变成产品时必须写进设计说明的句子。

描述符在 fork 之后属于谁 必须单独核对。

子进程继承父进程打开的每一个描述符。标准做法是:子进程关掉监听套接字,父进程关掉刚接受的已连接套接字。任一侧少关一次,FIN 或监听口的生命周期就会偏离直觉——客户端已经离开,服务器的 read 却不返回 0,只因为父进程还握着那份引用。线程模型没有这份拷贝,却共享同一份描述符表,close 一次就是真的关。从 fork 换到线程,关闭协议要重写,不能只换创建函数的名字。

fork 每客户隔离好、实现短;创建贵,必须收割僵尸 prefork摊掉创建成本;要设计 accept 串行化与池大小 每客户一线程共享地址空间,数据交换便宜;可重入与故障域是账单 事件驱动一份状态机管所有连接;任何阻塞都会冻住全集
易混淆:「多线程一定比多进程快」和「事件驱动一定能撑百万连接」都是口号。未收割的僵尸、未上锁的 accept、在事件循环里调用了阻塞的名字解析,都能让纸面冠军在负载测试第一分钟倒下。
08

经典陷阱:SIGPIPE、TIME_WAIT、半关闭、SO_REUSEADDR

前七章的机制会在四个地点反复咬人。它们不是边角料,而是套接字程序从「能跑」到「能上线」的分界。

SIGPIPE:对已复位的套接字写

对端已经close 甚至送出 RST 之后,本端再write,内核无法投递,默认动作是给进程发SIGPIPE。该信号的默认处理是终止进程——HTTP 服务器里一个客户端提前断开,就能带走整个监听进程。工程上几乎总是忽略该信号,让write 返回EPIPE,再按连接拆除处理。注意:忽略信号是进程级策略,与「给某条连接单独处理错误」要一起做。

TIME_WAIT 与重启绑定

服务器若是主动关闭方(例如自己读到错误后先close),重启时会发现bind 同一端口失败,错误常常是地址已被占用。选项SO_REUSEADDR 的本意是:允许新套接字绑定到仍处于TIME_WAIT 的本地地址,让服务能够尽快起来。它不是「两个服务同时占用同一 TCP 端口」的许可证,更不是关掉TIME_WAIT 的开关。后起的SO_REUSEPORT 才是让多个套接字绑定同一地址并一起接受连接的扩展,语义因平台而异,不能从名字推断。

SIGPIPE写已关闭连接 → 默认杀进程;应忽略信号并处理 EPIPE TIME_WAIT主动关闭方的保护态;用长连接或调整谁先关,而不是删等待时间 半关闭shutdown(SHUT_WR) 送 FIN 但仍可读;close 是两端一起放弃 SO_REUSEADDR让重启的监听套接字越过 TIME_WAIT 完成 bind;不要当成端口复用银弹

半关闭是协议,不是优化

当应用需要「请求已写完」这一明确信号、而响应还没读完时,半关闭是干净的做法:写完后shutdown 写端,对端read 得到 0,知道请求结束,再写响应。UNIX 管道和部分老式请求/响应协议靠这个 EOF。HTTP/1.1 长连接改用长度或分块编码来界定消息,不再依赖半关闭,但套接字层面的能力仍在——尤其在你自己设计面向流的应用协议时。

写完请求→ shutdown SHUT_WR→ 对端读到 0→ 仍可读响应‖ 误用 close→ 后续到达变 RST
FIG.8 — 半关闭保留读路径;过早 close 会把迟到的数据变成复位

把陷阱串回主线

回射服务器几乎能演示上述全部事故:客户在服务器写回之前断开,服务器第二轮write 触发SIGPIPE ;服务器先关连接,自己堆满 TIME_WAIT ;父进程close 错了描述符,FIN 发得太早;重启时没设SO_REUSEADDR ,监听口半天起不来。把这四个现象从「玄学」还原成状态机与引用计数,这本书的工程部分才算读完。

还有两个不那么出名、但同样经典的坑。

字节序是第一章留下、却在排障时反复出现的暗钉:端口用了主机序、地址填成了字符串的内存映像、比较两个sockaddr 时忘了先转换。它们表现为「本机回环正常、跨机器不通」或「偶发连错端口」。另一枚钉是SO_LINGER :默认关闭语义是「把发送缓冲交给内核就返回」;打开 linger 并给出超时,close 会等到数据被确认或超时;把超时设成 0,内核改发 RST 并丢弃未发送数据。后一种能让主动关闭方避开TIME_WAIT,也让对端看到一次粗暴复位。它可以当实验室里观察 RST 的开关,不能当重启服务的常规手段。

把选项、信号、状态机和并发模型放在同一张清单上复查, 比把某一章的函数表背下来更接近这本书的读法。 卷 1 教的不是「会调用套接字」, 而是「能指出下一个失败会从哪一个调用、哪一个状态里冒出来」。

一份可随身携带的上线前清单: 忽略或处理 SIGPIPE; 监听套接字设置 SO_REUSEADDR; 读写循环处理短读写与 EINTR; fork 后收割子进程并关对描述符; 客户输入结束用半关闭而不是直接 close; 名字解析走 getaddrinfo 并遍历链表; 任何 write 失败都按拆除连接处理,而不是重试同一条已复位的套接字。 能在自己的回射服务器上把这七条勾完,深读档的工程部分就算合上了。

读完卷 1,建议立刻用同一份回射代码做三次改装: 第一次改成 select 版客户,验证半关闭; 第二次改成 UDP,补上超时与报文标识; 第三次改成非阻塞事件循环,逼自己写出每连接状态机。 三次改装共用同一组陷阱清单。 不必把书中每一章的函数表都抄下来—— 能指出下一次失败会从哪个调用、哪个状态冒出来,这本书就完成了任务。 站内的握手演示和排障清单可以当改装后的对照卷。 案例页里的服务端事故,多数也能在这八章里找到对应的调用或状态。 先对照案例,再回这一章看调用,比单向精读更不容易漏陷阱。 事故复盘往往比函数表更能记住半关闭和 SIGPIPE。

易混淆:用 SO_LINGER 设超时 0 强制 RST,可以把本端从 TIME_WAIT 里「解救」出来,同时丢掉未确认数据和关闭的可靠性。这是调试手段,不是生产默认。先分清谁该主动关闭,再考虑选项。

读后自测 · 10 题

1. 为什么 TCP 回射服务器不能假设「一次 read 就是一行」?
TCP 交付字节流,不保留应用消息边界。 一次 read 可能得到半行、一行、或多行拼接。 按行回射必须在应用层自己做缓冲与拆分, 这与 UDP「一次 recvfrom 一份数据报」正好相反。
2. accept 返回时,三次握手通常已经完成了吗?
是。握手由内核完成,成功的连接进入已完成队列, accept 只是把已建立的套接字交给进程。 应用阻塞在 accept 上,等的是队列里的连接,不是自己在发 SYN-ACK。
3. 为什么主动关闭的一方要进入 TIME_WAIT,而不是立刻释放端口?
两个最大报文段寿命的等待让迟到的旧重复段过期, 并给最后的 ACK 留出重传窗口,避免对端停在 LAST_ACK。 它是正确性机制。需要快速重启监听时, 用 SO_REUSEADDR 允许 bind,而不是删掉等待。
4. select 和 epoll 解决的是同一件事吗?差别在哪?
都解决「同时等待多个描述符就绪」。 select/poll 每次把整个兴趣集合拷进内核并可能线性扫描; epoll 把集合留在内核,等待时只取回就绪列表。 二者都不代替你处理半关闭、错误和部分写。
5. 给 UDP 套接字调用 connect 会在网上建立连接吗?
不会。它只在本端记下默认对端, 使后续可以用 send/recv,并让 ICMP 异步错误能回到这条套接字。 网上仍然没有握手、没有会话状态。
6. 非阻塞 connect 在 select 报告可写之后,为什么还要查 SO_ERROR?
可写只表示该套接字上有事件,可能是握手成功,也可能是复位或其它错误。 必须用 getsockopt 取 SO_ERROR 才能区分。 把可写直接当成 ESTABLISHED,会把失败连接送进业务。
7. fork 模型里父进程为什么必须处理 SIGCHLD?
子进程退出后若无人 wait,会留下僵尸表项,耗尽进程号。 父进程应捕获 SIGCHLD 并循环 waitpid,收割所有已退出的子进程。 这与「关对描述符以免泄漏 FIN」是两件独立的事。
8. 忽略 SIGPIPE 就够了吗?SO_REUSEADDR 能让两个服务器同端口监听吗?
忽略 SIGPIPE 只避免进程被杀,写操作仍会失败,必须按连接拆除。 SO_REUSEADDR 主要让新的监听套接字能绑定仍处于 TIME_WAIT 的地址; 传统栈上它并不表示两个独立 TCP 服务可以同时占用同一单播端口。 后者需要平台相关的 SO_REUSEPORT 一类扩展。
9. 朴素回射客户把标准输入重定向成文件时,为什么可能丢掉回声甚至触发复位?
客户可能先把整份输入写进发送缓冲,再去读回声; 输入结束若直接 close,未读完的回声会变成 RST。 正确做法是输入结束只 shutdown 写端,继续读到服务器 FIN。 这也是 select 版客户存在的原因。
10. fork 之后父进程不关已连接套接字,子进程不关监听套接字,各会导致什么?
描述符是引用计数。 父进程不关已连接套接字,子进程 close 后 FIN 发不出去,对端读不到 0。 子进程不关监听套接字,监听口的生命周期会拖到最后一个子进程退出, 重启绑定和优雅退出都会偏离预期。 关对描述符与收割僵尸是两件独立的事,必须都做。