物联网接入层技术剖析(二):epoll到底是怎么工作的

Netty与高性能网络服务、Linux高并发网络编程实战、从epoll到Netty:物联网接入层技术剖析、深入理解I/O多路复用、服务端网络编程进阶指南

深入Linux内核:epoll到底是怎么工作的

0 写在前面

上一篇文章聊了 select、poll、epoll 的宏观对比,算是把 epoll 的设计思想讲了个大概。但说实话,光知道"红黑树加链表"这几个字,遇到实际问题时还是会有种似懂非懂的感觉。

这一篇我想再往下挖一层,看看 epoll 在 Linux 内核里到底长什么样。不会逐行分析源码(那样太枯燥了),而是挑出几个关键的数据结构和函数调用链,把 epoll 的工作原理串成一条线。理解了这条线,后面看 Netty 的源码或者排查线上问题都会轻松很多。

1 epoll的三个系统调用

先快速回顾一下 epoll 的三个 API,因为后面的分析都是围绕它们展开的:

c 复制代码
int epoll_create(int size);    // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  // 管理fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);  // 等待事件

epoll_create 中的 size 参数在 Linux 2.6.8 之后已经没有实际意义了(早期实现用的是 hash 表,size 用来指定 hash 桶的数量,后来换成了红黑树),但调用时仍然必须传一个大于 0 的值,这是为了向后兼容。

这三个系统调用分别对应了 epoll 的三个阶段:创建注册等待。我们一个一个来看。

2 epoll_create:在内核中安家

当你调用 epoll_create(1) 时,内核会做什么?

它会进入 do_epoll_create() 函数,主要做两件事:

第一,调用 ep_alloc() 分配并初始化一个 eventpoll 结构体。这个结构体就是 epoll 实例在内核中的"本体",所有后续操作都是围绕它展开的。

第二,调用 anon_inode_getfile() 把这个 eventpoll 对象包装成一个匿名文件,然后通过 get_unused_fd_flags() 分配一个文件描述符返回给用户态。

所以你在用户态拿到的那个 epfd,本质上就是一个文件描述符,指向内核中的 eventpoll 对象。这也解释了为什么 epoll 的 API 设计和文件操作那么像------因为底层确实就是文件系统的那套机制。

3 eventpoll:epoll的大脑

eventpoll 是整个 epoll 机制的核心数据结构,值得仔细看看它里面都装了什么:

c 复制代码
struct eventpoll {
    spinlock_t lock;           // 自旋锁,保护结构体访问
    struct mutex mtx;          // 互斥锁,防止使用时被删除
    wait_queue_head_t wq;      // epoll_wait 使用的等待队列
    wait_queue_head_t poll_wait;  // file->poll() 使用的等待队列
    struct list_head rdllist;  // 就绪事件的双向链表
    struct rb_root rbr;        // 管理所有fd的红黑树
    struct epitem *ovflist;    // 事件溢出链表
    struct user_struct *user;  // 创建者
    struct file *file;         // 关联的文件对象
    // ...
};

这里面有三个字段特别关键,我把它画成一张概念图来帮助理解:

复制代码
eventpoll
  |
  +-- rbr (红黑树)          <-- 存储所有被监控的fd
  |     |
  |     +-- epitem (fd=3)
  |     +-- epitem (fd=7)
  |     +-- epitem (fd=12)
  |     +-- ...
  |
  +-- rdllist (双向链表)    <-- 存储已就绪的fd
  |     |
  |     +-- epitem (fd=7)   <-- 这个fd有数据了
  |     +-- epitem (fd=12)  <-- 这个fd也有数据了
  |
  +-- wq (等待队列)         <-- epoll_wait阻塞的进程在这里等

红黑树 rbr 是"花名册",记录了所有你要监控的 fd。就绪链表 rdllist 是"待办事项",只有状态发生变化的 fd 才会出现在这里。等待队列 wq 则是"休息室",调用 epoll_wait 的进程在没有就绪事件时会在这里睡觉。

这三个结构各司其职,配合起来就构成了 epoll 的工作闭环。

4 epitem:每个fd的档案卡

红黑树上的每个节点都是一个 epitem 结构体,它是对被监控 fd 的完整描述:

c 复制代码
struct epitem {
    struct rb_node rbn;           // 红黑树节点
    struct list_head rdllink;     // 就绪链表节点
    struct epoll_filefd ffd;      // fd信息
    struct eppoll_entry *pwqlist; // 等待队列
    struct eventpoll *ep;         // 所属的eventpoll
    struct epoll_event event;     // 注册的事件
    // ...
};

你可以把 epitem 理解成一张"档案卡"。每个被监控的 fd 都有一张这样的卡片,卡片上记录了这个 fd 关心什么事件(读?写?异常?)、属于哪个 epoll 实例、以及各种链表指针(方便把它挂到不同的数据结构上)。

为什么要用红黑树来存这些卡片?因为当连接断开时,你需要通过 fd 快速找到对应的 epitem 并从 epoll 中移除。红黑树的 O(logN) 查找保证了这个操作的效率。如果用链表的话,最坏情况要遍历全部节点,那和 select 就没什么区别了。

5 epoll_ctl:注册fd的全过程

现在来看最复杂的部分------当你调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) 时,内核到底做了什么?

整个调用链是:epoll_ctldo_epoll_ctlep_insert(以添加操作为例)。

ep_insert 的核心流程大概是这样的:

第一步,分配并初始化 epitem。 内核从 slab 缓存中分配一个 epitem 对象,把 fd、事件类型等信息填进去。

第二步,设置回调函数。 这一步非常关键。ep_insert 会调用被监听文件的 poll 接口。如果这个文件是一个 socket,那实际调用的就是 tcp_poll()tcp_poll() 内部会调用 poll_wait(),最终触发 ep_ptable_queue_proc() 函数。

ep_ptable_queue_proc() 做了什么呢?它创建了一个等待队列项,把回调函数设置为 ep_poll_callback,然后把这个等待队列项挂到 socket 自身的等待队列上。

翻译成人话就是:告诉 socket,"以后你有事了(数据到了、连接断了之类的),记得叫我一声,我的电话号码是 ep_poll_callback"。

第三步,插入红黑树。 调用 ep_rbtree_insert() 把 epitem 插入到 eventpoll 的红黑树中。

第四步,检查是否已经就绪。 如果这个 fd 在注册的时候就已经处于就绪状态(比如缓冲区里已经有数据了),就直接把它挂到就绪链表 rdllist 上,并唤醒可能在 epoll_wait 中阻塞的进程。

整个过程可以用一个序列来表示:

复制代码
epoll_ctl(ADD)
  → ep_insert()
    → 分配 epitem
    → tfile->f_op->poll()  [即 tcp_poll()]
      → poll_wait()
        → ep_ptable_queue_proc()
          → 在socket等待队列上注册 ep_poll_callback
    → ep_rbtree_insert()   [插入红黑树]
    → 如果已就绪 → 加入 rdllist → 唤醒等待进程

6 ep_poll_callback:事件到达时的闹钟

前面反复提到的 ep_poll_callback,是整个 epoll 事件驱动模型的核心。当网卡收到数据,经过中断处理,最终会唤醒 socket 等待队列上的回调函数。如果这个 socket 被 epoll 监控了,那么被唤醒的就是 ep_poll_callback

这个回调函数的逻辑非常简洁:

c 复制代码
static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key) {
    // 把就绪的epitem加入就绪链表
    list_add_tail(&epi->rdllink, &ep->rdllist);
    // 唤醒在epoll_wait中阻塞的进程
    if (waitqueue_active(&ep->wq))
        wake_up_locked(&ep->wq);
    return 1;
}

就两步:把对应的 epitem 挂到就绪链表上,然后叫醒在等着的进程。

简单,但高效。不需要遍历任何数据结构,不需要做任何检查,事件来了直接挂链表、叫人。O(1) 的时间复杂度。

这也是为什么 epoll 在"大量连接、少量活跃"的场景下表现特别好的根本原因------大部分时候什么都不会发生,只有那几个活跃连接才会触发回调。连接数再多,不活跃的连接就是静默的,不会产生任何开销。

7 epoll_wait:等待与返回

最后来看看 epoll_wait 的工作流程。

当用户态调用 epoll_wait(epfd, events, maxevents, timeout) 时:

  1. 检查就绪链表 rdllist 是否为空
  2. 如果不为空,把链表中的就绪事件复制到用户态的 events 数组中,返回就绪数量
  3. 如果为空且 timeout > 0,把当前进程挂到 eventpoll 的等待队列 wq 上,让出 CPU
  4. 进程被 ep_poll_callback 唤醒后,重新检查就绪链表,复制事件,返回

注意第二步的"复制"。在早期的 epoll 实现中,这里确实需要从内核空间向用户空间拷贝数据。但 epoll 通过 mmap 技术,让内核和用户空间共享同一块物理内存,所以这个"复制"实际上就是一次内存读取,不需要跨越内核态和用户态的边界。

这也是 epoll 相比 select/poll 的一个隐性优势:select/poll 每次调用都要把全部 fd 从用户空间拷贝到内核空间再拷贝回来,而 epoll 只在 epoll_wait 返回时拷贝就绪的事件------通常只是全部 fd 中很小的一部分。

8 EPOLLONESHOT:一个有用的选项

在实际开发中,还有一个 epoll 选项值得了解:EPOLLONESHOT

考虑这样一个场景:你的物联网平台用线程池来处理设备消息。线程 A 从某个 socket 读取了数据,正在处理。处理过程中,这个 socket 又来了新数据,线程 B 被唤醒去读取。两个线程同时操作同一个 socket,数据就乱套了。

EPOLLONESHOT 就是解决这个问题的。设置了 EPOLLONESHOT 的 fd,在被触发一次之后会自动从 epoll 中"禁用",不再触发任何事件。直到处理完数据的线程主动调用 epoll_ctl 重新注册这个 fd,它才会再次生效。

这样就保证了同一个 socket 在同一时刻只会被一个线程处理,避免了竞态条件。在多线程的物联网网关中,这个选项非常实用。

9 小结

把 epoll 的完整工作流程串起来,其实就是一条清晰的链路:

复制代码
epoll_create() → 创建 eventpoll(红黑树 + 就绪链表 + 等待队列)
epoll_ctl(ADD) → 创建 epitem → 注册回调到 socket → 插入红黑树
socket收到数据 → 触发 ep_poll_callback → epitem 挂入就绪链表 → 唤醒进程
epoll_wait() → 检查就绪链表 → 复制事件到用户态 → 返回

理解了这条链路,你就理解了 epoll 的全部精髓。没有魔法,没有玄学,就是几个精心设计的数据结构加上一个回调机制,把"轮询"变成了"通知"。

下一篇文章,我们会从内核态回到用户态,看看 Java 是如何通过 NIO Selector 来使用 epoll 的,以及这中间隔着多少层抽象。

10 参考资料

相关推荐
敖正炀8 小时前
HashMap 源码深度拆解(JDK 7→8)
java
上海合宙LuatOS8 小时前
Air8000多网通信- RNDIS/ECM
物联网·lua·嵌入式开发·多网通信
Donk_678 小时前
什么是虚拟化
linux·运维
Shadow(⊙o⊙)8 小时前
Shell进程替换,自定义Shell解释器——字符串库函数灵活操作!
linux·运维·服务器·开发语言·c++·学习
DevOpenClub8 小时前
职教高考及高职分类招生控制线 API 接口
java·数据库·高考
funnycoffee1238 小时前
华为S5736交换机3层ECMP负载方式
linux·服务器·数据库
Tsuki_tl8 小时前
【总结】Java的线程状态
java·后端·面试·多线程·并发编程·线程状态
机器学习之心8 小时前
基于贝叶斯优化超参数的图卷积网络(BO-GCN)分类预测模型(附实验文档+Matlab代码)
网络·matlab·分类·分类预测模型·bo-gcn
苦逼的猿宝8 小时前
springboot的网页时装购物系统
java·毕业设计·springboot·计算机毕业设计