29、IO模型深度解析
约 12381 字大约 41 分钟
2025-09-04
写在前面:
IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。
I/O 一直是很多小伙伴难以理解的一个知识点,这篇文章我会将我所理解的 I/O 讲给你听,希望可以对你有所帮助。
I/O
何为 I/O?
I/O(Input/Outpu) 即输入/输出 。
我们先从计算机结构的角度来解读一下 I/O。
根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。

输入设备(比如键盘)和输出设备(比如鼠标)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
输入设备向计算机输入数据,输出设备接收计算机输出的数据。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
我们再先从应用程序的角度来解读一下 I/O。
根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
并且,用户空间的程序不能直接访问内核空间。
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。
因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间
我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
- 内核等待 I/O 设备准备好数据
- 内核将数据从内核空间拷贝到用户空间。
我们可以看下面的图:

同步和异步
同步与异步是针对应用程序与内核的交互而言。
也就是上图的read操做,从缓存中读取数据,若是缓存中数据没有准备好,若是同步操做,它会一直等待,直到操做完成,
若是异步操做,那么他会去作别的事情,等待数据准备好,内核通知它,它再去读取数据,同步过程当中进程触发IO操做并等待或者轮询的去查看IO操做是否完成。
异步过程当中进程触发IO操做之后,直接返回,做本身的事情,IO操做交给内核来处理,处理完后通知进程IO完成。
同步和异步是相对于操做结果来讲,会不会等待结果返回。
同步:发起调用放主动去轮询数据是否准备好,但是在讲数据从操作系统内核缓冲区复制到应用程序内核缓冲区的时候,发起调用的一方还是会阻塞。
异步:发送方不用去轮询查看数据是否准备好,而是等到系统来通知。
阻塞与非阻塞
应用进程请求IO操做时,若是数据未准备好,当即返回就是非阻塞,不当即返回就是阻塞。
简单来讲,就是做一件事若是不能当即得到返回,须要等待,就是阻塞,不然就是非阻塞。
阻塞与非阻塞是相对于线程是否被阻塞网络
阻塞和非阻塞,是函数/方法的实现方式,即在数据就绪之前是立刻返回还是等待,即发起IO请求是否会被阻塞。
异步/同步/ 阻塞/非阻塞的区别
二者存在本质的区别,他们的修饰对象是不一样的。阻塞和非阻塞是指进程访问的数据若是还没有准备就绪,进程是否须要等待,简单说:相等于函数内部的实现区别,就是未就绪时,直接返回仍是等待就绪。
同步和异步是指访问数据的机制,同步通常主动请求等待IO操做完毕的方式。当数据就绪后,再读写的时候必须阻塞,异步则主动请求数据后即可以继续处理其余任务,随后等待IO完毕通知,这可使进程在数据读写时也不阻塞多线程
以文件IO为例,一个IO读过程是文件数据从磁盘→内核缓冲区→用户内存的过程。同步与异步的区别主要在于数据从内核缓冲区→用户内存这个过程需不需要用户进程等待,即实际的IO读写是否阻塞请求进程。(网络IO把磁盘换做网卡即可)
- 阻塞,非阻塞:进程/线程要访问的数据是否就绪,进程/线程是否需要等待;
- 同步,异步:访问数据的方式,同步需要主动读写数据,在读写数据的过程中还是会阻塞;异步只需要I/O操作完成的通知,并不主动读写数据,由操作系统内核完成数据的读写。
异步/同步 阻塞/非阻塞的组合方式
四象限分类法
编程模型四象限:
┌─────────────────┬─────────────────────┐
│ │ 阻塞(Blocking) │
├─────────────────┼─────────────────────┤
│ 同步(Sync) │ 传统IO:read/write │
│ │ 线程等待结果返回 │
├─────────────────┼─────────────────────┤
│ 异步(Async) │ 异步IO:aio_read │
│ │ 回调/事件驱动 │
├─────────────────┼─────────────────────┤
│ │ 非阻塞(NonBlock) │
├─────────────────┼─────────────────────┤
│ 同步(Sync) │ 非阻塞IO+轮询 │
│ │ select/poll/epoll │
├─────────────────┼─────────────────────┤
│ 异步(Async) │ 真正的异步IO │
│ │ io_uring, IOCP │
└─────────────────┴─────────────────────┘同步阻塞:效率最低,实际程序中,就是fd未设置O_NONBLOCK标志位的read/write操做。
老王用水壶烧水,而且站在那里,看着水壶,等水开。
异步阻塞:异步操做是能够阻塞住的,只不过它不是在处理消息是阻塞,而是在等到消息时阻塞.
老王用响水壶烧水,站在那里,此次不看水壶了,而是等水开了水壶自动通知发出声音,老王听见了,知道水开了socket;
同步非阻塞:实际效率仍是低下的,注意fd设置O_NONBLOCK标志位
老王用水壶烧水,不在站在那里直接等,而是跑出去去干别的事情,好比打游戏,看电视,可是,老王内心不放心,每隔一段时间回来看一下,看水开了没(异步内核通知进程)
异步非阻塞:效率高效,注册一个回调函数,就能够去作别的事情.
老王用响水壶烧水,跑去作别的事情,等待响水壶发出声音。(异步内核通知进程)
socket的fd是什么?
fd是(file descriptor/文件描述符) ,这种通常是BSD Socket的用法,用在Unix/linux系统上。在Unix/linux系统下,一个Socket句柄,能够看作是一个文件,在socket上收发数据,可以对一个文件进行读写,因此一个socket句柄,一般也用表示文件句柄的fd来表示。
下面举一个例子说明上面四种模型
同步阻塞:传统点餐
- 你:点餐(调用函数)
- 服务员:去厨房做菜(执行IO)
- 你:坐在座位上等待,什么都不做(线程阻塞)
- 服务员:端菜回来(IO完成)
- 你:开始吃(继续执行)
- 特点:简单直观,但浪费时间
同步非阻塞:取号等餐
- 你:点餐拿到号码(立即返回)
- 你:玩手机,时不时看屏幕(轮询)
- 屏幕显示你的号码(数据就绪)
- 你:取餐(读取数据)
- 特点:需要主动检查,CPU可能忙等
异步非阻塞:外卖点餐
- 你:手机下单(发起请求)
- 你:继续工作/看电视(不等待)
- 外卖员:送餐(内核/其他线程处理)
- 门铃响(回调通知)
- 你:取餐(处理结果)
- 特点:高效,但编程复杂
异步阻塞:(理论上矛盾,实际不存在)
缓存IO
缓存IO又被称为标准IO,大多数文件系统的默认IO都是缓存IO,在linux的缓存IO机制中,系统会将IO的数据缓存在文件系统的页缓存(page cache),也就是说,数据会先被拷贝到操做系统内核的缓冲区中,而后才会从操做系统内核的缓冲拷贝到应用程序的地址空间。
用户读取数据,需要在内核态和用户态相互切换,标准的io流程如下:

缓存IO的缺点:
数据在传输过程当中须要在应用程序地址空间和内核进行屡次数据拷贝操做,这些数据拷贝带来的cpu以及内存开销是很是大的。
linux中有5钟io模型,下面介绍每一种io模型的特点:
Linxu钟有哪些常见的 IO 模型?
UNIX 系统下, IO 模型一共有 5 种:
- 同步阻塞 I/O;
- 同步非阻塞 I/O;
- I/O 多路复用(select,poll,spoll);
- 信号驱动 I/O ;
- 异步 I/O。
可以使用一张图说明:

这也是我们经常提到的 5 种 IO 模型。
同步阻塞IO(BIO)

recvfrom函数为系统调用函数,从图中能够看出,从进行系统调用到拷贝数据到应用进程缓冲区完成,整段时间都是被阻塞的。
在这个过程当中要么正确到达,要么系统调用被打断;直到数据报被拷贝到用户进程后,用户才接触阻塞状态。
这里的用户进程是本身进行阻塞,拷贝也是有用户进行完成。在等待数据和把数据从内核缓冲区复制到用户缓冲区两个阶段中,整个进程都是被阻塞的,不能处理别的网络IO,调用应用程序处于一种不在消费CPU而只是简单等待响应的状态。
去餐馆吃饭,点一个自己最爱吃的盖浇饭,然后在原地等着一直到盖浇饭做好,自己端到餐桌就餐。这就是典型的同步阻塞。当厨师给你做饭的时候,你需要一直在那里等着。
同步非阻塞IO

从图中能够看出,当用户进程发出read操做时,若是kernel中的数据尚未准备好,那么他并不会block用户进程,而是立马返回一个error,从用户角度来说,他发起一个read操做后,并不须要等待,而是立刻等到一个结果,用户进程判断是一个error时,他就知道数据换没有准备好,因而它再次发送read操做,一旦kermel中的数据准备好了,而且再次收到了用户进程的read,那么他此时就会将数据拷贝到用户内存,而后返回。
因此用户的第一个阶段不是阻塞的,须要不断的主动问kernel数据好了没;第二阶段依然整体是阻塞的。
接着上面的例子,你每次点完饭就在那里等着,突然有一天你发现自己真傻。于是,你点完之后,就回桌子那里坐着,然后估计差不多了,就问老板饭好了没,如果好了就去端,没好的话就等一会再去问,依次循环直到饭做好。这就是同步非阻塞。
IO多路复用(NIO)
select/epoll的好处就在于单个process就能够同时处理多个网络链接的IO。
虽然I/O多路复用的函数也是阻塞的,但是其与以上两种还是有不同的,I/O多路复用是阻塞在select,epoll这样的系统调用之上,而没有阻塞在真正的I/O系统调用如recvfrom之上。

IO复用和同步阻塞本质同样,不过利用了新的select系统调用,由内核来负责原本是请求进程该作的轮询操做,看似不非阻塞IO还多了一个系统调用开销,不过由于支持多路IO才算提升了效率。
传统阻塞IO问题:
┌─────────────────────────────────────────────────────┐
│ 进程A:读取socket1数据 │
│ ↓ │
│ 阻塞等待数据...(CPU空闲) │
│ │
│ 进程B:等待读取socket2数据 │
│ ↓ │
│ 被阻塞,无法执行 │
│ │
│ 结果:每个连接需要一个线程,资源浪费 │
└─────────────────────────────────────────────────────┘
IO多路复用解决方案:
┌─────────────────────────────────────────────────────┐
│ 单个线程监控多个socket │
│ │
│ 线程:select/poll/epoll → 内核 → 返回就绪的socket │
│ ↓ │
│ 处理socket1数据 │
│ ↓ │
│ select/poll/epoll → 内核 → 返回就绪的socket │
│ ↓ │
│ 处理socket2数据 │
│ │
│ 结果:一个线程处理数千连接,高效利用CPU │
└─────────────────────────────────────────────────────┘这里是什么意思呢,是由select系统调用来负责原本属于用户进程该做的任务,这个任务就是轮询操作,内核线程负责数据的轮询监听操作,当有数据就绪,就通知用户进程,用户线程仍然可以发起网络的io请求任务。 内核线程可以监听多个socket;
核心思想:事件驱动
// IO多路复用的核心:等待事件,而不是等待数据
struct io_event_driven_model {
// 传统阻塞模式:等待特定socket的数据
// read(socket_fd, buffer, size); // 阻塞在此
// 事件驱动模式:等待任意socket的事件
// 1. 告诉内核:"监控这些socket,有事件通知我"
// 2. 线程可以处理其他任务或睡眠
// 3. 内核:"socket X有数据可读了"
// 4. 线程:"好的,我去读取socket X"
// 关键优势:
// - 解耦:事件生产(内核)和消费(应用)分离
// - 高效:避免无意义的等待
// - 扩展:轻松支持大量连接
};它的基本原理就是select /epoll这个function会不断的轮询所负责的全部socket,当某个socket有数据到达了,就通知用户进程。
当用户线程调用select,那么整个进程会被阻塞,而同时,kernel会“监视”全部select负责的socket,当任何一个socket中的数据准备好了,select就会返回,这个时候用户进程在调用read操做,将数据kernel拷贝到用户进程。
接着上面的列子,你点一份饭然后循环的去问好没好显然有点得不偿失,还不如就等在那里直到准备好,但是当你点了好几样饭菜的时候,你每次都去问一下所有饭菜的状态(未做好/已做好)肯定比你每次阻塞在那里等着好多了。当然,你问的时候是需要阻塞的,一直到有准备好的饭菜或者你等的不耐烦(超时)。这就引出了IO复用,也叫多路IO就绪通知。这是一种进程预先告知内核的能力,让内核发现进程指定的一个或多个IO条件就绪了,就通知进程。使得一个进程能在一连串的事件上等待。
IO复用的实现方式目前主要有select、poll和epoll。
select 内核特性
位图大小限制:FD_SETSIZE通常为1024
- 意味着最多监控1024个文件描述符
- 编译时确定,无法动态调整
线性扫描开销:O(n)复杂度
- 每次调用都需要遍历所有监控的fd
- 即使只有一个fd就绪,也要扫描全部
内存拷贝开销:
- 每次调用需要将fd_set从用户空间拷贝到内核
- 返回时又需要拷贝回用户空间
重复初始化:
- select修改传入的fd_set
- 每次调用前需要重新设置
案例:
示例:监控1000个fd,只有一个活跃
每次select调用:扫描1000个fd
实际有用工作:处理1个fd
效率:0.1%poll系统调用
poll相比select的改进:
改进1:无文件描述符数量限制
- 使用动态数组,而非固定大小的位图
- 理论上只受系统内存限制
改进2:更丰富的事件类型
- POLLIN, POLLOUT, POLLPRI, POLLERR等
- 可以更精确地监控事件
改进3:分离的输入输出参数
- events是输入(用户关心什么)
- revents是输出(实际发生什么)
- 不需要每次重新初始化
仍然存在的问题:
- 仍然需要O(n)的线性扫描
- 每次调用需要拷贝整个数组
- 大量fd时性能仍然不佳
- 水平触发(Level-Triggered)可能导致忙等
案例:
示例:监控10000个fd
每次poll调用:扫描10000个fd
内存拷贝:10000 * sizeof(pollfd) = 160KB
仍然低效epoll系统调用
1. 红黑树 O(log n) 查找
监控10万个fd时:
- select/poll: 每次扫描10万次
- epoll: 红黑树查找,约17次比较
2. 回调机制避免轮询
select/poll: 主动轮询所有fd
epoll: 被动等待回调通知
文件有事件 → 内核调用回调 → epitem加入就绪链表
3. 就绪链表 O(1) 获取事件
epoll_wait只返回真正有事件的fd
监控10万fd,只有10个活跃:
- select/poll返回10万个fd位图
- epoll返回10个fd的数组
4. 内存共享减少拷贝
epoll_create: 在内核创建数据结构
epoll_ctl: 增删改时拷贝
epoll_wait: 只拷贝就绪事件
对比select: 每次调用都拷贝整个fd_set
5. 边缘触发模式(ET)
水平触发(LT,默认):
- 数据可读时持续通知
- 可能重复通知
边缘触发(ET,EPOLLET):
- 状态变化时只通知一次
- 必须读取所有数据
- 减少系统调用次数
性能测试数据(处理10000个连接):
模型 CPU使用率 内存占用 每秒请求
-------- --------- -------- ---------
select 85% 高 8000
poll 80% 中 8500
epoll-LT 25% 低 42000
epoll-ET 20% 低 48000
关键优化点:
1. 使用epoll ET模式
2. 非阻塞socket配合ET
3. 一次性读取所有可用数据
4. 合理设置epoll_wait超时相比select,poll解决了单个进程能够打开的文件描述符数量有限制这个问题:select受限于FD_SIZE的限制,如果修改则需要修改这个宏重新编译内核;而poll通过一个pollfd数组向内核传递需要关注的事件,避开了文件描述符数量限制。
此外,select和poll共同具有的一个很大的缺点就是包含大量fd的数组被整体复制于用户态和内核态地址空间之间,开销会随着fd数量增多而线性增大。
select和poll就类似于上面说的就餐方式。但当你每次都去询问时,老板会把所有你点的饭菜都轮询一遍再告诉你情况,当大量饭菜很长时间都不能准备好的情况下是很低效的。于是,老板有些不耐烦了,就让厨师每做好一个菜就通知他。这样每次你再去问的时候,他会直接把已经准备好的菜告诉你,你再去端。这就是事件驱动IO就绪通知的方式-epoll。
select/poll/epoll 对比分析
| 特性 | select | poll | epoll |
|---|---|---|---|
| 时间复杂度 | O(n) | O(n) | O(1) 就绪事件获取 O(log n) fd管理 |
| 最大连接数 | FD_SETSIZE(1024) | 无限制(受内存限制) | 无限制(受内存限制) |
| 内存拷贝 | 每次调用拷贝fd_set | 每次调用拷贝pollfd数组 | epoll_ctl时拷贝,epoll_wait只返回就绪事件 |
| 触发模式 | 水平触发 | 水平触发 | 支持水平触发和边缘触发 |
| 内核实现 | 线性扫描 | 线性扫描 | 红黑树+就绪链表+回调 |
| 适用场景 | 连接数少,跨平台 | 连接数中等,需要更多事件类型 | 高并发,大量连接 |
| 可移植性 | POSIX标准,所有平台 | POSIX标准,大多数Unix | Linux特有 |
| 监控fd变化 | 每次需要重新设置fd_set | 每次需要传递整个数组 | epoll_ctl单独管理 |
| 内存占用 | 固定(位图) | O(n)(数组) | O(n)(内核数据结构) |
| 事件精度 | 只有可读、可写、异常 | 丰富的事件类型 | 最丰富的事件类型 |
信号驱动IO

首先开启套接字的信号驱动式IO功能,而且经过sigaction(信号处理程序) 系统调用安装一个信号处理函数 ,该函数调用将当即返回,当前进程没有被阻塞 ,继续工做;当数据报准备好的时候,内核为该进程产生SIGIO 的信号,随后既能够在信号处理函数中调用recvfrom 读取数据报,而且通知主循环数据已经准备好等待处理;也能够直接通知主循环让它读取数据报;(其实就是一个待读取的通知和待处理的通知),基本不会用到。
上文的就餐方式还是需要你每次都去问一下饭菜状况。于是,你再次不耐烦了,就跟老板说,哪个饭菜好了就通知我一声吧。然后就自己坐在桌子那里干自己的事情。更甚者,你可以把手机号留给老板,自己出门,等饭菜好了直接发条短信给你。这就类似信号驱动的IO模型。
异步IO(AIO)
这类函数的工作机制是告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到用户空间)完成后通知我们

多线程和多进程的模型虽然解决了并发的问题,可是系统不能无限的增长线程,因为系统的切换线程的开销恒大,因此,一旦线程数量过多,CPU的时间就花在线程的切换上,正真运行代码的时间就会减小,结果致使性能严重降低
因为咱们要解决的问题是CPU高速执行能力和IO设备的龟速严重不匹配,多线程和多进程只是解决这一个问题的一种方法。
另外一种解决IO问题的方法是异步IO,当代码须要执行一个耗时的IO操做时,他只发出IO指令,并不等待IO结果真后就去执行其余代码,一段时间后,当IO返回结果是,在通知CPU进行处理
咱们调用aio_read函数,给内核传递描述符,缓冲区指针,缓冲区大小,和文件偏移量,而且告诉内核当整个操做完成时如何通知咱们,该函数调用后,当即返回,不会被阻塞
另外一方面:从kernel的角度,当他收到一个aio_read以后,首先它当即返回,因此不会对用户进程产生block,而后kernel会等待数据准备完成,而后将数据拷贝到用户内存(copy由内核完成),当着一切完成后,kernel会给用户进程发送一个singal或者执行下一个基于线程回调函数来完成这次IO处理过程,告诉他read操做完成
之前的就餐方式,到最后总是需要你自己去把饭菜端到餐桌。这下你也不耐烦了,于是就告诉老板,能不能饭好了直接端到你的面前或者送到你的家里(外卖)。这就是异步非阻塞IO了。
异步IO的拷贝是有内核完成, 其余几种IO都是由用户进程完成
其实前四种I/O模型都是同步I/O操作,他们的区别在于第一阶段,而他们的第二阶段是一样的:在数据从内核复制到应用缓冲区期间(用户空间),进程阻塞于recvfrom调用。相反,异步I/O模型在这两个阶段都要处理。
- 同步I/O操作:导致请求进程阻塞,直到I/O操作完成;
- 异步I/O操作:不导致请求进程阻塞。
核心思想:从“等待者”到“通知者”
要理解AIO,首先要对比传统的同步/阻塞IO和基于IO多路复用的“伪异步”。
- 同步阻塞IO:线程发起
read系统调用后,会一直阻塞,直到内核将数据准备好、并拷贝到用户空间缓冲区。这个过程中,线程什么也做不了,只能等待。这就是“等待者”模式。- 问题:大量线程用于等待IO,导致严重的上下文切换开销和内存占用(每个线程都需要独立的栈)。
- IO多路复用:使用
select/poll/epoll(Linux) 或kqueue(BSD) 等系统调用。一个线程可以同时监视多个文件描述符的IO状态。当任何一个被监视的描述符就绪(可读/可写)时,函数返回,线程再自己去执行实际的IO操作(如recv)。- 优势:用少量线程管理大量连接。
- 关键局限:它仍然是 同步 的。
epoll只负责通知“哪个连接有数据了”,但后续的数据从内核缓冲区拷贝到用户空间这个最耗时的操作,仍然需要线程同步地、阻塞地去完成。因此,它常被称为“事件驱动”或“反应器”模式,但本质是 同步非阻塞IO。
- 真正的异步IO:这才是我们今天的主角。在AIO模型中,应用程序发起一个IO请求(如
aio_read)后,立即返回,不会被阻塞。应用程序可以继续执行其他任务。内核会独自负责整个IO操作:包括等待数据准备和将数据从内核空间拷贝到用户空间提供的缓冲区。当整个操作全部完成后,内核会通过某种方式(如信号、回调函数、完成事件)主动通知应用程序。- 核心:应用程序从IO操作的“执行者+等待者”,变成了“发起者”和“结果处理者”。真正的“IO操作”与“应用程序计算”在时间线上是完全重叠的。
我们可以从两个层面来理解AIO的原理:用户层API模型和内核层实现机制。
一、用户层编程模型(原理的接口体现)
AIO主要提供两种编程模型:
- 回调模型
- 应用程序提交一个IO请求,并提供一个回调函数(Completion Handler)。
- 内核完成IO后,会自动调用这个回调函数来处理结果。
- 优点:逻辑紧凑,结果处理与请求绑定。
- 缺点:回调地狱,在复杂逻辑中可能难以控制流程。通常需要与事件循环配合。
- 完成事件/未来模型
- 应用程序提交IO请求后,获得一个代表“未来结果”的对象(如
Future,Promise或一个aiocb结构)。 - 之后,可以通过两种方式获取结果:
- 轮询:主动检查这个“未来”对象是否已完成。
- 等待:同步等待这个“未来”对象完成(
future.get()),此时会阻塞,但这是在结果已经产生后的等待,而非等待IO本身。
- 优点:更符合直觉的编程流程,易于组合和链式调用。是现代异步框架(如asyncio, Tokio)的主流模型。
- 应用程序提交IO请求后,获得一个代表“未来结果”的对象(如
二、内核层实现机制(以Linux为例)
Linux的AIO实现主要有两套:
- Linux Native AIO (KAIO)
- 系统调用:
io_setup,io_submit,io_getevents等。 - 工作原理:
- 用户通过
io_submit提交一个或多个IO请求(iocb结构体),每个请求指定了文件描述符、操作类型、用户缓冲区等。 - 内核将这些请求放入一个队列,立即返回。
- 内核的IO调度器异步地处理这些请求。对于直接IO或缓冲区已就绪的情况,处理非常高效。
- 用户线程可以通过
io_getevents来轮询已完成的事件。 - 关键限制:
- 早期只对直接IO(
O_DIRECT)支持较好,对缓冲IO支持不佳。 - 对网络套接字的支持一直不完善且不稳定。
- 因此,Linux Native AIO 在数据库、存储服务器等场景应用较多,在网络编程中较少直接使用。
- 早期只对直接IO(
- 用户通过
- 系统调用:
io_uring(The Ultimate Solution)- 这是Linux 5.1+引入的现代异步IO接口,旨在解决之前所有AIO方案的缺陷。
- 核心原理:使用两个无锁的环形队列在内核和用户空间之间进行通信。
- 提交队列:用户程序将IO请求放入此队列。
- 完成队列:内核将已完成的IO结果放入此队列。
- 工作流程:
- 应用准备IO请求(SQE),放入提交队列。
- 应用调用
io_uring_enter系统调用通知内核(或通过轮询模式完全避免系统调用)。 - 内核从提交队列取走请求并进行处理。
- 内核处理完成后,将结果(CQE)放入完成队列。
- 应用从完成队列中取出结果进行处理。
- 巨大优势:
- 真正的零拷贝:支持注册固定缓冲区,数据可直接从内核送达用户指定内存。
- 支持所有IO类型:文件、网络、管道等。
- 极高性能:通过轮询模式可大幅减少甚至消除系统调用开销。
- 批处理:单次系统调用可提交/完成大量请求。
io_uring是目前Linux上性能最高、最彻底的异步IO解决方案,已被Nginx、Redis等主流软件采用。
与传统模型的对比图
我们可以用一张图来直观对比三种模型处理一个“从磁盘读文件”请求的流程:
同步阻塞IO:
线程: [发起 read()] ------------> [阻塞等待] --------------------------------> [处理数据]
| | ^
| v |
内核: [等待数据就绪] -> [数据拷贝到用户空间] --------------+
IO多路复用 + 非阻塞IO:
线程: [发起 epoll_wait()] -> [得知fd就绪] -> [发起 read()] ----------> [阻塞拷贝] -> [处理数据]
| | | | ^
| | v v |
内核: [标记就绪fd] [数据已就绪] [拷贝数据] ------------+
异步IO (AIO):
线程: [发起 aio_read()] -------------------------------------------------------> [收到信号/回调,处理数据]
| ^
| |
内核: [等待数据就绪] -> [数据拷贝到用户空间] -> [发送通知] --------------+优缺点总结
优点:
- 高吞吐,低延迟:在IO密集型应用中,能最大化利用CPU,避免线程空转等待。
- 高并发:用极少的线程(甚至单线程)即可处理海量并发连接,典型代表如Nginx、Redis。
- 资源高效:大幅减少线程数量,节省内存和上下文切换开销。
缺点:
- 编程复杂:异步代码的编写、调试和维护比同步代码困难,容易陷入“回调地狱”。
- 心智负担:需要以事件驱动的方式思考问题,状态管理变得更复杂。
- 生态兼容:并非所有库都原生支持异步,可能需要使用线程池来封装同步库,增加了复杂度。
异步IO的原理,本质上是将IO操作的任务和等待工作完全委托给内核,应用程序只负责发起和接收结果,从而实现计算与IO的完美重叠。
为了便于理解,下面说一个场景:
以上五种IO模型的通俗理解
- 活动:演唱会
- 角色一:满满
- 角色二:举办方-》售票员
- 角色三:黄牛
- 角色四:送票快递员
同步阻塞IO:
满满从家到售票点买票,售票员告诉满满,票明天才能卖。满满直接在售票点等到明天买票,而后回家。(等待就体现了同步并且阻塞)
非阻塞IO:
满满从家到演唱会现场向售票员买票,但票还未出来,而后满满就走了,去干别的事情,过了几个小时再来询问票是否出来,还没出来继续干别的事情。重复以上操做,直到票能够买。(每隔几小时来询问就体现了非阻塞)
IO复用:
JAVA->selector / linux->select,poll,epoll满满想买演唱会的票,打电话给黄牛(select)帮留一张票,票出来后,小明不须要花费时间去售票点买票(阻塞)(可以从黄牛哪里直接获取票,也就是找了一个中间人)。
信号IO:
满满想买演唱会门票,给举办方打电话,帮我留意票,能够售票了给我打个电话(打完就返回结果,等待kernel信号通知),我本身来买票。票出来后,满满亲自去售票点买票
`异步IO:
满满要看演唱会,给举办方打电话,能够售票了让送票快递员帮我把票送家里,满满就不用本身去专门买票了 `
Java 中 3 种常见 IO 模型
BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型 。
同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到在内核把数据拷贝到用户空间。

在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
BIO是一个典型的网络编程模型,是通常我们实现一个服务端程序的过程,步骤如下:
主线程accept请求阻塞
请求到达,创建新的线程来处理这个套接字,完成对客户端的响应。
主线程继续accept下一个请求
这种模型有一个很大的问题是:当客户端连接增多时,服务端创建的线程也会暴涨,系统性能会急剧下降。因此,在此模型的基础上,类似于 tomcat的bio connector,采用的是线程池来避免对于每一个客户端都创建一个线程。有些地方把这种方式叫做伪异步IO(把请求抛到线程池中异步等待处理)。
NIO (Non-blocking/New I/O)
Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。
Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
跟着我的思路往下看看,相信你会得到答案!
我们先来看看 同步非阻塞 IO 模型。

同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作(可以比作一种CAS锁状态,一直在做忙等),避免了一直阻塞。
但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。
这个时候,I/O 多路复用模型 就上场了。

IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间->用户空间)还是阻塞的。
目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,是目前几乎在所有的操作系统上都有支持
- select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
- epoll 调用 :linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。

AIO (Asynchronous I/O)
JDK1.7引入NIO2.0,提供了异步文件通道和异步套接字通道的实现。其底层在windows上是通过IOCP,在Linux上是通过epoll来实现的(LinuxAsynchronousChannelProvider.java,UnixAsynchronousServerSocketChannelImpl.java)。
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。

最后我们来对比以下IO模型

参考
- 《深入拆解 Tomcat & Jetty》
- 如何完成一次 IO:https://llc687.top/post/如何完成一次-io/
- 程序员应该这样理解 IO:https://www.jianshu.com/p/fa7bdc4f3de7
- 10 分钟看懂, Java NIO 底层原理:https://www.cnblogs.com/crazymakercircle/p/10225159.html
- IO 模型知多少 | 理论篇:https://www.cnblogs.com/sheng-jie/p/how-much-you-know-about-io-models.html
- 《UNIX 网络编程 卷 1;套接字联网 API 》6.2 节 IO 模型
IO多路复用模型的实现
什么是IO多路复用
- IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
- 没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程
select 时间复杂度O(n)
原理
- 它仅仅知道 有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流对他们进行操作。
- 所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
- select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。
缺点
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
- 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:
- 当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
- select支持的文件描述符数量太小了,默认是1024
- 单个进程可监视的fd数量被限制,即能监听端口的大小有限。
- 一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.
poll 时间复杂度O(n)
原理
1. poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,
2. 如果设备就绪则在设备等待队列中加入一项并继续遍历,
3. 如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
4. **但是它没有最大连接数的限制,原因是它是基于链表来存储的**.
缺点
- 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
- poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
epoll 时间复杂度O(1)
原理
- epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。
- 所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
- epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll有EPOLLLT和EPOLLET两种触发模式:
LT模式(默认的模式) 只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作, ET模式(边缘触发) “高速”模式 它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。 所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者 遇到EAGAIN错误。
epoll的优点:
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
- 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
- 即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
- 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
select、poll、epoll 区别
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现
支持一个进程所能打开的最大连接数
select 单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。 poll poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的 epoll 虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接
FD剧增后带来的IO效率问题
select 因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。 poll 同上 epoll 因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
消息传递方式
select 内核需要将消息传递到用户空间,都需要内核拷贝动作 poll 同上 epoll epoll通过内核和用户空间共享一块内存来实现的。
总结:
- 表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
- select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善
- select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
- select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
epoll的缺点:
epoll只能工作在linux下
epoll的应用:
Rides
三者的区别
select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 红黑树
最大连接数 1024(x86)或2048(x64) 无上限 无上限
IO效率
每次调用都进行线性遍历,时间复杂度为O(n)
每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当fd就绪,系统注册的回调函数就 会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
fd拷贝
每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝面试题
什么是IO多路复用?
nginx/redis 所使用的IO模型是什么?
select、poll、epoll之间的区别
epoll 水平触发(LT)与 边缘触发(ET)的区别?贡献者
版权所有
版权归属:codingLab
许可证:bugcode