盘点LinuxEpoll那些致命弱点

一口Linux 2022-02-07 00:00

内容目录

1. 引言2. 脉络3 epoll 多线程扩展性3.1特定TCP listen fd的accept(2) 的问题3.1.1 水平触发的问题:不必要的唤醒3.1.2 边缘触发的问题:不必要的唤醒以及饥饿3.1.3 怎样才是正确的做法?3.1.4 其他方案3.2 大量TCP连接的read(2)的问题3.2.1 水平触发的问题:数据乱序3.2.2 边缘触发的问题:数据乱序3.2.3 怎样才是正确的做法?3.3 epoll load balance 总结4. epoll之file descriptor与file description4.1 总结5 引用

1 引言

本文来自 Marek’s 博客中 I/O multiplexing part 系列之三和四,原文一共有四篇,主要讲 Linux 上 IO 多路复用的一些问题,本文加入了我的一些个人理解,如有不对之处敬请指出。原文链接如下:

The history of the Select(2) syscall [1]
Select(2) is fundamentally broken [2]
Epoll is fundamentally broken 1/2 [3]
Epoll is fundamentally broken 2/2 [4]

2 脉络

系列三和系列四分别讲 epoll(2) 存在的两个不同的问题:

  1. 系列三主要讲 epoll 的多线程扩展性的问题

  2. 系列四主要讲 epoll 所注册的 fd (file descriptor) 和实际内核中控制的结构 file description 拥有不同的生命周期

我们在此也按照该顺序进行阐述。

3 epoll 多线程扩展性

epoll 的多线程扩展性的问题主要体现在做多核之间负载均衡上,有两个典型的场景:

  1. 一个 TCP 服务器,对同一个 listen fd 在多个 CPU 上调用 accept(2) 系统调用

  2. 大量 TCP 连接调用 read(2) 系统调用上

3.1 特定 TCP listen fd 的 accept(2) 的问题

一个典型的场景是一个需要处理大量短连接的 HTTP 1.0 服务器,由于需要 accept() 大量的 TCP 建连请求,所以希望把这些 accept() 分发到不同的 CPU 上来处理,以充分利用多 CPU 的能力。

这在实际生产环境是存在的, Tom Herbert 报告有应用需要处理每秒 4 万个建连请求;当有这么多请求的时候,很显然,将其分散到不同的 CPU 上是合理的。

然后实际上,事情并没有这么简单,直到 Linux 4.5 内核,都无法通过 epoll(2) 把这些请求水平扩展到其他 CPU 上。下面我们来看看 epoll 的两种模式 LT(level trigger, 水平触发) 和 ET(edge trigger, 边缘触发) 在处理这种情况下的问题。

3.1.1 水平触发的问题:不必要的唤醒

一个愚蠢的做法是是将同一个 epoll fd 放到不同的线程上来 epoll_wait(),这样做显然行不通,同样,将同一个用于 accept 的 fd 加到不同的线程中的 epoll fd 中也行不通。

这是因为 epoll 的水平触发模式和 select(2) 一样存在 “惊群效应”,在不加特殊标志的水平触发模式下,当一个新建连接请求过来时,所有的 worker 线程都都会被唤醒,下面是一个这种 case 的例子:

11. 内核:收到一个新建连接的请求
22. 内核:由于 "惊群效应" ,唤醒两个正在 epoll_wait() 的线程 A 和线程 B
33. 线程A:epoll_wait() 返回
44. 线程B:epoll_wait() 返回
55. 线程A:执行 accept() 并且成功
66. 线程B:执行 accept() 失败,accept() 返回 EAGAIN

其中,线程 B 的唤醒完全没有必要,仅仅只是浪费宝贵的 CPU 资源而已,水平触发模式的 epoll 的扩展性很差。

3.1.2 边缘触发的问题:不必要的唤醒以及饥饿

既然水平触发模式不行,那是不是边缘触发模式会更好呢?实际上并没有。我们来看看下面这个例子:

11. 内核:收到第一个连接请求。线程 A 和 线程 B 两个线程都在 epoll_wait() 上等待。由于采用边缘触发模式,所以只有一个线程会收到通知。这里假定线程 A 收到通知
22. 线程A:epoll_wait() 返回
33. 线程A:调用 accpet() 并且成功
44. 内核:此时 accept queue 为空,所以将边缘触发的 socket 的状态从可读置成不可读
55. 内核:收到第二个建连请求
66. 内核:此时,由于线程 A 还在执行 accept() 处理,只剩下线程 B 在等待 epoll_wait(),于是唤醒线程 B
77. 线程A:继续执行 accept() 直到返回 EAGAIN
88. 线程B:执行 accept(),并返回 EAGAIN,此时线程 B 可能有点困惑("明明通知我有事件,结果却返回 EAGAIN")
99. 线程A:再次执行 accept(),这次终于返回 EAGAIN

可以看到在上面的例子中,线程 B 的唤醒是完全没有必要的。另外,事实上边缘触发模式还存在饥饿的问题,我们来看下面这个例子:

11. 内核:接收到两个建连请求。线程 A 和 线程 B 两个线程都在等在 epoll_wait()。由于采用边缘触发模式,只有一个线程会被唤醒,我们这里假定线程 A 先被唤醒
22. 线程A:epoll_wait() 返回
33. 线程A:调用 accpet() 并且成功
44. 内核:收到第三个建连请求。由于线程 A 还没有处理完(没有返回 EAGAIN),当前 socket 还处于可读的状态,由于是边缘触发模式,所有不会产生新的事件
55. 线程A:继续执行 accept() 希望返回 EAGAIN 再进入 epoll_wait() 等待,然而它又 accept() 成功并处理了一个新连接
66. 内核:又收到了第四个建连请求
77. 线程A:又继续执行 accept(),结果又返回成功

在这个例子中个,这个 socket 只有一次从不可读状态变成可读状态,由于 socket 处于边缘触发模式,内核只会唤醒 epoll_wait() 一次。在这个例子中个,所有的建连请求全都会给线程 A,导致这个负载均衡根本没有生效,线程 A 很忙而线程 B 没有活干。

3.1.3 怎样才是正确的做法?

既然水平触发和边缘触发都不行,那怎样才是正确的做法呢?有两种 workaround 的方式:

  1. 最好的也是唯一支持可扩展的方式是使用从 Linux 4.5+ 开始出现的水平触发模式新增的 EPOLLEXCLUSIVE 标志,这个标志会保证一个事件只有一个 epoll_wait() 会被唤醒,避免了 “惊群效应”,并且可以在多个 CPU 之间很好的水平扩展。

  2. 当内核不支持EPOLLEXCLUSIVE 时,可以通过 ET 模式下的 EPOLLONESHOT 来模拟 LT + EPOLLEXCLUSIVE 的效果,当然这样是有代价的,需要在每个事件处理完之后额外多调用一次 epoll_ctl(EPOLL_CTL_MOD) 重置这个 fd。这样做可以将负载均分到不同的 CPU 上,但是同一时刻,只能有一个 worker 调用 accept(2)。显然,这样又限制了处理 accept(2) 的吞吐。下面是这样做的例子:

  3. 内核:接收到两个建连请求。线程 A 和 线程 B 两个线程都在等在 epoll_wait()。由于采用边缘触发模式,只有一个线程会被唤醒,我们这里假定线程 A 先被唤醒

  4. 线程A:epoll_wait() 返回

  5. 线程A:调用 accpet() 并且成功

  6. 线程A:调用 epoll_ctl(EPOLL_CTL_MOD),这样会重置 EPOLLONESHOT 状态并将这个 socket fd 重新准备好 “

3.1.4 其他方案

当然,如果不依赖于 epoll() 的话,也还有其他方案。一种方案是使用 SO_REUSEPORT 这个 socket option,创建多个 listen socket 共用一个端口号,不过这种方案其实也存在问题: 当一个 listen socket fd 被关了,已经被分到这个 listen socket fd 的 accept 队列上的请求会被丢掉,具体可以参考 https://engineeringblog.yelp.com/2015/04/true-zero-downtime-haproxy-reloads.html 和 LWN 上的 comment[5]

从 Linux 4.5 开始引入了 SO_ATTACH_REUSEPORT_CBPFSO_ATTACH_REUSEPORT_EBPF 这两个 BPF 相关的 socket option。通过巧妙的设计,应该可以避免掉建连请求被丢掉的情况。

3.2 大量 TCP 连接的 read(2) 的问题

除了 3.1 中说的 accept(2) 的问题之外, 普通的 read(2) 在多核系统上也会有扩展性的问题。设想以下场景:一个 HTTP 服务器,需要跟大量的 HTTP client 通信,你希望尽快的处理每个客户端的请求。而每个客户端连接的请求的处理时间可能并不一样,有些快有些慢,并且不可预测,因此简单的将这些连接切分到不同的 CPU 上,可能导致平均响应时间变长。一种更好的排队策略可能是:用一个 epoll fd 来管理这些连接并设置 EPOLLEXCLUSIVE,然后多个 worker 线程来 epoll_wait(),取出就绪的连接并处理[注1]。油管上有个视频介绍这种称之为 “combined queue” 的模型。

下面我们来看看 epoll 处理这种模型下的问题:

3.2.1 水平触发的问题:数据乱序

实际上,由于水平触发存在的 “惊群效应”,我们并不想用该模型。另外,即使加上 EPOLLEXCLUSIVE 标志,仍然存在数据竞争的情况,我们来看看下面这个例子:

11. 内核:收到 2047 字节的数据
22. 内核:线程 A 和线程 B 两个线程都在 epoll_wait(),由于设置了 EPOLLEXCLUSIVE,内核只会唤醒一个线程,假设这里先唤醒线程 A
33. 线程A:epoll_wait() 返回
44. 内核:内核又收到 2 个字节的数据
55. 内核:线程 A 还在干活,当前只有线程 B 在 epoll_wait(),内核唤醒线程 B
66. 线程A:调用 read(2048) 并读走 2048 字节数据
77. 线程B:调用 read(2048) 并读走剩下的 1 字节数据

这上述场景中,数据会被分片到两个不同的线程,如果没有锁保护的话,数据可能会存在乱序。

3.2.2 边缘触发的问题:数据乱序

既然水平触发模型不行,那么边缘触发呢?实际上也存在相同的竞争,我们看看下面这个例子:

 11. 内核:收到 2048 字节的数据
22. 内核:线程 A 和线程 B 两个线程都在 epoll_wait(),由于设置了 EPOLLEXCLUSIVE,内核只会唤醒一个线程,假设这里先唤醒线程 A
33. 线程A:epoll_wait() 返回
44. 线程A:调用 read(2048) 并返回 2048 字节数据
55. 内核:缓冲区数据全部已经读完,又重新将该 fd 挂到 epoll 队列上
66. 内核:收到 1 字节的数据
77. 内核:线程 A 还在干活,当前只有线程 B 在 epoll_wait(),内核唤醒线程 B
88. 线程B:epoll_wait() 返回
99. 线程B:调用 read(2048) 并且只读到了 1 字节数据
1010. 线程A:再次调用 read(2048),此时由于内核缓冲区已经没有数据,返回 EAGAIN

3.2.3 怎样才是正确的做法?

实际上,要保证同一个连接的数据始终落到同一个线程上,在上述 epoll 模型下,唯一的方法就是 epoll_ctl 的时候加上 EPOLLONESHOT 标志,然后在每次处理完重新把这个 socket fd 加到 epoll 里面去。

3.3 epoll load balance 总结

要正确的用好 epoll(2) 并不容易,要用 epoll 实现负载均衡并且避免数据竞争,必须掌握好 EPOLLONESHOTEPOLLEXCLUSIVE 这两个标志。而 EPOLLEXCLUSIVE 又是个 epoll 后来新加的标志,所以我们可以说 epoll 最初设计时,并没有想着支持这种多线程负载均衡的场景。

4. epoll 之 file descriptor 与 file description

这一章我们主要讨论 epoll 的另一个大问题:file descriptor 与 file description 生命周期不一致的问题。

Foom 在 LWN[6] 上说道:

1显然 epoll 存在巨大的设计缺陷,任何懂得 file descriptor 的人应该都能看得出来。事实上当你回望 epoll 的历史,你会发现当时实现 epoll 的人们显然并不怎么了解 file descriptor 和 file description 的区别。:(

实际上,epoll() 的这个问题主要在于它混淆了用户态的 file descriptor (我们平常说的数字 fd) 和内核态中真正用于实现的 file description。当进程调用 close(2) 关闭一个 fd 时,这个问题就会体现出来。

epoll_ctl(EPOLL_CTL_ADD) 实际上并不是注册一个 file descriptor (fd),而是将 fd 和 一个指向内核 file description 的指针的对 (tuple) 一块注册给了 epoll,导致问题的根源在于,epoll 里管理的 fd 的生命周期,并不是 fd 本身的,而是内核中相应的 file description 的。

当使用 close(2) 这个系统调用关掉一个 fd 时,如果这个 fd 是内核中 file description 的唯一引用时,内核中的 file description 也会跟着一并被删除,这样是 OK 的;但是当内核中的 file description 还有其他引用时,close 并不会删除这个 file descrption。这样会导致当这个 fd 还没有从 epoll 中挪出就被直接 close 时,epoll() 还会在这个已经 close() 掉了的 fd 上上报事件。

这里以 dup(2) 系统调用为例来展示这个问题:

 1rfd, wfd = pipe()
2write(wfd, "a")             # Make the "rfd" readable
3
4epfd = epoll_create()
5epoll_ctl(efpd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd))
6
7rfd2 = dup(rfd)
8close(rfd)
9
10r = epoll_wait(epfd, -1ms)  # What will happen?

由于 close(rfd) 关掉了这个 rfd,你可能会认为这个 epoll_wait() 会一直阻塞不返回,而实际上并不是这样。由于调用了 dup(),内核中相应的 file description 仍然还有一个引用计数而没有被删除,所以这个 file descption 的事件仍然会上报给 epoll。因此 epoll_wait() 会给一个已经不存在的 fd 上报事件。更糟糕的是,一旦你 close() 了这个 fd,再也没有机会把这个死掉的 fd 从 epoll 上摘除了,下面的做法都不行:

1epoll_ctl(efpd, EPOLL_CTL_DEL, rfd)
2epoll_ctl(efpd, EPOLL_CTL_DEL, rfd2)

Marc Lehmann 也提到这个问题:

1因此,存在 close 掉了一个 fd,却还一直从这个 fd 上收到 epoll 事件的可能性。并且这种情况一旦发生,不管你做什么都无法恢复了。

因此,并不能依赖于 close() 来做清理工作,一旦调用了 close(),而正好内核里面的 file description 还有引用,这个 epoll fd 就再也修不好了,唯一的做法是把的 epoll fd 给干掉,然后创建一个新的并将之前那些 fd 全部再加到这个新的 epoll fd 上。所以记住这条忠告:

1永远记着先在调用 close() 之前,显示的调用 epoll_ctl(EPOLL_CTL_DEL)

4.1 总结

显式的将 fd 从 epoll 上面删掉在调用 close() 的话可以工作的很好,前提是你对所有的代码都有掌控力。然后在一些场景里并不一直是这样,譬如当写一个封装 epoll 的库,有时你并不能禁止用户调用 close(2) 系统调用。因此,要写一个基于 epoll 的轻量级的抽象层并不是一个轻松的事情。

另外,Illumos 也实现了一套 epoll() 机制,在他们的手册上,明确提到 Linux 上这个 epoll()/close() 的奇怪语义,并且拒绝支持。

希望本所提到的问题对于使用 Linux 上这个糟糕的 epoll() 设计的人有所帮助。


注1:笔者认为该场景下或许直接用一个 master 线程来做分发,多个 worker 线程做处理 或者采用每个 worker 线程一个自己独立的 epoll fd 可能是更好的方案。

5 引用

[1]https://idea.popcount.org/2016-11-01-a-brief-history-of-select2/

[2]https://idea.popcount.org/2017-01-06-select-is-fundamentally-broken/

[3]https://idea.popcount.org/2017-02-20-epoll-is-fundamentally-broken-12/

[4]https://idea.popcount.org/2017-03-20-epoll-is-fundamentally-broken-22/

[5]https://lwn.net/Articles/542866/

[6]https://lwn.net/Articles/542866/

[7]https://kernel.taobao.org/2019/12/epoll-is-fundamentally-broken/

[8]https://zh.wikipedia.org/wiki/Epoll

[9]https://stackoverflow.com/questions/4058368/what-does-eagain-mean

 

end



一口Linux 


关注,回复【1024】海量Linux资料赠送

精彩文章合集

文章推荐

【专辑】ARM
【专辑】粉丝问答
【专辑】所有原创
专辑linux入门
专辑计算机网络
专辑Linux驱动
【干货】嵌入式驱动工程师学习路线
【干货】Linux嵌入式所有知识点-思维导图


点击“阅读原文”查看更多分享,欢迎点分享、收藏、点赞、在看

一口Linux 写点代码,写点人生!
评论 (0)
  • 探针本身不需要对焦。探针的工作原理是通过接触被测物体表面来传递电信号,其精度和使用效果取决于探针的材质、形状以及与检测设备的匹配度,而非对焦操作。一、探针的工作原理探针是检测设备中的重要部件,常用于电子显微镜、坐标测量机等精密仪器中。其工作原理主要是通过接触被测物体的表面,将接触点的位置信息或电信号传递给检测设备,从而实现对物体表面形貌、尺寸或电性能等参数的测量。在这个过程中,探针的精度和稳定性对测量结果具有至关重要的影响。二、探针的操作要求在使用探针进行测量时,需要确保探针与被测物体表面的良好
    锦正茂科技 2025-04-02 10:41 71浏览
  • 在智能交互设备快速发展的今天,语音芯片作为人机交互的核心组件,其性能直接影响用户体验与产品竞争力。WT588F02B-8S语音芯片,凭借其静态功耗<5μA的卓越低功耗特性,成为物联网、智能家居、工业自动化等领域的理想选择,为设备赋予“听得懂、说得清”的智能化能力。一、核心优势:低功耗与高性能的完美结合超低待机功耗WT588F02B-8S在休眠模式下待机电流仅为5μA以下,显著延长了电池供电设备的续航能力。例如,在电子锁、气体检测仪等需长期待机的场景中,用户无需频繁更换电池,降低了维护成本。灵活的
    广州唯创电子 2025-04-02 08:34 152浏览
  • 提到“质量”这两个字,我们不会忘记那些奠定基础的大师们:休哈特、戴明、朱兰、克劳士比、费根堡姆、石川馨、田口玄一……正是他们的思想和实践,构筑了现代质量管理的核心体系,也深远影响了无数企业和管理者。今天,就让我们一同致敬这些质量管理的先驱!(最近流行『吉卜力风格』AI插图,我们也来玩玩用『吉卜力风格』重绘质量大师画象)1. 休哈特:统计质量控制的奠基者沃尔特·A·休哈特,美国工程师、统计学家,被誉为“统计质量控制之父”。1924年,他提出世界上第一张控制图,并于1931年出版《产品制造质量的经济
    优思学院 2025-04-01 14:02 148浏览
  • 退火炉,作为热处理设备的一种,广泛应用于各种金属材料的退火处理。那么,退火炉究竟是干嘛用的呢?一、退火炉的主要用途退火炉主要用于金属材料(如钢、铁、铜等)的热处理,通过退火工艺改善材料的机械性能,消除内应力和组织缺陷,提高材料的塑性和韧性。退火过程中,材料被加热到一定温度后保持一段时间,然后以适当的速度冷却,以达到改善材料性能的目的。二、退火炉的工作原理退火炉通过电热元件(如电阻丝、硅碳棒等)或燃气燃烧器加热炉膛,使炉内温度达到所需的退火温度。在退火过程中,炉内的温度、加热速度和冷却速度都可以根
    锦正茂科技 2025-04-02 10:13 70浏览
  • 随着汽车向智能化、场景化加速演进,智能座舱已成为人车交互的核心承载。从驾驶员注意力监测到儿童遗留检测,从乘员识别到安全带状态判断,座舱内的每一次行为都蕴含着巨大的安全与体验价值。然而,这些感知系统要在多样驾驶行为、复杂座舱布局和极端光照条件下持续稳定运行,传统的真实数据采集方式已难以支撑其开发迭代需求。智能座舱的技术演进,正由“采集驱动”转向“仿真驱动”。一、智能座舱仿真的挑战与突破图1:座舱实例图智能座舱中的AI系统,不仅需要理解驾驶员的行为和状态,还要同时感知乘员、儿童、宠物乃至环境中的潜在
    康谋 2025-04-02 10:23 98浏览
  • 文/郭楚妤编辑/cc孙聪颖‍不久前,中国发展高层论坛 2025 年年会(CDF)刚刚落下帷幕。本次年会围绕 “全面释放发展动能,共促全球经济稳定增长” 这一主题,吸引了全球各界目光,众多重磅嘉宾的出席与发言成为舆论焦点。其中,韩国三星集团会长李在镕时隔两年的访华之行,更是引发广泛热议。一直以来,李在镕给外界的印象是不苟言笑。然而,在论坛开幕前一天,李在镕却意外打破固有形象。3 月 22 日,李在镕与高通公司总裁安蒙一同现身北京小米汽车工厂。小米方面极为重视此次会面,CEO 雷军亲自接待,小米副董
    华尔街科技眼 2025-04-01 19:39 209浏览
  • 据先科电子官方信息,其产品包装标签将于2024年5月1日进行全面升级。作为电子元器件行业资讯平台,大鱼芯城为您梳理本次变更的核心内容及影响:一、标签变更核心要点标签整合与环保优化变更前:卷盘、内盒及外箱需分别粘贴2张标签(含独立环保标识)。变更后:环保标识(RoHS/HAF/PbF)整合至单张标签,减少重复贴标流程。标签尺寸调整卷盘/内盒标签:尺寸由5030mm升级至**8040mm**,信息展示更清晰。外箱标签:尺寸统一为8040mm(原7040mm),提升一致性。关键信息新增新增LOT批次编
    大鱼芯城 2025-04-01 15:02 202浏览
  • 职场之路并非一帆风顺,从初入职场的新人成长为团队中不可或缺的骨干,背后需要经历一系列内在的蜕变。许多人误以为只需努力工作便能顺利晋升,其实核心在于思维方式的更新。走出舒适区、打破旧有框架,正是让自己与众不同的重要法宝。在这条道路上,你不只需要扎实的技能,更需要敏锐的观察力、不断自省的精神和前瞻的格局。今天,就来聊聊那改变命运的三大思维转变,让你在职场上稳步前行。工作初期,总会遇到各式各样的难题。最初,我们习惯于围绕手头任务来制定计划,专注于眼前的目标。然而,职场的竞争从来不是单打独斗,而是团队协
    优思学院 2025-04-01 17:29 200浏览
  • 文/Leon编辑/cc孙聪颖‍步入 2025 年,国家进一步加大促消费、扩内需的政策力度,家电国补政策将持续贯穿全年。这一利好举措,为行业发展注入强劲的增长动力。(详情见:2025:消费提振要靠国补还是“看不见的手”?)但与此同时,也对家电企业在战略规划、产品打造以及市场营销等多个维度,提出了更为严苛的要求。在刚刚落幕的中国家电及消费电子博览会(AWE)上,家电行业的竞争呈现出胶着的态势,各大品牌为在激烈的市场竞争中脱颖而出,纷纷加大产品研发投入,积极推出新产品,试图提升产品附加值与市场竞争力。
    华尔街科技眼 2025-04-01 19:49 210浏览
  • 北京贞光科技有限公司作为紫光同芯授权代理商,专注于为客户提供车规级安全芯片的硬件供应与软件SDK一站式解决方案,同时配备专业技术团队,为选型及定制需求提供现场指导与支持。随着新能源汽车渗透率突破40%(中汽协2024数据),智能驾驶向L3+快速演进,车规级MCU正迎来技术范式变革。作为汽车电子系统的"神经中枢",通过AEC-Q100 Grade 1认证的MCU芯片需在-40℃~150℃极端温度下保持μs级响应精度,同时满足ISO 26262 ASIL-D功能安全要求。在集中式
    贞光科技 2025-04-02 14:50 124浏览
我要评论
0
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦