第 61 章 Sockets: 高级主题 (Sockets: Advanced Topics)
核心结论
-
流式 socket 上的 partial read/write:
read/write都可能传输少于请求字节数(信号中断、非阻塞模式、异步错误);readn/writen包装循环自动恢复直到读完/写完。 -
shutdown(sockfd, how)vsclose():close关闭双向并依赖 fd 引用计数;shutdown直接关闭单向(SHUT_RD/SHUT_WR/SHUT_RDWR),无视其他 dup/fork 后的 fd 副本——这是「半关闭」(half-close)。 -
recv/send是 read/write 的 socket 增强版:额外flags参数支持MSG_DONTWAIT、MSG_PEEK、MSG_WAITALL、MSG_OOB、MSG_NOSIGNAL、MSG_MORE。 -
sendfile实现 zero-copy:内核直接把 regular file 拷到 socket,避免用户态往返;HTTP 服务器常配合TCP_CORK合并 header + body 到同一 TCP segment。 -
TCP 状态机与 TIME_WAIT:三次握手建连(SYN → SYN/ACK → ACK),四次挥手拆连(FIN/ACK/FIN/ACK);主动关闭方经历 TIME_WAIT(2×MSL = 60s on Linux)以 (1) 处理最后 ACK 丢失 (2) 让旧 segment 在网络中过期。
-
socket 选项
SO_REUSEADDR解决重启时报EADDRINUSE:默认内核不让绑定还在 TIME_WAIT 的端口;SO_REUSEADDR允许重用,避免绕开 TIME_WAIT 可靠性保证。
|
本章主旨
本章是 socket 编程「深水区」——前面第 56-60 章是基本功,本章解释常见误解和调试技能。读者需建立:(1) |
一、核心概念
本章围绕 6 个核心概念:partial I/O、shutdown、半关闭、socket 专用 I/O、TCP 状态机与 TIME_WAIT、SO_REUSEADDR。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
Partial reads / writes |
流式 socket 的 |
§61.1;TLPI Listing 61-1; |
|
|
§61.2; |
|
较 |
§61.3;SUSv3 仅 |
|
|
§61.4;Linux 2.4 起, |
TCP 状态机 / TIME_WAIT |
三次握手(SYN → SYN/ACK → ACK)→ ESTABLISHED;四次挥手(FIN/ACK/FIN/ACK);主动关闭方经历 TIME_WAIT(2×MSL),目的 (1) 重发最后 ACK if lost;(2) 让旧 segment 过期。Linux MSL=30s,TIME_WAIT=60s。 |
§61.6;其余状态:LISTEN、SYN_SENT、SYN_RECV、ESTABLISHED、FIN_WAIT1/2、CLOSING、CLOSE_WAIT、LAST_ACK、CLOSED;netstat |
SO_REUSEADDR / socket 选项 |
|
§61.9–61.10; |
二、详细笔记
61.1 流式 socket 的 partial read/write 与 readn/writen
What:流式 socket 是无 record-boundary 的字节流。read 可能返回少于请求的字节数(仅返回内核缓冲区已有的数据);write 可能写入少于请求的字节数(信号中断、非阻塞模式、异步错误:TCP peer 突然 RST 等)。
Why:应用协议如 HTTP、is_seqnum 都基于「按行读取」或「读够 N 字节」语义;若不显式循环,要么丢数据要么死锁。
How:TLPI Listing 61-1 的 readn/writen 循环:
// 摘自《The Linux Programming Interface》 第 61 章 Listing 61-1
#include <unistd.h>
#include <errno.h>
#include "rdwrn.h"
ssize_t readn(int fd, void *buffer, size_t n) {
ssize_t numRead; size_t totRead; char *buf;
buf = buffer;
for (totRead = 0; totRead < n; ) {
numRead = read(fd, buf, n - totRead);
if (numRead == 0) return totRead; /* EOF */
if (numRead == -1) {
if (errno == EINTR) continue; /* restart */
else return -1;
}
totRead += numRead;
buf += numRead;
}
return totRead;
}
ssize_t writen(int fd, const void *buffer, size_t n) {
ssize_t numWritten; size_t totWritten; const char *buf;
buf = buffer;
for (totWritten = 0; totWritten < n; ) {
numWritten = write(fd, buf, n - totWritten);
if (numWritten <= 0) {
if (numWritten == -1 && errno == EINTR) continue;
else return -1;
}
totWritten += numWritten;
buf += numWritten;
}
return totWritten;
}
When:(1) 任何「发送长度明确的二进制 blob」、「按固定长度解析」协议——使用 writen/readn;(2) 类似 MSG_WAITALL 能在单次系统调用阻塞直到收到 N 字节,但被信号打断它不自动重启。
Example:writen(cfd, &len, sizeof(len)); writen(cfd, payload, len); readn(cfd, &len, sizeof(len)); readn(cfd, payload, len); —— 长度前缀 + payload 是最常见的「定长协议」模式。
61.2 shutdown 系统调用与 half-close
What:shutdown(sockfd, how) 关闭 socket 双向通道中一个方向或两个方向;不受 dup 或 fork 出来的其它 fd 副本的影响——它作用在 open file description 上。
Why:很多协议需要「我已写完,但现在还想读你的回应」——典型如 HTTP 请求后等响应、rsh 远程命令、SSH 双向独立通信。close 必须等所有 fd 副本关闭才真正关连接,无法实现半关闭。
How:
| how | 语义 |
|---|---|
SHUT_RD |
关闭读半;之后 read 立即返回 0(EOF);TCP 上对端若继续写,本地仍能读到部分实现是有的(Linux 一致;但 BSD 不同),应用不建议跨平台用 SHUT_RD |
SHUT_WR |
关闭写半;对端读到 EOF;本地仍可读对端后续数据;触发 active close 序列(FIN) |
SHUT_RDWR |
关闭两个半(sequenced) |
#include <sys/socket.h>
int shutdown(int sockfd, int how);
shutdown 不关闭 fd 本身;fd 仍需 close()。
When:(1) client 发送完请求后等响应 —— shutdown(cfd, SHUT_WR) 让 server 看到 EOF 关连接,client 继续读响应;(2) daemon 重启 / 子进程退出;想发 EOF 但保留 fd 复用 —— shutdown(sfd, SHUT_WR)。
Example:TLPI Listing 61-2 is_echo_cl 中的 echo client:
// 摘自《The Linux Programming Interface》 第 61 章 Listing 61-2 (片段)
switch (fork()) {
case -1: errExit("fork");
case 0: /* Child: read echo server's response */
for (;;) {
numRead = read(sfd, buf, BUF_SIZE);
if (numRead <= 0) break;
printf("%.*s", (int) numRead, buf);
}
exit(EXIT_SUCCESS);
default: /* Parent: copy stdin to socket */
for (;;) {
numRead = read(STDIN_FILENO, buf, BUF_SIZE);
if (numRead <= 0) break;
if (write(sfd, buf, numRead) != numRead)
fatal("write() failed");
}
/* Close writing channel, so server sees EOF */
if (shutdown(sfd, SHUT_WR) == -1) errExit("shutdown");
exit(EXIT_SUCCESS);
}
61.3 socket 专用 I/O:recv/send 与 flags
What:recv(sockfd, buf, length, flags) 和 send(sockfd, buf, length, flags) 在 read/write 基础上加 flags 标志位实现 socket 特殊行为。
Why:很多 socket 行为不能仅靠 read/write 表达(如不要 SIGPIPE、non-stream peek、cork data 等)。
How:
| Flag | 方向 | 语义 |
|---|---|---|
MSG_DONTWAIT |
recv/send |
临时非阻塞 |
MSG_PEEK |
recv |
从 socket buffer peek 但不移走 |
MSG_WAITALL |
recv |
阻塞直到 length 字节就绪(被信号/EOF/OOB/error 打断) |
MSG_OOB |
recv/send |
带外数据(SUSv3) |
MSG_NOSIGNAL |
send |
对端关时不发 SIGPIPE(仅 Linux) |
MSG_MORE |
send |
cork 数据同 TCP segment 等;UDP 时聚合成一个 datagram |
When:(1) 想在一段代码中混合阻塞与非阻塞——MSG_DONTWAIT;(2) HTTP server 想确定请求是否完整又不想消耗数据——MSG_PEEK;(3) 写 handler 想防 SIGPIPE——signal(SIGPIPE, SIG_IGN) 或 MSG_NOSIGNAL;(4) 想优化 small HTTP 响应——MSG_MORE cork headers 后接 body。
Example:recv(sfd, buf, n, MSG_PEEK) 看但不取,再调 recv 真正取(用于协议分帧);MSG_WAITALL 等价 readn,但系统调用次数少且行为更严格。
61.4 sendfile 与 TCP_CORK 零拷贝传输
What:sendfile(out_fd, in_fd, offset, count) 把 regular file 直接由内核送 socket,不经用户空间。TCP_CORK socket 选项在 TCP 层 corks 后续 write 直到 unsets,形成「HTTP header + body 一个 segment」。
Why:(1) 普通 read+write 至少 2 次用户态 ↔ 内核态拷贝 + 2 次系统调用;(2) sendfile 一次系统调用、内核直接 page flip;HTTP server、文件下载、CDN 大幅提效。
How:sendfile 用法:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
/* out_fd: socket;in_fd: regular file(可 mmap);offset != NULL 时为 value-result。 */
/* TCP_CORK 配 HTTP: 头 + body 一个 segment */
int optval = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, &optval, sizeof(optval));
write(sockfd, headers, header_len);
sendfile(sockfd, body_fd, NULL, body_size);
optval = 0;
setsockopt(sockfd, IPPROTO_TCP, TCP_CORK, &optval, sizeof(optval));
Linux 2.4:out_fd 可以是 regular file(互拷两份文件);Linux 2.6 起不再支持;2.6.17 后增加 splice/tee/vmsplice 提供更通用 zero-copy;FreeBSD 上有 TCP_NOPUSH 等价物。
When:(1) 文件下载 / 静态资源服务器;(2) HTTP server 想要 header + body 一个 segment;(3) 不需要预读 / 处理 文件内容场景。
Example:图 61-1 左侧 read+write 浪费用户态 buffer;右侧 sendfile 直接内核 page cache → socket send buffer。
61.5 getsockname / getpeername 与连接后的地址
What:getsockname(sockfd, addr, *addrlen) 返回 socket 绑定的本地地址;getpeername 返回对端地址(限连接的 socket)。
Why:(1) 被 inetd 启动的程序无法用 accept 取对端地址,只能用 getpeername;(2) 想取得内核隐式 bind 给的 ephemeral port;(3) 调试 / 日志打印对端信息。
How:addrlen 是 value-result,传入 buf 大小、返回实际写入。
When:(1) 程序 accept 后想记录 client 信息或实现黑名单;(2) client 想确认自己连的是哪个 ephemeral port 或哪个 local IP;(3) port=0 隐式 bind 后想确认分到的实际 port。
Example:TLPI Listing 61-3 socknames.c 同时跑 listener+client+accept,从 netstat 也能看到两端端口号一致。
61.6 TCP 深度:状态机、TIME_WAIT
What:TCP 连接在内核里有离散状态——LISTEN / SYN_SENT / SYN_RECV / ESTABLISHED / FIN_WAIT1/2 / CLOSING / TIME_WAIT / CLOSE_WAIT / LAST_ACK / CLOSED。
Why:调试 TCP 应用必须能解释为什么「服务器重启报 EADDRINUSE」、「主动关闭方多耗时 2 MSL 才释放」、「为什么对端先 FIN 一段时间后才进入 CLOSED」——这些都来自状态机。
How:
| 阶段 | 主动关闭方(client 视角) |
|---|---|
被动关闭方(server 视角) |
数据传输 |
ESTABLISHED |
ESTABLISHED |
本端 close / 发 FIN |
FIN_WAIT1 → (收 ACK) → FIN_WAIT2 → (收 FIN) → TIME_WAIT → (2MSL) → CLOSED |
CLOSE_WAIT → close → LAST_ACK → (收 ACK) → CLOSED |
异常路径 |
FIN_WAIT1 同时收 FIN → CLOSING → (收 ACK) → TIME_WAIT |
(无) |
TIME_WAIT 两大作用: . 确保可靠终止:主动方发的最后 ACK 可能丢失;保留 socket 让它在 2MSL 期间重发最后 ACK,对端不会收到 RST 而把连接当错。 . 清掉旧 segment:避免同一 4-tuple(local-IP, local-port, foreign-IP, foreign-port)的新连接接收旧 connection 的延迟 segment,导致数据错乱。
Linux MSL = 30s(BSD 同理),TIME_WAIT = 60s;RFC 1122 推荐 MSL=120s,TIME_WAIT 可达 240s。
When:(1) 重启服务器报 EADDRINUSE —— 不是真的 TIME_WAIT 防错,而是默认内核禁止在 active state 存在时 bind;(2) 想要短重启间隔不撞端口 —— 用 SO_REUSEADDR;(3) 不要尝试 SO_LINGER l_linger=0 绕过 TIME_WAIT——会让 socket 不发 FIN 直接 RST,绕开可靠性。
Example:netstat -atn | grep TIME_WAIT | wc -l 看 server 关闭后的 TIME_WAIT 数;client 主动 close 后会用 ephemeral port 出现一次 TIME_WAIT。
61.7–61.8 netstat / tcpdump 调试
What:netstat 显示本机 socket 状态;tcpdump 抓包显示 TCP segment 序列号、SYN/FIN/RST 标志、ack、window 等。
Why:调试 TCP 应用必备工具——「为什么对端没收到数据」、「为什么 server close 后立刻 bind 失败」、「三次握手在哪一步中断」——都靠它们。
How:netstat -a --inet 列出全部 Internet socket,State 列指明 LISTEN / ESTABLISHED / TIME_WAIT 等;-p 看 pid;/proc/net/{tcp,udp,tcp6,udp6,unix} 也可读取。
tcpdump -tn 'port 55555' 抓某端口的包;每行 src > dst: flags data-seqno ack window urg <options>。
When:(1) 怀疑端口被占 → netstat -tlnp;(2) 怀疑三次握手失败 → tcpdump;(3) 想看 server TIME_WAIT 数 → netstat -t | grep TIME_WAIT | wc -l。
Example:TLPI 给的 TCP 连接建立 3 个 segment —— SYN、SYN/ACK、ACK,flags 列分别 S、S. (SYN+ACK)、. (ACK)。
61.9–61.10 socket 选项与 SO_REUSEADDR
What:getsockopt/setsockopt(sockfd, level, optname, &val, optlen) 读取 / 修改 socket 选项。level=SOL_SOCKET 是套接字级;IPPROTO_TCP 等对应协议级;SO_REUSEADDR 是套接字级选项。
Why:(1) SO_REUSEADDR —— 解决 server 重启 EADDRINUSE;(2) SO_KEEPALIVE —— 启用 TCP keepalive;(3) SO_RCVBUF/SO_SNDBUF —— 调整 buffer 大小;(4) SO_LINGER —— 控制 close 行为(一般不建议);(5) SO_BROADCAST —— 允许 UDP 广播;(6) SO_OOBINLINE —— 把 OOB 数据混入正常流。
How:
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname, void *optval,
socklen_t *optlen); /* optlen 是 value-result */
int setsockopt(int sockfd, int level, int optname, const void *optval,
socklen_t optlen); /* setsockopt optlen 是 value */
SO_TYPE 是只读;不能 set 类型。
-
4-元组
{local-IP, local-port, foreign-IP, foreign-port}必须唯一——但多数内核做了更严的「本地端口被任何 connection 占用时禁止 bind」,SO_REUSEADDR把规则放松到 TCP 规范。
When:(1) TCP server 启用 SO_REUSEADDR(accept 前)+ bind + listen;(2) 想调整 buffer 用 SO_RCVBUF/SO_SNDBUF;(3) 想探测已存在 socket 类型用 SO_TYPE。
Example:TLPI Listing 61-4 标准用法:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
bind(sockfd, &addr, addrlen);
listen(sockfd, backlog);
61.11 accept() 后的继承与 socket 高级特性
What:accept 返回的新 fd 在 Linux 上 不继承 listen fd 的 open-file status flags (O_NONBLOCK)、fd flags (FD_CLOEXEC)、F_SETOWN/F_SETSIG 信号驱动 I/O 关联;但 继承 大部分 socket 选项(SO_REUSEADDR 等)。SUSv3 对此没规定,跨实现行为有差异。
Why:可移植代码需要在 accept 后显式重设这些属性;TLPI 给出 fcntl 调用模式。
How:fcntl(cfd, F_SETFL, O_NONBLOCK); 在子进程里显式设非阻塞。
When:(1) 写可移植代码必须显式重设 O_NONBLOCK;(2) UDP 通知 / SIGURG 行为要在 accept 后重新 fcntl(F_SETOWN, getpid())。
Example:accept 后立刻 fcntl(cfd, F_SETFD, FD_CLOEXEC) 防 EXEC leak。
61.12 TCP vs UDP / 高级 topics
What:UDP 在某些场景比 TCP 更优——单次 RTT 节省、广播 / 多播、流式媒体偶尔丢包可容忍;但可靠性 / 流控 / 拥塞控制需应用自实现;不少场景还是 TCP 更稳。
Why:TCP best-case 时间 = 2×RTT + SPT(建连 → 请求 → 响应 → 拆连),UDP best-case = RTT + SPT;远距离 WAN 上 RTT 数百 ms,UDP 节省可观,但需要应用层重传。
How:
| 场景 | TCP 选择 | UDP 选择 |
|---|---|---|
一次性请求/响应 |
— |
DNS、SNMP 查询 |
长会话 / 大块传输 |
HTTP 文件传输、FTP、SSH |
— |
实时视频/音频 |
— |
RTP、游戏状态、直播 |
广播 / 多播 |
— |
DHCP、mDNS、TV 流 |
简单 RPC 重试机制 |
一般选 TCP 省事 |
QUIC 类重传机制可让 UDP 容忍丢包 |
高级话题:
. Out-of-band data:MSG_OOB 发送接收;TCP 实际只支持 1 byte 紧急(urgent pointer 指向 1 byte);现代建议用双 socket 替代。
. sendmsg/recvmsg:最通用 I/O,支持 scatter-gather + ancillary data (cmsg);UNIX 域上可传 fd、sender credentials。
. Passing file descriptors:UNIX 域 + SCM_RIGHTS 跨进程传 fd(master server → worker pool)。
. Sequenced-packet sockets:SOCK_SEQPACKET 结合 stream 可靠性 + dgram 边界;Linux 2.6.4 起 UNIX 域支持;Internet 上需 SCTP。
. SCTP / DCCP:SCTP 多 stream 通信;DCCP UDP-like + 拥塞控制。
When:(1) 写跨进程文件描述符传递——UNIX 域 sendmsg + SCM_RIGHTS;(2) 想 1 字节紧急信号——OOB;(3) 多流可靠传输——SCTP。
Example:TLPI 练习 61-5 建议 server 用 socket + dup2(STDOUT/STDERR) + execl 让 shell 命令输出回流 client —— 演示 child handle single connection 模式。
三、关键图表
|
关键系统调用与选项
|
|
TCP 状态
|
四、思维导图
mindmap
root((第 61 章 高级主题))
部分读写
readn writen
EINTR 重启
MSG_WAITALL
shutdown 半关闭
SHUT_RD
SHUT_WR
SHUT_RDWR
跨 fd 引用关闭
socket I/O flags
MSG_PEEK
MSG_DONTWAIT
MSG_NOSIGNAL
MSG_MORE
sendfile zero copy
文件 socket
TCP_CORK
splice tee
TCP 状态
三次握手
四次挥手
TIME_WAIT 2MSL
netstat tcpdump
socket 选项
SO_REUSEADDR
SO_TYPE 只读
accept 不继承 flags
inherit options
高级特性
OOB data
sendmsg recvmsg
传 fd credentials
SOCK_SEQPACKET
SCTP DCCP
五、重点与易错点
-
read()/write() 可能 partial——必须循环——除非明确只想要「任何字节」,否则都得用 readn/writen 或 MSG_WAITALL。
-
close() ≠ shutdown()——close 关闭整个 socket,受 fd 引用计数;shutdown 关闭通道,作用在 open file description。要半关闭必须 shutdown(SHUT_WR)。
-
SHUT_RD 在 TCP 上跨实现行为不一致——Linux / BSD 表现不同;除非必要不要在跨平台代码用 SHUT_RD。
-
MSG_PEEK 配合两次 recv 是常见协议分帧模式——看但不移走数据;不要忘记 MSG_PEEK 后第二次 recv 必须不传 MSG_PEEK 才真实消费。
-
MSG_NOSIGNAL 比 signal(SIGPIPE, SIG_IGN) 粒度更细——前者 per-call 控制;后者全局;某些库内部用前者避免影响调用方。
-
MSG_MORE 是 TCP_CORK 的 per-call 版本——都 cork 数据合 segment;要确保 finally 关 cork 否则数据不发出。
-
sendfile 只支持 file → socket——不能 socket ↔ socket、不能 socket → file(2.6 起);splice/tee 是更通用的 zero-copy 工具。
-
TCP TIME_WAIT 是设计而非缺陷——2MSL 让最后 ACK 可重发 + 让旧 segment 过期;不要试图关掉,SO_REUSEADDR 已可解决常见 bind 冲突。
-
EADDRINUSE 不一定来自 TIME_WAIT——也可能是其他活跃 TCP 占本地端口;用
netstat -tlnp查实际占用者。 -
SO_REUSEADDR 必须在 bind 前设置——在 listen 前调用 setsockopt;否则无效。
-
SO_LINGER 是陷阱多于价值——
l_linger=0时 close 直接 RST 不发 FIN,丢弃未发数据、破坏可靠性;生产代码几乎不用。 -
accept 后新 fd 不继承 O_NONBLOCK / FD_CLOEXEC——跨进程 / fork-per-client 设计必须在子进程显式重设。
-
OOB (MSG_OOB) 在 TCP 上只 1 byte——TCP 用 urgent pointer,紧急数据实际是「后面第 1 个 byte」语义;现代建议用双 socket。
-
splice + tee + vmsplice 是 sendfile 的现代替代——更灵活(fd ↔ pipe、pipe ↔ socket);研究 Linux 2.6.17+ 的这组系统调用。
-
netstat / tcpdump 必备工具——ESTABLISHED、TIME_WAIT、LISTEN 三种状态是日常调试核心;tcpdump 看 SYN/FIN 标志验证三次握手、四次挥手。
-
跨章衔接:第 59 章 IPv4/IPv6 地址结构与 getaddrinfo;第 60 章 fork-per-client 主动关闭后产生 TIME_WAIT;第 63 章备选 I/O 模型(select/poll/epoll)是对高负载服务器 model 的延伸;第 44 章管道与本节「partial read/write」互为对照(管道也是字节流)。