Epoll分析
本文集中于对于多路复用api Epoll部分需要注意的问题的分析
Epoll惊群
对于Epoll的使用,存在几个基础的规则,其一就是不要在多个线程中监听同一个EpollFd。但是,为什么呢?
这一点相对来说比较容易理解,对于Epoll来说,其基础的使用规则就是创建epollfd->注册fd事件->epoll_wait等待内核通知。惊群的可能触发点就在”epoll_wait等待内核通知”上。对于这个阶段,可能存在以下场景:
- 一个epollfd只被一个工作线程监听,那么自然只有一个线程被唤醒,此时不会存在问题
- 一个epollfd被多个工作线程监听,此时出现了一个IO事件,但是最终只唤醒了一个工作线程,此时也不会存在问题
- 一个epollfd被多个工作线程监听,但是此时出现了连续多个IO事件,导致在应用层的观测中,多个工作线程被同时唤醒,此时存在较大的并发上的问题
首先,我们明确一点,对于epoll本身来说,其所提供的保证实际上确实保证了其每次唤醒线程时都是一种类似于wake_up的行为,即每次最多只唤醒一个线程。但是实际上在应用侧来观测的话,我们不难看到会存在在部分并发场景中出现多监听epoll的线程同时被唤醒的情况,我们需要理解这种场景来进行分析。
在进入实际的分析之前,我期望你能够理解什么是end-to-end appointment,这对于一个CSer来说是一个必须深刻理解以及体会的观点,可以阅读该论文进行了解。https://web.mit.edu/saltzer/www/publications/endtoend/endtoend.pdf
竞态分析
首先,我们需要明确epoll系统层所能够给与我们的抽象。对于一次IO事件的到达,由于其使用的唤醒机制是类似于wake_up_locked的单唤醒api,而不是wake_up_all的全唤醒api。所以其每次唤醒确实能够提供有且只有一个阻塞线程被解除阻塞的场景。但是,如果你了解端到端的观点。你会尝试去提问,这种由底层能够提供的线程单唤醒保证是否足够,在上层观察者看来,是否存在不同的行为。
碰巧,这里确实也存在这种场景的分析。场景如下:
- 一个IO完成事件到达,此时epoll监听到事件后开始调用唤醒api来使得就绪队列中的一个线程解除阻塞
- 在第一步推完完整流程之前,此时出现了另外一个IO完成事件,该事件也会导致一次唤醒
如果在上诉描述的场景中,我们就能够观测到实际上会存在多个唤醒,虽然其本身唤醒的事件不同,但是其导致的效果是相同的。所以后续就需要考虑并发唤醒导致的问题了。
在进入下一步之前,我们回过头来,分析一下epoll实际上观测起来的行为会是怎么样的
并发模型
你是否存在一个问题,epoll本身是如何导致唤醒api的并发的?或者说,epoll内部是否实际上会存在唤醒api的并发,还是说其会存在的是一个串行的唤醒事件队列呢?这里涉及到如何理解epoll的行为。
对于epoll本身来说,其本质上是在内核中维护的一系列数据结构,包括The interest list和 The ready list以及一系列额外的api。

那么,考虑一个问题,如果epoll本身如此的无状态,那么对应的各种IO事件到来时,到底是谁来驱动epoll本身的事件的更新呢?epoll本身并不存在一个独立的线程来执行,那么势必就只能够将这种职责交给上层的IO事件源。由对应的IO事件源来调用epoll本身的api来实现对应的事件注册,以及后续的等待线程唤醒。那么你想必也能够发现一个问题,如果事件源本身是分散在多核中的呢?在这种情况下,实际上的epoll行为就是一种并发的行为,此时内核中的epoll数据结构与api提供一种原子的修改状态api,多核用户态可能并行调用这些api。其中关于数据结构的修改,内核通过自旋锁进行了保护,而对于就绪队列(这里指的是关于休眠线程的就绪队列),其也存在一种机制来保护每次的唤醒正确。但是,由于实际上的唤醒api可能出现并行的调用,那么即使底层的api保证为一种串行的唤醒,在更上层看起来,其也是一种并发的唤醒场景。
对于epoll的并发模型简单的分析之后,不免让人惊叹设计者的设计哲学,其就像一个持着手术刀的医生,使用其极为精湛的技术,完全的切分了每一层的职责,底层的数据结构和api保证了对应的操作原子性,上层的多核并行性提供了一种并发模型的抽象。再给上层提供了一种高效的IO多路复用方式。这就是end-to-end appointment在系统设计中一次淋漓尽致的体现。这种刚刚好的美感,真的令人着迷。
在了解上面的内容之后,我们可以来总结一下当前epoll所能够提供给上层的抽象。
- epoll的底层行为由IO事件源驱动,而事件源可能存在多个,在多核的调度下,可能使得对于一个epoll的状态修改并行化
- epoll本身保证其状态修改的串行化,但是由于其唤醒线程的操作没有位于临界区内,所以即使前面已经串行化修改操作,由于各种硬件软件上的场景,实际上存在线程唤醒的api调用是非常可能发生的
- epoll本身保证了其在并发下涉及其数据结构改动操作的正确,也保证了一次IO事件的到达只可能唤醒一个等待线程。但是其并没有提供在并发下的多监听线程的唤醒模型。
由此,我们可以来回答我们前面涉及到的并发唤醒竞态问题
对于epoll来说,如果存在多个线程同时监听了同一个epollfd,同时阻塞在了epoll_wait处,那么这些多个线程实际上是可能被同时唤醒的。而且由于唤醒本身是无状态的,所以这种唤醒可能会导致对于就绪fd的事件的争抢,这样又可能导致更大的并发问题。所以为了来解决这种问题,我们可以存在俩种策略
- 在应用层设计一种更加严格的唤醒-执行机制,保证多线程监听同一个fd时候的逻辑正确
- 保持KISS原则,拒绝在多线程中监听同一个epollfd,这从根本上解决了问题
进一步分析
接下来,我们进入进一步的竞态分析。在进入分析之前,我们需要先给出一定的假设
- 对于一个epollfd的监听,只会由一个master线程进行监听,这样消除了先前讨论的竞态
- 当前的架构中除开一个监听epollfd的master线程,还会存在一系列处理IO完成事件的worker线程,在master监听到IO事件之后,需要将事件分发给worker进行执行
- worker本身是单线程的逻辑处理,只要数据包顺序到达,其就能够保证数据正确处理
在进入下一步之前,你可以先停一下,分析一下在先前的假设下是否存在什么竞态场景。
先前我们分析的第一个竞态的粒度是在监听线程的唤醒上,这次我们将视角移动到IO完成事件的派发上。此时可能存在俩种场景:
- master线程对于一个fd产生的事件,其只会将其派发到同一个worker-thread上
- master线程对于fd产生的事件,其只会进行随机进行分配,不提供任何派发上的保证
对于这里的第一种策略,只要该worker中的逻辑正确,由于fd本身是一种在宏观上串行的处理逻辑,那么自然不会出现一些基础的错误。但是对于第二种场景,其需要的注意场景就比较多了。我们假设存在以下场景
- 在epoll中注册的关于一个fd的IO完成事件到达,epoll_wait解除阻塞,thread_worker1接收并处理该fd数据
- 在第一步的逻辑处理完全完成之前,此时又出现了新的IO完成事件,而且该IO事件也还是该fd上的事件,而且此时master将该事件派发给了thread_worker2处理
此时你应该能够识别出上诉场景中可能存在的问题了,我不再赘诉,下面给出个图辅助理解
在理想情况下,此时对应的俩个读写事件都能够按照原有的逻辑正常执行,此时不会出现逻辑上的错误。但是在实际的场景中,出现”竞态”中的场景几乎是必然的,当多线程同时对于一个fd进行操作时,对应的读写操作之间必须存在互斥机制,否则在出现交错时通常就会出现期望之外的行为。所以我们必须来针对这种场景来进行解决。给出的解决方法有俩种
- 应用层保证一种一致性哈希,使得一个fd只会被固定分配到同一个worker-thread中
- 使用一种额外的机制来保证读写之间的互斥
这俩种场景都有其应用场景,第一种操作看起来更加的优雅,其没有引入额外的互斥机制,对于原有的性能没有多大的损耗,但是附带的,其的应用场景也比较有限,或者换句话说,使用场景更加明确,其需要自己来明确一致性哈希派发逻辑,如果后续想要复用,必须遵守这里的派发的规则。
更加灵活但是代价更大的就是实现一种额外的机制来保证读写的互斥,所幸,epoll底层本身提供了这么一种机制,即EPOLLONESHOT,这个事件属性可以使得一个epoll内部事件在被通知后立刻被消耗,不再保持原有的持续存活的状态,如果需要再次监听该事件,必须再次通过epoll_ctl来进行注册。也就是说,这个EPOLLONESHOT属性更加严格的限制了一个事件的生命周期,使得在应用侧可以更加灵活的来进行管理。试想,如果你能够在应用层上保证你在一次处理来自epoll的IO事件之后再重新注册之后,你本身就在应用层上保证了互斥,而底层也仅仅只是管理了一定的生命周期,并没有重复的互斥机制存在。此处实际上也是一种优雅的端到端的体现。而且,你是否想到了,在ASIO等一众网络库中,当你监听完一个事件后,你需要重新注册该事件来监听。这不难让人猜测去其实际上也是通过对于EPOLLONESHOT属性的利用来设计的。
额外给出一个简单的思考题,为什么ASIO等一众网络库要采用重复注册的做法而不是使用前文所提到的一致性哈希?
总结
这次对于epoll的简单分析,对于我来说是一种酣畅淋漓的体验,其让我看到了一些系统设计上的优雅,到底设计者是怎么在软件与硬件之间做的tradeoff来平衡,拼接俩者之间的特性来组合出一个优雅的底层系统。一个底层api所能够提供的抽象到底应该怎么来理解,我们应该怎么利用这一层抽象来构建出属于自己的另外一层抽象,自己的这一层抽象又对外暴露了什么。还有,我们怎么将这些知识给迁移到其他的学习中,特备是一些流行的网络库中,就比如ASIO等,如何通过epoll本身的特性,框架本身的使用方法,来推测框架这个黑盒的实现,进而来反哺我们在应用层上的优雅设计以及对于底层的剖析和对于系统架构的理解。这种分析过程是一种全方位的只是的整合,是一种令人酣畅淋漓的学习过程。