盘点Linux Epoll那些致命弱点
wptr33 2024-12-14 15:35 15 浏览
1 引言
本文主要讲 Linux 上 IO 多路复用的一些问题
2 脉络
分别讲 epoll(2) 存在的两个不同的问题:
- epoll 的多线程扩展性的问题
- epoll 所注册的 fd (file descriptor) 和实际内核中控制的结构 file description 拥有不同的生命周期
我们在此也按照该顺序进行阐述。
3 epoll 多线程扩展性
epoll 的多线程扩展性的问题主要体现在做多核之间负载均衡上,有两个典型的场景:
- 一个 TCP 服务器,对同一个 listen fd 在多个 CPU 上调用 accept(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 的例子:
内核:收到一个新建连接的请求
内核:由于 "惊群效应" ,唤醒两个正在 epoll_wait() 的线程 A 和线程 B
.线程A:epoll_wait() 返回
线程B:epoll_wait() 返回
线程A:执行 accept() 并且成功
线程B:执行 accept() 失败,accept() 返回 EAGAIN
其中,线程 B 的唤醒完全没有必要,仅仅只是浪费宝贵的 CPU 资源而已,水平触发模式的 epoll 的扩展性很差。
3.1.2 边缘触发的问题:不必要的唤醒以及饥饿
既然水平触发模式不行,那是不是边缘触发模式会更好呢?实际上并没有。我们来看看下面这个例子:
内核:收到第一个连接请求。线程 A 和 线程 B 两个线程都在 epoll_wait() 上等待。由于采用边缘触发模式,所以只有一个线程会收到通知。这里假定线程 A 收到通知
线程A:epoll_wait() 返回
线程A:调用 accpet() 并且成功
内核:此时 accept queue 为空,所以将边缘触发的 socket 的状态从可读置成不可读
内核:收到第二个建连请求
内核:此时,由于线程 A 还在执行 accept() 处理,只剩下线程 B 在等待 epoll_wait(),于是唤醒线程 B
线程A:继续执行 accept() 直到返回 EAGAIN
线程B:执行 accept(),并返回 EAGAIN,此时线程 B 可能有点困惑("明明通知我有事件,结果却返回 EAGAIN")
线程A:再次执行 accept(),这次终于返回 EAGAIN
可以看到在上面的例子中,线程 B 的唤醒是完全没有必要的。另外,事实上边缘触发模式还存在饥饿的问题,我们来看下面这个例子:
内核:接收到两个建连请求。线程 A 和 线程 B 两个线程都在等在 epoll_wait()。由于采用边缘触发模式,只有一个线程会被唤醒,我们这里假定线程 A 先被唤醒
线程A:epoll_wait() 返回
线程A:调用 accpet() 并且成功
内核:收到第三个建连请求。由于线程 A 还没有处理完(没有返回 EAGAIN),当前 socket 还处于可读的状态,由于是边缘触发模式,所有不会产生新的事件
线程A:继续执行 accept() 希望返回 EAGAIN 再进入 epoll_wait() 等待,然而它又 accept() 成功并处理了一个新连接
内核:又收到了第四个建连请求
线程A:又继续执行 accept(),结果又返回成功
在这个例子中个,这个 socket 只有一次从不可读状态变成可读状态,由于 socket 处于边缘触发模式,内核只会唤醒 epoll_wait() 一次。在这个例子中个,所有的建连请求全都会给线程 A,导致这个负载均衡根本没有生效,线程 A 很忙而线程 B 没有活干。
3.1.3 怎样才是正确的做法?
既然水平触发和边缘触发都不行,那怎样才是正确的做法呢?有两种 workaround 的方式:
- 最好的也是唯一支持可扩展的方式是使用从 Linux 4.5+ 开始出现的水平触发模式新增的 EPOLLEXCLUSIVE 标志,这个标志会保证一个事件只有一个 epoll_wait() 会被唤醒,避免了 “惊群效应”,并且可以在多个 CPU 之间很好的水平扩展。
- 当内核不支持EPOLLEXCLUSIVE 时,可以通过 ET 模式下的 EPOLLONESHOT 来模拟 LT + EPOLLEXCLUSIVE 的效果,当然这样是有代价的,需要在每个事件处理完之后额外多调用一次 epoll_ctl(EPOLL_CTL_MOD) 重置这个 fd。这样做可以将负载均分到不同的 CPU 上,但是同一时刻,只能有一个 worker 调用 accept(2)。显然,这样又限制了处理 accept(2) 的吞吐。下面是这样做的例子:
- 内核:接收到两个建连请求。线程 A 和 线程 B 两个线程都在等在 epoll_wait()。由于采用边缘触发模式,只有一个线程会被唤醒,我们这里假定线程 A 先被唤醒
- 线程A:epoll_wait() 返回
- 线程A:调用 accpet() 并且成功
- 线程A:调用 epoll_ctl(EPOLL_CTL_MOD),这样会重置 EPOLLONESHOT 状态并将这个 socket fd 重新准备好 “
相关视频推荐
面试中正经“八股文”网络原理tcp/udp,网络编程epoll/reactor
linux多线程之epoll原理剖析与reactor原理及应用
学习地址:C/C++Linux服务器开发/后台架构师【零声教育】-学习视频教程-腾讯课堂
需要C/C++ Linux服务器架构师学习资料加群812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
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_CBPF 和 SO_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 标志,仍然存在数据竞争的情况,我们来看看下面这个例子:
内核:收到 2047 字节的数据
内核:线程 A 和线程 B 两个线程都在 epoll_wait(),由于设置了 EPOLLEXCLUSIVE,内核只会唤醒一个线程,假设这里先唤醒线程 A
线程A:epoll_wait() 返回
内核:内核又收到 2 个字节的数据
内核:线程 A 还在干活,当前只有线程 B 在 epoll_wait(),内核唤醒线程 B
线程A:调用 read(2048) 并读走 2048 字节数据
线程B:调用 read(2048) 并读走剩下的 1 字节数据
这上述场景中,数据会被分片到两个不同的线程,如果没有锁保护的话,数据可能会存在乱序。
3.2.2 边缘触发的问题:数据乱序
既然水平触发模型不行,那么边缘触发呢?实际上也存在相同的竞争,我们看看下面这个例子:
内核:收到 2048 字节的数据
内核:线程 A 和线程 B 两个线程都在 epoll_wait(),由于设置了 EPOLLEXCLUSIVE,内核只会唤醒一个线程,假设这里先唤醒线程 A
线程A:epoll_wait() 返回
线程A:调用 read(2048) 并返回 2048 字节数据
内核:缓冲区数据全部已经读完,又重新将该 fd 挂到 epoll 队列上
内核:收到 1 字节的数据
内核:线程 A 还在干活,当前只有线程 B 在 epoll_wait(),内核唤醒线程 B
线程B:epoll_wait() 返回
线程B:调用 read(2048) 并且只读到了 1 字节数据
线程A:再次调用 read(2048),此时由于内核缓冲区已经没有数据,返回 EAGAIN
3.2.3 怎样才是正确的做法?
实际上,要保证同一个连接的数据始终落到同一个线程上,在上述 epoll 模型下,唯一的方法就是 epoll_ctl 的时候加上 EPOLLONESHOT 标志,然后在每次处理完重新把这个 socket fd 加到 epoll 里面去。
3.3 epoll load balance 总结
要正确的用好 epoll(2) 并不容易,要用 epoll 实现负载均衡并且避免数据竞争,必须掌握好 EPOLLONESHOT 和 EPOLLEXCLUSIVE 这两个标志。而 EPOLLEXCLUSIVE 又是个 epoll 后来新加的标志,所以我们可以说 epoll 最初设计时,并没有想着支持这种多线程负载均衡的场景。
4. epoll 之 file descriptor 与 file description
这里我们主要讨论 epoll 的另一个大问题:file descriptor 与 file description 生命周期不一致的问题。
Foom 在 LWN[6] 上说道:
显然 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) 系统调用为例来展示这个问题:
rfd, wfd = pipe()
write(wfd, "a") # Make the "rfd" readable
epfd = epoll_create()
epoll_ctl(efpd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd))
rfd2 = dup(rfd)
close(rfd)
r = 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 上摘除了,下面的做法都不行:
epoll_ctl(efpd, EPOLL_CTL_DEL, rfd)
epoll_ctl(efpd, EPOLL_CTL_DEL, rfd2)
Marc Lehmann 也提到这个问题:
1因此,存在 close 掉了一个 fd,却还一直从这个 fd 上收到 epoll 事件的可能性。并且这种情况一旦发生,不管你做什么都无法恢复了。
因此,并不能依赖于 close() 来做清理工作,一旦调用了 close(),而正好内核里面的 file description 还有引用,这个 epoll fd 就再也修不好了,唯一的做法是把的 epoll fd 给干掉,然后创建一个新的并将之前那些 fd 全部再加到这个新的 epoll fd 上。所以记住这条忠告:
永远记着先在调用 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 可能是更好的方案。
相关推荐
- Python自动化脚本应用与示例(python办公自动化脚本)
-
Python是编写自动化脚本的绝佳选择,因其语法简洁、库丰富且跨平台兼容性强。以下是Python自动化脚本的常见应用场景及示例,帮助你快速上手:一、常见自动化场景文件与目录操作...
- Python文件操作常用库高级应用教程
-
本文是在前面《Python文件操作常用库使用教程》的基础上,进一步学习Python文件操作库的高级应用。一、高级文件系统监控1.1watchdog库-实时文件系统监控安装与基本使用:...
- Python办公自动化系列篇之六:文件系统与操作系统任务
-
作为高效办公自动化领域的主流编程语言,Python凭借其优雅的语法结构、完善的技术生态及成熟的第三方工具库集合,已成为企业数字化转型过程中提升运营效率的理想选择。该语言在结构化数据处理、自动化文档生成...
- 14《Python 办公自动化教程》os 模块操作文件与文件夹
-
在日常工作中,我们经常会和文件、文件夹打交道,比如将服务器上指定目录下文件进行归档,或将爬虫爬取的数据根据时间创建对应的文件夹/文件,如果这些还依靠手动来进行操作,无疑是费时费力的,这时候Pyt...
- python中os模块详解(python os.path模块)
-
os模块是Python标准库中的一个模块,它提供了与操作系统交互的方法。使用os模块可以方便地执行许多常见的系统任务,如文件和目录操作、进程管理、环境变量管理等。下面是os模块中一些常用的函数和方法:...
- 21-Python-文件操作(python文件的操作步骤)
-
在Python中,文件操作是非常重要的一部分,它允许我们读取、写入和修改文件。下面将详细讲解Python文件操作的各个方面,并给出相应的示例。1-打开文件...
- 轻松玩转Python文件操作:移动、删除
-
哈喽,大家好,我是木头左!Python文件操作基础在处理计算机文件时,经常需要执行如移动和删除等基本操作。Python提供了一些内置的库来帮助完成这些任务,其中最常用的就是os模块和shutil模块。...
- Python 初学者练习:删除文件和文件夹
-
在本教程中,你将学习如何在Python中删除文件和文件夹。使用os.remove()函数删除文件...
- 引人遐想,用 Python 获取你想要的“某个人”摄像头照片
-
仅用来学习,希望给你们有提供到学习上的作用。1.安装库需要安装python3.5以上版本,在官网下载即可。然后安装库opencv-python,安装方式为打开终端输入命令行。...
- Python如何使用临时文件和目录(python目录下文件)
-
在某些项目中,有时候会有大量的临时数据,比如各种日志,这时候我们要做数据分析,并把最后的结果储存起来,这些大量的临时数据如果常驻内存,将消耗大量内存资源,我们可以使用临时文件,存储这些临时数据。使用标...
- Linux 下海量文件删除方法效率对比,最慢的竟然是 rm
-
Linux下海量文件删除方法效率对比,本次参赛选手一共6位,分别是:rm、find、findwithdelete、rsync、Python、Perl.首先建立50万个文件$testfor...
- Python 开发工程师必会的 5 个系统命令操作库
-
当我们需要编写自动化脚本、部署工具、监控程序时,熟练操作系统命令几乎是必备技能。今天就来聊聊我在实际项目中高频使用的5个系统命令操作库,这些可都是能让你效率翻倍的"瑞士军刀"。一...
- Python常用文件操作库使用详解(python文件操作选项)
-
Python生态系统提供了丰富的文件操作库,可以处理各种复杂的文件操作需求。本教程将介绍Python中最常用的文件操作库及其实际应用。一、标准库核心模块1.1os模块-操作系统接口主要功能...
- 11. 文件与IO操作(文件io和网络io)
-
本章深入探讨Go语言文件处理与IO操作的核心技术,结合高性能实践与安全规范,提供企业级解决方案。11.1文件读写11.1.1基础操作...
- Python os模块的20个应用实例(python中 import os模块用法)
-
在Python中,...
- 一周热门
-
-
C# 13 和 .NET 9 全知道 :13 使用 ASP.NET Core 构建网站 (1)
-
因果推断Matching方式实现代码 因果推断模型
-
git pull命令使用实例 git pull--rebase
-
面试官:git pull是哪两个指令的组合?
-
git 执行pull错误如何撤销 git pull fail
-
git pull 和git fetch 命令分别有什么作用?二者有什么区别?
-
git fetch 和git pull 的异同 git中fetch和pull的区别
-
git pull 之后本地代码被覆盖 解决方案
-
还可以这样玩?Git基本原理及各种骚操作,涨知识了
-
git命令之pull git.pull
-
- 最近发表
- 标签列表
-
- git pull (33)
- git fetch (35)
- mysql insert (35)
- mysql distinct (37)
- concat_ws (36)
- java continue (36)
- jenkins官网 (37)
- mysql 子查询 (37)
- python元组 (33)
- mybatis 分页 (35)
- vba split (37)
- redis watch (34)
- python list sort (37)
- nvarchar2 (34)
- mysql not null (36)
- hmset (35)
- python telnet (35)
- python readlines() 方法 (36)
- munmap (35)
- docker network create (35)
- redis 集合 (37)
- python sftp (37)
- setpriority (34)
- c语言 switch (34)
- git commit (34)