I/O多路复用详解
1 前要知识
1.1 文件I/O
文件I/O分类,常见的有
缓冲与⾮缓冲 I/O
直接与⾮直接 I/O
阻塞与⾮阻塞 I/O VS 同步与异步 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 VS 同步与异步 I/O
阻塞等待的是「内核数据准备好」和「数据从内核态拷⻉到⽤户态」这两个过程
⾮阻塞 I/O,⾮阻塞的 read 请求在数据未准备好的情况下⽴即返回,可以继续往下执⾏,此时应⽤程序不断轮询内核,直到数据准备好,内核将数据拷⻉到应⽤程序缓冲区, read 调⽤才可以得到结果
同步I/O就是指「内核数据准备好」和「数据从内核态拷⻉到⽤户态」这两个过程都需要等待。
异步I/O 是「内核数据准备好」和「数据从内核态拷⻉到⽤户态」这两个过程都不⽤等待。
1.2 I/O模型
来看看Linux中提供的5种I/O处理模型
阻塞IO
阻塞IO意味着当我们发起一次IO操作后应用程序一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。阻塞IO操作只能对单个文件描述符进行操作,例如read和write。
非阻塞IO
非阻塞IO是指每次应用程序询问内核是否有数据准备好。如果就绪,就进行拷贝操作;如果未就绪,就不阻塞程序,内核直接返回未就绪的返回值,等待用户程序下一个轮询。
I/O多路复用
发送方向接收方请求后,不等待响应,可以继续其他工作。 接收方处理请求时进行IO操作如果不能马上得到结果,就一直等到返回结果后,才响应发送方,期间不能进行其他操作。
这里复用的是指复用一个或几个线程,用一个或一组线程处理多个IO操作,减少系统开销小,不必创建和维护过多的进程/线程。
信号驱动I/O
信号驱动I/O是利用信号机制,让内核告知应用程序文件描述符的相关事件。
异步非阻塞I/O
发送方向接收方请求后,不等待响应,可以继续其他工作。 接收方处理请求时进行IO操作如果不能马上得到结果,也不等待,而是马上返回去做其他事情。 当IO操作完成以后,将完成状态和结果通知接收方,接收方再响应发送方。
需要说明的是,⽆论是阻塞 I/O、⾮阻塞 I/O,还是基于⾮阻塞 I/O 的多路复⽤都是同步调⽤。因为它们在 read调⽤时,内核将数据从内核空间拷⻉到应⽤程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷⻉效率不⾼,read调⽤就会在这个同步过程中等待⽐较⻓的时间。
2 I/O多路复用:select/poll/epoll
I/O多路复用其实就是使用一个进程来维护多个Socket。
⼀个进程虽然任⼀时刻只能处理⼀个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1秒内就可以处理上千个请求,把时间拉⻓来看,多个请求复⽤了⼀个进程,这就是多路复⽤,这种思想很类似⼀个 CPU 并发多个进程,所以也叫做时分多路复⽤。
select/poll/epoll 内核提供给⽤户态的多路复⽤系统调⽤,进程可以通过⼀个系统调⽤函数从内核中获取多个事件。
2.1 select
select 实现多路复⽤的⽅式是,将已连接的 Socket 都放到⼀个⽂件描述符集合,然后调⽤ select 函数将⽂件描述符集合拷⻉到内核⾥,让内核来检查是否有⽹络事件产⽣,检查的⽅式很粗暴,就是通过遍历⽂件描述符集合的⽅式,当检查到有事件产⽣后,将此 Socket 标记为可读或可写, 接着再把整个⽂件描述符集合拷⻉回⽤户态⾥,然后⽤户态还需要再通过遍历的⽅法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种⽅式,需要进⾏ 2 次「遍历」⽂件描述符集合,⼀次是在内核态⾥,⼀个次是在⽤户态⾥ ,⽽且还会发⽣ 2 次「拷⻉」⽂件描述符集合,先从⽤户空间传⼊内核空间,由内核修改后,再传出到⽤户空间中。
select 使⽤固定⻓度的 BitsMap,表示⽂件描述符集合,⽽且所⽀持的⽂件描述符的个数是有限制的,在Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最⼤值为 1024 ,只能监听 0~1023 的⽂件描述符。
2.2 poll
poll 不再⽤ BitsMap 来存储所关注的⽂件描述符,取⽽代之⽤动态数组,以链表形式来组织,突破了select 的⽂件描述符个数限制,当然还会受到系统⽂件描述符限制。但是 poll 和 select 并没有太⼤的本质区别,都是使⽤「线性结构」存储进程关注的 Socket 集合,因此都需要遍历⽂件描述符集合来找到可读或可写的Socket,时间复杂度为O(n),⽽且也需要在⽤户态与内核态之间拷⻉⽂件描述符集合,这种⽅式随着并发数上来,性能的损耗会呈指数级增⻓。
2.3 epoll
epoll 通过两个⽅⾯,很好解决了 select/poll 的问题。
第⼀点,epoll 在内核⾥使⽤红⿊树来跟踪进程所有待检测的⽂件描述字,把需要监控的 socket 通过epoll_ctl()
函数加⼊内核中的红⿊树⾥,红⿊树是个⾼效的数据结构,增删查⼀般时间复杂度是O(logn) ,通过对这棵⿊红树进⾏操作,这样就不需要像 select/poll 每次操作时都传⼊整个 socket 集合,只需要传⼊⼀个待检测的 socket,减少了内核和⽤户空间⼤量的数据拷⻉和内存分配。
第⼆点, epoll 使⽤事件驱动的机制,内核⾥维护了⼀个链表来记录就绪事件,当某个 socket 有事件发⽣时,通过回调函数内核会将其加⼊到这个就绪事件列表中,当⽤户调⽤epoll_wait()
函数时,只会返回有事件发⽣的⽂件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,⼤⼤提⾼了检测的效率。
从下图你可以看到 epoll 相关的接⼝作⽤:
epoll 的⽅式即使监听的 Socket 数量越多的时候,效率不会⼤幅度降低,能够同时监听的 Socket 的数⽬也⾮常的多了,上限就为系统定义的进程打开的最⼤⽂件描述符个数。因⽽,epoll 被称为解决 C10K问题
要注意的是,epoll使⽤的并不是共享内存的⽅式,即⽤户态和内核态都指向了就绪链表,所以就避免了内存拷⻉消耗。
epoll ⽀持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和⽔平触发(level-triggered,LT)。
使⽤边缘触发模式时,当被监控的 Socket 描述符上有可读事件发⽣时,服务器端只会从 epoll_wait中苏醒⼀次,即使进程没有调⽤ read 函数从内核读取数据,也依然只苏醒⼀次,因此我们程序要保证⼀次性将内核缓冲区的数据读取完;
使⽤⽔平触发模式时,当被监控的 Socket 上有可读事件发⽣时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被** read 函数读完才结束,⽬的是告诉我们有数据需要读取;
3 总结
select 和 poll 并没有本质区别,它们内部都是使⽤「线性结构」来存储进程关注的 Socket 集合。
在使⽤的时候,⾸先需要把关注的 Socket 集合通过 select/poll
系统调⽤从⽤户态拷⻉到内核态,然后由内核检测事件,当有⽹络事件产⽣时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷⻉到⽤户态,⽤户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。
很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越⼤,Socket 集合的遍历和拷⻉会带来很⼤的开销,因此也很难应对 C10K。
epoll 是解决 C10K 问题的利器,通过两个⽅⾯解决了 select/poll 的问题。
- epoll 在内核⾥使⽤「红⿊树」来关注进程所有待检测的 Socket,红⿊树是个⾼效的数据结构,增删查⼀般时间复杂度是 O(logn),通过对这棵⿊红树的管理,不需要像 select/poll 在每次操作时都传⼊整个 Socket 集合,减少了内核和⽤户空间⼤量的数据拷⻉和内存分配。
- epoll 使⽤事件驱动的机制,内核⾥维护了⼀个「链表」来记录就绪事件,只将有事件发⽣的 Socket集合传递给应⽤程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和⽆事件的 Socket ),⼤⼤提⾼了检测的效率。
⽽且,epoll ⽀持边缘触发和⽔平触发的⽅式,⽽ select/poll
只⽀持⽔平触发,⼀般⽽⾔,边缘触发的⽅式会⽐⽔平触发的效率⾼。
参考
图解系统-亮白风格-小林coding-v1.0.pdf