五种 IO 模型与非阻塞 IO
本文介绍了五种IO模型及其核心概念,阐述了不同IO模型的特点与应用场景。
文章目录
1. 五种 IO 模型
1.1 阻塞 IO
定义:在内核将数据准备好之前,系统调⽤会⼀直等待,所有的套接字,默认都是阻塞⽅式。
阻塞 IO 是最常见的 IO 模型,如下图所示:

1.2 非阻塞 IO
定义:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK 错误码。
非阻塞 IO 往往需要程序员以循环的方式,反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用。
如下图所示:

1.3 信号驱动 IO
定义:内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行 IO 操作。
如下图所示:

1.4 IO 多路转接
定义:虽然从流程图上看起来和阻塞 IO 类似,实际上最核心在于 IO 多路转接能够同时等待多个文件描述符的就绪状态。
如下图所示:

1.5 异步 IO
定义:由内核在数据拷贝完成时,通知应用程序。注意,它和信号驱动有所不同,信号驱动 IO 是告诉应用程序何时可以开始拷贝数据。
如下图所示:

1.6 总结
任何 IO 过程中,都包含两个步骤:
- 第一步:等待
- 第二步:拷贝
- 而且在实际的应用场景中,【等待】消耗的时间往往都远远高于【拷贝】的时间,所以让 IO 更高效、最核心的办法就是让【等待】的时间尽量少。
2. 高级 IO 重要概念
2.1 同步通信 vs 异步通信
在 Linux 网络编程中,同步 / 异步 关注的是 消息通信机制(即调用方与内核、远程主机之间的交互方式)。
同步(Synchronous)
- 发出调用后,在没有得到结果之前,调用不会返回。
- 一旦返回,就能立即获得结果。
- 换句话说:调用者必须 主动等待 被调用者的结果。
例如:
recv(sockfd, buf, len, 0);
如果没有数据,这个调用会阻塞,直到有数据到达。这是典型的同步通信。
异步(Asynchronous)
- 发出调用后,调用立即返回,不会等待结果。
- 当操作真正完成时,被调用方通过事件、通知或回调函数告知调用方。
- 换句话说:调用者发出请求后继续做别的事,结果准备好后再通知它。
例如:
aio_read(&aiocb);
aio_read() 立即返回,读操作在后台完成后内核通过信号或回调通知用户,这是异步通信。
需要注意的是:同步通信 ≠ 进程 / 线程同步,这两个 “同步” 完全不是一个概念,千万不要混淆。
如下表所示:
| 分类 | 所在领域 | 含义 | 举例 |
|---|---|---|---|
| 同步通信 | 网络编程、I/O | 调用是否等待结果返回 | 同步 I/O、异步 I/O |
| 线程同步 | 并发控制 | 多线程间协调执行次序 | pthread_mutex_lock()、wait()/notify() |
线程同步的本质:
- 是进程或线程之间为了完成某种任务而建立的制约关系,
- 目的是让它们在某些位置上协调工作顺序或安全访问临界资源。
举例:
- 线程 A 必须等待线程 B 初始化完成后再执行(同步)。
- 多个线程不能同时修改同一全局变量(互斥)。
2.2 阻塞 vs 非阻塞
这两个概念关注的是:当程序发起 I/O 调用后,如果结果暂时不可得,当前线程的状态。
阻塞调用(Blocking I/O):当请求的数据还没准备好时,调用会一直等待(阻塞)直到数据到来或操作完成。
特点:
- 调用线程会被挂起,CPU 可切换去执行其他任务;
- 一旦数据准备好或操作完成,函数返回;
- 简单易用,但效率较低。
例如:
recv(sockfd, buf, len, 0); // 默认是阻塞的
如果没有数据到达,这个调用就会一直等,不返回。
非阻塞调用(Non-blocking I/O):当请求的数据暂时不可得时,调用立即返回,不等待,通常返回错误码 EAGAIN 或 EWOULDBLOCK。
特点:
- 调用线程不会被挂起;
- 程序需要自己 “轮询” 或配合
select/poll/epoll检查状态; - 更复杂,但能实现高并发。
例如:
fcntl(sockfd, F_SETFL, O_NONBLOCK); // 设置非阻塞
recv(sockfd, buf, len, 0); // 立即返回,无数据则返回 -1, errno = EAGAIN
对比总结:
| 类型 | 调用行为 | 是否等待结果 | 返回时机 | 优点 | 缺点 |
|---|---|---|---|---|---|
| 阻塞 I/O | 调用后等待数据 | 等待 | 数据准备好后返回 | 简单,逻辑清晰 | 线程被挂起,效率低 |
| 非阻塞 I/O | 调用后立即返回 | 不等待 | 立即返回 | 高并发性能好 | 需要轮询,逻辑复杂 |
一句话:阻塞是 “等结果”,非阻塞是 “马上走”。
2.3 理解四者的关系
我们来举个例子,有这样一个场景设定:
- 唐僧:要被吃的目标(任务目标)。
- 妖怪:发起 I/O 请求的一方(相当于应用程序)。
- 锅:内核空间(负责准备结果)。
- 蒸唐僧的过程:数据准备的过程(I/O 操作)。
妖怪要吃唐僧(拿到结果),但唐僧还没蒸好(数据未就绪)。于是,不同类型的妖怪就出现了
一、同步阻塞妖怪
妖怪把唐僧丢进锅里后,坐在锅边一直等着唐僧蒸熟。
💬 妖怪自言自语:“不蒸好我不走!我要看着!”
📌 特点:
- 调用后等待结果(同步)
- 在等待过程中自己也闲着不动(阻塞)
👉 对应:同步阻塞 I/O(最传统)
- 例如:recv() 等待数据到来。
二、同步非阻塞妖怪
妖怪把唐僧丢进锅里,蒸一会儿后掀开锅盖看看:“熟了吗?还没?那我再去看看。”
💬 妖怪不停轮询:“熟了没?熟了没?熟了没?”
📌 特点:
- 仍然要自己来确认是否蒸好(同步)
- 但每次检查不会傻等,检查不到就去干别的再回来(非阻塞)
👉 对应:同步 + 非阻塞 I/O
- 例如:设置
O_NONBLOCK的socket+ 轮询。
三、异步阻塞妖怪(很少见)
妖怪请了一个锅神(操作系统)帮忙蒸唐僧。妖怪说:“你蒸好后叫我!”,然后妖怪自己在一旁睡觉,不干别的事。
💬 锅神:“好嘞,熟了我叫你!”
📌 特点:
- 发出请求后不用自己检查(异步)
- 但仍然 “阻塞” 自己等待通知(阻塞)
👉 实际上这种情况不常见,但可以理解为:异步调用 + 被动等待回调。
四、异步非阻塞妖怪(高端妖)
妖怪让锅神蒸唐僧后,转身去抓猪八戒、编草帽、修洞府。
唐僧一蒸好,锅神主动通知:“妖大王,唐僧蒸熟啦!”
💬 妖怪:“好嘞!端上来!”
📌 特点:
- 发出请求就走(异步)
- 不会等待(非阻塞)
- 锅神完成任务后主动通知(回调 / 信号)
👉 对应:真正的异步 I/O(如 aio_read)
五、四者关系总结表
| 类型 | 等不等结果(同步 vs 异步) | 等的方式(阻塞 vs 非阻塞) | 妖怪行为 | 编程对应 |
|---|---|---|---|---|
| 同步阻塞 | 等结果 | 傻等 | 坐在锅边等唐僧蒸好 | recv() |
| 同步非阻塞 | 等结果 | 时不时看看 | 不停掀锅盖看熟没 | O_NONBLOCK + 轮询 |
| 异步阻塞 | 不主动查结果 | 睡觉等锅神叫醒 | 一直睡着等通知 | 异步 + wait |
| 异步非阻塞 | 不主动查结果 | 干别的事 | 让锅神蒸,熟了通知 | aio_read() 回调 |
最后一句话总结
- “同步 / 异步” 关注:谁来等结果(自己等 or 别人通知)。
- “阻塞 / 非阻塞” 关注:等的方式(傻等 or 干别的)。
2.4 其他高级 IO
非阻塞 IO,纪录锁,系统 V 流机制,I/O 多路转接(也叫 I/O 多路复用),readv 和 writev 函数以及存储映射
IO(mmap),这些统称为高级 IO。
我们重点讨论的是 I/O 多路转接。
3. 非阻塞 IO
我们知道,IO 操作可以理解为【等待 + 数据拷贝】。
当程序在进行网络通信或本地文件读取时,底层(如内核或设备)可能暂时还没有准备好数据,因此程序就需要【等待】。
这种【等待】就意味着时间被消耗在了 IO 上,也就是程序员或用户承担了这段时间成本。
为了更高效地利用这段等待时间,不让程序白白闲着,各种 IO 模型(如阻塞、非阻塞、IO 多路复用等)便应运而生。
3.1 阻塞 IO
所谓 阻塞 IO,是指当程序发起 IO 操作(如读取数据)时,如果数据尚未准备好,那么 进程会被阻塞。
被阻塞意味着程序暂时停止运行、进入睡眠状态,直到数据准备完成。
例如,当你调用 read() 读取文件时,如果文件缓冲区中没有数据,那么操作系统会将当前进程挂起,并将其加入该文件对应的 struct file 结构体中维护的等待队列。只有当数据准备好后,系统才会唤醒进程继续执行。
3.2 非阻塞 IO
而 非阻塞 IO 的行为正相反。
当我们将文件描述符设置为非阻塞模式后,程序在执行 read() 或 recv() 时,如果底层数据尚未准备好,系统调用不会等待,而是 立刻返回 一个结果(通常是错误码,如 EAGAIN 或 EWOULDBLOCK)。
这样,程序就可以根据返回结果自行决定下一步操作,比如稍后再次尝试读取,而不会因为等待数据而被挂起。
这种【不会因为条件不满足而进入等待】的 IO 模型,就是我们所说的 非阻塞 IO。
3.3 如何启用非阻塞 IO
要使用非阻塞 IO,我们需要显式地将文件描述符设置为非阻塞模式。
因为默认情况下,所有文件描述符都是阻塞的。
通常我们会通过 fcntl() 函数来修改文件描述符的属性,将其设置为非阻塞模式。
3.4 fcntl
一个文件描述符,默认都是阻塞 IO,函数原型如下:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的 cmd 的值不同,后面追加的参数也不相同。
fcntl 函数有 5 种功能:
- 复制一个现有的描述符(cmd = F_DUPFD)
- 获得/设置文件描述符标记(cmd = F_GETFD 或 F_SETFD)
- 获得/设置文件状态标记(cmd = F_GETFL 或 F_SETFL)
- 获得/设置异步 I/O 所有权(cmd = F_GETOWN 或 F_SETOWN)
- 获得/设置记录锁(cmd = F_GETLK、F_SETLK 或 F_SETLKW)
我们此处只是用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞。
3.5 实现函数 SetNoBlock
基于 fcntl,我们实现一个 SetNoBlock 函数,将文件描述符设置为非阻塞。
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>
#include <cstdio>
#include <vector>
#include <functional>
using namespace std;
void setNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
cerr << "fcntl : " << strerror(errno) << endl;
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
使用 F_GETFL 将当前的文件描述符的属性取出来(这是一个位图)。
然后再使用 F_SETFL 将文件描述符设置回去,设置回去的同时,加上一个 O_NONBLOCK 参数。
3.6 阻塞的情况
我们现在编写主函数,在实验开始之前,我们先来看一个阻塞的例子:
int main()
{
char buffer[1024];
while (true)
{
printf(">>> ");
fflush(stdout);
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if (s > 0)
{
buffer[s-1] = 0;
cout << "echo# " << buffer << endl;
}
else if (s == 0)
{
cout << "read end" << endl;
break;
}
else
{
//
}
}
return 0;
}
结果如下:

3.7 轮询方式读取标准输入
接下来,我们把非阻塞函数 SetNoBlock 添加进去:
结果如下,可以看到,我输入我的,程序打印它自己的,我们互不相干!
意味着我们当前在进行读取的时候呢,我们并不会在 0 号文件描述符处卡住,然后 read 系统调用会立马返回。那么立马返回之后,我们此时可以在代码空闲的时候可以做其他的工作。
比如我们写一个小 demo,在 SetNoBlock 函数中添加下面的代码:
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>
#include <cstdio>
#include <vector>
#include <functional>
using namespace std;
// 设置非阻塞的函数
void setNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
cerr << "fcntl : " << strerror(errno) << endl;
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
void printLog()
{
cout << "this is a log" << endl;
}
void download()
{
cout << "this is a download" << endl;
}
void executeSql()
{
cout << "this is a executeSql" << endl;
}
接着修改主函数:
#include "util.hpp"
using func_t = function<void ()>; // 设置回调函数
#define INIT(v) do{\
cbs.push_back(printLog);\
cbs.push_back(download);\
cbs.push_back(executeSql);\
} while(0)
#define EXEC_OTHER(cbs) do{\
for (auto const &cb : cbs) cb();\
} while(0)
int main()
{
vector<func_t> cbs;
INIT(cbs);
setNonBlock(0); // 设置非阻塞
char buffer[1024];
while (true)
{
printf(">>> ");
fflush(stdout);
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if (s > 0)
{
buffer[s-1] = 0;
cout << "echo# " << buffer << endl;
}
else if (s == 0)
{
cout << "read end" << endl;
break;
}
else
{
//
}
EXEC_OTHER(cbs);
sleep(1);
}
return 0;
}
结果如下,可以看到,我们自己封装了些接口,那么在空闲的时候,可以做这些其他的事情:

那么思考一下:
- 当我不输入的时候,底层没有数据,算错误吗?当然不算错误!只不过以错误的形式返回了。
- 那么我又如何区是真的错了?还是底层没有数据呢?很明显单纯靠返回值无法区分!
我们可以使用 man 2 read 查一下手册:

所以,我们修改一下主函数的代码:
#include "util.hpp"
using func_t = function<void ()>; // 设置回调函数
#define INIT(v) do{\
cbs.push_back(printLog);\
} while(0)
#define EXEC_OTHER(cbs) do{\
for (auto const &cb : cbs) cb();\
} while(0)
int main()
{
vector<func_t> cbs;
INIT(cbs);
setNonBlock(0); // 设置非阻塞
char buffer[1024];
while (true)
{
ssize_t s = read(0, buffer, sizeof(buffer)-1);
if (s > 0)
{
buffer[s-1] = 0;
cout << "echo# " << buffer << endl;
}
else if (s == 0)
{
cout << "read end" << endl;
break;
}
else
{
if (errno == EAGAIN || errno == EWOULDBLOCK) // 说明是非阻塞, 只是数据没有准备好
{
cout << "我没错, 只是没数据" << endl;
EXEC_OTHER(cbs);
}
else if (errno == EINTR) // 此时说明是 中断的系统调用, 那么就继续
{
continue;
}
else // 除了上面两种, 其余全是真正的错误
{
cout << "return value : " << s << ", errno : " << strerror(errno) << endl; // 可以看到返回值为-1
break;
}
}
sleep(1);
}
return 0;
}
结果如下:

更多推荐



所有评论(0)