利用钓鱼的案例深刻理解五大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提供了专门的系统调用,帮我们在内核态检测海量的文件描述符

注意:别被多路转接的名字骗了,这个过程是同步的!

记住我们在定义里的两个阶段:

  1. 等鱼上钩(数据到内核缓冲区)
  2. 拉鱼上岸(数据从内核拷贝到用户空间)

多路复用(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内核微观世界:

image-20260301151521719

三大核心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 次,摸到了空气(返回 -1errno == 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一些细节梳理

epollselect快,到底快在哪里?

  • 拷贝开销剥离: 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),但遇上高并发场景,哈希冲突极其严重,并且哈希表需要巨大的连续内存,扩容代价大
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐