全书骨架:一条回射服务器走完全书
卷 1 的第 3 版仍以「套接字联网 API」为副标题。书很厚,但真正的骨架只有一条:先写出能工作的 TCP 回射,再问它在阻塞、复用、数据报、双栈、非阻塞和多进程下分别会怎样坏掉。后面每一章都是对同一条服务器的一次改装。
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, 比多背十个套接字选项更接近这本书的目标。 选项很多,真正反复咬人的就是这四条。 把它们讲给别人听,比对着手册默写函数原型记得更牢。
套接字基础与 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
:内核替它选临时端口,并完成主动打开。
回射为什么能当全书主线
回射把「读」和「写」绑在同一条连接上,又足够短,便于观察半关闭、缓冲与组帧。典型客户端从标准输入读一行,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,却能把第一章的结论钉死。
close 后引用计数仍大于零,FIN 发不出去,服务器会以为客户还在。TCP 连接管理与状态机
套接字调用之所以有时阻塞、有时立刻失败、有时返回「还在进行」,是因为它们卡在 TCP 状态机的不同位置。这一章把connect、accept、close 和shutdown 重新翻译成状态迁移。
握手如何映射到调用
客户端connect 发出 SYN,自己进入SYN_SENT ;服务器内核完成三次握手后,把连接挂进已完成队列,accept 只是把这份状态交给应用。accept 返回时,连接往往已经是ESTABLISHED
——应用以为自己「接受了连接」,其实内核早就替它握完了手。backlog 溢出时,新的 SYN 可能被丢弃,客户表现为超时而不是立刻被拒绝,排障时容易误判成网络故障。
关闭是两个方向的事
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 是常见的健康现象,不是服务器泄漏。IO 复用:select、poll 与现代 epoll 注记
一个进程往往要同时盯监听套接字、多条已连接套接字,以及标准输入。阻塞在任意一个 read 上,其他描述符就会饿死。复用把「等哪一个可读可写」从多次阻塞调用收成一次等待。
select:最老、也最能暴露代价
select
接收三组描述符集合(读、写、异常)和一个可选超时。返回后集合被改成「就绪的那些」,所以每次进入内核前必须重填。它的硬伤几乎都写在接口上:描述符用位图表示,容量受FD_SETSIZE 一类编译期上限约束;内核要扫描整张位图;应用还要自己记住「最大描述符加一」。异常集合在 TCP 里主要对应带外数据,日常服务器很少用对。
poll:换掉位图,不换模型
poll 用pollfd
数组代替位图,不再受那道编译期上限,事件掩码也更清楚(普通可读、挂起、错误分开)。代价模型没变:每次调用仍要把整表拷进内核,内核仍可能做线性扫描。它适合中等规模的描述符集合,也是理解后续接口的台阶——你开始按「描述符
+ 关心的事件」思考,而不是按三个位图思考。
就绪条件要比「有数据」更细
套接字可读,可能是有数据、对端 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 返回后若忘记重填 fd_set,下一轮等待的集合是被内核改过的残留,表现为「有的连接再也等不到」。这是教学代码里最高频的复用 bug,与性能无关。另一高频错误是把 maxfd 写成最大描述符本身,而不是「最大描述符加一」。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 的起点。
fork 一个子进程,看起来对称于 TCP 并发服务器,其实是反模式——没有连接可继承,进程创建成本会被小报文淹没,错误路径也更难绑回原来的客户。名字与地址转换
程序里写死点分十进制,只能做实验。真实服务要在主机名、服务名、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
一类选项处理双栈映射——具体组合要以目标平台手册为准,笔记只保留「链表必须走完」这一条硬规则。
反向转换与呈现
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 不可达、某个任播节点静默、服务名对应多种套接字类型,都会让「只试一次」的客户显得像网络坏了。五种 IO 模型与非阻塞
复用解决「等谁」,非阻塞解决「等的时候进程睡不睡」。UNP 把 UNIX 上常见的等待方式收成五种模型,用来给后续的服务器设计当词汇表,而不是给它们排座次。
五种模型只回答一个问题
EWOULDBLOCK / EAGAIN;应用自己重试或改去等事件
IO 复用先在 select / poll 上睡,就绪后再做一次(通常仍是阻塞的)读写
信号驱动内核在描述符就绪时发 SIGIO;信号处理里不宜做复杂逻辑,现代服务器很少当主路径
异步 IO投递读写请求后立即返回,完成时再通知;真正的「拷贝也在后台」,与非阻塞不是同一件事
关键对比:非阻塞是「现在不行就告诉我」;异步是「你去做,做完再叫我」。很多自称异步的框架,内核路径其实是「非阻塞 + 复用」。
非阻塞 connect 的正确姿势
把套接字设为非阻塞后再connect,握手来不及完成时返回EINPROGRESS。应用把该描述符放入写集合等待;就绪后必须用
getsockopt(...,SO_ERROR,...) 取出真正的结果——可写只表示「有下文」,可能是成功,也可能是复位或超时。把「select
说可写」直接当成连接建立,会把失败连接送进业务逻辑。
部分写与事件循环
非阻塞write 写满发送缓冲就会短写。事件循环必须记住「还剩哪些字节没送出」,等套接字再次可写再续。只对可读做复用、假设每次write
都能一次送完,会在回声变大、窗口变小或对端变慢时丢失数据。读路径同样如此:边缘触发下尤其要把缓冲抽到EAGAIN 为止。
每个连接因此不再是「一个描述符」,而是一份应用自己维护的对象:接收拼帧缓冲、发送未完成队列、当前该等读还是等写、半关闭进行到哪一步。书后半本的事件驱动服务器,本质上就是给这份对象写状态机。谁把状态机写回全局变量或静态缓冲,谁就会在第二个客户进来时踩掉第一个客户的半行。
非阻塞 accept 与惊群的交界
监听套接字也可以是非阻塞的:accept 在已完成队列为空时返回EWOULDBLOCK
,而不是睡过去。多个进程同时盯同一条监听套接字时,水平触发会让大家都看到「可读」,只有一个accept
成功,其余得到错误——这就是复用接口上的惊群。把它当成「偶发的 EAGAIN」处理好即可;当成致命错误去重启进程,会把正常竞争变成事故。
五种模型可以画在同一张时间轴上: 应用发出读请求的时刻、内核把数据拷进用户缓冲的时刻、应用被通知的时刻。 阻塞模型里这三个点黏在一次调用里; 非阻塞把「还没好」提前返回; 复用把「等」从读写调用里抽出来; 异步才把拷贝本身也从调用线程拿开。 分清这三根针,就不会再把「epoll 服务器」随口叫成「异步 IO」。
O_NONBLOCK,得到的是空转轮询,CPU 占用会先于吞吐量上涨。并发模型:fork、线程与预创建
回射服务器一旦要同时服务多个客户,就要在「一个进程一条连接」「一池进程抢连接」「一个进程管所有事件」之间做选择。UNP 后半本把这些设计并列出来,强调的是权衡,不是冠军。
每客户一子进程
accept 之后fork
,子进程继承已连接套接字,父进程继续听。地址空间隔离好,一个客户的崩溃不容易带走整个服务;代价是进程创建与上下文切换。父进程必须处理SIGCHLD 并循环
waitpid,否则退出的子进程会变成僵尸,耗尽进程表。这是书中最早给出、也最适合用来讲描述符引用计数的模型。
预创建进程与预创建线程
高峰到来再fork 往往来不及。预创建(prefork / prethread)在启动时就做好一池工作者,大家在accept
上竞争或通过一把锁串行化accept。旧内核上无锁的多进程accept
会惊群:一个连接到来,所有睡眠者被唤醒,只有一个得到连接。现代内核多半已经削弱这种现象,但「谁来
accept、锁放哪、连接如何分发」仍然是设计问题。线程池则共享地址空间,数据交换便宜,错误隔离更弱,可重入与信号安全变成日常功课。
单进程事件驱动
一个进程用复用接口盯所有描述符,自己维护每条连接的缓冲与状态机。没有按客户的栈与页表,内存效率高,也没有锁。代价是:任何阻塞调用(磁盘、DNS、忘记设非阻塞的 write)都会让所有客户停摆;状态机写不好就会变成「回调里再回调」。它与线程池并不互斥——许多产品用事件循环收包,把重活丢给工作线程。
选择模型的三把尺
看连接数、看每连接工作量、看故障域。连接多而每次只是搬几个缓冲,事件驱动更合适;每连接要跑阻塞式业务或本地计算,进程/线程更自然;需要把客户之间的内存错误隔开,进程胜于线程。书里用同一套回射基准把它们摆在一起,是为了让你看见:性能数字会随操作系统与负载翻转,接口复杂度却是写下去就还不清的债。
预创建还要决定池有多大、要不要按负载伸缩、空闲工作者睡在accept 上还是睡在自己的任务队列上。池太小,连接在已完成队列里排队;池太大,缓存被跑冷,上下文切换把
CPU 吃光。锁若保护accept
,要把持锁时间减到一次调用;锁若保护共享业务表,就要承认线程模型已经不再「无共享」。这些不是附录,而是把第五章的回射服务器变成产品时必须写进设计说明的句子。
描述符在 fork 之后属于谁 必须单独核对。
子进程继承父进程打开的每一个描述符。标准做法是:子进程关掉监听套接字,父进程关掉刚接受的已连接套接字。任一侧少关一次,FIN 或监听口的生命周期就会偏离直觉——客户端已经离开,服务器的
read 却不返回 0,只因为父进程还握着那份引用。线程模型没有这份拷贝,却共享同一份描述符表,close 一次就是真的关。从 fork
换到线程,关闭协议要重写,不能只换创建函数的名字。
经典陷阱: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
才是让多个套接字绑定同一地址并一起接受连接的扩展,语义因平台而异,不能从名字推断。
EPIPE
TIME_WAIT主动关闭方的保护态;用长连接或调整谁先关,而不是删等待时间
半关闭shutdown(SHUT_WR) 送 FIN 但仍可读;close 是两端一起放弃
SO_REUSEADDR让重启的监听套接字越过 TIME_WAIT 完成 bind;不要当成端口复用银弹
半关闭是协议,不是优化
当应用需要「请求已写完」这一明确信号、而响应还没读完时,半关闭是干净的做法:写完后shutdown 写端,对端read 得到
0,知道请求结束,再写响应。UNIX 管道和部分老式请求/响应协议靠这个 EOF。HTTP/1.1
长连接改用长度或分块编码来界定消息,不再依赖半关闭,但套接字层面的能力仍在——尤其在你自己设计面向流的应用协议时。
把陷阱串回主线
回射服务器几乎能演示上述全部事故:客户在服务器写回之前断开,服务器第二轮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 里「解救」出来,同时丢掉未确认数据和关闭的可靠性。这是调试手段,不是生产默认。先分清谁该主动关闭,再考虑选项。