第4章:网络编程重难点解析

对齐原书第 4 章:TCP socket 流程、跨平台差异、阻塞/非阻塞 I/O、select/poll/epoll、EINTR/SIGPIPE、向量 I/O、字节序与域名解析。

学习目标

  • 能解释 socket/bind/listen/accept/connect/send/recv/shutdown/close 的 TCP 状态转换,并比较 Linux 与 Windows 返回值和错误码
  • 能分析 blocking/nonblocking send、recv、connect、EINTR、SIGPIPE、partial I/O 和 zero-byte operation,写出不丢数据的连接状态机
  • 能比较 select、poll、epoll LT/ET/EPOLLONESHOT,设计 readv/writev、network byte order 与 getaddrinfo 的可移植边界

机制总览

TCP 字节流与非阻塞连接状态机

  1. 1

    建立连接

    非阻塞 connect 通过可写事件和 SO_ERROR 确认结果。

  2. 2

    收发字节

    循环 read/write 到 EAGAIN,并由协议 decoder 消费完整 frame。

  3. 3

    关闭连接

    EOF、half-close、RST 与主动 close 对应不同状态迁移。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

TCP 字节流与非阻塞连接状态机

切换 I/O 阶段,检查 read/write、半关闭和事件通知的真实语义。

选择推理阶段

当前阶段 · 建立连接

非阻塞 connect 通过可写事件和 SO_ERROR 确认结果。

可核验证据

socket error、超时器与连接状态日志。

可靠网络代码不把一次系统调用等同于一条消息;所有分支都必须回到连接状态机和缓冲区不变量。

失效—证据矩阵

TCP 字节流与非阻塞连接状态机

建立连接

典型失效

看到 writable 就当成连接成功,忽略异步错误。

核验证据

socket error、超时器与连接状态日志。

收发字节

典型失效

短读短写、粘包或边缘触发未排空造成停滞。

核验证据

缓冲区游标、抓包和系统调用 trace。

关闭连接

典型失效

把 EOF 当临时无数据,或双边同时持有悬空请求。

核验证据

FIN/RST 抓包、pending request 清单与关闭时序。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

为什么网络 API 必须作为状态机学习

socket API 返回的不是“成功/失败”两个答案,而是连接状态、readiness、buffer progress 和 asynchronous error 的组合。一次 send 不保证发送完整 buffer,一次 recv 不对应一个消息,nonblocking connect 返回失败码也可能表示握手正在进行。

原书第 4 章从基础函数一路推进到 epoll、向量 I/O、字节序和 DNS,真正主线是:不把 byte stream 猜成 message,不把 readiness 猜成 operation success,不把 platform coincidence 猜成 portable contract。

4.1 应掌握哪些 socket 函数

Linux 先用 man 2 socketman 2 recvman 7 socket 区分 syscall 与协议选项;Windows 查 Winsock 文档并先 WSAStartup。核心函数族包括 endpoint 创建与配置、地址绑定、监听与接入、连接、数据传输、关闭、错误查询和地址解析。

socket / setsockopt / getsockopt
bind / listen / accept / connect
send / recv / sendto / recvfrom / readv / writev
shutdown / close (Linux) / closesocket (Windows)
select / poll / epoll_wait
getaddrinfo / freeaddrinfo

accept 返回新的 connected socket;listening socket 继续接收后续连接。把二者放进同一个“connection object”而不区分角色,会导致错误 close、错误 event registration 或把 listen error 当 session error。

4.2 TCP 通信基本流程

server 通常按 socket → setsockopt → bind → listen → accept,client 按 socket → connect,双方随后 send/recv,最后通过 shutdown/close 进入 half-close 或 full-close。TCP handshake 与 accept 是两个层次:kernel 可先完成握手并排队,应用之后才 accept

先预测:当 server 已 listen 但还没 accept 时,client 的 blocking connect 是否一定等待应用 accept?切换下面阶段,观察 kernel queue 与应用 fd 的边界。

分步1 / 3

沿 TCP endpoint 阶段检查角色

4.3 跨平台网络通信库的边界

Linux socket 是 integer file descriptor,失败常返回 -1 并设置 errno;Winsock 使用 SOCKET,无效值是 INVALID_SOCKET,调用失败后取 WSAGetLastError(),关闭用 closesocket。不要把 SOCKET 截断成 int

#ifdef _WIN32
using NativeSocket = SOCKET;
constexpr NativeSocket kInvalidSocket = INVALID_SOCKET;
int last_socket_error() { return WSAGetLastError(); }
#else
using NativeSocket = int;
constexpr NativeSocket kInvalidSocket = -1;
int last_socket_error() { return errno; }
#endif

Windows 旧式 select 的第一个参数会被忽略;POSIX 要传 maxfd + 1。Linux 的 would-block 可是 EAGAINEWOULDBLOCK;Winsock 对应 WSAEWOULDBLOCK。返回值适配必须先按平台解析,再转成统一 enum,不应直接搬运整数错误码。

4.4 bind:地址、端口与重启语义

绑定 127.0.0.1 只接受 loopback,绑定某个 interface address 限定网卡,0.0.0.0/:: 是 wildcard。production 服务要显式决定暴露范围;“为了能连上”绑定 wildcard 可能扩大攻击面。

端口 0 让 kernel 分配 ephemeral port,可用 getsockname 读取,适合测试。SO_REUSEADDR 的具体语义依 OS 而异,通常帮助 server 在旧连接处于 TIME_WAIT 时重启,但不等于允许任意多个 listener 抢同一地址;SO_REUSEPORT 又是独立策略。

sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(8080);
address.sin_addr.s_addr = htonl(INADDR_ANY);
if (::bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) < 0) {
  throw SocketError("bind", errno);
}

4.5 select:集合会被修改,复杂度与上限都要计算

selectfd_set 传入关注集合,返回时把它们改写为 ready subset,所以循环每轮必须从 master set 复制;timeout 也可能被修改。POSIX 还受 FD_SETSIZE 约束,并按 nfds 线性扫描。

fd_set readable = master_readable;
timeval timeout{.tv_sec = 1, .tv_usec = 0};
int n = select(max_fd + 1, &readable, nullptr, nullptr, &timeout);
if (n < 0 && errno == EINTR) continue;

Windows select 的 fd_set 实现更像 socket array,限制和 POSIX 不同。跨平台封装应暴露 readiness event,而不是假设底层 bitset layout 相同。

4.6 阻塞与非阻塞 send/recv

blocking socket 允许调用线程睡眠;它仍可能 partial send、被 signal 打断或因 timeout 失败。nonblocking socket 立即返回当前 progress;would-block 不是错误终态,而是“等待下一次对应 readiness”。

while (offset < bytes.size()) {
  ssize_t n = ::send(fd, bytes.data() + offset, bytes.size() - offset,
                     MSG_NOSIGNAL);
  if (n > 0) { offset += static_cast<std::size_t>(n); continue; }
  if (n < 0 && errno == EINTR) continue;
  if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    enable_write_interest(fd);
    break;
  }
  fail_connection(errno);
  break;
}

recv > 0 表示收到 bytes,recv == 0 表示 peer 发来 FIN 且 stream 已读到 EOF,recv < 0 再区分 EINTR、would-block 与 fatal error。不能把 0 当作“本轮暂无数据”。

分步1 / 3

先预测 nonblocking 返回值

4.7 发送 0 字节不是健康探测

长度为 0 的 stream send/recv 通常不会在 wire 上产生可供对端业务读取的 payload,返回值也不能证明 peer application 健康。TCP 存活判断应结合真实 I/O error、keepalive 或应用层 heartbeat;不要依赖 zero-byte send 的平台偶然行为。

4.8 nonblocking connect:writable 之后查询 SO_ERROR

nonblocking connect 在 Linux 常返回 -1/EINPROGRESS,Winsock 常返回 SOCKET_ERROR/WSAEWOULDBLOCK。这表示 handshake pending。把 fd 同时关注 writable/error;事件到来后调用 getsockopt(SOL_SOCKET, SO_ERROR),0 才成功,其他值是异步连接错误。

int error = 0;
socklen_t size = sizeof(error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &size) < 0 || error != 0) {
  fail_connect(error == 0 ? errno : error);
} else {
  on_connected(fd);
}

连接同时发送第一组数据可以减少状态切换,但必须等 connect completion 成功后进入 normal output buffer;不能因 writable 就直接丢弃 connect state。

4.9 接收第一组数据与连接状态

协议若由 server greeting 开始,client connect 后应关注 readable;若由 client request 开始,连接完成后 flush output buffer。把“连接建立”“首包发送”“首包接收”“鉴权完成”分成独立 states,timeout 也分别统计,才能定位 DNS、handshake 或 application stall。

4.10 查询当前可读字节数的局限

Linux ioctl(fd, FIONREAD, &bytes) 或 Windows ioctlsocket(FIONREAD) 可查询当前 receive buffer 中可读 bytes,但值立即可能变化,也不表示完整 frame 已到。对于 length-prefixed protocol,应先累积固定 header,再解析 declared length,最后等待完整 body。

int available = 0;
if (ioctl(fd, FIONREAD, &available) == 0) {
  metrics.observe_kernel_readable_bytes(available);
}
// parsing still uses the application receive buffer, not this snapshot

4.11 EINTR:重试前先看操作和 deadline

Linux blocking syscall 可能因 signal handler 返回 -1/EINTR。是否重试要看 operation 是否产生 partial progress、是否设置 SA_RESTART、deadline/cancellation 是否已到。无限 while (errno == EINTR) 会让 shutdown signal 无法终止等待。

4.12 SIGPIPE:写入已关闭连接的进程级风险

向已收到 RST/已关闭的 stream 写入,Linux 可能让 send 返回 EPIPE 并产生 SIGPIPE;默认 disposition 会终止进程。服务器可全局忽略 SIGPIPE,或每次 send 使用 MSG_NOSIGNAL,BSD/macOS 也有 SO_NOSIGPIPE。无论屏蔽方式如何,都必须处理 EPIPE 并关闭 connection state。

4.13 poll:去掉 fd_set,但仍是 level-triggered 扫描

poll 使用 pollfd[],没有 maxfd + 1FD_SETSIZE bitset 约束,返回 revents;输入 events 可保留。但每轮仍要把数组交给 kernel 并扫描 ready entries,规模大时成本随 fd 数增长。

std::vector<pollfd> entries = {{listen_fd, POLLIN, 0}, {client_fd, POLLIN, 0}};
int n = poll(entries.data(), entries.size(), timeout_ms);
for (auto& entry : entries) {
  if (entry.revents & (POLLERR | POLLHUP | POLLNVAL)) handle_error(entry.fd);
  if (entry.revents & POLLIN) handle_readable(entry.fd);
}

4.14 epoll:interest list、ready list 与触发模式

Linux epoll 用 epoll_create1 创建 instance,epoll_ctl 管理 interest,epoll_wait 取 ready events。它避免每轮重传完整 fd set,但应用仍需正确管理 fd lifetime、重复 registration 与 stale event。

分步1 / 3

改变 trigger 与本次读取量

epoll_waitpoll 都报告 readiness,不替你读取数据。ET 不天然更快;它减少重复通知的同时增加 drain loop、fairness budget 和 state complexity。单连接持续有大量数据时应限制每轮 bytes/messages,避免饿死其他 fd。

4.15 readv/writev:scatter/gather 仍要处理 partial progress

readv 把 stream bytes scatter 到多个 iovec,writev 把 header/body 等 gather 成一次 syscall。返回值是所有 vectors 合计完成的 bytes,可能停在某个 vector 中间;output queue 必须同时推进 vector index 与 intra-vector offset。

iovec vectors[2] = {
  {.iov_base = header.data(), .iov_len = header.size()},
  {.iov_base = body.data(), .iov_len = body.size()},
};
ssize_t n = writev(fd, vectors, 2);
output.consume(static_cast<std::size_t>(n));

4.16 主机字节序与网络字节序

network byte order 是 big-endian。16/32-bit integer 可用 htons/htonl/ntohs/ntohl;64-bit 与 floating point 应使用明确的 protocol encoding,不要直接发送 C++ struct,因为 padding、alignment、width、endianness 与 ABI 都可能变化。

std::uint32_t encode_u32(std::uint32_t host) {
  return htonl(host);
}
 
std::uint32_t decode_u32(std::uint32_t network) {
  return ntohl(network);
}

4.17 域名解析:不要只支持 IPv4

getaddrinfo(host, service, hints, &result) 同时返回 IPv4/IPv6 candidates,并把 DNS/service lookup 与 sockaddr construction 统一。遍历 candidates,逐个 socket/connect;保存解析与连接各自的 timeout/error。结束后 freeaddrinfo

addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo* result = nullptr;
int rc = getaddrinfo(host.c_str(), service.c_str(), &hints, &result);
if (rc != 0) throw ResolveError(gai_strerror(rc));

DNS 成功不代表 connect 成功;缓存 TTL、IPv6/IPv4 fallback、resolver thread blocking 和 cancellation 都要在调用层建模。不要用 gethostbyname 的 static storage 与 IPv4-only contract 构建新库。

本章回顾:返回值驱动连接状态

  1. listen socket、connected socket、application session 是三个不同 ownership 层。
  2. send/recv 的 partial、would-block、FIN、EINTR 与 fatal error 必须分支处理。
  3. nonblocking connect 在 writable 后读取 SO_ERROR,readiness 不是 success。
  4. select/poll/epoll 都只报告 readiness;ET 要 drain,ONESHOT 要 rearm。
  5. readv/writev、byte order 和 getaddrinfo 提升效率与可移植性,但不改变 stream/state-machine 基础。

练习

问题 1:nonblocking client 收到 writable event 后立即把 connection 标为 connected,并调用 send;为什么可能错误?写出正确检查链。

问题 2:ET 模式收到 12 bytes,只读 4 bytes 就返回 event loop。之后连接沉默,问题在哪里?LT 和 EPOLLONESHOT 又分别怎样处理?

问题 3:设计跨平台 send wrapper 时,哪些结果至少必须独立表达?为什么返回 bool 不够?

术语表

名词解释

本章出现的专业名词,用大白话再讲一遍。

socket state machine
listening socket
portable socket boundary
wildcard bind
I/O readiness
partial write
connect completion
EINTR policy
epoll LT
epoll ET
EPOLLONESHOT
vectored I/O
network byte order
getaddrinfo candidate list

资料与写作方式声明

本章以C++服务器开发精髓,第4章 网络编程重难点解析权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…