概念回顾
这篇文章主要来讲一下IO多路复用的一些细节性的东西,虽然我们前面的文章提到了IO多路复用的大致思想,但是实际上IO多路复用在具体的实现方案上还是有着一些区别的,
在讲多路复用之前,我们还是要再来回顾一下传统BIO模型和NIO模型的缺点,通过一步一步的比较,我们才能更好的理解多路复用的优点和本质首先我们知道,对于一次IO,我们有两个阶段会阻塞,分别是内核处理数据阶段和内核数据拷贝到用户空间阶段。
那么对于BIO来说,这两个阶段任意一个阶段没有完成,整个主线程都会被阻塞,如果有一个线程一直卡在了内核处理数据阶段,比如说有一个客户端一直占着线程,就是什么都不发,那么服务端也无法接收其他客户端的连接。就像是你排队去吃饭,前面有一个人,啥也不干也不离开,这样队伍后面所有的人都吃不上饭,这合理吗?这不合理。
对于传统的NIO模型,他的解决方案则是,你不是可能会被阻塞在第一阶段吗,那每来一个客户端,我就开一个线程,由这个线程去看内核数据他准备好没有,准备好了我就可以开始进行处理。所以这个模型第一阶段是非阻塞的。但是我们要知道,服务的资源也是有限的,如果每来一个IO请求,就开一个线程,如果请求多了起来,那么服务器资源迟早会有被耗光的一天。所以这种方案其实也不是很合理。
好,接下来就可以进入到本篇文章的正题了,IO多路复用。以上我们提到的两种模型,我们能不能想一个好一点的解决办法,来解决上面这个问题呢。我们来分析一下这个问题,问题在于为每来一个IO请求就要分配一个线程很耗费资源,但是我们又需要知道哪些数据已经是内核准备好了的。那么我们能不能创建一个数组,用来存放所有的IO所对应的那个请求的文件描述符呢?(就像是你去饭店里吃饭,你跟服务员点餐,服务员把你需要的菜记录在他手里的菜单上,其他顾客的菜也同样的记录在这个菜单上)然后用一个线程去遍历这个数组,去查询这个数组中有哪些IO请求所对应的数据是已经准备好了的,然后返回通知处理。
看起来这个思路好像确实是解决了上面的新建线程耗资源的问题,所以方向来说是对的,但是,我们再仔细想想,这个其实是在用户层的调用,一次又一次的反复遍历,如果得到的结果是数据还没有准备好,依然是一次浪费资源的调用。这就像是我们在for循环中进行rpc一样,你A服务反复的调B服务,问他哪些数据好了。还不如先告诉A服务我需要哪些东西,然后让B服务自己遍历,看看哪些好了,然后再告诉A服务。这样的话节省下来的就是n多次RPC调用的开销。
其实上面说的这种就是IO多路复用中的select/poll 的一个思路,所谓IO多路复用就是多路复用一次调用
select
select所使用的方式就是上面我们说的这种思路,每次会将一组的文件描述符一次性传入内核中,让内核自己去遍历。为什么要让内核自己去遍历呢?我们知道用户态请求内核的资源开销是比较高的,就相当于我们上面说的那种,在for循环里进行RPC调用一样,内核态用户态空间的反复切换比较浪费cpu资源,所以select的方式从用户态拷贝一组的fds数组到内核,内核自己遍历标记哪个已经就绪了,然后将文件描述符数组再拷贝回用户态,返回一个就绪的个数,用户态再对自己的这个数组进行遍历,找到可用的文件描述符进行处理。
所以在select整个过程中,要经过两次拷贝:一次是用户态向内核态的一次拷贝,一次是内核准备好数据之后,内核向用户的拷贝。两次遍历,一次遍历发生在内核,一次遍历发生在用户空间,以及一次系统调用,即用户态携带fds向内核的一次请求。相比较我们上面说的那种方式,用户态进行系统调用遍历,有n个io请求就要进行n次系统调用来说。select这种方式可以大幅度的减少系统调用的次数。
可以总结一下select的特点
- 每次select调用需要传入一个fds数组。
- 把遍历就绪操作放在了内核态,让内核自己去标记哪个文件描述符好了 。
- 只返回一个就绪个数给用户态,还需要用户态进行一次遍历 。
poll
poll和select的总体过程都是一样的,区别在意select有一个最大只能监听1024个文件描述符的限制,而poll则去掉了1024大小的限制,select用的是BitsMap 结构,poll取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,不过还是会受到系统文件描述符限制。
不过这两个在本质上没有什么大的区别,也需要在用户态与内核态之间拷贝文件描述符数组。时间复杂度也没有什么变化
epoll
我们上面说的select/poll的特点。但是我们仔细想一想,是不是还有可以优化的点,或者说还存在一些问题。
- 每次select调用需要传入一个fds数组。(问题:在并发量很高的时候,需要频繁执行用户到内核的复制,资源消耗量巨大)
- 把遍历就绪操作放在了内核态,让内核自己去标记哪个文件描述符好了 。(问题:能否改成事件驱动机制,哪个文件描述符就绪了,就把那个数组的文件描述符标记起来即可,避免内核的无效遍历)
- 只返回一个就绪个数给用户态,还需要用户态进行一次遍历 (问题:用户态的遍历也存在无效遍历的可能,可以优化成只返回已经就绪的文件描述符给用户态直接用,这样的话用户态就无需遍历了,可避免用户态的无效遍历)
新技术的出现必然是为了解决旧技术存在的问题,因此epoll的出现,也是为了解决我们上述说到的select/poll所存在的问题。
- epoll在内核中维护了一个文件描述符的集合(采用红黑树结构,可以高效的维护文件描述符,增删查一般时间复杂度是
O(logn)
),用户态无需每次重新传入,只需要告诉内核修改的部分即可,可以大幅度减少内核和用户空间之间的数据拷贝的资源消耗 - epoll采用的是事件驱动机制,不需要通过轮询来找到对应的那个文件描述符。当某个文件描述符就绪的时候,会通过回调函数,把它放到一个就绪链表中记录起来,内核就不用遍历文件描述符了
- 内核只返回就绪的文件描述符集合,也就是上面提到的那个就绪链表,因为返回给用户态的肯定是已经就绪了的,所以用户态可以拿来即用,也无需做多余的遍历
总结一下epoll的过程,首先内核会维护一个所有待检测的文件描述符的红黑树,然后用户态每次只需传入有更改或者新增的那个部分的文件描述符,内核会在红黑树上维护好传过来的文件描述符,接着假如有一个文件描述符好了,会触发回调函数,将这个文件描述符添加到就绪链表中,当epoll_wait触发的时候就返回就绪链表给用户态直接使用。可以看到,epoll很好的解决了我们上面说到的那些select和poll所存在的问题,使得io效率在高并发环境下高了不少。
至于epoll_wait是怎么触发的,可以看看下面这段描述 ,我觉得是写的比较通俗易懂的,所以就直接转过来了,转自公众号 【小林coding】
epoll 支持两种事件触发模式,分别是边缘触发(*edge-triggered,ET*)和水平触发(*level-triggered,LT*)。
这两个术语还挺抽象的,其实它们的区别还是很好理解的。
- 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
- 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如
read
和write
)返回错误,错误类型为EAGAIN
或EWOULDBLOCK
。一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
总结
本篇文章我们回顾了传统的IO的一些缺陷,因为有这些BIO的阻塞缺陷,只能一对一的服务客户端。NIO模型的多线程消耗资源缺陷,当有大量客户端的io请求的时候,建立大量的线程会过度消耗服务器资源,所以引出了IO多路复用这种机制,所谓IO多路复用就是多路复用一次调用,而对于IO多路复用在内核中实现方式又有三种,分别是select、poll、epoll。
select/poll的的方式会把先从用户空间拷贝一个文件描述符集合到内核态遍历就绪的文件描述符集合,然后返回一个就绪个数,并将文件描述符集合再拷贝回用户态进行遍历。poll则是去掉了select的限制。但是这种方式在高并发的时候,频繁的内核拷贝还是很浪费资源。
epoll则是采用红黑树的集合来维护用户描述符集合,用户只需传入修改部分的文件描述符,无需整个传入,节省了复制的开销。并且采用了事件驱动回调的机制,来触发就绪的文件描述符回调,将其添加到就绪链表中再返回给用户态,减少了内核和用户的无效遍历,大大的提升了检测的效率。也是目前采用的最多的,最好的处理方式。