1 基本概念
IO操作是程序设计里的一个重头戏,尤其是在Linux中,管道、网络等设备都看成了文件描述符,因此IO对于Linux程序设计更加重要。近年来,随着Nginx、lighttpd等新型高性能web服务器的广泛使用,其内部采用的epoll、异步IO等IO模型逐渐走入了人们的视野。本文将探究Linux各个IO模型的来龙去脉、基于原理与相互之间的联系,并进行IO效率与性能的简要分析,解决人们对于Linux IO众多概念、模型的理解混乱与误解,理顺出一个清晰的技术体系。
在介绍Linux IO模型之前,需要先介绍两个对于论述IO模型非常重要的概念。
1.1 阻塞IO与非阻塞IO
所谓IO的阻塞与非阻塞,是指当进行IO操作时,需要的资源不可用,这时程序的表现。阻塞IO将让程序处于等待状态,直到需要的资源可用;而非阻塞IO将直接返回,不等需要的资源可用。
比如通过read函数从一个网络socket中读取数据,对于阻塞IO来说,如果网络中没有数据,则read函数将一直阻塞直到有数据可用,程序阻塞期间CPU将把时间片调度给其他进程;如果处于非阻塞IO模式,read函数将直接返回一个错误码,不会等待。
Linux支持将文件描述符设置为NOBOLCK支持IO的非阻塞模式。
1.2 同步IO与异步IO
IO的同步异步与IO的阻塞非阻塞这两种概念很容易混淆,实际上这两种概念的定义并不相似。同步IO是指发出IO操作后,后面的操作不能进行,要么等待IO操作完成,要么放弃IO操作,总之后面的操作和当前的IO操作只能有一个在进行;异步IO是指发出IO操作后,马上返回而继续执行后面的操作,而IO操作也在此时刻执行。
从以上的概念可以看出,同步与异步、阻塞与非阻塞这两种概念关注点不同,没有半毛钱关系。实现IO和其他任务的异步操作并难,开启两个线程,一个进行IO操作,另一个线程继续进行后面的任务处理就行了,至于进行IO操作的线程既可以使用阻塞IO,也可以使用非阻塞IO。
而同步IO就更简单了,一般单个线程下的普通阻塞IO或者非阻塞IO都是同步的,因为IO操作和其他任务无法同时进行,要么想阻塞IO那样,一直等待进行IO操作而不管后面的任务操作,要么想非阻塞IO那样在资源不可用时不管IO操作,直接返回,进行后面的业务操作。
需要指出的是,本文介绍的异步IO并不是采用多线程或者多进程那样简单的实现,而是在单线程内实现IO操作和其他任务的异步执行,这需要Linux操作系统内核的支持,而且效率更高。
2 Linux IO模型分析
2.1 普通阻塞IO
普通阻塞IO是最基础最简单,也是使用最广泛的一种IO编程模型,菜鸟程序员在提高之前必须熟练掌握。
这种编程模型的详细流程如下图所示,各种IO操作,比如socket accept、read、write等,在资源不可用时,在此模型下都处于等待状态,让出CPU资源给其他进程。
利用普通阻塞IO模型实现对多个文件描述符进行IO操作时,或者实现异步操作时,只能使用多线程方式,从而增加了线程切换方面的开销,在后面对各个IO模型性能的分析中将详细比较多线程阻塞IO和多路IO复用的性能差异。
2.2 普通非阻塞IO
普通非阻塞IO模型与阻塞模型的区别只是当IO操作遇到资源不可用时,非阻塞IO不会等待,而直接返回。普通非阻塞IO模型是实现多路复用技术和异步IO技术的重要基础,但是单独使用非阻塞IO则是一种效率极低的行为。比如当进行read操作时没有可读数据,因为不等待直接返回,所以不得不自己编写while循环,一遍遍的进行非阻塞的read操作,也就是轮询。这时程序一直处在运行状态,因此不会让出CPU,但是轮询实际上是一种无用功,最终的结果就是CPU都用在了无谓的轮询上了,造成CPU占用量高,但是IO吞吐量却不会超过普通阻塞IO编程。
普通非阻塞IO模型的时序流程如下图所示:
正如上图显示的轮询过程中不断的系统调用,上下文切换,使单独使用非阻塞IO模型效率低下,并不可取。如果程序员不了解Linux系统或者刚刚接触非阻塞IO,可能会尝试这种效率低下的编程方式,但是这实在是一种很脑残的行为。以下介绍的较为高级的IO模型,尤其较新引入的epoll模型和异步IO,因此效率较高,编程复杂,可以看成是文艺青年的选择。
2.3 多路IO复用
由于自己轮询的极度低效,开发者不会单独去使用非阻塞IO,然而多路IO复用是非阻塞IO的一个重要应用。
多路IO复用是为了处理多个IO文件句柄的数据操作。一个典型场景是当有很多socket服务监听不同端口以接收数据时,如果采用阻塞IO,同时处理不同的socket句柄不得不采用多线程或者多进程,每个线程和进程负责一个端口socket。但是,大量的线程和进程往往造成CPU利用率的低下,尤其是在线程和进程数量上限未知的情况。
Linux的IO多路复用技术提供一个单进程单线程内监听多个IO读写事件的机制。其基本原理是各个IO将其句柄设置为非阻塞IO,然后将各个IO句柄注册到Linux提供的多路IO复用函数上(select、poll或epoll),如果某个句柄的IO数据就绪,则函数返回,由开发者进行该IO数据处理。实际上,多路IO复用函数帮我们进行了多个非阻塞IO数据是否就绪的轮询操作,只不过IO多路复用函数的轮询更有效率,因为函数一次性传递文件描述符到内核态,在内核态中进行轮询(epoll则是进行等待边缘事件的触发),不必反复进行用户态和内核态的切换。IO多路复用的时序流程如下:
IO多路复用技术应该属于同步非阻塞IO模型,因为在select函数执行期间应用程序处于等待状态,无法执行其他的异步任务,而且该技术使用的是IO操作的非阻塞模式。因此根据本文对同步异步概念的定义,参考资料[1]中将IO多路复用技术归类为异步阻塞IO模型并不合理。
Linux的IO多路复用技术有三种主要的实现方式,select、poll和epoll,根据触发方式不同,与是否需要轮询的不同,性能差别还是比较大的。
2.3.1 Select/pselect
select是Linux最早支持的多路IO复用函数,其函数原型为:
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* errorfds, struct timeval* timeout);
参数nfds是所有文件描述符的数量+1,而readfds、writefds和errorfds分别为等待读、写和错误IO操作的文件描述符的集合,而timeout是超时时间,超过timeout时间select将返回(0表示不阻塞,NULL则是没有超时时间)。
select的返回值是有可用的IO操作的文件描述符数量,如果超时返回0,如果发生错误返回-1。
select函数需要和四个宏配合使用:FD_SET()、FD_CLR()、FD_ZERO()和FD_ISSET()。具体使用不再介绍,可以参考资料[7,8]的相关内容,下面介绍select函数的内部实现原理和主要流程:
1、使用copy_from_user从用户空间拷贝fd_set到内核空间;
2、遍历所有fd,调用其对应的poll函数,再由poll函数调用__pollwait函数;
3、poll函数会判断当前文件描述符上的IO操作是否就绪,并利用__pollwait的主要工作就是把当前进程挂到设备的等待队列中,但这并不代表进程会睡眠;
4、poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值;
5、如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout使进程进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程,更新fd_set后select返回;
6、如果超过超时时间schedule_timeout,还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd,流程如上;
7、把fd_set从内核空间拷贝到用户空间,select返回。
从上面的select内部流程中可以看出,select操作既有阻塞等待,也有主动轮询,相比于纯粹的轮询操作,效率应该稍微高一些。但是其缺点仍然十分明显:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态返回时还要从内核态拷贝到用户态,这个开销在fd很多时会很大;
2、每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大;
3、select返回后,用户不得不自己再遍历一遍fd集合,以找到哪些fd的IO操作可用;
4、再次调用select时,fd数组需要重新被初始化;
5、select支持的文件描述符数量太小了,默认是1024。
2.3.2 poll
poll的函数原型为int poll(struct pollfd *fds, nfds_t nfds, int timeout)。其实现和select非常相似,只是描述fd集合的方式不同,poll通过一个pollfd数组向内核传递需要关注的事件,故没有描述符个数的限制。
pollfd中的events字段和revents分别用于标示关注的事件和发生的事件,故pollfd数组只需要被初始化一次。
poll的实现机制与select类似,其对应内核中的sys_poll,只不过poll向内核传递pollfd数组,然后对pollfd中的每个描述符进行poll。
poll返回后,同样需要对pollfd中的每个元素检查其revents值,来得指事件是否发生。
由此可见,poll除了没有文件描述个数限制和文件描述符数组只需初始化一次以外,select的其他缺点扔存在,而存在的缺点是select和poll性能低的主要原因。
2.3.3 epoll
Epoll是Linux 2.6版本之后才引入的一种新的多路IO复用技术,epoll解决了select技术的所有主要缺点,可以取代select方式成为推荐的多路IO复用技术。
epoll通过epoll_create创建一个用于epoll轮询的描述符,通过epoll_ctl添加/修改/删除事件,通过epoll_wait等待IO就绪或者IO状态变化的事件发生,epoll_wait的第二个参数用于存放结果。
epoll与select、poll不同,首先,其不用每次调用都向内核拷贝事件描述信息,在第一次调用后,事件信息就会与对应的epoll描述符关联起来。另外epoll不是通过轮询,而是通过在等待的描述符上注册回调函数,当事件发生时,回调函数负责把发生的事件存储在就绪事件链表中,最后写到用户空间。
epoll返回后,该参数指向的缓冲区中即为发生的事件,对缓冲区中每个元素进行处理即可,而不需要像poll、select那样进行轮询检查。
之所以epoll能够避免效率低下的主动轮询,而完全采用效率更高的被动等待IO事件通知,是因为epoll在返回时机上支持被成为“边沿触发”(edge=triggered)的新思想,与此相对,select的触发时机被成为“水平触发”(level-triggered)。epoll同时支持这两种触发方式。
边沿触发是指当有新的IO事件发生时,epoll才唤醒进程之后返回;而水平触发是指只要当前IO满足就绪态的要求,epoll或select就会检查到然后返回,即使在调用之后没有任何新的IO事件发生。
举例来说,一个管道内收到了数据,注册该管道描述符的epoll返回,但是用户只读取了一部分数据,然后再次调用了epoll。这时,如果是水平触发方式,epoll将立刻返回,因为当前有数据可读,满足IO就绪的要求;但是如果是边沿触发方式,epoll不会返回,因为调用之后还没有新的IO事件发生,直到有新的数据到来,epoll才会返回,用户可以一并读到老的数据和新的数据。
通过边沿触发方式,epoll可以注册回调函数,等待期望的IO事件发生,系统内核会在事件发生时通知,而不必像水平触发那样去主动轮询检查状态。边沿触发和水平触发方式类似于电子信号中的电位高低变化,由此得名。
2.4 信号驱动的IO
信号驱动的IO是一种半异步的IO模型。使用信号驱动I/O时,当网络套接字可读后,内核通过发送SIGIO信号通知应用进程,于是应用可以开始读取数据。
具体的说,程序首先允许套接字使用信号驱动I/O模式,并且通过sigaction系统调用注册一个SIGIO信号处理程序。当有数据到达后,系统向应用进程交付一个SIGIO信号,然后应用程序调用read函数从内核中读取数据到用户态的数据缓存中。这样应用进程都不会因为尚无数据达到而被阻塞,应用主循环逻辑可以继续执行其他功能,直到收到通知后去读取数据或者处理已经在信号处理程序中读取完毕的数据。
设置套接字允许信号驱动IO的步骤如下:
1.注册SIGIO信号处理程序。(安装信号处理器)
2.使用fcntl的F_SETOWN命令,设置套接字所有者。(设置套接字的所有者)
3.使用fcntl的F_SETFL命令,置O_ASYNC标志,允许套接字信号驱动I/O。(允许这个套接字进行信号输入输出)
信号驱动的IO内部时序流程如下所示:
之所以说信号驱动的IO是半异步的,是因为实际读取数据到应用进程缓存的工作仍然是由应用自己负责的,而这部分工作执行期间进程依然是阻塞的,如上图中的后半部分。而在下面介绍的异步IO则是完全的异步。
2.5 异步IO
异步I/O模型是一种处理与I/O重叠进行的模型。读请求会立即返回,说明read 请求已经成功发起了。在后台完成读操作时,应用程序然后会执行其他处理操作。当read 的响应到达时,就会产生一个信号或执行一个基于线程的回调函数来完成这次I/O 处理过程。
在一个进程中为了执行多个I/O请求而对计算操作和I/O 处理进行重叠处理的能力利用了处理速度与I/O速度之间的差异。当一个或多个I/O 请求挂起时,CPU可以执行其他任务;或者更为常见的是,在发起其他I/O的同时对已经完成的I/O 进行操作。
在传统的I/O模型中,有一个使用惟一句柄标识的I/O 通道。在 UNIX® 中,这些句柄是文件描述符(这对等同于文件、管道、套接字等等)。在阻塞I/O中,我们发起了一次传输操作,当传输操作完成或发生错误时,系统调用就会返回。
在异步非阻塞I/O中,我们可以同时发起多个传输操作。这需要每个传输操作都有惟一的上下文,这样我们才能在它们完成时区分到底是哪个传输操作完成了。在AIO中,这是一个aiocb(AIO I/O Control Block)结构。这个结构包含了有关传输的所有信息,包括为数据准备的用户缓冲区。在产生I/O(称为完成)通知时,aiocb结构就被用来惟一标识所完成的I/O操作。
以read操作为例,一个异步IO操作的时序流程如下图所示:
从上图中可以看出,比起信号驱动的IO那种半异步模式,异步IO中从内核拷贝数据到用户缓存空间的工作也是有系统完成的异步过程,用户程序只需要在指定的数组中引用数据即可。
数据接收后的处理程序是一个回调函数,Linux提供了两种机制实现异步IO的回调函数:
一种是信号回调函数机制,这种机制跟信号驱动的IO类似,利用信号触发回调函数的执行以处理接收的数据,这回中断正在执行的代码,而不会产生新的进程和线程;
另一种是线程回调函数机制,在这种机制下也需要编写相同的回调函数,但是这个函数将注册到异步IO的事件回调结构体对象中,当数据接收完成后将创建新的线程,在新的线程中调用回调函数进行数据处理。
2.6 各个IO模型的性能比较
为了比较各个IO模型的性能,这里设计了三种最主要的应用场景,分别是单个用户连接的频繁IO操作、少量用户连接的并发频繁IO操作、大量用户连接的并发频繁IO操作。在进行性能比较时,主要考虑的是总的IO等待、系统调用情况和CPU调度切换,IO等待越少、系统调用越少、CPU调度切换越少意味着IO操作的高效率。
在单个用户连接频繁的IO操作中,可以采用单线程单进程的方式,这样可以不用考虑进程内部的CPU调度,只需关注IO等待和系统调用的频率。从上面各个IO模型的流程时序图来看,AIO的用户程序在执行Io操作时没有任何Io等待,而且只需要调用IO操作时一次系统调用,由于是异步操作,信号操作的回传不需要进行系统调用,连由内核返回用户态的系统调用都省了,因此效率最高。
在信号驱动的IO模型中,IO等待时间要比基本的阻塞式IO和多路复用IO要少,只需要等待数据从内核到用户缓存的操作。但是信号驱动的IO模型和多路复用IO的系统调用次数一样,需要两次系统调用,共四次上下文切换,而基本的阻塞模式只需要一次系统调用。在IO频繁的场景下,还是基本阻塞IO效率最高,其次为信号驱动IO,然后是多路复用IO。
基本非阻塞IO的性能最差,因为在IO等待期间不仅不交出CPU控制权,还一遍又一遍进行昂贵的系统调用操作进行主动轮询,而主动轮询对于IO操作和业务操作都没有实际的意义,因此CPU计算资源浪费最严重。
在单个用户连接的频繁IO操作中,性能排名有好到差为:AIO>基本阻塞IO>信号IO>epoll>poll>select>基本非阻塞IO。
在少量用户下的频繁IO操作中,基本阻塞IO一般要使用多线程操作,因此要产生额外的线程调度工作。虽然由于线程较少,远少于系统的总进程数,但是由于IO操作频繁,CPU切换还是会集中在IO操作的各个线程内。
对于基本阻塞IO和多路复用IO来讲,虽然多路IO复用一次系统调用可以完成更多的IO操作,但是在IO操作完成后对于每个IO操作还是要系统调用将内核中的数据取回到用户缓存中,因此系统调用次数仍然比阻塞IO略多,但线程切换的开销更大。特别对于select来说,由于select内部采用半轮询方式,效率不如阻塞方式,因此在这种少量用户连接的IO场景下,还不能只通过理论判断基本阻塞IO和select方式孰优孰劣。
其他的IO模型类似于单用户下,不再分析,由此得出在少量用户连接IO操作下的IO模型性能,由好到坏依次为AIO>信号IO>epoll>基本阻塞IO?poll>select>基本非阻塞IO。
在大量,甚至海量用户的并发频繁IO操作下,多路IO复用技术的性能会全面超越简单的多线程阻塞IO,因为这时大量的CPU切换操作将显著减少CPU效率,而多路复用一次完成大量的IO操作的优势更加明显。对于AIO和信号IO,在这种场景下依然有着更少的IO等待和更少的系统调用操作,性能依然最好。
由此可见,在大量用户的并发频繁IO操作下,IO性能由好到差依次为AIO>信号IO>epoll>poll>select>基本阻塞IO>基本非阻塞IO。
需要说明的是,以上三种场景都强调了是IO频繁的,如果是IO不频繁的以上三种场景,各个IO模型的性能表现又如何呢?结果不得而知,但是没必要纠结于此,选择一个最擅长的IO模型编程即可,既然IO不频繁,这种性能优劣的比较也就没有太大意义了。
3 结论
本文对Linux IO的基本概念和主要IO技术进行了梳理,并做了理论上的性能比较。最终异步IO在各种场景下都具有更好的性能优势,鼓励采用。同时也得出在Nginx等高性能服务器上广泛使用的epoll模型并非完美,在单用户连接的频繁IO下性能不如普通的阻塞IO,所以程序开发者不能盲目的选择新技术,要结合目标场景的特点。当然,本文做的各个IO模型的性能比较仅仅是从理论上分析的,最终还需要以实验进行验证和完善才行。