本文主题内容
引言:网络通信中的很多性能问题,本质上并不是数据拷贝本身太慢,而是进程在等待数据到达。理解 IO 模型,就是理解操作系统如何通知进程数据已经就绪,以及数据最终由谁拷贝到用户空间。
一、理解 IO
1.1 一次 IO 的两个阶段
以网络读取为例,一次完整的 IO 通常包含两个阶段:
- 等待数据就绪:等待网卡接收数据,并由内核协议栈将数据放入 Socket 接收缓冲区。
- 将数据从内核空间拷贝到用户空间:read、recv 等系统调用把数据交给应用程序。
所以,一次 IO 的总时间可以简单理解为:
text
IO 时间 = 等待时间 + 数据拷贝时间
对网络服务器来说,等待时间往往比真正的数据拷贝时间更长。不同 IO 模型的主要区别,也集中在等待阶段和通知方式上。
1.2 用户空间与内核空间
应用程序不能直接访问网卡,也不能随意读取内核缓冲区。数据到达主机后,先由内核接收和处理,再通过系统调用拷贝到用户空间。
IO 就绪表示内核已经具备完成某类 IO 操作的条件,并不等于业务数据已经处理完成。
二、阻塞 IO
2.1 阻塞读取

默认情况下,大多数 Socket 都是阻塞的。当进程调用 recv,而接收缓冲区中没有数据时,进程会进入睡眠状态,直到出现以下情况之一:
- 有数据到达。
- 对端关闭连接。
- 发生错误。
- 系统调用被信号中断。
例:
cpp
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
//处理收到的数据
}
else if (n == 0)
{
//对端关闭连接
}
else
{
//读取失败
}
阻塞 IO 的优点是代码简单,调用返回时通常已经得到结果;缺点是一个执行流在等待期间不能处理其他连接。
2.2 阻塞不等于低效
阻塞只是当前执行流暂时不能继续运行。如果服务器使用多个线程,每个线程阻塞等待一个连接,在连接数量不大时仍然能够正常工作。
不过,当连接数量非常大、活跃连接比例又很低时,为每个连接创建一个线程会带来明显的线程切换和内存开销。
三、非阻塞 IO
3.1 非阻塞读取

将 Socket 设置为非阻塞后,如果数据尚未就绪,recv 不会让进程睡眠,而是立即返回 -1,并将 errno 设置为 EAGAIN 或 EWOULDBLOCK。
例:
cpp
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
//处理收到的数据
}
else if (n == 0)
{
//对端关闭连接
}
else if (errno == EAGAIN || errno == EWOULDBLOCK)
{
//当前没有数据,稍后再读
}
else if (errno == EINTR)
{
//被信号中断,可以重试
}
else
{
//发生真正的读取错误
}
3.2 轮询的问题
如果应用程序不断调用 recv 检查数据是否就绪,就形成了忙轮询:
cpp
while (true)
{
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
break;
}
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
{
break;
}
}
这种方式虽然不会阻塞在一次 recv 上,却会持续占用 CPU。实际开发中,非阻塞 IO 通常要与 select、poll、epoll 等事件通知机制配合使用。
四、IO 多路转接
4.1 基本思想

IO 多路转接让一个执行流同时等待多个文件描述符。当其中任意描述符就绪时,select、poll 或 epoll 返回,应用程序再对就绪描述符执行 read、recv、write 等操作。
它的核心不是让单次 IO 更快,而是减少无效等待,让一个执行流能够管理大量连接。
text
多个文件描述符 -> select、poll、epoll -> 返回就绪事件 -> 执行真正的 IO
4.2 多路转接仍属于同步 IO
select、poll、epoll 只负责告诉应用程序哪些描述符已经就绪。数据从内核缓冲区拷贝到用户缓冲区,仍然需要应用程序主动调用 recv 或 read 完成。
因此,IO 多路转接属于同步 IO。
五、信号驱动 IO

信号驱动 IO 允许进程先注册信号处理方式。当描述符就绪时,内核向进程发送 SIGIO 信号,进程再执行读取操作。
这种模型避免了持续轮询,但信号处理本身有较多限制:
- 信号处理函数中只能安全调用异步信号安全函数。
- 复杂业务逻辑不适合直接放在信号处理函数中。
- 信号合并、并发状态和错误处理会增加程序复杂度。
所以,网络服务器更常使用 epoll 等多路转接机制。
六、异步 IO

异步 IO 中,应用程序提交 IO 请求后可以继续执行其他任务。内核不仅负责等待数据就绪,还负责把数据拷贝到用户指定的缓冲区,最后再通知应用程序操作已经完成。
text
提交异步请求 -> 内核等待并完成数据拷贝 -> 通知应用程序处理结果
这和 epoll 的区别在于:
- epoll 通知的是描述符已经就绪,应用程序还要主动完成 IO。
- 异步 IO 通知的是 IO 操作已经完成。
七、阻塞、非阻塞、同步与异步
7.1 阻塞与非阻塞
阻塞和非阻塞描述的是:当调用暂时不能得到结果时,当前执行流是否停下来等待。
- 阻塞:调用暂时不能完成时,执行流进入等待状态。
- 非阻塞:调用暂时不能完成时,立即返回一个状态,执行流可以继续处理其他任务。
7.2 同步与异步
同步和异步描述的是:真正的数据拷贝由谁完成,以及完成后如何通知调用者。
- 同步 IO:应用程序主动调用 read、recv 等函数完成数据拷贝。
- 异步 IO:内核完成等待和数据拷贝,再通知应用程序。
注意:非阻塞 IO 不等于异步 IO,epoll 也不等于异步 IO。非阻塞 Socket 配合 epoll,依然属于同步 IO。
7.3 五种模型对比
| IO 模型 | 等待数据 | 数据拷贝 | 调用者是否可能等待 | 常见使用方式 |
|---|---|---|---|---|
| 阻塞 IO | 内核等待 | 应用程序发起 | 是 | 简单客户端、线程式服务器 |
| 非阻塞 IO | 应用程序反复检查 | 应用程序发起 | 单次调用不等待 | 通常配合事件通知 |
| IO 多路转接 | select、poll、epoll 等待 | 应用程序发起 | 等待多个描述符 | 高并发网络服务器 |
| 信号驱动 IO | 内核通过信号通知 | 应用程序发起 | 提交后可继续执行 | SIGIO |
| 异步 IO | 内核等待 | 内核完成 | 提交后可继续执行 | aio、io_uring 等机制 |
八、使用 fcntl 设置非阻塞
8.1 获取和设置文件状态标志
fcntl 可以获取和修改文件描述符的状态标志:
cpp
#include <fcntl.h>
bool SetNonBlock(int fd)
{
int flags = fcntl(fd, F_GETFL);
if (flags == -1)
{
return false;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1)
{
return false;
}
return true;
}
设置新标志时要保留原有 flags,再通过按位或加入 O_NONBLOCK。直接把状态设置为 O_NONBLOCK,可能会意外清除其他标志。
8.2 非阻塞读的完整处理
非阻塞模式下,一次 recv 不一定读取到全部数据,所以常见做法是循环读取,直到返回 EAGAIN 或 EWOULDBLOCK。
例:
cpp
bool ReadAll(int sockfd, std::string* input)
{
char buffer[4096];
while (true)
{
ssize_t n = recv(sockfd, buffer, sizeof(buffer), 0);
if (n > 0)
{
input->append(buffer, n);
continue;
}
if (n == 0)
{
return false;
}
if (errno == EINTR)
{
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
return true;
}
return false;
}
}
在边缘触发的 epoll 中,必须一直读取到 EAGAIN,才能保证已经到达的数据不会被遗漏。
九、总结
- 一次 IO 通常包含等待数据就绪和把数据拷贝到用户空间两个阶段。
- 阻塞 IO 代码简单,但一个执行流等待时不能处理其他任务。
- 非阻塞 IO 会在数据未就绪时立即返回,单独忙轮询会浪费 CPU。
- select、poll、epoll 可以让一个执行流等待多个文件描述符,但它们仍属于同步 IO。
- 信号驱动 IO 在就绪时发送信号,异步 IO 则由内核完成等待和数据拷贝。
- 阻塞与非阻塞关注调用是否等待,同步与异步关注 IO 由谁完成。
- fcntl 可以为文件描述符添加 O_NONBLOCK,实际读取时要正确处理 EINTR、EAGAIN 和 EWOULDBLOCK。