深入理解epoll:高效I/O多路复用的核心技术(上)

简介: 深入理解epoll:高效I/O多路复用的核心技术

前言:epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

一、什么是epoll?

epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集 合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符 集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

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

1.1为什么要使用epoll?

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

1)文件描述符数量的对比

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

cat /proc/sys/fs/file-max

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

#define __FD_SETSIZE    1024

2)效率对比

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

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

3)内存处理方式对比

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

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

1.2epoll的工作方式

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为边缘触发,只有状态发生变化时才会通知,可以理解为电平变化。

1.3如何使用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 在性能上有优势。

【文章福利】小编推荐自己的Linux内核技术交流群:【 865977150】整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!!!

二、epoll原理

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

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

1)阻塞 OR 非阻塞

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

  • 第一个是我们可以先去睡觉,外卖小哥送到楼下了自然会给我们打电话,这个时候我们在醒来取外卖就可以了。
  • 第二个是我们可以每隔一段时间就给外卖小哥打个电话,这样就能实时掌握外卖的动态信息了。

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

2)线程池OR轮询

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

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

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

3)代理

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

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

2.2epoll优缺点

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,在linux下是epoll,unix下则是kqueue。幸好tornado都做了封装,对于开发者及其友好,下面看一个tornado写的回显例子。

import errno
import functools
import tornado.ioloop
import socket
def handle_connection(connection, address):
    """ 处理请求,返回数据给客户端 """
    data = connection.recv(2014)
    print data
    connection.send(data)
def connection_ready(sock, fd, events):
    """ 事件回调函数,主要用于socket可读事件,用于获取socket的链接 """
    while True:
        try:
            connection, address = sock.accept()
        except socket.error as e:
            if e.args[0] not in (errno.EWOULDBLOCK, errno.EAGAIN):
                raise
            return
        connection.setblocking(0)
        handle_connection(connection, address)
if __name__ == '__main__':
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setblocking(0)
    sock.bind(("", 5000))
    sock.listen(128)
    # 使用tornado封装好的epoll接口,即IOLoop对象
    io_loop = tornado.ioloop.IOLoop.current()
    callback = functools.partial(connection_ready, sock)
    # io_loop对象注册网络io文件描述符和回调函数与io事件的绑定
    io_loop.add_handler(sock.fileno(), callback, io_loop.READ)
    io_loop.start()

上面的代码来者tornado的模块IOLoop源码的文档,很简明的介绍了在tornado中如何使用网络IO。当然具体的封装实现,可以参考tornado源码获知,在此不做介绍了。

说了这么多,总算引出了我们的主人公 epoll 了。不同于忙轮询和无差别轮询,epoll 会把哪个流发生了怎样的 I/O 事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(k),k为产生 I/O 事件的流的个数。

2.3epoll实现原理

1.epoll 操作

epoll 在 linux 内核中申请了一个简易的文件系统,把原先的一个 select 或者 poll 调用分为了三个部分:调用 epoll_create 建立一个 epoll 对象(在 epoll 文件系统中给这个句柄分配资源)、调用 epoll_ctl 向 epoll 对象中添加连接的套接字、调用 epoll_wait 收集发生事件的连接。

这样只需要在进程启动的时候建立一个 epoll 对象,并在需要的时候向它添加或者删除连接就可以了,因此,在实际收集的时候,epoll_wait 的效率会非常高,因为调用的时候只是传递了发生 IO 事件的连接。

2.epoll 实现

我们以 linux 内核 2.6 为例,说明一下 epoll 是如何高效的处理事件的。

当某一个进程调用 epoll_create 方法的时候,Linux 内核会创建一个 eventpoll 结构体,这个结构体中有两个重要的成员。

  • 第一个是 rb_root rbr,这是红黑树的根节点,存储着所有添加到 epoll 中的事件,也就是这个 epoll 监控的事件。
  • 第二个是 list_head rdllist 这是一个双向链表,保存着将要通过 epoll_wait 返回给用户的、满足条件的事件。

每一个 epoll 对象都有一个独立的 eventpoll 结构体,这个结构体会在内核空间中创造独立的内存,用于存储使用 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂到 rbr 红黑树中,这样就能够高效的识别重复添加的节点。

所有添加到 epoll 中的事件都会与设备(如网卡等)驱动程序建立回调关系,也就是说,相应的事件发生时会调用这里的方法。这个回调方法在内核中叫做 ep_poll_callback,它把这样的事件放到 rdllist 双向链表中。在 epoll 中,对于每一个事件都会建立一个 epitem 结构体。

当调用 epoll_wait 检查是否有发生事件的连接时,只需要检查 eventpoll 对象中的 rdllist 双向链表中是否有 epitem 元素,如果 rdllist 链表不为空,则把这里的事件复制到用户态内存中的同时,将事件数量返回给用户。通过这种方法,epoll_wait 的效率非常高。epoll-ctl 在向 epoll 对象中添加、修改、删除事件时,从 rbr 红黑树中查找事件也非常快。这样,epoll 就能够轻易的处理百万级的并发连接。

pollable

首先,linux 的 file 有个 pollable 的概念,只有 pollable 的 file 才可以加入到 epoll 和 select 中。一个 file 是 pollable 的当且仅当其定义了 file->f_op->poll。file->f_op->poll 的形式如下:

__poll_t poll(struct file *fp, poll_table *wait)

不同类型的 file 实现不同,但做的事情都差不多:

  1. 通过 fp 拿到其对应的 waitqueue
  2. 通过 wait 拿到外部设置的 callback[[1]]
  3. 执行 callback(fp, waitqueue, wait),在 callback 中会将另外一个 callback2[[2]] 注册到 waitqueue[[3]]中,此后 fp 有触发事件就会调用 callback2

waitqueue 是事件驱动的,与驱动程序密切相关,简单来说 poll 函数在 file 的触发队列中注册了个 callback, 有事件发生时就调用callback。感兴趣可以根据文后 [[4]] 的提示看看 socket 的 poll 实现

了解了 pollable 我们看看 epoll 的三个系统调用 epoll_create, epoll_ctl, epoll_wait

epoll_create 只是在内核初始化一下数据结构然后返回个 fd

epoll_ctl 支持添加移除 fd,我们只看添加的情况。epoll_ctl 的主要操作在 ep_insert, 它做了以下事情:

  1. 初始化一个 epitem,里面包含 fd,监听的事件,就绪链表,关联的 epoll_fd 等信息
  2. 调用 ep_item_poll(epitem, ep_ptable_queue_proc[[1]])。ep_item_poll 会调用 vfs_poll, vfs_poll 会调用上面说的 file->f_op->poll 将 ep_poll_callback[[2]] 注册到 waitqueue
  3. 调用 ep_rbtree_insert(eventpoll, epitem) 将 epitem 插入 evenpoll 对象的红黑树,方便后续查找

ep_poll_callback

在了解 epoll_wait 之前我们还需要知道 ep_poll_callback 做了哪些操作

  1. ep_poll_callback 被调用,说明 epoll 中某个 file 有了新事件
  2. eventpoll 对象有一个 rdllist 字段,用链表存着当前就绪的所有 epitem
  3. ep_poll_callback 被调用的时候将 file 对应的 epitem 加到 rdllist 里(不重复)
  4. 如果当前用户正在 epoll_wait 阻塞状态 ep_poll_callback 还会通过 wake_up_locked 将 epoll_wait 唤醒

epoll_wait 主要做了以下操作:

  1. 检查 rdllist,如果不为空则去到 7,如果为空则去到 2
  2. 设置 timeout
  3. 开始无限循环
  4. 设置线程状态为 TASK_INTERRUPTIBLE [参看 Sleeping in the Kernal](Kernel Korner - Sleeping in the Kernel)
  5. 检查 rdllist 如果不为空去到 7, 否则去到 6
  6. 调用 schedule_hrtimeout_range 睡到 timeout,中途有可能被 ep_poll_callback 唤醒回到 4,如果真的 timeout 则 break 去到 7
  7. 设置线程状态为 TASK_RUNNING,rdllist如果不为空时退出循环,否则继续循环
  8. 调用 ep_send_events 将 rdllist 返回给用户态

epoll 的原理基本上就这些,还有很多细节如红黑树在哪里用,怎样实现 level-triggered 和 edge-triggered... 我还没看。

PS. 普通文件不是 pollable 的,详情请看 epoll_does_not_work_with_file

2.4epoll 工作模式

epoll 有两种工作模式,LT(水平触发)模式与 ET(边缘触发)模式。默认情况下,epoll 采用 LT 模式工作。两个的区别是:

  • Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait() 时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。
  • Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait() 会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

当然,在 LT 模式下开发基于 epoll 的应用要简单一些,不太容易出错,而在 ET 模式下事件发生时,如果没有彻底地将缓冲区的数据处理完,则会导致缓冲区的用户请求得不到响应。注意,默认情况下 Nginx 采用 ET 模式使用 epoll 的。

相关文章
|
6月前
|
消息中间件 存储 监控
实战Linux I/O多路复用:借助epoll,单线程高效管理10,000+并发连接
本文介绍了如何使用Linux的I/O多路复用技术`epoll`来高效管理超过10,000个并发连接。`epoll`允许单线程监控大量文件描述符,显著提高了资源利用率。文章详细阐述了`epoll`的几个关键接口,包括`epoll_create`、`epoll_ctl`和`epoll_wait`,以及它们在处理并发连接中的作用。此外,还探讨了`epoll`在高并发TCP服务场景的应用,展示了如何通过`epoll`和线程/协程池来构建服务框架。
750 12
|
7月前
|
NoSQL Java Linux
【Linux IO多路复用 】 Linux 网络编程 认知负荷与Epoll:高性能I-O多路复用的实现与优化
【Linux IO多路复用 】 Linux 网络编程 认知负荷与Epoll:高性能I-O多路复用的实现与优化
202 0
|
7月前
网络编程-epoll模型
网络编程-epoll模型
|
7月前
|
缓存 监控 Java
高性能网络编程 - 解读3种线程模型
高性能网络编程 - 解读3种线程模型
124 0
|
7月前
|
网络协议 Linux 应用服务中间件
高性能网络编程 - 解读5种I/O模型
高性能网络编程 - 解读5种I/O模型
139 0
|
存储 缓存 负载均衡
深入理解epoll:高效I/O多路复用的核心技术(下)
深入理解epoll:高效I/O多路复用的核心技术
|
存储 网络协议 Linux
解密异步IO:使用C++进行高效的网络编程
解密异步IO:使用C++进行高效的网络编程
|
监控 大数据 Linux
高性能网络设计秘笈:深入剖析Linux网络IO与epoll
本文介绍了网络IO模型,引入了epoll作为Linux系统中高性能网络编程的核心工具。通过分析epoll的特点与优势,并给出使用epoll的注意事项和实践技巧,该文章为读者提供了宝贵的指导。通过掌握这些知识,读者能够构建高效、可扩展和稳定的网络应用,提供出色的用户体验。
285 0
高性能网络设计秘笈:深入剖析Linux网络IO与epoll
|
NoSQL Redis
I/O多路复用模型?
I/O多路复用模型?
65 0
I/O多路复用模型实现——epoll
I/O多路复用模型实现——epoll
154 0