【Linux网络】深入理解五种IO模型与非阻塞IO:从本质到实战

🎬 博主简介:

前言:
做后端开发的朋友们应该都有一个共识:绝大多数服务的性能瓶颈,最终都会落到 IO 上。无论是网络请求的收发、磁盘文件的读写,还是数据库的交互,本质上都是在处理 IO 操作。很多人写代码时只会用默认的阻塞 IO,遇到高并发就只会无脑加线程,结果线程越开越多,上下文切换的开销反而拖垮了整个系统。其实问题的根源在于没有真正理解 IO 的本质,也不知道操作系统提供了哪些更高效的 IO 模型。本文将从最底层的 IO 本质讲起,用大家都能听懂的 “钓鱼” 类比,带你彻底搞懂 Linux 下的五种 IO 模型,最后通过实战代码演示非阻塞 IO 的实现。读完这篇文章,你不仅能清晰区分同步 / 异步、阻塞 / 非阻塞这些容易混淆的概念,还能理解为什么 IO 多路复用是高并发服务器的基石。

一. IO 的本质:等 + 拷贝
1.1 IO 是什么?
在讲具体的 IO 模型之前,我们必须先搞清楚一个最核心的问题:IO 到底是什么?
很多人会说 IO 就是输入输出,没错,但这只是表面。从操作系统的角度来看,任何一次 IO 操作,都可以拆分成两个不可分割的阶段:
- 等待:等待数据准备就绪(对于读操作,是等待网卡 / 磁盘把数据传到内核缓冲区;对于写操作,是等待内核发送缓冲区有空闲空间)
- 拷贝:将数据从内核缓冲区拷贝到用户空间(读),或者从用户空间拷贝到内核缓冲区(写)
也就是说,IO 的本质 = 等 + 拷贝。
这是一个极其重要的结论,后面所有的 IO 模型,本质上都是在这两个阶段做文章。

1.2 为什么 IO 会慢?
知道了 IO 的本质,我们就能明白 IO 慢的根本原因:拷贝的速度是由硬件决定的,几乎无法优化;而等待的时间占比,才是决定 IO 效率的关键。
尤其是网络 IO,数据需要经过千里之外的传输、路由器的转发,等待的时间往往是拷贝时间的成百上千倍。所以,高效 IO 的核心目标,就是在单位时间内,尽可能降低等待的比重。
为了让大家更好地理解,我们用一个生活中最常见的例子 —— 钓鱼,来类比整个 IO 过程:
- 钓鱼的人 = 应用进程
- 鱼竿 = 文件描述符(fd)
- 鱼漂 = 就绪事件(数据是否准备好)
- 鱼 = 我们要读写的数据
- 池塘 = 网络 / 磁盘
钓鱼的过程也分为两步:等鱼咬钩(等待),把鱼钓上来(拷贝)。钓鱼效率低,就是因为大部分时间都在等鱼咬钩;要提高钓鱼效率,就要想办法减少等待的时间占比。

二. 五种 IO 模型详解
基于上面的 “钓鱼” 类比,我们来逐一讲解 Linux 下的五种 IO 模型。这五种模型分别对应了五种不同的钓鱼方式,也代表了操作系统为我们提供的五种 IO 处理策略。




2.1 阻塞 IO(Blocking IO)
对应人物:张三 张三钓鱼的特点是:死盯着鱼漂,鱼漂不动我不动,鱼漂动了我才动。在鱼咬钩之前,他什么事都不干,就一直等着。
这就是最基础的阻塞 IO 模型,也是所有套接字默认的工作方式。

工作流程
- 应用进程调用
recvfrom系统调用,向内核发起读请求 - 如果内核缓冲区中没有数据,进程会被挂起(阻塞),进入等待状态
- 当数据到达内核缓冲区后,内核将数据拷贝到用户空间
- 拷贝完成后,
recvfrom返回成功,进程被唤醒,开始处理数据
阻塞 IO 的优点是简单、易理解,代码写起来最省事。但缺点也很明显:一个线程只能处理一个 IO 操作,在高并发场景下,会导致线程数量爆炸,系统性能急剧下降。

2.2 非阻塞 IO(Non-blocking IO)
对应人物:李四 李四钓鱼就聪明多了,他不会死盯着鱼漂。每隔一会儿就看一眼鱼漂,如果没动静,就去干别的事(玩手机、看书),过会儿再回来检查。看到鱼漂动了,就立刻把鱼钓上来。
这就是非阻塞 IO 模型。

工作流程
- 应用进程调用
recvfrom系统调用 - 如果内核缓冲区没有数据,系统调用不会阻塞,而是立即返回
EWOULDBLOCK错误码 - 进程不会被挂起,可以去执行其他任务
- 进程需要轮询(反复调用
recvfrom)检查数据是否就绪 - 当数据就绪后,内核将数据拷贝到用户空间,
recvfrom返回成功
这里要特别注意一个误区:非阻塞 IO 并没有提高 IO 本身的效率,它只是改变了等待的方式。在钓鱼这件事上,李四和张三的效率是一样的,但是李四把等待的时间利用起来做了别的事,提高了整体的吞吐量。
非阻塞 IO 的缺点也很突出:轮询会大量消耗 CPU 资源。如果没有数据,进程会一直在空转,这在实际生产环境中是非常浪费的。所以非阻塞 IO 一般不会单独使用,而是配合 IO 多路复用一起工作。

2.3 信号驱动 IO(Signal-driven IO)
对应人物:王五 王五经验更老道,他在鱼竿顶部挂了一个铃铛。当鱼咬钩拉动鱼线时,铃铛就会响。王五不用一直盯着鱼漂,也不用每隔一会儿就检查,他可以放心地去干别的事,听到铃铛响再过来钓鱼就行。
这就是信号驱动 IO 模型。

工作流程
- 应用进程提前注册
SIGIO信号的处理函数 - 调用
sigaction系统调用,告诉内核:当数据准备好时,给我发一个SIGIO信号 - 进程继续执行其他任务,不会被阻塞
- 当数据到达内核缓冲区后,内核向进程发送
SIGIO信号 - 进程在信号处理函数中调用
recvfrom,将数据从内核拷贝到用户空间 - 拷贝完成后,处理数据
信号驱动 IO 的优点是:在数据准备阶段,进程完全不需要等待,CPU 利用率很高。但它有一个致命的缺点:在大量并发连接的场景下,信号会被频繁触发,导致信号队列溢出,最终丢失信号。所以信号驱动 IO 在实际开发中用得很少。

2.4 IO 多路复用(IO Multiplexing)
对应人物:赵六 赵六是村里的钓鱼高手,他一次拉了一车鱼竿,把所有鱼竿都抛到水里。然后他来回巡视这些鱼竿,只要看到哪个鱼漂动了,就立刻过去把那条鱼钓上来。运气好的时候,他可以同时钓上来好几条鱼。
这就是IO 多路复用模型,也叫 IO 多路转接,是目前高并发服务器中使用最广泛的 IO 模型。

工作流程
- 应用进程调用
select/poll/epoll等多路复用函数 - 内核会同时监视多个文件描述符(fd)的就绪状态
- 进程会阻塞在
select调用上,直到有一个或多个 fd 就绪 - 当有 fd 就绪后,
select返回,进程调用recvfrom将数据从内核拷贝到用户空间 - 处理数据,然后继续循环调用
select
IO 多路复用的核心优势在于:一个线程可以同时处理成百上千个 IO 连接。它不需要为每个连接创建一个单独的线程,大大减少了线程创建和上下文切换的开销。这也是为什么 Nginx、Redis 等高性能中间件都采用 IO 多路复用模型的原因。


2.5 异步 IO(Asynchronous IO)
对应人物:田七 田七是村里的首富,他根本不想自己钓鱼。他找了个司机小王,对小王说:“我想吃鱼了,你帮我去钓,钓满一桶了给我打电话。” 然后田七就回公司开会去了,小王怎么钓鱼他完全不管,等小王打电话通知他,他直接过来拿鱼就行。
这就是异步 IO 模型,是五种模型中最彻底的非阻塞 IO。

工作流程
- 应用进程调用
aio_read等异步 IO 系统调用 - 内核立即返回,进程继续执行其他任务
- 内核负责等待数据准备,并且自动将数据从内核缓冲区拷贝到用户指定的缓冲区
- 拷贝完成后,内核向进程发送信号,或者调用预先注册的回调函数
- 进程直接处理已经准备好的数据
异步 IO 和前面四种模型最大的区别在于:前面四种都是同步 IO,而异步 IO 是真正的非阻塞。

2.6 同步 IO vs 异步 IO:核心区别
很多人容易把同步 / 异步和阻塞 / 非阻塞搞混,这里我们给出最清晰的定义:
- 同步 IO:应用进程需要亲自参与 IO 的任何一个阶段(等待或拷贝)。也就是说,当数据准备好后,需要应用进程自己调用
read/recv来完成数据拷贝。 - 异步 IO:应用进程完全不参与 IO 的任何阶段。内核会自动完成等待和拷贝两个步骤,完成后通知应用进程直接使用数据。
根据这个定义,阻塞 IO、非阻塞 IO、信号驱动 IO、IO 多路复用都属于同步 IO。因为它们虽然在等待阶段的方式不同,但最终都需要应用进程自己来完成数据拷贝的工作。只有异步 IO,才是真正意义上的异步。
2.7 一张表搞懂五种 IO 模型
为了方便大家对比和记忆,我把五种 IO 模型的特点整理成了下面这张表:
| IO 模型 | 等待方式 | 拷贝方式 | 同步 / 异步 | 并发能力 | 适用场景 |
|---|---|---|---|---|---|
| 阻塞 IO | 进程阻塞等待 | 进程自己拷贝 | 同步 | 极低 | 简单的、并发量小的程序 |
| 非阻塞 IO | 进程轮询检查 | 进程自己拷贝 | 同步 | 低 | 一般不单独使用 |
| 信号驱动 IO | 信号通知 | 进程自己拷贝 | 同步 | 中 | 并发量不大的 UDP 程序 |
| IO 多路复用 | 内核同时监视多个 fd | 进程自己拷贝 | 同步 | 高 | 高并发服务器(Nginx、Redis) |
| 异步 IO | 内核等待 | 内核自动拷贝 | 异步 | 极高 | 对性能要求极高的场景(目前应用较少) |
三. 非阻塞 IO 实战:基于 fcntl 实现
讲完了理论,我们来看一下如何在代码中实现非阻塞 IO。Linux 下,我们可以通过fcntl函数来修改文件描述符的属性,将其设置为非阻塞模式。
3.1 fcntl 函数简介
fcntl函数是 Linux 下用来操作文件描述符的万能函数,它的原型如下:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
fd:要操作的文件描述符cmd:要执行的操作命令arg:可选参数,根据不同的 cmd 传入不同的值
fcntl函数有很多功能,我们这里只用到其中一种:获取 / 设置文件状态标记,对应的 cmd 是F_GETFL和F_SETFL。
3.2 实现 SetNoBlock 函数
我们可以封装一个通用的SetNoBlock函数,用来将任意文件描述符设置为非阻塞模式:
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
void SetNoBlock(int fd)
{
// 获取当前文件描述符的状态标记
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl F_GETFL failed");
return;
}
// 在原有标记的基础上,添加非阻塞标记
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
代码解读:
- 首先调用
fcntl(fd, F_GETFL)获取文件描述符当前的状态标记,这是一个位图 - 然后使用按位或操作,将
O_NONBLOCK(非阻塞标记)添加到原有标记中 - 最后调用
fcntl(fd, F_SETFL, fl)将新的标记设置回去
3.3 轮询读取标准输入示例
下面我们用非阻塞 IO 来实现一个轮询读取标准输入的程序:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
void SetNoBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl F_GETFL failed");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
// 将标准输入(fd=0)设置为非阻塞模式
SetNoBlock(0);
printf("请输入内容,程序会非阻塞读取:\n");
while (1)
{
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size > 0)
{
// 正常读取到数据
printf("读取成功:%s\n", buf);
}
else if (read_size == 0)
{
// 对端关闭(标准输入一般不会出现)
printf("输入结束\n");
break;
}
else
{
// 出错,需要区分是真错误还是数据未就绪
if (errno == EWOULDBLOCK || errno == EAGAIN)
{
// 数据未就绪,这是正常情况
printf("当前没有数据,1秒后重试...\n");
sleep(1);
continue;
}
else
{
// 真的发生了错误
perror("read failed");
break;
}
}
}
return 0;
}
代码关键点解读
- 非阻塞设置:
SetNoBlock(0)将标准输入设置为非阻塞模式,这个设置是永久有效的,不需要每次调用 read 都设置 - read 返回值处理:
read_size > 0:正常读取到数据read_size == 0:表示对端已经关闭连接(对于标准输入来说,就是用户按下了 Ctrl+D)read_size < 0:需要进一步判断errno的值- 如果
errno是EWOULDBLOCK或EAGAIN:表示当前没有数据就绪,这是非阻塞 IO 下的正常情况,不是错误 - 其他值:表示真的发生了 IO 错误
- 如果
运行效果
编译运行这个程序,你会看到:
- 当你没有输入任何内容时,程序会每隔 1 秒打印一次 “当前没有数据,1 秒后重试…”
- 当你输入内容并按下回车后,程序会立即打印 “读取成功:xxx”
这就是非阻塞 IO 的典型表现:调用 read 不会阻塞,没有数据就立即返回,有数据就正常读取。


3.4 代码实战演示(更加详细的解读)
// 非阻塞 I/O 读取测试程序
// 功能:演示如何以非阻塞方式从标准输入读取数据
// 特点:当数据未就绪时不会阻塞,而是执行其他任务后继续轮询
#include <cerrno>
#include <cstdio>
#include <unistd.h>
#include <iostream>
#include <fcntl.h>
// ============================================================
// 函数功能:将指定文件描述符设置为非阻塞模式
// 参数:fd - 要设置为非阻塞的文件描述符
// 说明:通过 fcntl 获取并修改文件状态标志,添加 O_NONBLOCK
// ============================================================
void SetNoBlock(int fd)
{
// 获取当前文件状态标志
int flag = fcntl(fd, F_GETFL);
if(flag < 0)
{
perror("fcntl");
return;
}
// 设置非阻塞标志(保留原有标志,添加 O_NONBLOCK)
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}
// ============================================================
// 函数功能:执行"其他任务"(模拟在数据未就绪时做其他工作)
// 说明:在实际应用中,这里可以处理其他 I/O 或业务逻辑
// ============================================================
void doOtherThing()
{
std::cout << "0 not ready, do other thing!" << std::endl;
}
// ============================================================
// 主函数:非阻塞读取标准输入
// 流程:设置非阻塞 -> 循环读取 -> 处理各种返回值情况
// ============================================================
int main()
{
// 定义输入缓冲区,大小为 128 字节
char inbuffer[128];
// 将标准输入(fd=0)设置为非阻塞模式
SetNoBlock(0);
// 持续轮询直到遇到 EOF 或发生错误
while(true)
{
// 将缓冲区第一个字节设为 0(常量时间初始化)
// 即使 read 失败,缓冲区也有确定状态
inbuffer[0] = 0;
// 尝试非阻塞读取,最多读取 sizeof(inbuffer) - 1 字节
// 预留 1 字节空间用于 null 终止符
ssize_t n = read(0, inbuffer, sizeof(inbuffer) - 1);
// -----------------------
// 情况1:成功读取到数据
// -----------------------
if(n > 0)
{
// 添加字符串终止符,确保可以安全打印
inbuffer[n] = 0;
std::cout << "inbuffer: " << inbuffer << std::endl;
}
// -----------------------
// 情况2:读到 EOF(对方关闭了写端)
// -----------------------
else if(n == 0)
{
std::cout << "enter done, break" << std::endl;
break; // 正常退出循环
}
// -----------------------
// 情况3:read 返回 -1(出错)
// -----------------------
else
{
// 1. 非阻塞模式下,只能以出错形式返回
// 2. 底层没有数据,read返回,算不算出错?不算!!
// 3. 如果返回值<0,你下来关系什么??因为什么原因出错的??错误码更详细的标识出错原因!
// 4. errno == 11, EAGAIN || EWOULDBLOCK 不是真正的出错,只是表明fd没有就绪!
// --- 假错误:数据未就绪 ---
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
// 执行其他任务,避免空轮询浪费 CPU
doOtherThing();
sleep(1); // 暂停 1 秒再试(降低 CPU 占用)
std::cout << "read 0, 0 fd data not ready!" << std::endl;
continue; // 继续下一次轮询
}
// --- 信号中断:被信号打断 ---
else if(errno == EINTR)
{
// 系统调用被信号中断,可重试
continue;
}
// --- 真错误:其他未知错误 ---
else
{
std::cout << "read error: n=" << n << ", errno=" << errno << std::endl;
break; // 发生未知错误,退出循环
}
}
// 每次成功读取后暂停 1 秒
sleep(1);
}
return 0;
}










四. 其他 IO 模型代码示例(了解即可,想看就看看)
为了让大家对其他 IO 模型也有一个直观的认识,这里再给出两个简单的代码示例。
4.1 信号驱动 IO 示例
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
int fd;
// SIGIO信号处理函数
void io_handler(int sig)
{
char buf[100];
int n = read(fd, buf, sizeof(buf) - 1);
if (n > 0)
{
buf[n] = '\0';
printf("收到输入:%s", buf);
}
}
int main()
{
fd = STDIN_FILENO;
// 注册SIGIO信号的处理函数
signal(SIGIO, io_handler);
// 设置信号的接收进程为当前进程
fcntl(fd, F_SETOWN, getpid());
// 开启文件描述符的异步IO模式
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC);
printf("请输入内容(程序会在后台等待信号)\n");
// 主进程循环等待信号
while (1)
{
pause();
}
return 0;
}
4.2 异步 IO(POSIX AIO)示例
注意:编译时需要链接实时库librt,即gcc demo.c -o demo -lrt
#include <aio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>
int main()
{
char buf[1024] = {0};
// 初始化异步IO控制块
struct aiocb cb =
{
.aio_fildes = STDIN_FILENO,
.aio_buf = buf,
.aio_nbytes = sizeof(buf)
};
// 发起异步读取请求
aio_read(&cb);
printf("异步读取请求已发起,程序继续执行...\n");
// 轮询等待读取完成(实际项目中一般用信号或回调)
while (aio_error(&cb) == EINPROGRESS)
{
printf("正在读取数据...\n");
sleep(1);
}
// 获取读取结果
ssize_t read_size = aio_return(&cb);
if (read_size > 0)
{
printf("读取成功:%s\n", buf);
}
else
{
perror("aio_read failed");
}
return 0;
}
本文核心要点总结:
- IO 的本质是等 + 拷贝,高效 IO 的核心是降低等待的比重
- 五种 IO 模型中,IO 多路复用是目前高并发场景下的最优解,也是我们需要重点掌握的
- 前四种都是同步 IO,因为都需要应用进程自己完成数据拷贝;只有异步 IO 是真正的非阻塞
- 非阻塞 IO 一般不单独使用,而是配合 IO 多路复用一起工作
- 注意区分同步 / 异步和阻塞 / 非阻塞:同步异步关注的是消息通信机制,阻塞非阻塞关注的是程序等待时的状态
结尾:
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!
结语:本文从 IO 的本质出发,用 “钓鱼” 的类比详细讲解了 Linux 下的五种 IO 模型,并通过实战代码演示了非阻塞 IO 的实现。随着技术的发展,现在协程(C++20 coroutine)逐渐成为了 IO 编程的新趋势。协程在保留同步 IO 代码简洁性的同时,又能达到异步 IO 的性能,是未来 IO 编程的发展方向。但无论技术怎么发展,IO 的本质永远不会变,理解了这五种 IO 模型,你就能更好地理解和使用这些新技术。下一篇文章,我会带大家深入讲解 IO 多路复用的三种实现:select、poll 和 epoll,分析它们的优缺点和底层原理。如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注,我们下期再见!
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)