**摘要:**本文通过生动的场景假设,深入浅出地讲解Linux系统中的五种主要IO模型:阻塞IO、非阻塞IO、信号驱动IO、IO多路复用(select/poll/epoll)。文章结合代码示例和对比表格,帮助读者理解不同IO模型的工作原理、优缺点及适用场景。
一、引言:理解IO模型的重要性
在Linux系统编程中,IO(输入/输出)操作是程序与外部世界交互的基础。不同的IO模型对程序的性能、资源消耗和并发处理能力有着决定性影响。理解各种IO模型的工作原理,对于编写高效、稳定的网络服务和系统程序至关重要。
二、场景假设一:妈妈与孩子的故事

假设妈妈有一个孩子,孩子在房间里睡觉,妈妈需要及时获知孩子是否醒了,如何做?
- **妈妈在房间待着,和孩子一起睡:**妈妈不累,但是不能干其他的事情
- **时不时的看一下孩子,其他时间可以干一些其他的事情:**累,但是可以干其他的事情
- **妈妈在客厅干活,听孩子是否哭了:**二者互不耽误
这三种方式分别对应了三种不同的IO模型:
- 方式1:阻塞IO - 妈妈一直等待,不能做其他事
- 方式2:非阻塞IO - 妈妈定期检查,可以兼顾其他事但很累
- 方式3:信号驱动IO - 孩子醒了主动通知妈妈
三、阻塞式IO(Blocking IO)
阻塞IO模式是最普遍使用的I/O模式,大部分程序使用的都是阻塞模式的I/O。
学习的读写函数在调用过程中会发生阻塞相关函数如下:
读操作中的read
读阻塞 → 需要读缓冲区中有数据可读,读阻塞解除
写操作中的write
写阻塞 → 阻塞情况比较少,主要发生在写入的缓冲区的大小小于要写入的数据量的情况下,写操作不进行任何拷贝工作,将发生阻塞,一旦缓冲区有足够的空间,内核将唤醒进程,数据从用户缓冲区拷贝到相应的发送数据缓冲区。
阻塞IO的特点
- **同步性:**同步
- **描述:**调用IO操作的线程会被阻塞,直到操作完成
- **优点:**编程简单,CPU利用率高(等待时不占用CPU)
- **缺点:**一个线程只能处理一个IO,并发能力差
- **适用场景:**小规模IO操作,对性能要求不高
四、非阻塞IO(Non-blocking IO)
当我们设置为非阻塞模式,我们相当于告诉系统内核:"当我请求IO操作不能够马上完成,你想让我的进程进行休眠等待的时候,不要这么做。请马上返回一个错误给我。"
当一个应用程序使用了非阻塞模式的套接字,它需要使用一个循环来不停的测试是否一个文件描述符有数据可读(称作polling)。
应用程序不停的polling内核来检查是否IO操作已经就绪,这将是一个极浪费CPU资源的操作。
这种模式使用中不普遍。
4.1 通过函数自带参数设置

4.2 通过设置文件描述符的属性设置非阻塞
fcntl函数详解
cpp
int fcntl(int fd, int cmd, ... /* arg */ );
功能:设置文件描述符属性
参数:
fd: 文件描述符
cmd:设置方式 - 功能选择
F_GETFL 获取文件描述符的状态信息 第三个参数忽略
F_SETFL 设置文件描述符的状态信息 通过第三个参数设置
O_NONBLOCK 非阻塞
O_ASYNC 异步
O_SYNC 同步
arg: 设置的值 (in)
返回值:
特殊选择返回特殊值 - F_GETFL 返回的状态值(int)
其他:成功0 失败-1,更新errno
使用示例(以标准输入0为例):
0-原本:阻塞、读权限 修改或添加非阻塞
示例代码:设置标准输入为非阻塞模式
cpp
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char const *argv[])
{
char buf[32] = {};
// 读/改/写三步法设置非阻塞
// 1. 获取文件描述符的属性
int flag = fcntl(0, F_GETFL);
// 2. 修改文件描述符的属性(添加非阻塞标志)
flag |= O_NONBLOCK;
// 3. 设置文件描述符属性
fcntl(0, F_SETFL, flag);
// 测试非阻塞读取
while (1)
{
if (fgets(buf, sizeof(buf), stdin) == NULL)
printf("读取失败(非阻塞模式下无数据可读)\n");
else
printf("读取成功:%s\n", buf);
sleep(1);
}
return 0;
}
恢复阻塞模式
**注意:**如果需要恢复阻塞模式,可以按以下方式设置:
cpp
// 清除非阻塞标志
flag &= ~O_NONBLOCK;
// 重新设置文件描述符属性
fcntl(0, F_SETFL, flag);
非阻塞IO的特点
- **同步性:**非同步(异步轮询)
- **描述:**调用IO操作时,如果不能立即完成操作,会立即返回,线程可以继续执行其他操作
- **优点:**可以同时处理多路IO,提高并发能力
- **缺点:**轮询方式耗费CPU资源,效率较低
- **适用场景:**高并发网络服务器,减少线程阻塞时间
五、信号驱动IO(Signal-driven IO)
5.1 异步通知机制
**异步通知:**异步通知是一种非阻塞的通知机制,发送方发送通知后不需要等待接收方的响应或确认。通知发送方可以继续执行其他操作,而无需等待接收方处理通知。
5.2 工作原理
- 通过信号方式,当内核检测到设备数据后,会主动给应用发送信号SIGIO
- 应用程序收到信号后做异步处理即可
- 应用程序需要把自己的进程号告诉内核,并打开异步通知机制
5.3 示例代码:鼠标事件信号驱动
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
int fd;
// 信号处理函数
void handler(int sig)
{
char buf[32] = {0};
read(fd, buf, sizeof(buf));
printf("mouse事件: %s\n", buf);
}
int main(int argc, char const *argv[])
{
// 打开鼠标设备文件
fd = open("/dev/input/mouse1", O_RDONLY);
if (fd < 0)
{
perror("open err");
return -1;
}
// 1. 设置将文件描述符和进程号提交给内核驱动
// 一旦fd有事件响应,则内核驱动会给进程号发送一个SIGIO的信号
fcntl(fd, F_SETOWN, getpid());
// 2. 设置异步通知
int flags;
flags = fcntl(fd, F_GETFL); // 获取原属性
flags |= O_ASYNC; // 给flags设置异步 O_ASYNC通知
fcntl(fd, F_SETFL, flags); // 修改的属性设置进去,此时fd属于异步
// 3. signal捕捉SIGIO信号 --- SIGIO:内核通知进程有新的IO信号可用
// 一旦内核给进程发送SIGIO信号,则执行handler
signal(SIGIO, handler);
// 主程序可以继续执行其他任务
while (1)
{
printf("主程序正在运行...\n");
sleep(1);
}
return 0;
}
5.4 信号驱动IO的特点
- **同步性:**异步
- **描述:**当IO操作可以进行时,内核会发送信号通知进程
- **优点:**不需要轮询,CPU利用率高
- **缺点:**需要底层驱动的支持,编程复杂度较高
- **适用场景:**实时性要求高的应用,避免轮询开销
六、三种IO模型对比
| 对比项 | 阻塞IO(Blocking IO) | 非阻塞IO(Non-blocking IO) | 信号驱动IO(Signal-driven IO) |
|---|---|---|---|
| 同步性 | 同步 | 非同步 | 异步 |
| 描述 | 调用IO操作的线程会被阻塞,直到操作完成 | 调用IO操作时,如果不能立即完成操作,会立即返回,线程可以继续执行其他操作 | 当IO操作可以进行时,内核会发送信号通知进程 |
| 特点 | 最常见、效率低、不耗费CPU | 轮询、耗费CPU,可以处理多路IO,效率高 | 异步通知方式,需要底层驱动的支持 |
| 适应场景 | 小规模IO操作,对性能要求不高 | 高并发网络服务器,减少线程阻塞时间 | 实时性要求高的应用,避免轮询开销 |
七、场景假设二:妈妈与三个孩子的故事

假设妈妈有三个孩子,分别在不同的房间里睡觉,需要及时获知每个孩子是否醒了,如何做?
- **阻塞IO?**在一个房间 - 只能照顾一个孩子
- **非阻塞IO?**不停的每个房间查看 - 超级无敌累,但是也可以干点其他的事情
- **信号驱动IO?**不行,因为只有一个信号,不知道是哪个孩子醒了
更好的解决方案:
- **不停的每个房间看:**超级无敌累,但是也可以干点其他的事情
- **妈妈在客厅睡觉,孩子醒了之后自己找妈妈:**既可以休息,也可以及时获取状态
这个场景引出了IO多路复用的概念,即一个进程可以同时监控多个IO事件。
八、IO多路复用:select/poll/epoll
8.1 为什么需要IO多路复用?
应用程序中同时处理多路输入输出流时面临的问题:
- **阻塞模式:**若采用阻塞模式,得不到预期的目的
- **非阻塞模式:**对多个输入进行轮询,但又太浪费CPU时间
- **多进程/线程:**若设置多个进程/线程分别处理一条数据通路,将产生进程/线程间的同步与通信问题,使程序变得更加复杂
**比较好的方法是使用IO多路复用技术。**其基本思想是:
- 先构造一张有关描述符的表(最大1024),然后调用一个函数
- 当这些文件描述符中的一个或多个已准备好进行IO时函数才返回
- 函数返回时告诉进程哪个描述符已就绪,可以进行IO操作
九、select模型
9.1 select的特点
- **监听数量限制:**一个进程最多只能监听1024个文件描述符
- **效率问题:**select被唤醒之后要重新轮询,效率相对低
- **内存拷贝:**select每次都会清空未发生响应的文件描述符,每次拷贝都需要从用户空间到内核空间,效率低,开销大
9.2 select编程步骤
- 先构造一张关于文件描述符的表
- 清空表 FD_ZERO
- 将关心的文件描述符添加到表中 FD_SET
- 调用select函数
- 判断是哪一个或者哪些文件描述符产生了事件 FD_ISSET
- 做对应的逻辑处理
9.3 select函数原型
cpp
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
功能:实现IO的多路复用
参数:
nfds:关注的最大的文件描述符+1
readfds:关注的读表
writefds:关注的写表
exceptfds:关注的异常表
timeout:超时的设置
NULL:一直阻塞,直到有文件描述符就绪或出错
时间值为0:仅仅检测文件描述符集的状态,然后立即返回
时间值不为0:在指定时间内,如果没有事件发生,则超时返回0,并清空设置的时间值
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 微秒 = 10^-6秒 */
};
返回值:
准备好的文件描述符的个数
-1:失败
0:超时检测时间到并且没有文件描述符准备好
注意:
select返回后,关注列表中只存在准备好的文件描述符
操作表函数:
void FD_CLR(int fd, fd_set *set); // 清除集合中的fd位
void FD_SET(int fd, fd_set *set); // 将fd放入关注列表中
int FD_ISSET(int fd, fd_set *set); // 判断fd是否在集合中 是→1 不是→0
void FD_ZERO(fd_set *set); // 清空关注列表
9.4 练习:同时监听键盘和鼠标输入
**需求:**输入鼠标的时候,响应鼠标事件,输入键盘的时候,响应键盘事件(两路IO)
cpp
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char const *argv[])
{
char buf[32] = {};
int ret;
// 打开鼠标设备文件
int fd = open("/dev/input/mouse0", O_RDONLY);
if (fd < 0)
{
perror("open err");
return -1;
}
// 1. 构造一张关于文件描述符的表
fd_set rfds;
while (1)
{
// 2. 清空表