我们知道,为了OS的安全性等的考虑,进程是无法直接操作I/O设备的,其必须通过系统调用请求内核来协助完成I/O动作,Linux中共分为 5 种I/O模型:
(1) 阻塞I/O (Blocking I/O)
(2) 非阻塞I/O (Non-Blocking I/O)
(3) I/O复用(I/O Multiplexing),经典的是Reactor模式(我们过去通信模块TCPModule在Linux OS 下用的就是Reactor模式);
(4) 信号驱动的I/O (Signal Driven I/O)
(5) 异步I/O (Asynchrnous I/O),经典的是Proactor 模式(我们过去通信模块TCPModule 在Windows OS 下用的就是Proactor模式);
同步和异步:描述的是用户线程与内核的交互方式,同步是指用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成才能继续执行;而异步是指用户线程发起I/O请求后仍继续执行,当内核I/O操作完成后会通知用户线程或者调用用户线程注册的回调函数。
阻塞和非阻塞:描述的用户线程调用内核I/O操作方式,阻塞是指I/O操作需要彻底完成后才返回用户空间,而非阻塞是指I/O操作被调用后立即返回给用户一个状态,无需等到I/O操作彻底完成。
严格来说,前四种都是同步I/O,只有最后一种属于异步I/O;本文将从网络数据的读取来重点介绍I/O多路复用技术。
I/O多路复用技术
I/O多路复用让一个进程能够处理多个请求,我们当然可以采用多线程去实现,让一个线程处理一路I/O请求,这样势必会造成线程的创建、切换、销毁等一些开销,尽管对于少数的I/O请求这种线程级开销很少,但是如果I/O 请求并发量比较大时,过多的线程将会是不小的开销。为了降低多线程带来的开销,本篇将重点讲解I/O多路复用技术,这里的“多路”指的的接受多个连接请求,“复用”指的是复用一个线程去处理。注意,这里并不是说I/O多路复用技术一定就优于多线程方式,只是强调在并发请求较大时,I/O多路复用技术优势才比较明显。
select、poll、epoll三种方式的实现
select实现方式
select 内部是采用一个数组保存所有套接字,这样它的的缺陷就是单个进程/线程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。其次,用户程序调用select接口将存放套接字的数组拷贝到内核空间,即告诉内核本次我(应用程序)要监听这些套接字的某些事件(可以根据参数设置,读、写或错误事件),内核采用轮询的方式负责检查套接字是否有事件产生;
理解select重点要理解fd_set的实现,其相关定义如下:
#define __NFDBITS (8 * sizeof(unsigned long)) //每个ulong型可以表示多少个bit,
#define __FD_SETSIZE 1024 //socket最大取值为1024
#define __FDSET_LONGS (__FD_SETSIZE/__NFDBITS) //bitmap一共有1024个bit,共需要多少个ulong
typedef struct {
unsigned long fds_bits [__FDSET_LONGS]; //用ulong数组来表示bitmap
} __kernel_fd_set;
typedef __kernel_fd_set fd_set;
//每个ulong为32位,可以表示32个bit。
//fd >> 5 即 fd / 32,找到对应的ulong下标i;fd & 31 即fd % 32,找到在ulong[i]内部的位置
#define FD_ZERO(fdsetp) (memset (fdsetp, 0, sizeof (*(fd_set *)(fdsetp)))) //memset bitmap
#define FD_SET(fd, fdsetp) (((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] |= (1<<((fd) & 31))) //设置对应的bit
#define FD_ISSET(fd, fdsetp) ((((fd_set *)(fdsetp))->fds_bits[(fd) >> 5] & (1<<((fd) & 31))) != 0) //判断对应的bit是否为1
下面给出应用程序调用FD_SERO以及FD_SET之后,fd_set中每个bit位的值情况:
定义:fd_set fdset;
初始化后,fdset->fds_bit[]数组的每一位全部为0;
因为1/32 = 0,所以套接字句柄为1一定是设置数组fdset->fds_bit[0]某一位bit值,1%32 =1,所以讲fdset->fds_bit[0]的第1位设置为1;
因为1000/32 = 31,所以套接字句柄为1000一定是设置数组fdset->fds_bit[31]某一位bit值,1000%32 =8,所以将fdset->fds_bit[0]的第8位设置为1;
当应用程序调用select(…)函数时,会将这个fd_set数组集合从用户空间拷贝到内核空间,内核检查各个socket句柄是否可读并修改fd_set集合中对应的bit值,假设上面的1没有数据可读,1000有数据可读,当select返回时,fd_set集合中数据值为:
这时通常应用程序会轮询检查哪个套接字句柄有数据可读,即循环调用FD_ISSET(…),根据select返回的fd_set集合就能判断出哪个句柄有数据可读,再调用recv(…)函数将数据用内核空间拷贝到用户空间并放到应用程序接收buffer中等待处理。
poll实现方式
Poll 与select原理类似,最大的区别是监听的所有套接字是通过链表的方式组织的,这样就解决了数组大小限制的问题,理论上可以向内核拷贝无数多个soeket句柄(注:当然最大也不能超过OS能表示的最大文件句柄数),内核通过检查以后再将socket结果拷贝到用户空间,之后poll也是挨个检查各个socket是否有数据可读。同样在socket请求量很大时,效率和性能都比较低下。
epoll实现方式
鉴于select和poll的缺陷,epoll对其进行了改进,其使用红黑树存放所有需要监听的socket句柄,用一个链表存放有事件发生的所有句柄。当某个socket有数据可读时,才会将该套接字加入到这个链表中(通过各个socket句柄对应的回调函数实现的),最后返回给应用程序的都是存在数据可读的句柄,由于网络中,大部分时间只会有少量的socket是处于活跃的,所以epoll通常效率较select、poll效率高一些,性能也较优的原因就在此。
例如:
该例子中假设select只检测各个套接字上的读事件,当采用select/poll模型时,当某个或某些套接字有事件产生时,如socket1、socket2有数据可读,select/poll 返回时内核将套接字数组/链表再拷贝到用户空间,由应用程序去挨个检查所有套接字上是否可读(这时socket3根本没有数据可读,但是应用程序开始并不知道);从这个过程来看,将所有套接字再从内核传到用户空间以及应用程序挨个检查是否有数据可读,效率是低下的,尤其当socket并发请求很大时。而采用epoll实现时,只会将包含socket1和socket2套接字句柄的链表从内核空间拷贝到用户空间,用户不需轮询就能确定socket1和socket2有数据需要读取,分别调用accept(套接字,…)、recv(套接字,…)接收数据处理即可。在系统比较庞大,并发请求比较多的情况下,epoll表现出的优势将越明显。