epoll大量通信出问题 盘点Linux epoll的致命弱点

 2024-03-10 01:11:58  阅读 0

由于未开通留言功能,如有疑问或建议请私信;

目录

1.简介 2.上下文 3 epoll 多线程可扩展性 3.1 特定 TCP fd 的问题(二) 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加载总结4. epoll文件和文件.1总结5引用

1 简介

本文来自 Marek 博客中的 I/O 系列第三和第四部分。 原创文章一共有四篇。 他们主要讲的是Linux上IO复用的一些问题。 这篇文章添加了一些我个人的理解。 如果有什么不对的地方,还请见谅。 指出。 原文链接如下:

t(2)[1]

(2)n[2]

/2[3]

/2[4]

2 背景

系列3和4分别讲了epoll(2)的两个不同问题:

系列3主要讲epoll的多线程扩展性。

系列4主要讲epoll注册的fd(文件)和实际内核中控制的结构体文件的不同生命周期。

我们这里也按照这个顺序来详细说明。

3 epoll多线程扩展性

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

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

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

3.1 具体TCP fd的(2)的问题

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

实际生产环境中存在这种情况。 Tom 报告称,应用程序每秒需要处理 40,000 个连接建立请求; 当有如此多的请求时,显然将它们分布在不同的CPU上是合理的。

但事实上,事情并没有那么简单。 直到Linux 4.5内核,这些请求都无法通过epoll(2)水平扩展到其他CPU。 我们来看看epoll的两种模式LT(level,电平触发)和ET(edge,边沿触发)是如何处理这种情况的。

通信中发生异常_epoll大量通信出问题_通信eutran

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

一个愚蠢的做法是把同一个epoll fd放在不同的线程上()。 这显然是行不通的。 同样,在不同线程中添加用于 epoll fd 的相同 fd 也不起作用。

这是因为epoll的水平触发方式具有与(2)相同的“惊群效应”。 在没有特殊标志的水平触发模式下,当有新的连接请求到来时,所有线程都会被唤醒。 下面是这个案例的一个例子:

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(),结果又返回成功

在这个例子中,它只从不可读状态变为可读状态一次,并且由于处于边沿触发模式,内核只会wake()一次。 在这个例子中,所有的连接建立请求都会发送到线程A,导致这个负载均衡根本不起作用。 线程A很忙,线程B无事可做。

3.1.3 正确的做法是什么?

既然水平触发和边缘触发都不起作用,那么正确的做法是什么? 有两种方法:

支持可扩展性的最好也是唯一的方法是使用自 Linux 4.5+ 以来出现的水平触发模式的新标志。 该标志将确保一个事件只有一个()被唤醒,避免了“惊群效应”,并且可以很好地跨多个CPU进行水平扩展。

当内核不支持时,可以在ET模式下模拟LT+的效果。 当然,这是有代价的。 每次处理完事件后都需要再调用一次()来重置fd。 这样可以将负载均匀分配到不同的CPU上,但同一时间只能有一次调用(2)。 显然,这限制了处理(2)的吞吐量。 下面是执行此操作的示例:

内核:收到两个连接建立请求。 线程A和线程B这两个线程都在等待()。由于边沿触发模式,只有一个线程会被唤醒。 这里我们假设线程A首先被唤醒。

线程A:()返回

线程A:调用()并成功

线程A:调用(),这将重置状态并再次准备这个fd”

3.1.4 其他解决方案

当然,如果不依赖epoll(),还有其他解决方案。 一种解决方案是使用它来创建共享相同端口号的多个端口。 然而这个方案实际上存在问题:当一个fd关闭时,已经分配到这个fd的队列中的请求将会被丢弃。 详情请参阅LWN上的和[5]

从Linux 4.5开始,引入了两个BPF相关组件CBPF和EBPF。 通过巧妙的设计,应该可以避免连接建立请求丢失的情况。

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

除了3.1中提到的(2)的问题之外,普通的read(2)在多核系统上也会存在扩展性问题。 想象一下以下场景:一个HTTP服务器需要与大量的HTTP进行通信,并且您希望尽快处理每个客户端请求。 每个客户端连接的请求处理时间可能不同,有的更快,有的更慢,并且是不可预测的。 因此,简单地将这些连接拆分到不同的CPU上可能会导致平均响应时间变得更长。 更好的排队策略可能是:使用一个epoll fd来管理这些连接并设置它们,然后多线程(),取出就绪的连接并处理它们[注1]。 上有一个视频介绍了这个模型,叫做“队列”。

我们来看看epoll是如何处理该模型下的问题的:

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

事实上,由于水平触发的“惊群效应”,我们不想使用这种模型。 另外,即使添加了flag,仍然存在数据竞争的情况。 让我们看一下下面的例子:

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模型下,唯一的办法就是当时添加一个flag,然后每次处理完之后再将fd添加到epoll中。

3.3 epoll负载总结

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

4.epoll文件和文件

本章我们主要讨论epoll的另一个大问题:文件与文件生命周期的不一致。

Foom 在 LWN[6] 上说道:

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

事实上,epoll()的主要问题在于它混淆了用户态下的文件(我们通常所说的数字fd)和内核态下实际用于实现的文件。 当进程调用 close(2) 关闭 fd 时,这个问题就会显现出来。

() 其实并不是注册一个文件(fd),而是将fd和一对指向内核文件的指针(元组)注册到epoll中。 问题的根本原因在于epoll中管理的fd的生命周期。 它不是fd本身,而是内核中对应的文件。

当使用close(2)系统调用关闭一个fd时,如果这个fd是内核中该文件的唯一引用,那么内核中的该文件也会被删除,这是可以的; 但是当该文件在内核中有其他引用时,close并不会删除该文件。 这会导致当fd在从epoll中移除之前直接关闭时,epoll()也会报告已经被close()的fd上的事件。

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

通信中发生异常_通信eutran_epoll大量通信出问题

 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,你可能会认为this()会一直阻塞而不返回,但事实上并非如此。 由于调用了dup(),内核中对应的文件仍然有引用计数,并没有被删除,所以这个文件的事件仍然会报告给epoll。 因此()将会向一个不再存在的fd报告一个事件。 更糟糕的是,一旦你 close() 这个 fd,就没有机会从 epoll 中删除死掉的 fd。 以下方法将不起作用:

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

马克也提到了这个问题:

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

因此,您不能依赖 close() 来完成清理工作。 一旦调用close()并且内核中的文件仍然被引用,epoll fd就无法再被修复。 唯一的办法就是把这个epoll fd杀掉,然后创建一个新的,并将之前的所有fd都添加到这个新的epoll fd中。 所以请记住这个建议:

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

4.1 总结

如果您可以控制所有代码,则从 epoll 中显式删除 fd 并调用 close() 可以很好地工作。 然而,在某些情况下情况并非总是如此。 例如,在编写封装epoll的库时,有时无法阻止用户调用close(2)系统调用。 因此,编写一个基于epoll的轻量级抽象层并不是一件容易的事。

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

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

注1:作者认为,这种场景下,直接使用一个线程进行分发,多个线程进行处理,或者每个线程使用独立的epoll fd可能会更好。

5 条报价

[1]

[2]

[3]

[4]

[5]

[6]

[7]

[8]

[9]

如果您觉得本文对您有帮助,可以点击收藏、点赞、阅读来支持作者。

标签: fd epoll linux系统

如本站内容信息有侵犯到您的权益请联系我们删除,谢谢!!


Copyright © 2020 All Rights Reserved 京ICP5741267-1号 统计代码