Epoll工作方式
1.水平触发(LT)
我觉得这里用生活中的概念来理解是很好的。假设你妈喊你吃饭,你现在在打游戏,先喊第一声"吃饭啦儿子",你没理她,然后她就会喊第二声、第三声...直到你回应她为止,不过后果嘛哼哼~。这就是水平触发,在操作系统中,或者说I/O多路转接中,考虑这样一个例子:我们已经把一个tcp socket添加到epoll描述符,这个时候socket的另一端被写入2KB数据。此时我们调用epoll_wait,并且它会返回就绪的文件描述符,说明它已经准备好进行读取操作,然后调用read,只读取了1KB的数据,继续调用epoll_wait。
epoll默认工作模式就是LT(水平工作模式),当epoll检测到socket上事件就绪,可以不立刻进行处理,或者只处理一部分。如上⾯的例⼦,由于只读了1K数据,缓冲区中还剩1K数据,在第⼆次调⽤epoll_wait 时,epoll_wait 仍然会⽴刻返回并通知socket读事件就绪。直到缓冲区上所有的数据都被处理完,epoll_wait 才不会⽴刻返回,而是会等待数据到来。⽀持阻塞读写和⾮阻塞读写。
2.边缘触发(ET)
如果你妈喊你⼀次, 你没动, 你妈就不管你了。这就是边缘触发,如果我们在第一步将socket添加到epoll描述符的时候使用了EPOLLET标志,epoll进行ET工作模式。
当epoll检测到socket上事件就绪时,必须立刻处理。如上面的例子,虽然只读了1KB的数据,缓冲区还剩1KB数据,但是在第二次调用epoll_wait的时候就再也不会返回已经就绪的文件描述符了,因此一次性处理缓冲区的所有的数据这个工作就交给了用户自己去进行处理,保证一次提醒全部接受。ET的性能比LT更高(epoll_wait返回的次数少了很多)。Nginx默认采用ET模式使用。ET只支持非阻塞读写:指的是当调用read或者write的时候如果没有数据则会立刻返回而不是等在这里(阻塞)。
ET模式下需要使用非阻塞轮询的方式,以保证把所有的数据读出来~
EPOLL的使用场景
epoll的⾼性能,是有⼀定的特定场景的.如果场景选择的不适宜,epoll的性能可能适得其反。对于多连接,且多连接中只有一部分连接比较活跃时,比较适合用epoll。为什么?
这个主要是因为epoll核心用红黑树管理连接,就绪链表返回活跃的连接,时间复杂度是O(1)不像select/poll需要全量扫描O(N),比如1万连接仅仅100活跃,epoll只需要处理这100个在就绪链表中的,而不是要遍历10000次去寻找哪个就绪。相反如果连接少且都活跃,epoll维护红黑树的开销可能比数组要高,因此这种情况下不如选择select/poll要效率高。
EPOLL的惊群问题
1.什么是惊群问题
在多进程/线程条件下,当多个进程/线程阻塞在同一个 epoll 实例上等待事件时,如果这个事件发生(如新连接到达),所有等待的进程/线程都会被唤醒,但最终只有一个能成功处理该事件,其他被唤醒的进程/线程会发现自己无事可做,重新进入睡眠状态。
在早期的Linux内核设计中,当事件触发时,内核会遍历所有注册到epoll实例上的进程,全部唤醒,这是因为当时内核并没有做精细化的进程筛选,默认"宁滥勿缺",避免漏掉可能需要处理事件的进程。虽然这种设计简单,但在多进程高并发场景下,就造成了大量无效唤醒和资源浪费(锁竞争)。后来才引入EPOLLEXCLUSIVE标志来优化这个问题。
2.怎么解决
Linux 内核 4.5+ 的 <font style="color:rgb(15, 17, 21);">EPOLLEXCLUSIVE</font> 标志可以解决这个问题。它的原理是:让 epoll 在唤醒时只唤醒等待队列中的一个进程/线程,而不是全部。
cpp
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
// 关键:添加 EPOLLEXCLUSIVE 标志
ev.events |= EPOLLEXCLUSIVE;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
-
内核级解决,效率最高
-
无需应用层额外处理
-
自动负载均衡(内核选择唤醒哪个)
-
需要 Linux 4.5+ 内核
-
仅支持共享同一个 epoll 实例的场景
3.有其他解决方法吗
3.1各进程单独 epoll + 文件描述符共享(传统方案)
cpp
// 父进程创建 listen_fd
int listen_fd = socket(...);
bind(...);
listen(...);
for (int i = 0; i < 4; i++) {
if (fork() == 0) {
// 子进程创建自己的 epoll
int epfd = epoll_create(1);
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
while (1) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 新连接到来时,只有一个进程被唤醒?
// 不一定!取决于内核实现
// 实际上早期内核仍然可能惊群
}
}
}
内核会遍历所有关联了目标文件描述符的epoll实例,对每个实例唤醒一个进程。比如有两个epoll实例,实例A关联了进程1,进程2(等待队列)。实例B关联了进程3和进程4。当事件触发时,内核会从实例A的等待队列选择一个进程唤醒,再从实例B的等待队列选择一个进程唤醒,最终只有两个进程唤醒,这两个进程再通过竞争锁决定哪个进程来处理这个到来的连接。这种方式通过分摊压力来减少惊群程度,在一定程度上缓解,但是还是不能完全解决。
3.2 应用层加锁(不推荐)
cpp
// 使用文件锁或 mutex
pthread_mutex_t lock;
while (1) {
pthread_mutex_lock(&lock);
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 处理事件
pthread_mutex_unlock(&lock);
}
缺点:
- 锁竞争严重
- 完全破坏了并发性
- 性能极差
3.3 SO_REUSEPORT 端口重用
多个进程可以绑定到同一个端口,内核自动负载均衡新连接。
cpp
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
// 每个进程独立 bind、listen
bind(listen_fd, ...);
listen(listen_fd, 128);
// 每个进程在自己的 epoll 中监听自己的 listen_fd
// 内核直接将新连接分发到某个进程
优点:
- 彻底避免惊群
- 内核级负载均衡(支持哈希、轮询等策略)
- 每个进程独立,互不影响
缺点:
- 需要内核 3.9+ 支持
- 负载均衡策略不可自定义(内核决定)
4.为什么选择这个解决方法?
这里比较推荐的就是EPOLLEXCLUSIVE和SO_REUSEPORT这两个方法。一方面是内核帮我们处理好了,用户层使用比较简单,另一方面是惊群问题相比于其他两种解决的要彻底。