在 Linux 系统中,处理 I/O 操作有多种方式,像我们熟知的 select 和 poll 等。在连接数较少的情况下,它们或许还能应付自如,但一旦面对大量的并发连接,它们的性能就会大打折扣,就像小马拉大车一样,显得力不从心。然而,有一个技术却能在这种高并发的场景下脱颖而出,它就是 epoll。

epoll 作为一种高效的 I/O 多路复用技术,与传统的 select 和 poll 相比,具有许多独特的优势和强大的性能。它就像是一把专门为高并发场景打造的利器,能够让我们的程序在处理大量连接时更加高效、稳定。那么,epoll 到底是如何做到的呢?让我们一起深入理解 epoll,探寻它的奥秘吧。

一、epoll简介

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

1.1epoll 初印象

epoll 可是 Linux 下多路复用 I/O 接口的 “超级增强版”,专为应对高并发而生。与传统的 select 和 poll 相比,那优势可不是一星半点。select 在处理大量并发连接时,就像个没头苍蝇,每次都得把所有文件描述符集合一股脑从用户态拷贝到内核态,开销巨大,而且还得在内核里线性遍历这些描述符,看看哪个 “有事”,效率低得感人,关键它还有个致命弱点,默认最多只能处理 1024 个文件描述符,稍微多点连接就应付不来。

poll 虽说在一些方面改进了 select,比如不需要计算最大文件描述符加一的大小,对大批文件描述符处理速度稍快,基于链表存储没了最大连接数限制,但本质上还是得遍历所有描述符找就绪的,大量无谓的遍历让它在高并发下也力不从心。

epoll 就不一样了,它采用全新的设计理念。当创建一个 epoll 实例后,在内核中有个精心构建的数据结构,像是用红黑树来高效管理所有要监听的文件描述符,添加、删除操作那叫一个快,时间复杂度仅 O (log n);还有个就绪列表,通常用双向链表实现,专门存放已经就绪、有事件发生的文件描述符。

当调用 epoll_wait 时,压根不用像 select、poll 那样大海捞针般遍历所有描述符,只需瞅瞅这个就绪列表就行,轻松定位到 “有事” 的连接,大大节省了 CPU 时间。就好比在一个大型仓库里找几件特定物品,select 和 poll 是逐个货架、逐件货物查看,epoll 则是有个智能清单,直接指引到目标货物所在货架,效率高下立判。这使得 epoll 在面对海量并发连接时,系统资源开销小,响应迅速,成为众多高性能网络应用的坚实后盾。

epoll除了提供select/poll那种IO事件的电平触发 (Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。Linux2.6内核中对/dev/epoll设备的访问的封装(system epoll)。这个使我们开发网络应用程序更加简单,并且更加高效。

1.2为什么要使用epoll?

同样,我们在linux系统下,影响效率的依然是I/O操作,linux提供给我们select/poll/epoll等多路复用I/O方式(kqueue暂时没研究过),为什么我们对epoll情有独钟呢?原因如下:

⑴文件描述符数量的对比

epoll并没有fd(文件描述符)的上限,它只跟系统内存有关,我的2G的ubuntu下查看是20480个,轻松支持20W个fd。可使用如下命令查看:

cat /proc/sys/fs/file-max

再来看select/poll,有一个限定的fd的数量,linux/posix_types.h头文件中

#define __FD_SETSIZE    1024

⑵效率对比

当然了,你可以修改上述值,然后重新编译内核,然后再次写代码,这也是没问题的,不过我先说说select/poll的机制,估计你马上会作废上面修改枚举值的想法。

select/poll会因为监听fd的数量而导致效率低下,因为它是轮询所有fd,有数据就处理,没数据就跳过,所以fd的数量会降低效率;而epoll只处理就绪的fd,它有一个就绪设备的队列,每次只轮询该队列的数据,然后进行处理。

⑶内存处理方式对比

不管是哪种I/O机制,都无法避免fd在操作过程中拷贝的问题,而epoll使用了mmap(是指文件/对象的内存映射,被映射到多个内存页上),所以同一块内存就可以避免这个问题。

btw:TCP/IP协议栈使用内存池管理sk_buff结构,你还可以通过修改内存池pool的大小,毕竟linux支持各种微调内核。

二、epoll核心原理

2.1epoll的工作方式

epoll分为两种工作方式LT和ET:

LT(level triggered) 是默认/缺省的工作方式,同时支持 block和no_block socket。这种工作方式下,内核会通知你一个fd是否就绪,然后才可以对这个就绪的fd进行I/O操作。就算你没有任何操作,系统还是会继续提示fd已经就绪,不过这种工作方式出错会比较小,传统的select/poll就是这种工作方式的代表。

ET(edge-triggered) 是高速工作方式,仅支持no_block socket,这种工作方式下,当fd从未就绪变为就绪时,内核会通知fd已经就绪,并且内核认为你知道该fd已经就绪,不会再次通知了,除非因为某些操作导致fd就绪状态发生变化。如果一直不对这个fd进行I/O操作,导致fd变为未就绪时,内核同样不会发送更多的通知,因为only once。所以这种方式下,出错率比较高,需要增加一些检测程序。

LT可以理解为水平触发,只要有数据可以读,不管怎样都会通知。而ET为边缘触发,只有状态发生变化时才会通知,可以理解为电平变化。

2.2如何使用epoll?

使用epoll很简单,只需要:

#include <sys/epoll.h>

有三个关键函数:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_events* event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

当然了,不要忘记关闭函数。

epoll和select

epoll 和 select 的主要区别是:

epoll 监听的 fd(file descriptor)集合是常驻内核的,它有 3 个系统调用 (epoll_create, epoll_wait, epoll_ctl),通过 epoll_wait 可以多次监听同一个 fd 集合,只返回可读写那部分

select 只有一个系统调用,每次要监听都要将其从用户态传到内核,有事件时返回整个集合。

从性能上看,如果 fd 集合很大,用户态和内核态之间数据复制的花销是很大的,所以 select 一般限制 fd 集合最大1024。

从使用上看,epoll 返回的是可用的 fd 子集,select 返回的是全部,哪些可用需要用户遍历判断。

尽管如此,epoll 的性能并不必然比 select 高,对于 fd 数量较少并且 fd IO 都非常繁忙的情况 select 在性能上有优势。

2.3epoll原理

⑴为什么要 I/O 多路复用

epoll 是一个优秀的 I/O 多路复用方式。所以,在讲解 epoll 之前,我们先来看一下为什么需要 I/O 多路复用。

阻塞 OR 非阻塞

我们知道,对于 linux 来说,I/O 设备为特殊的文件,读写和文件是差不多的,但是 I/O 设备因为读写与内存读写相比,速度差距非常大。与 cpu 读写速度更是没法比,所以相比于对内存的读写,I/O 操作总是拖后腿的那个。网络 I/O 更是如此,我们很多时候不知道网络 I/O 什么时候到来,就好比我们点了一份外卖,不知道外卖小哥们什么时候送过来,这个时候有两个处理办法:

第一个是我们可以先去睡觉,外卖小哥送到楼下了自然会给我们打电话,这个时候我们在醒来取外卖就可以了。

第二个是我们可以每隔一段时间就给外卖小哥打个电话,这样就能实时掌握外卖的动态信息了。

第一种方式对应的就是阻塞的 I/O 处理方式,进程在进行 I/O 操作的时候,进入睡眠,如果有 I/O 时间到达,就唤醒这个进程。第二种方式对应的是非阻塞轮询的方式,进程在进行 I/O 操作后,每隔一段时间向内核询问是否有 I/O 事件到达,如果有就立刻处理。

线程池OR轮询

在现实中,我们当然选择第一种方式,但是在计算机中,情况就要复杂一些。我们知道,在 linux 中,不管是线程还是进程都会占用一定的资源,也就是说,系统总的线程和进程数是一定的。如果有许多的线程或者进程被挂起,无疑是白白消耗了系统的资源。而且,线程或者进程的切换也是需要一定的成本的,需要上下文切换,如果频繁的进行上下文切换,系统会损失很大的性能。一个网络服务器经常需要连接成千上万个客户端,而它能创建的线程可能之后几百个,线程耗光就不能对外提供服务了。这些都是我们在选择 I/O 机制的时候需要考虑的。这种阻塞的 I/O 模式下,一个线程只能处理一个流的 I/O 事件,这是问题的根源。

这个时候我们首先想到的是采用线程池的方式限制同时访问的线程数,这样就能够解决线程不足的问题了。但是这又会有第二个问题了,多余的任务会通过队列的方式存储在内存只能够,这样很容易在客户端过多的情况下出现内存不足的情况。

还有一种方式是采用轮询的方式,我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了。

代理

采用轮询的方式虽然能够处理多个 I/O 事件,但是也有一个明显的缺点,那就是会导致 CPU 空转。试想一下,如果所有的流中都没有数据,那么 CPU 时间就被白白的浪费了。

为了避免CPU空转,可以引进了一个代理。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流。这就是 select 与 poll 所做的事情,可见,采用 I/O 复用极大的提高了系统的效率。

(2)核心原理大揭秘

红黑树 —— 精准管理的 “魔法树”

红黑树在 epoll 里可是扮演着 “大管家” 的关键角色,它专门负责存储和管理海量的文件描述符。这棵树有着独特的 “魔力”,它是一种自平衡的二叉搜索树,意味着无论插入、删除还是查找操作,时间复杂度都能稳稳地保持在 O (log n)。

想象一下,在高并发场景下,每秒有成千上万个新连接涌入,每个连接对应一个文件描述符。要是没有红黑树,查找一个特定的文件描述符就如同大海捞针,效率极其低下。而有了红黑树,就好比给每个文件描述符都安排了一个专属的智能导航。当需要添加新连接(即新文件描述符)时,它能快速指引插入位置;要关闭某个连接删除对应描述符,也能迅速定位并移除,丝毫不乱。举个例子,在大型在线游戏服务器里,同时在线玩家众多,网络连接频繁变动,红黑树就能高效管理这些连接,确保游戏运行顺畅,玩家操作即时响应,不会因连接管理混乱而卡顿。

就绪链表 —— 即时响应的 “情报站”

就绪链表就像是 epoll 的 “情报收集站”。当某个被监听的文件描述符状态发生变化,比如有数据可读、可写,内核立马知晓,并通过回调机制,闪电般地将这个就绪的文件描述符添加到就绪链表中。这个链表通常是用双向链表实现,插入和删除操作那叫一个快,时间复杂度仅 O (1)。

打个比方,这就好比快递驿站收到了你的包裹(数据就绪),立马把你的取件码(文件描述符)放到一个专门的 “待取货架”(就绪链表)上,你一来就能快速拿到包裹。对于应用程序而言,当调用 epoll_wait 时,根本不用费时费力去遍历所有文件描述符,直接到这个 “情报站”—— 就绪链表瞅一眼,就能瞬间获取所有已就绪的文件描述符,第一时间进行数据读写操作,大大提升了响应速度,让数据处理快如闪电。

mmap—— 高效传输的 “隐形桥梁”

mmap 堪称 epoll 实现高效的幕后英雄,它搭建起了内核空间与用户空间的 “隐形桥梁”—— 共享内存。在传统的 I/O 操作里,数据从内核缓冲区拷贝到用户缓冲区,这个过程就像搬运工来回搬货,费时费力,还增加系统开销。

但有了 mmap 就不一样了,它直接让内核空间和用户空间共享同一块内存区域,数据来了,双方都能直接访问,减少了数据拷贝次数。这就好比图书馆有个公共书架,管理员(内核)和读者(用户程序)都能直接在上面取放书籍(数据),无需来回搬运。像是视频流处理应用,大量视频数据频繁传输,mmap 使得数据能快速从内核流向用户空间,减少传输延迟,让视频播放流畅无卡顿,极大提升了系统整体性能。

2.4epoll优缺点

select 与 poll 的缺陷

上文中我们发现,实现一个代理来帮助我们处理 I/O 时间能够极大的提高工作效率,select 与 poll 就是这样的代理。但是它们也不是完美的,从上文中我们可以发现,我们能够从 select 中知道是只是有 I/O 事件发生了。但是我们不知道那一个事件发生,每一个 I/O 事件发生的时候,都需要轮询所有的流,这样的时间复杂度 O(N)。但是很多情况下,发生 I/O 时间的只是少数的几个。通过轮询所有的找出少数的几个发生 I/O 的流显然效率非常低下,因此 select 和 epoll 通常只能处理几千个并发连接。

epoll 的优势

select的缺点之一就是在网络IO流到来的时候,线程会轮询监控文件数组,并且是线性扫描,还有最大值的限制。相比select,epoll则无需如此。服务器主线程创建了epoll对象,并且注册socket和文件事件即可。当数据抵达的时候,也就是对于事件发生,则会调用此前注册的那个io文件。

先看一个python的epoll例子,采用了网络上一段著名的code:

import socket
import select
 
EOL1 = b'\n\n'
EOL2 = b'\n\r\n'
response = b'HTTP/1.0 200 OK\r\nDate: Mon, 1 Jan 1996 01:01:01 GMT\r\n'
response += b'Content-Type: text/plain\r\nContent-Length: 13\r\n\r\n'
response += b'Hello, world!'
 
# 创建套接字对象并绑定监听端口
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serversocket.bind(('0.0.0.0', 8080))
serversocket.listen(1)
serversocket.setblocking(0)
 
# 创建epoll对象,并注册socket对象的 epoll可读事件
epoll = select.epoll()
epoll.register(serversocket.fileno(), select.EPOLLIN)
 
try:
    connections = {}
    requests = {}
    responses = {}
    while True:
        # 主循环,epoll的系统调用,一旦有网络IO事件发生,poll调用返回。这是和select系统调用的关键区别
        events = epoll.poll(1)
        # 通过事件通知获得监听的文件描述符,进而处理
        for fileno, event in events:
            # 注册监听的socket对象可读,获取连接,并注册连接的可读事件
            if fileno == serversocket.fileno():
                connection, address = serversocket.accept()
                connection.setblocking(0)
                epoll.register(connection.fileno(), select.EPOLLIN)
                connections[connection.fileno()] = connection
                requests[connection.fileno()] = b''
                responses[connection.fileno()] = response
            elif event & select.EPOLLIN:
                # 连接对象可读,处理客户端发生的信息,并注册连接对象可写
                requests[fileno] += connections[fileno].recv(1024)
                if EOL1 in requests[fileno] or EOL2 in requests[fileno]:
                    epoll.modify(fileno, select.EPOLLOUT)
                    print('-' * 40 + '\n' + requests[fileno].decode()[:-2])
            elif event & select.EPOLLOUT:
                # 连接对象可写事件发生,发送数据到客户端
                byteswritten = connections[fileno].send(responses[fileno])
                responses[fileno] = responses[fileno][byteswritten:]
                if len(responses[fileno]) == 0:
                    epoll.modify(fileno, 0)
                    connections[fileno].shutdown(socket.SHUT_RDWR)
            elif event & select.EPOLLHUP:
                epoll.unregister(fileno)
                connections[fileno].close()
                del connections[fileno]
finally:
    epoll.unregister(serversocket.fileno())
    epoll.close()
    serversocket.close()

可见epoll使用也很简单,并没有过多复杂的逻辑,当然主要是在系统层面封装的好。至于Epoll的原理,也不是三言两语可以解释清楚,作为开发者,先学会如何使用API。

epoll与tornado

既然epoll是一种高性能的网络io模型,很多web框架也采取epoll模型。大名鼎鼎tornado是python框架中一个高性能的异步框架,其底层也是来者epoll的IO模型。

当然,tornado是跨平台的,因此他的网络io,在