作者:symen
在Linux系统中,实际上所有的I/O设备都被抽象为文件这个概念,一切皆文件(Everything is File)。无论是磁盘、网络数据、终端,还是进程间通信工具(如:管道pipe)等都被抽象为文件的概念。 这种设计使得 I/O 操作可以通过统一的文件描述符(File Descriptor, FD)来管理。 在了解多路复用select、poll、epoll实现之前,我们先简单回忆复习以下两个概念。
阻塞式I/O模型:
在阻塞式 I/O 模型中,在I/O操作的两个阶段均会阻塞线程:
非阻塞式I/O模型:
在非阻塞式 I/O 模型中,当进程发起 I/O 系统调用(如 )时:
信号驱动IO:
在信号驱动 I/O 模型中,进程发起一个 I/O 操作时,会向内核注册一个信号处理函数(如 ),然后立即返回,不会被阻塞。当内核数据就绪时,会向进程发送一个信号,进程在信号处理函数中调用 I/O 操作(如 )读取数据。
异步IO
在异步 I/O 模型中,当进程发起一个 I/O 操作时,会立即返回,不会被阻塞,也不会立即返回结果。内核会负责完成整个 I/O 操作(包括数据准备和复制到用户空间),并在操作完成后通知进程。如果 I/O 操作成功,进程可以直接获取到数据。
IO复用模型
大多数文件系统的默认IO操作都是缓存IO。在Linux的缓存IO机制中,操作系统会将IO的数据缓存在文件系统的页缓存(page cache)。也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓存区拷贝到应用程序的地址空间中。这种做法的缺点就是,需要在应用程序地址空间和内核进行多次拷贝,这些拷贝动作所带来的CPU以及内存开销是非常大的。 至于为什么不能直接让磁盘控制器把数据送到应用程序的地址空间中呢?最简单的一个原因就是应用程序不能直接操作底层硬件。 总的来说,IO分两阶段: 1)数据准备阶段 2)内核空间复制回用户进程缓冲区阶段。如下图:
目前支持I/O多路复用的系统调用有select,pselect,poll,epoll。与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。 I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select
函数返回值:
从上述的select函数声明可以看出,fd_set本质是一个数组,为了方便我们操作该数组,操作系统提供了以下函数:
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds,当用户process调用select的时候,select会将需要监控的readfds集合拷贝到内核空间(假设监控的仅仅是socket可读),然后遍历自己监控的skb(SocketBuffer),挨个调用skb的poll逻辑以便检查该socket是否有可读事件,遍历完所有的skb后,如果没有任何一个socket可读,那么select会调用schedule_timeout进入schedule循环,使得process进入睡眠。如果在timeout时间内某个socket上有数据可读了,或者等待timeout了,则调用select的process会被唤醒,接下来select就是遍历监控的集合,挨个收集可读事件并返回给用户了,相应的伪码如下:
下面的动图能更直观的让我们了解select:
通过上面的select逻辑过程分析,相信大家都意识到,select存在三个问题:
[1] 每次调用select,都需要把被监控的fds集合从用户态空间拷贝到内核态空间,高并发场景下这样的拷贝会使得消耗的资源是很大的。[2] 能监听端口的数量有限,单个进程所能打开的最大连接数由FD_SETSIZE宏定义,监听上限就等于fds_bits位数组中所有元素的二进制位总数,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上为3264),当然我们可以对宏FD_SETSIZE进行修改,然后重新编译内核,但是性能可能会受到影响,一般该数和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认1024个,64位默认2048。
[3] 被监控的fds集合中,只要有一个有数据可读,整个socket集合就会被遍历一次调用sk的poll函数收集可读事件:由于当初的需求是朴素,仅仅关心是否有数据可读这样一个事件,当事件通知来的时候,由于数据的到来是异步的,我们不知道事件来的时候,有多少个被监控的socket有数据可读了,于是,只能挨个遍历每个socket来收集可读事件了。
poll
函数参数:
函数返回值:
下面是poll的函数原型,poll改变了fds集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的fds集合限制远大于select的1024。poll虽然解决了fds集合大小1024的限制问题,从实现来看。很明显它并没优化大量描述符数组被整体复制于用户态和内核态的地址空间之间,以及个别描述符就绪触发整体描述符集合的遍历的低效问题。poll随着监控的socket集合的增加性能线性下降,使得poll也并不适合用于大并发场景。
epoll
在 Linux 网络编程中, 曾长期被用作事件触发的机制。然而,随着高并发场景的需求增加, 的性能瓶颈逐渐显现。为了解决这些问题,Linux 内核引入了 机制。相比于 , 的最大优势在于其性能不会随着监听的文件描述符(fd)数量的增加而显著下降。如前面我们所说,在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。并且,在linux/posix_types.h头文件有这样的声明:
#define __FD_SETSIZE 1024 ( 最多只能同时监听 1024 个 fd(由 定义)。虽然可以通过修改内核头文件并重新编译内核来扩大这一限制,但这并不能从根本上解决问题。) 而 使用基于事件回调的机制,而不是轮询。它只会关注活跃的 fd,因此性能不会随着 fd 数量的增加而显著下降。
epoll_create 函数
的源码实现:
其中eventpoll 这个结构体中的几个成员的含义如下:
函数
函数
如下图,可以帮助我们理解的更加丝滑(/手动狗头):
epoll的边缘触发与水平触发
1. 水平触发(LT): 关注点是数据是否有无,只要读缓冲区不为空,写缓冲区不满,那么epoll_wait就会一直返回就绪,水平触发是epoll的默认工作方式。适合对事件处理逻辑要求不高的场景。
2. 边缘触发(ET): 关注点是数据的变化。只有当缓冲区状态发生变化时(例如从空变为非空,或从非空变为空), 才会返回就绪状态。这里的数据变化并不单纯指缓冲区从有数据变为没有数据,或者从没有数据变为有数据,还包括了数据变多或者变少。即当buffer长度有变化时,就会触发。 假设epoll被设置为了边缘触发,当客户端写入了100个字符,由于缓冲区从0变为了100,于是服务端epoll_wait触发一次就绪,服务端读取了2个字节后不再读取。这个时候再去调用epoll_wait会发现不会就绪,只有当客户端再次写入数据后,才会触发就绪。 这就导致如果使用ET模式,那就必须保证要「一次性把数据读取&写入完」,否则会导致数据长期无法读取/写入。适合高性能场景,可以减少事件通知的次数,提高效率。
为什么比select,poll更高效?
从上图可以看出,epoll使用红黑树管理文件描述符,红黑树插入和删除的都是时间复杂度 O(logN),不会随着文件描述符数量增加而改变。 select、poll采用数组或者链表的形式管理文件描述符,那么在遍历文件描述符时,时间复杂度会随着文件描述的增加而增加,我们从以下几点分析epoll的优势: