IO 模型

本文讨论“程序如何等待和读取网络数据”。select、poll、epoll、kqueue 的具体用法集中在《IO多路复用》笔记,避免重复。

理解一次网络读取

以 TCP 的 recv() 为例,读取通常包含两个阶段:

1
2
阶段一:等待内核接收缓冲区中出现可读取的数据
阶段二:把数据从内核缓冲区复制到用户提供的缓冲区

其中“等待”可能让线程睡眠,也可能立即返回;“复制”通常仍需要 CPU 执行。理解这两个阶段,才能区分阻塞、非阻塞、就绪通知和异步完成通知。

注意:TCP 是字节流。即使 recv() 返回“可读”,也只表示当前至少能读到一些数据或连接状态发生了变化,不表示收到了完整业务消息。消息边界应由应用协议处理。

同步 I/O 与异步 I/O

这组概念最容易与“阻塞/非阻塞”混淆。

概念 关键问题 含义
阻塞 / 非阻塞 调用会不会立刻返回 阻塞调用会等待条件满足;非阻塞调用立即返回结果或“暂时不可做”
同步 / 异步 谁负责完成实际 I/O 同步 I/O 中,应用在就绪后自己调用 recv() / send();异步 I/O 中,内核完成请求后再通知应用

例如:epoll_wait() 等待“某个 fd 可读”,随后程序自行 recv(),这仍是同步 I/O,只是等待阶段通过 I/O 多路复用实现。Windows IOCP 或 Linux io_uring 的完成通知式用法更接近异步 I/O。

阻塞 I/O

默认创建的 Socket 通常处于阻塞模式。

1
2
char buffer[4096];
const ssize_t n = recv(fd, buffer, sizeof(buffer), 0);

如果没有数据,recv() 会让当前线程睡眠;有数据、对端关闭或出错时才返回。

返回值 TCP 中的含义
> 0 实际读到的字节数
0 对端正常关闭了发送方向(收到 FIN)
-1 出错;若 errno == EINTR,通常可重试

阻塞 I/O 的优点是代码直观、CPU 不会因空转而浪费;缺点是一个慢客户端可能长期占用一个线程。它适合连接数量很少的工具、学习示例,或“每个连接独占一个工作线程”的服务器。

非阻塞 I/O

非阻塞 Socket 上,如果当前不能继续读写,调用不会等待,而是立即返回 -1,并把 errno 设为 EAGAIN 或 EWOULDBLOCK(两者可能是同一个值)。

1
2
3
4
5
6
7
8
9
#include <fcntl.h>

bool setNonBlocking(int fd) {
const int old_flags = fcntl(fd, F_GETFL, 0);
if (old_flags == -1) {
return false;
}
return fcntl(fd, F_SETFL, old_flags | O_NONBLOCK) != -1;
}

F_GETFL 读取现有状态标志,F_SETFL 写入新标志。必须保留原有标志并使用按位或加入 O_NONBLOCK,不能直接用 O_NONBLOCK 覆盖所有旧标志。

非阻塞读取的典型处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
char buffer[4096];
const ssize_t n = recv(fd, buffer, sizeof(buffer), 0);

if (n > 0) {
// 处理 buffer[0..n-1]
} else if (n == 0) {
// 对端关闭连接
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 当前没有可读取的数据;稍后再等待读事件
} else if (errno == EINTR) {
// 调用被信号中断;通常重试
} else {
// 真正的读取错误
}

仅仅把 Socket 设为非阻塞,再用循环不断调用 recv(),会形成忙轮询并占满 CPU。非阻塞 I/O 通常应和 I/O 多路复用配合:没有事件时线程休眠,有事件后才读写。

I/O 多路复用

I/O 多路复用让一个线程等待多个文件描述符的事件。典型过程是:

1
2
3
4
5
6
7
注册或准备关注的 fd
↓
等待任意 fd 变为可读、可写或出错
↓
对已就绪的 fd 调用 recv()/send()/accept()
↓
回到等待

它适合大量连接大部分时间空闲、偶尔有数据到来的场景,例如聊天服务器、网关、Web 服务器。常见接口是 select、poll、Linux epoll 和 BSD/macOS kqueue。

多路复用报告的是就绪(readiness),不是“完整 I/O 已完成”:

  • 可读:可能有数据、收到 FIN,或发生某些错误;仍须调用 recv() 判断。
  • 可写:通常表示内核发送缓冲区有空间;一次 send() 仍可能只发送部分数据。
  • 错误/挂起:应结合具体接口的错误标志、recv() 返回值和 getsockopt(SO_ERROR) 处理。

详细 API、触发模式及 C++ 示例见《IO多路复用》。

信号驱动 I/O

信号驱动 I/O 使用 SIGIO 等信号通知进程某个 fd 可能可读或可写。传统 Unix 可通过 fcntl() 设置 O_ASYNC 并指定接收信号的进程。

它的优点是等待时不必显式轮询;但信号处理受限、调试困难、可移植性与可维护性较差。现代网络服务器很少把它作为主要模型,通常优先使用 epoll、kqueue 或完成队列型异步 I/O。

异步 I/O

异步 I/O(AIO)的目标是:应用提交读写请求后立即继续执行,内核在数据传输完成后以完成事件通知应用。

1
2
3
4
5
6
7
应用提交异步读请求
↓
应用继续处理其他任务
↓
内核完成数据读取和拷贝
↓
完成队列 / 回调通知应用结果

它与 I/O 多路复用的区别在于:多路复用通知“现在可以自己读了”,异步 I/O 通知“此前提交的读已经完成,结果在这里”。

常见实现:

平台 机制 说明
Windows IOCP 高性能完成端口,典型 Proactor 风格
Linux io_uring 通过提交队列和完成队列支持多种异步操作
POSIX POSIX AIO 历史接口,网络场景使用相对有限

异步 I/O 可减少等待线程,但会引入请求生命周期、缓冲区所有权、取消、超时、并发回调和错误处理等复杂性。是否采用应根据平台、库支持和实际性能需求决定。

常见错误与处理原则

情况 含义 常见处理
EINTR 系统调用被信号中断 大多数情况下重试;同时遵守程序自身的退出逻辑
EAGAIN / EWOULDBLOCK 非阻塞 fd 暂时不能读或写 停止当前读写,等待下一次对应事件
recv() == 0 TCP 对端关闭发送方向 处理完已读数据后关闭或进入协议规定的收尾流程
send() 部分成功 只写入了部分字节 记录已发送位置,等待可写后继续发送剩余数据
可读后 recv() 失败 就绪状态与调用间可能被其他线程改变,或发生错误 按 errno 和连接状态处理,不要假定“可读必然有正文”

选择建议

场景 更合适的起点
学习、命令行小工具、连接数很少 阻塞 I/O
每个连接需要长时间阻塞式业务处理 阻塞 I/O + 有上限的线程池
大量空闲长连接,业务处理很短 非阻塞 I/O + 多路复用 + 事件循环
高吞吐、跨平台且对平台原生能力有要求 评估 IOCP、io_uring 或成熟网络库

无论选择哪种模型,都不能跳过协议解析、超时控制、资源限制和背压(下游处理不过来时限制继续读取/发送)这些基础问题。