作者:黄湘龙
IO在计算机世界中地位举足轻重,IO效率一直是码农们孜孜不倦最求的目标。本文我们一起来研究下Linux的IO的工作方式是如何一步步进化到今天的。我们说的IO主要是指应用程序在工作过程中用到的IO类型,包括两种IO:文件IO和网络IO,本文主要研究的是网络IO。应用进程和内核之间的数据交互方式一直在演进,下面我们对各种形态的交互方式进行介绍。在这之前,我们先明确几个概念:内核空间和用户空间、同步和异步、阻塞和非阻塞。
- 内核空间
操作系统单独拥有的内存空间为内核空间,这块内存空间独立于其他的应用内存空间,除了操作系统,其他应用程序不允许访问这块空间。但操作系统可以同时操作内核空间和用户空间。
- 用户空间
单独给用户应用进程分配的内存空间,操作系统和应用程序都可以访问这块内存空间。
- 同步
调用线程发出同步请求后,在没有得到结果前,该调用就不会返回。所有同步调用都必须是串行的,前面的同步调用处理完了后才能处理下一个同步调用。
- 异步
调用线程发出异步请求后,在没有得到结果前,该调用就返回了。真正的结果数据会在业务处理完成后通过发送信号或者回调的形式通知调用者。
- 阻塞
调用线程发出请求后,在没有得到结果前,该线程就会被挂起,此时CPU也不会给此线程分配时间,此线程处于非可执行状态。直到返回结果返回后,此线程才会被唤醒,继续运行。划重点:线程进入阻塞状态不占用CPU资源。
- 非阻塞
调用线程发出请求后,在没有得到结果前,该调用就返回了,整个过程调用线程不会被挂起。
1. 同步阻塞IO
同步阻塞IO模式是Linux中最常用的IO模型,所有Socket通信默认使用同步阻塞IO模型。同步阻塞IO模型中,应用线程在调用了内核的IO接口后,会一直被阻塞,直到内核将数据准备好,并且复制到应用线程的用户空间内存中。常见的Java的BIO,阻塞模式的Socket网络通信就是使用这种模式进行网络数据传输的。
如图所示,当应用线程发起读操作的IO请求时,内核收到请求后进入等待数据阶段,此时应用线程会处于阻塞的状态。当内核准备好数据后,内核会将数据从内核空间拷贝到用户内存空间,然后内核给应用线程返回结果,此时应用线程才解除阻塞的状态。
同步阻塞模式的特点是内核 IO 在执行等待数据读取到内核空间和将数据复制到用户空间的两个阶段,应用线程都被阻塞。
同步阻塞模式简单直接,没有下面几种模式的线程切换、回调、通知等消耗,在并发量较少的网络通信场景下是最好的选择。
在大规模网络通信的场景下,大量的请求和连接需要处理,线程被阻塞是不可接受的。虽然当前的网络 I/O 有一些解决办法,如使用一个线程来处理一个客户客户端的连接,出现阻塞时只是一个线程阻塞而不会影响其它线程工作,还有为了减少系统线程的开销,采用线程池的办法来减少线程创建和回收的成本,但是有一些使用场景同步阻塞模式仍然是无法解决的。如当前一些需要大量 HTTP 长连接的情况,像淘宝现在使用的 Web 旺旺项目,服务端需要同时保持几百万的 HTTP 连接,但是并不是每时每刻这些连接都在传输数据,这种情况下不可能同时创建这么多线程来保持连接。这种情况,我们想给某些客户端更高的服务优先级,很难通过设计线程的优先级来完成,我们需要另外一种新的 I/O 操作模式。
优点:
并发量较少的网络通信场景较高效
应用程序开发简单
缺点:
不适合并发量较大的网络通信场景
2. 同步非阻塞IO
同步非阻塞IO是同步阻塞IO的一种变种IO模式,它和同步阻塞区别在于,应用线程在向内核发送IO请求后,内核的IO数据在没有准备好的时候会立刻给应用线程返回一个错误代码(EAGAIN 或 EWOULDBLOCK),在内核的IO数据准备好了之后,应用线程再发起IO操作请求时候,内核会在将IO数据从内核空间复制到用户空间后给应用线程返回正常应答。常见的Non-Blocking模式的Socket网络通信就是同步非阻塞模式。
如图所示,当用户线程发起读操作时,如果内核的IO数据还没有准备好,那么它不会阻塞掉用户线程,而是会直接返回一个 EAGAIN/EWOULDBLOCK 错误。从用户线程的角度,它发起一个读操作后立即就得到了一个结果,用户进程判断结果是 EAGAIN/EWOULDBLOCK 之后会再次发起读操作。这种利用返回值不断调用被称为轮询(polling),显而易见,这么做会耗费大量 CPU 时间。一旦内核中的IO数据准备好了,并且又再次收到了用户进程的请求,那么它马上就将数据拷贝到了用户内存,然后返回。
优点:
在内核IO数据准备阶段不会阻塞应用线程,适合对线程阻塞敏感的网络应用
缺点:
轮询查询内核IO数据状态,耗费大量CPU,效率低
需要不断轮序,增加开发难度
3. 多路复用
多路复用是目前大型互联网应用中最常见的一种IO模型,简单说就是应用进程中有一个IO状态管理器,多个网络IO注册到这个管理器上,管理器使用一个线程调用内核API来监听所有注册的网络IO的状态变化情况,一旦某个连接的网络IO状态发生变化,能够通知应用程序进行相应的读写操作。多路网络IO复用这个状态管理器,所以叫多路复用模式。多路复用本质上是同步阻塞,但与传统的同步阻塞多线程模型相比,IO 多路复用的最大优势是在处理IO高并发场景时只使用一个线程就完成了大量的网络IO状态的管理工作,系统资源开销小。Java的NIO,Nginx都是用的多路复用模式进行网络传输。多路复用的基本工作流程:
- 应用程序将网络IO注册到状态管理器;
- 状态管理器通过调用内核API来确认所管理的网络IO的状态;
- 状态管理器探知到网络IO的状态发生变化后,通知应用程序进行实质的同步阻塞读写操作。
目前Linux主要有三种状态管理器:select,poll,epoll。epoll是Linux目前大规模网络并发程序开发的首选模型,在绝大多数情况下性能远超select和poll。目前流行的高性能Web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。他们也是不同历史时期的产物:
- select出现是1984年在BSD里面实现的;
- 14年之后也就是1997年才实现了poll,其实拖那么久也不是效率问题, 而是那个时代的硬件实在太弱,一台服务器处理1千多个链接简直就是神一样的存在了,select很长段时间已经满足需求;
- 2002, 大神 Davide Libenzi 实现了epoll;
这三种状态管理器都是通过不同的内核API来监视网络连接的状态,不同的API提供不同的能力,导致了性能上的差异,下面我们逐个分析下。
3.1 select
select是最古老的多路复用模型,Linux在2.6版本之前仅提供select模式,一度是主流的网络IO模式。select采取定期轮询的方式将自己管理的所有网络IO对应的文件句柄发送给内核,进行状态查询,下面是内核系统对应用程序提供的API:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
fd_set是一个Long的数组的数据结构,用于存放的是文件句柄(file descriptor)。这个API有三个关键参数,即readset/writeset/exceptset,前两个参数是注册到select,所有需要监听的网络IO文件句柄数组,第三个参数是一个空数组,由内核轮询所有网络IO文件句柄后,将状态有变化的文件句柄值写入到exceptset数组中,也就是说readset/writeset是输入数据,exceptset是输出数据。最后,内核将变化的句柄数数量返回给调用者。
从接口的细节,我们可以看到归纳下select的工作流程:
- 应用线程将需要监视的网络IO文件句柄注册到select状态监视器;
- select状态监视器工作线程定期调用内核API,将自己所有管理的文件句柄通过(readset/writeset)两个参数传给内核;
- 内核轮询所有传进来的文件句柄的网络IO状态,将有变化的文件句柄值写入exceptset数组中,并且将变化的句柄数数量返回给调用者;
- select工作线程通知应用程序进行实质的同步阻塞读写操作。
select机制的特性分析:
- 每次调用select,都需要把readset/writeset集合从用户空间态拷贝到内核空间,如果readset/writeset集合很大时,那这个开销很大;
- 每次调用select都需要在内核遍历传递进来的所有文件句柄,每次调用都进行线性遍历,时间复杂度为O(n),文件句柄集合很大时,那这个开销也很大;
- 内核对被监控的文件句柄集合大小做了限制,X86为1024,X64为2048。
3.2 poll
poll模型和select模型非常类似,状态监视器同样管理一批网络IO状态,内核同样对传输过来的所有网络IO文件句柄进行线性轮询来确认状态,唯一区别是应用线程传输给内核的文件句柄数组不限制大小,解决了select中说道的第三个问题,其他两个问题依然存在。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd {
int fd; // 需要被检测或选择的文件描述符
short events; // 对文件描述符fd上感兴趣的事件
short revents; // 文件描述符fd上当前实际发生的事件
} pollfd_t;
这个是内核提供的poll的API,fds是一个struct pollfd类型的数组,用于存放需要检测其状态的网络IO文件句柄,并且调用poll函数之后fds数组不会被清空;pollfd结构体表示一个被监视的文件描述符。其中,结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个字段,结构体的revents字段是文件句柄的操作结果事件掩码,内核在调用返回时设置这个字段。
3.3 epoll
epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有文件句柄个数限制,将应用程序关心的网络IO文件句柄的事件存放到内核的一个事件表中,在用户空间和内核空间的copy只需一次。epoll内核和网络设备建立了订阅回调机制,一旦注册到内核事件表中的网络连接状态发生了变化,内核会收到网络设备的通知,订阅回调机制替换了select/poll的轮询查询机制,将时间复杂度从原来的O(n)降低为O(1),大幅提升IO效率,特别是在大量并发连接中只有少量活跃的场景。
Linux提供的三个epoll的API:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll将select/poll的一个大API变成了三个API,目的就是先把需要监听的句柄数据通过epoll_ctl接口在内核注册了,不用每次查询网络IO状态的时候传一大堆数据过去。epoll在内核内存里建了一个红黑树用于存储epoll_ctl传来的连接,epoll内核还会建立一个rdllist双向链表,用于存储网络状态发生变化的文件句柄,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可,有数据就返回,没有数据就让epoll_wait睡眠,等到timeout时间到后即使链表没数据也返回。因为epoll不像select/poll那样采取轮询每个连接来确认状态的方法,而是监听一个双向链表,在连接数很多的情况下,epoll_wait非常高效。
所有添加到epoll中的网络连接都会与设备(如网卡)驱动程序建立回调关系,也就是说相应连接状态的发生时网络设备会调用回调方法通知内核,这个回调方法在内核中叫做ep_poll_callback,它会把网络状态发生变化的变更事件放到上面的rdllist双向链表中。
当调用epoll_wait检查是否有状态变更事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有数据而已,如果rdllist链表不为空,则将链表中的事件复制到用户态内存(使用MMAP提高效率)中,同时将事件数量返回给用户。epoll_ctl在向epoll对象中添加、修改、删除监听的网络IO文件句柄时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。
epoll的epoll_wait调用,有EPOLLLT和EPOLLET两种触发返回模式,LT是默认的模式,更加安全,ET是“高速”模式,更加高效:
- 水平触发(LT):默认工作模式,即当epoll_wait检测到某网络连接状态发生变化并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件;
- 边缘触发(ET): 当epoll_wait检测到某网络连接状态发生变化并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。
epoll机制的特性分析:
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听超过10万个连接);
- 通过epoll_ctl方法将网络连接注册到内核,不用每次查询连接状态时将所有网络文件句柄传输传输给内核,大幅提高效率;
- 内核和网络设备建立事件订阅机制,监听连接网络状态不使用轮询的方式,不会随着文件句柄数目的增加效率下降,只有活跃可用的文件句柄才会触发回调函数;Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关;
- 利用MMAP内存映射技术加速用户空间与内核空间的消息传递,减少复制开销。
目前大部分主流的应用都是基于此种IO模型构建的,比如Nginx,NodeJS,Netty框架等,总结一下多路复用的特点:
优点:
使用一个线程监控多个网络连接状态,性能好,特别是进化最终形态epoll模式,适合大量连接业务场景
缺点:
较复杂,应用开发难度大
4 信号驱动
信号驱动模式利用linux信号机制,通过sigaction函数将sigio读写信号以及handler回调函数注册到内核队列中,注册后应用进程不堵塞,可以去干别的工作。当网络IO状态发生变化时触发SIGIO中断,通过调用应用程序的handler通知应用程序网络IO就绪了。信号驱动的前半部分操作是异步行为,后面的网络数据操作仍然属于同步阻塞行为。
优点:
这种异步回调方式避免用户或内核主动轮询设备造成的资源浪费
缺点:
handler是在中断环境下运行,多线程不稳定,而且平台兼容性不好,不是一个完善可靠的解决方案,实际应用场景少
较复杂,开发难度大
5. 异步IO
异步IO通过一些列异步API实现,是五种IO模式中唯一个真正的异步模式,目前Java的AIO使用的就是本模式。异步模式的读操作通过调用内核的aio_read函数来实现。应用线程调用aio_read,递交给内核一个用户空间下的缓冲区。内核收到请求后立刻返回,不阻塞应用线程。当网络设备的数据到来后,内核会自动把数据从内核空间拷贝到aio_read函数递交的用户态缓存。拷贝完成后以信号的方式通知用户线程,用户线程拿到数据后就可以执行后续操作。
异步IO模式与信号驱动IO的区别在于:信号驱动IO由内核通知应用程序什么时候可以开始IO操作,异步IO则由内核告诉应用程序IO操作何时完成。异步IO主动把数据拷贝到用户空间,不需要调用recvfrom方法把数据从内核空间拉取到用户态空间。异步IO是一种推数据的机制,相比于信号处理IO拉数据的机制效率会更高。
异步IO还是属于比较新的IO模式,需要操作系统支持,Linux2.5版本首次提供异步IO,Linux2.6及以后版本,异步IO的API都属于标准提供。异步IO目前没有太多的应用场景。
优点:
纯异步,高效率高性能。
缺点:
效率比多路复用模式没有质的提升,成熟应用迁移模式的动力不足,一直没有大规模成熟应用来支撑
较复杂,开发难度大
6. 总结
五种Linux的IO模式各有特色,存在即合理,各有自己的应用场景。目前,大家在写一些简单的低并发Socket通信时大多数还是使用多线程加同步阻塞的方式,效率和其他模式差不多,实现起来会简单很多。
目前市面上流行的高并发网络通信框架,Nginx、基于Java的NIO的Netty框架和NodeJS等都是使用使用的多路复用模型,经过大量实际项目验证,多路复是目前最成熟的高并发网络通信IO模型。而多路复用模型中的epoll是最优秀的,目前Linux2.6以上的系统提供标准的epoll的API,Java的NIO在Linux2.6及以上版本都会默认提供epoll的实现,否者会提供poll的实现。而Windows目前还不支持epoll,只支持select,不过也什么,基本上没什么人用Windows来做网络服务器。
而信号驱动IO感觉不太成熟,基本上没有见过使用场景。纯异步模式,内核把所有事情做了,看起来很美好,Java也提供了响应的实现,但由于效率比多路复用模式没有质的提升,成熟应用迁移模式的动力不足,一直没有大规模成熟应用来支撑。
参考:
《使用异步 I/O 大大提高应用程序的性能》
https://www.ibm.com/developerworks/cn/linux/l-async/index.html
《深入分析 Java I/O 的工作机制》
https://www.ibm.com/developerworks/cn/java/j-lo-javaio/index.html
《IO多路复用的三种机制Select,Poll,Epoll》
https://www.jianshu.com/p/397449cadc9a