五大IO模型和多路转接详解
摘要:本文通过钓鱼案例形象类比五种IO模型,重点解析了epoll的底层原理。阻塞IO和非阻塞轮询IO存在效率问题,而IO多路转接(如epoll)通过内核事件通知机制显著提升性能。epoll采用红黑树存储监控的fd,双向链表记录就绪事件,避免了select/poll的遍历开销。核心API包括epoll_create创建监控结构、epoll_ctl管理fd、epoll_wait获取就绪事件。真正的异步
文章目录
利用钓鱼的案例深刻理解五大IO模型
在深入解析之前,我们需要明确,对于钓鱼(IO),只有两个动作:
- **等鱼上钩:**等待数据从网络到网卡,并拷贝到内核的缓冲区
- 拉鱼上岸: 将数据从内核空间缓冲区拷贝到用户空间缓冲区
那什么是高效的IO呢?受到硬件限制,任何通信场景都会有效率上限
要提高钓鱼的效率,就是减少等鱼上钩的比重
同理,要提高IO效率,就是减少IO中的等待比重
高效的IO:单位事件内,等待的比重越低,IO效率就越高!
阻塞IO
- 场景: 你拿着一根鱼竿坐在岸边,死死盯着鱼漂,鱼不上钩你什么都不干,任何事情都影响不了你
- 痛点: 一个进程只能盯着一个Socket(一根鱼竿),想接一万个客户端,就得开一万个进程。OS在几万个进程之间做上下文切换的开销,直接会把服务器卡死
代码片段:
// 进程在这里卡死,直到有数据出现
ssize_t n = ::recv(sockfd, buffer, sizeof(buffer), 0);
非阻塞轮询IO
- 场景: 你把鱼漂放好,看一眼鱼漂有没有动静,没动静就去打游戏,又继续检测,循环往复不断
- 技术演进: 设置
O_NONBLOCK,底层调用如果没有数据会立刻返回一个错误码EAGAIN,进程不会卡死 - 痛点: ** 这种
while循环被称为忙轮询**,没有数据容易造成CPU空转,占用CPU资源
通过socket()或open()拿到一个文件描述符后,这个文件的属性在创建时就定死了(默认是阻塞的),在程序运行期间,想要改变文件的性质,很难做到
Linux提供了fcntl函数实现了在不重新打开文件的前提下,直接深入内核去修改一个已有fd的属性
#include <sys/type.h>
#include <fcntl.h>
int fcntl(int fd, int op, .../* arg */);
一下是函数的五种功能:
- 复制一个现有的描述符:
F_DUPFD- 获得/设置文件描述符标记:
F_GETFD/F_SETFD- 获得/设置文件状态标记:
F_GETFL/F_SETFL- 获得/设置异步IO所有权:
F_GETTOWN/F_SETOWN- 获得/设置记录锁:
F_GETLK,F_SETLK/F_SETLKW
我们用第三种功能,把一个文件描述符设为非阻塞:
void SetNonBlock(int fd){
// 1. 获得文件描述符
int fl = ::fcntl(fd, F_GETFL);
if(fl < 0){
perror("fcntl err\n");
return;
}
// 2. 设置非阻塞
::fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
核心代码片段:
::fcntl(sockfd, F_SETFL, fl | O_NONBLOCK);
while(true) {
ssize_t n = recv(sockefd, buf, sizeof(buf), 0);
if (n < 0 && errno == EAGAIN) {
// 鱼没来,干点别的,然后马上回来继续看
continue;
}
// 鱼来了,拉鱼上岸(此时拷贝过程仍是阻塞的)
break;
}
IO多路转接
- 场景: 你承包了一个鱼塘,摆了一万根鱼竿,雇佣了一个眼观六路的大手子(epoll),你只管去睡觉,大手子帮你盯着。只要有任何一根鱼竿有鱼咬钩,大手子就叫醒你,并精确告诉你“第8766号鱼竿有鱼”,你过去拉鱼上岸。
- 技术演进: OS提供了专门的系统调用,帮我们在内核态检测海量的文件描述符
注意:别被多路转接的名字骗了,这个过程是同步的!
记住我们在定义里的两个阶段:
- 等鱼上钩(数据到内核缓冲区)
- 拉鱼上岸(数据从内核拷贝到用户空间)
多路复用(epoll)确实解决了第 1 阶段的痛点,让一个进程能等一万条鱼。但是,当大手子告诉你“有鱼了”之后,去调用 recv() 把数据从内核态复制出来时,调用 recv() 的这个进程在这个复制期间是阻塞的! 只要有阻塞,按照 POSIX 标准,它就是同步 I/O
核心代码片段:
// 1. 告诉大手子你要监控哪些鱼竿
epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &event);
// 2. 你去睡觉,等大手子叫醒你
int ready_count = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 3. 醒来后,精确处理那些有鱼的鱼竿
for(int i = 0; i < ready_count; ++i) {
if(events[i].events & EPOLLIN) {
// 直接拉鱼,不用做无用功
recv(events[i].data.fd, buf, sizeof(buf), 0);
}
}
信号驱动IO
- 场景:你在鱼竿上挂个铃铛,然后去干别的。鱼咬钩了,铃铛响(触发
SIGIO信号),你立刻放下手头的事跑过去拉鱼 - 技术演进:不需要大手子,也不用自己轮询
- 痛点:信号处理极其麻烦,如果几万个连接同时发来数据,满屏的铃铛狂响(信号风暴),会直接破坏主进程的执行流
异步IO
- 场景: 你雇了一个全自动钓鱼机器人,吩咐它:“钓到鱼后,帮我宰好洗净放进我指定的桶里(用户态缓冲区),弄好了发个微信通知我”,你全程连“拉鱼上岸”的力气都不用出,没有参与钓鱼(IO)的过程
- 技术演进:前四种模型,无论是谁帮你看鱼漂,“拉鱼上岸”(将数据从内核空间拷贝到用户空间)这个动作,都必须由你自己的进程亲自、阻塞地去完成,而真正的异步 I/O,连拷贝动作都是操作系统在后台做完的
详解IO多路转接
我们主要学习IO多路转接,而重点是epoll模型
IO多路转接的核心:一个进程通过一个系统调用函数从内核中获取多个事件
select/poll
回忆之前雇佣的大手子,select/poll雇佣的是不太靠谱的大爷,这个大爷记忆力不太好
你把一堆鱼竿交给他,他跑去水边看了一圈,回来满头大汗告诉你:“老板,有三根鱼竿咬钩了”
你问:“哪三根?”
大爷说:“我忘了,你自己跑去看吧”—>我们要自己去遍历!
select将已经连接的Socket都放到一个文件描述符集合,调用select函数将文件描述符集合拷贝到内核里,让内核检查是否有网络事件产生,采用遍历文件描述符集合的方式,检测到有事件发送,将这个Socket标记为可读或可写,再把整个文件描述符集合拷贝回用户态,用户态还需要遍历找到可读或可写的Socket,然后处理
两次遍历,两次拷贝代价太大了
并且使用固定长度的
BitsMap保存文件描述符,一般都是1024
poll采用动态数组,突破了select的文件描述符个数限制,但还会受到系统文件描述符限制
二者没有太大本质区别,都是使用线性结构存储进程关心的Socket,都需要遍历,遍历时间复杂度必然为 O ( N ) O(N) O(N),都要进行内核到用户,用户到内核的拷贝
epoll底层原理剖析
epoll内核微观世界:

三大核心API剖析
epoll_create–建模型
#include <sys/epoll.h>
int epoll_create(int size);
- 参数
size: 现已废弃,填一个大于0的数接客 - 返回值
- 成功返回一个epoll句柄,占用一个文件描述符
- 失败返回-1,并设置
errno
在底层干了什么?
**表层:**创建一个epoll
**底层:**调用这个函数,Linux内核会在内核空间中申请一块内存,并创建一个名为
struct eventepoll的结构体// 内核视角:为你创建的管家 struct eventpoll { spinlock_t lock; // 保护就绪链表的自旋锁 struct mutex mtx; // 保护红黑树的互斥锁 wait_queue_head_t wq; // 阻塞等待队列(epoll_wait 没数据时睡在这里) struct list_head rdllist; // 【核心】双向链表:专门放来消息的 fd struct rb_root rbr; // 【核心】红黑树:专门放所有被监控的 fd };做两件事:
- 初始化一棵空的红黑树(存放要监听的socket)
- 初始化一个空的双向链表(作为就绪队列,存放来消息的socket)
epoll_ctl–增删改监控fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
参数
epfd:epoll_create返回的fd -
参数
op:EPOLL_CTL_ADD:新增监控EPOLL_CTL_MOD:修改监控EPOLL_CTL_DEL:取消监控
-
参数
fd: 要监控的文件描述符 -
**参数
event:**要监控什么动作?是一个结构体:struct epoll_event { uint32_t events; // 关心的事件:EPOLLIN(可读), EPOLLOUT(可写), EPOLLET(边缘触发) epoll_data_t data; // 用户数据载体,通常是一个联合体,我们最常用的是 data.fd = sockfd };-
uint32_t events是一个位图epoll_ctrl中是站在用户->内核的用户视角下,告诉内核:”帮我盯着这个fd,并且我只关心它能不能读(EPOLLIN),并且用**边缘触发(EPOLLET)**方式通知我。“—>这里具体化了epoll_wait中是站在内核->用户的内核视角下。epoll_wait返回时,内核把events填好还给用户,告诉用户:”你交代的那个fd,现在确实可读了“
所以,
uint32_t events是一个事件类型(我关心什么动作)+ 通知策略(你该怎么通知我)的集合
-
如何做到网卡回调?
- 包装节点: 内核把传来的
fd和关心的event包装成一个struct epitem对象- 挂载红黑树: 把这个
epitem作为树节点,按 O ( l o g N ) O(logN) O(logN)的速率插入到管家的红黑树中- 注册回调: 内核走到协议栈底层的
vfs_poll,指着这个fd对底层的网卡驱动说:”兄弟,以后如果有发给这个fd的报文到了,别让我去轮询找你,你直接触发我留给你的回调函数–ep_poll_callback“数据包顺着网线到达网卡,触发硬件中断,底层驱动执行回调方法,找到对应的
epitem,插入到就绪队列中
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 参数
epfd: 监控中心的钥匙 - 参数
events: 传出参数,在用户态提前开辟好的一段数组,内核会把真正发生事件的fd信息填入到这个数组给你 - 参数
maxevents: 这个数组最大能装多少个事件,不能越界 - 参数
timeout: 等多久?-1:阻塞等待,直到有时间发生或异常返回0:非阻塞轮询,看一眼立刻走,不管有没有数据>0:最多等这么多毫秒,如果有事件或超时返回
- 返回值:
>0:有几个fd发生了事件0:超时了,目前没发现有事件发生-1:系统调用出错
底层做了什么?
- 查就绪队列: 直接看管家里的双向链表是不是空的
- 睡眠等待: 如果是空的且设置了阻塞等待,内核就把你的线程丢进等待队列里睡觉,让出CPU
- 搬运: 只要就绪队列里有节点(无论是本来就有,还是刚进来的),内核立刻把链表里的节点拷贝到传入的
events用户态数组中- 清理现场: 拷贝完后,把这些节点删除(如果是LT模式且数据没读完,会塞回来,后话)
ET边缘触发模式和LT水平触发模式
场景1::快递驿站到了你 10 个包裹。 老实巴交的 LT 小哥给你打电话:“有包裹,快来拿!” 你下楼拿了 2 个包裹就上去了。 LT 小哥一看货架上还有你的 8 个包裹,马上又给你打一个电话:“有包裹,快来拿!” 只要货架上还有你的东西,他就会无休止地夺命连环打电话,直到你全部拿光
// LT 模式下,不需要 while 循环,也不强制要求非阻塞 (虽然实战中通常还是会设)
// epoll_wait 唤醒后,你随便读多少都行
int n = recv(fd, buf, 1024, 0);
// 读完了去干别的,如果内核里还有剩余数据,下一次 epoll_wait 肯定还会立刻唤醒你
场景2: 驿站到了 10 个包裹。 高冷的 ET 小哥给你打了一个电话:“你的包裹到了,我只通知一次。” 你下楼拿了 2 个包裹就上去了。 ET 小哥看了一眼剩下的 8 个包裹,转身就走,绝对不会再给你打第二次电话。 除非明天又来了一个你的新包裹(状态再次发生跳变),他才会再打一个电话。而你之前剩下的那 8 个包裹,如果不再来新包裹,就永远烂在驿站里了!
那么问题又来了,ET只通知一次,你就必须在这次通知里一口气把所有包裹搬空,怎么保证搬空?只能死循环一直搬:
- 如果是阻塞 IO:你搬到第 11 次时(包裹已经空了),你的手就会在货架上死死卡住(线程阻塞休眠),整个服务器直接挂掉
- 所以必须用非阻塞 IO:搬到第 11 次,摸到了空气(返回
-1且errno == EAGAIN),你心里有数了:“哦,彻底搬空了”,然后从容地break退回主进程
while(true) {
int n = recv(fd, buf, sizeof(buf), 0);
if (n > 0) {
// 继续读...
} else if (n < 0) {
if (errno == EAGAIN) {
// 包裹彻底搬空了!只有看到 EAGAIN,才允许离开这个循环!
break;
}
}
}
总结一下:
LT:只要底层有数据,就一直通知上层,告诉上层条件是就绪的—>要求尽快处理就绪事件的场景
ET:当新收到数据,只会通知上层一次,从无到有或从有到多,增多时,才通知—>高并发场景
epoll一些细节梳理
epoll比select快,到底快在哪里?
- 拷贝开销剥离:
select每次调用,需要把fd集合从用户态拷贝到内核态,调用完再拷贝回来,代价极大。epoll把配置epoll_ctl和等待epoll_wait分开了,只要ctl加进红黑树,红黑树就相当于select的辅助数组,后续就不用管了- 没有无脑 O ( N ) O(N) O(N)的遍历:
select不知道到底是哪几个fd来了数据,每次必须要暴力遍历所有的fd,并发越高越慢。epoll基于事件驱动的回调机制,只有真正活跃的fd会被回调放入就绪队列,epoll_wait是 O ( 1 ) O(1) O(1)的,只和被激活的连接数有关,和总连接数无关
回调机制,一个网络包从网卡到达,到epoll_wait返回,内核发生了什么?
- 硬件中断: 数据包到达网卡,网卡把数据包拷贝到内存,然后向CPU发硬件中断
- 协议栈解析: CPU暂停当前任务,执行中断。内核协议栈剥离Mac帧、IP投、TCP头,把有效子啊和放到对应socket的接收缓冲区
- 触发回调: 内核发现这个socket之前通过
epoll_ctl注册过回调函数,立即执行- 进入就绪队列: 回调函数找到对应socket在红黑树上对应的节点,把它拎下来,插入到就绪队列中,同时唤醒
epoll_wait阻塞的线程- 用户态收网: 被唤醒后,直接将就绪队列中的数据拷贝回用户空间,业务代码拿到
fd开始执行后续逻辑
为什么epoll用红黑树存关心的fd,用双向链表存就绪的fd,哈希表不行吗?
- 双向链表: 插入和删除都是 O ( 1 ) O(1) O(1),只用检测是否为空,空休眠,非空就拷贝
- 红黑树:
- 红黑树增删查改复杂度稳定为 O ( l o g N ) O(logN) O(logN),高并发场景也是
- 为啥不用哈希表?哈希表理论是 O ( 1 ) O(1) O(1),但遇上高并发场景,哈希冲突极其严重,并且哈希表需要巨大的连续内存,扩容代价大
更多推荐


所有评论(0)