redis再战之IO网络传输演变与区别《二》

BIO、NIO、Epoll发展历程以及原理回顾

BIO原理与缺陷

redis再战之IO网络传输演变与区别《二》
Linux有OS内核,内核会有很多的Client连接,这些连接就是文件描述符(fd8,fd9),程序/进程/线程可以从kernel中read这些描述符获得数据。
在BIO时期,当Client1想要发送数据给Server,首先要先把数据通过fd8(假设)发送给kernel,再由Server进程1 read(fd8)得到数据进行计算处理,处理的时候进程1不能做别的事情,是一个阻塞的状态,这个时候如果Client2也来数据了,Server需要再开启一个进程2才能处理Client2发送过来的数据。

这样就引发很多问题:
JVM开辟一个线程的成本为1M,造成了内存的浪费。
线程多了CUP调度会有压力。
如果在只有一个CPU的情况下,CPU在调度进程1的时间片里,数据没到,进程1会block。这个时候如果进程2的数据包到了,但是CPU的时间片在处理进程1,还没有轮到进程2,所以进程2需要等待进程1CPU时间片结束后才能得到处理。

NIO的原理与缺陷

为了解决BIO的问题,kernel发生了改变,诞生了同步非阻塞NIO。
redis再战之IO网络传输演变与区别《二》
这里kernel的改变是指建立连接后socket对应的fd是nonblock(非阻塞,不必等read数据完成后才能做其他事),那么就可以使用一个线程,在这个线程里可以写一个while循环遍历这些fd(文件描述符),检查哪些fd有数据到达。

需要注意的问题是:

同步:遍历和处理数据都是一个线程来完成的。
非阻塞:指的是socket I/O非阻塞,不阻塞线程而导致后面的代码无法执行。
优点:同步非阻塞NIO,通过修改内核fd非阻塞的方式,可以使用一个线程完成对数据的读取,减少了线程的创建和CUP切换线程所带来的的成本。
缺点:轮询发生在用户空间,如果有1000个fd,轮询一次需要调用1000次kernel的成本依然很大(用户态到内核态的切换过于频繁)。

同步非阻塞NIO 到 多路复用NIO

redis再战之IO网络传输演变与区别《二》
为了解决同步非阻塞NIO调用内核过于频繁的问题,kernel又发生了改变。
在socke非阻塞的基础上,kernel添加了新的OS调用select。select如何工作呢?就是Client发送数据给Server的进程会建立fd(文件描述符),进程收到多少描述符都会传递给select,由select监控这些fd是否有数据包到达。之前同步非阻塞NIO轮询检查数据到达是发生在用户态,而 多路复用NIO 是把检查轮询的工作转移到了kernel。select监控到有fd并且把这些fd从新发送给Server进程,Server进程遍历这些fd查看哪些标记为有数据到达,根据这些有数据到达的fd调用read(fd)读取数据。

优点:相较于同步非阻塞NIO,减少了调用kernel调用的频率。
缺点:fd相关数据在用户态和内核态之间传递其实是一个copy的过程,并且server要遍历查看标记哪些fd有数据,还是会造成资源的损耗。

多路复用NIO 到 epoll

redis再战之IO网络传输演变与区别《二》
在epoll的模型中,用户空间进程会先创建epoll的fd,只要外界有连接到进程,进程就会把和外界连接的fd写给epoll的fd,然后内核的epoll会准备一个mmap的映射,就是用户态和内核态的fd对应数据位置做了一个映射,mmap的本质是给用户态和内核态建立一个共享空间,这个共享空间里有一个红黑树的数据结构和一个链表的数据结构,epoll再把收到的fd存到这个共享空间的红黑树,内核检测红黑树中的fd哪个有数据了,就把这个fd放到链表中。用户空间进程调用wait()等待,只要Client那边有数据了,wait就可以返回变成不阻塞从链表中取出fd,然后用户空间的进程可以直接调用read(fd)读取数据就可以了。

select poll和epoll的区别

select的几大缺点:

(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024

poll实现

poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

epoll

epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

总结:

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。