一、阻塞IO与非阻塞IO

阻塞 I/O:当⽤户程序执⾏ read ,线程会被阻塞,⼀直等到内核数据准备好,并把数据从内核缓冲区拷⻉到应⽤程序的缓冲区中,当拷⻉过程完成, read 才会返回。
注意,阻塞等待的是「内核数据准备好」和「数据从内核态拷⻉到⽤户态」这两个过程
过程如下图:
image.png
⾮阻塞 I/O:⾮阻塞的 read 请求在数据未准备好的情况下⽴即返回,可以继续往下执⾏。此时应⽤程序不断轮询内核,直到数据准备好,内核将数据拷⻉到应⽤程序缓冲区,read 调⽤才可以获取到结果。
过程如下图:
image.png
注意,这⾥最后⼀次 read 调⽤,获取数据的过程,是⼀个同步的过程,是需要等待的过程。这⾥的同步指的是内核态的数据拷⻉到⽤户程序的缓存区这个过程。

举个例⼦,访问管道或 socket 时,如果设置了 O_NONBLOCK 标志,那么就表示使⽤的是⾮阻塞 I/O 的
⽅式访问,⽽不做任何设置的话,默认是阻塞 I/O。

应⽤程序每次轮询内核的 I/O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应⽤程序啥也做不了,
只是在循环。
为了解决这种傻乎乎轮询⽅式,于是 I/O 多路复⽤技术就出来了,如 select、poll,它是通过 I/O 事件分 发,当内核数据准备好时,再以事件通知应⽤程序进⾏操作。
这个做法⼤⼤改善了应⽤进程对 CPU 的利⽤率,在没有被通知的情况下,应⽤进程可以使⽤ CPU 做其他的事情。

二、I/O多路复用

下图是使⽤ select I/O 多路复⽤过程。
注意, read 获取数据的过程(数据从内核态拷⻉到⽤户态的过程),也是⼀个同步的过程,需要等待。
image.png
实际上,⽆论是阻塞 I/O、⾮阻塞 I/O,还是基于⾮阻塞 I/O 的多路复⽤都是同步调⽤。
因为它们在 read调⽤时,内核将数据从内核空间拷⻉到应⽤程序空间,过程都是需要等待的。
也就是说这个过程是同步的,如果内核实现的拷⻉效率不⾼,read 调⽤就会在这个同步过程中等待⽐较⻓的时间。

2.0 C10K问题

并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词⾸字⺟缩写,C10K 就是单机同时处理 1 万个
请求的问题。

2.1 select

select 实现多路复⽤的⽅式是:将已连接的 Socket 都放到⼀个⽂件描述符集合,然后调⽤ select 函数将
⽂件描述符集合拷⻉到内核⾥,让内核来检查是否有⽹络事件产⽣。
检查的⽅式很粗暴,就是通过遍历⽂件描述符集合的⽅式,当检查到有事件产⽣后,将此 Socket 标记为可读或可写, 接着再把整个⽂件描述符集合拷⻉回⽤户态⾥,然后⽤户态还需要再通过遍历的⽅法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种⽅式,需要进⾏ 2 次「遍历」⽂件描述符集合,⼀次是在内核态⾥,⼀个次是在⽤户态⾥ ,⽽且还会发⽣ 2 次「拷⻉」⽂件描述符集合,先从⽤户空间传⼊内核空间,由内核修改后,再传出到⽤户空间中。select使⽤固定⻓度的 BitsMap,表示⽂件描述符集合,⽽且所⽀持的⽂件描述符的个数是有限制的,在Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最⼤值为 1024 ,只能监听0~1023 的⽂件描述符。

2.2 poll

  1. poll 不再⽤ BitsMap 来存储所关注的⽂件描述符,取⽽代之⽤动态数组,以链表形式来组织,突破了select 的⽂件描述符个数限制,当然还会受到系统⽂件描述符限制。 <br /> 但是 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 相关的接⼝作⽤:
image.png
epoll 的⽅式即使监听的 Socket 数量越多的时候,效率不会⼤幅度降低,能够同时监听的 Socket 的数⽬
也⾮常的多了,上限就为系统定义的进程打开的最⼤⽂件描述符个数。因⽽,epoll 被称为解决 C10K 问
题的利器

epoll触发模式:
1. epoll⽀持的事件触发模式:(1)边缘触发ET (2)⽔平触发LT
2. 边缘触发模式ET
当被监控的 Socket 描述符上有可读事件发⽣时,服务器只会从 epoll_wait中苏醒⼀次,即使进程没有调⽤read 函数从内核读取数据,也依然只苏醒⼀次,因此我们程序要保证⼀次性将内核缓冲区的数据读取完,只有第⼀次满⾜条件的时候才触发,之后就不会再传递同样的事件了。
3. ⽔平触发模式LT
当被监控的 Socket 上有可读事件发⽣时,服务器不断地从 epoll_wait中苏醒,直到内核缓冲区数据被 read函数读完才结束,⽬的是告诉我们有数据,只要满⾜事件的条件,⽐如内核中有数据需要读,就⼀直不断地把这个事件传递给⽤户。

2.4 select和epoll的区别

  1. select 和 poll 采⽤轮询的⽅式检查就绪事件,每次都要扫描整个⽂件描述符,复杂度O(N);
    2. epoll 采⽤回调⽅式检查就绪事件,只会返回有事件发⽣的⽂件描述符的个数,复杂度O(1);
    3. select 只⼯作在低效的LT模式,epoll 可以在 ET ⾼效模式⼯作;
    4. epoll 是 Linux 所特有,⽽ select 则应该是 POSIX 所规定,⼀般操作系统均有实现;
    5. select 单个进程可监视的fd数ᰁ有限,即能监听端⼝的⼤⼩有限,64位是2048;epoll 没有最⼤并发连接的限制,能打开的 fd 的上限远⼤于2048(1G的内存上能监听约10万个端⼝);
    6. select:内核需要将消息传递到⽤户空间,都需要内核拷⻉动作;epoll通过内核和⽤户空间共享⼀块内存来实现的。

2.4 总结

最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能⼀对⼀通信,那为了服务更多的客户端,
我们需要改进⽹络 I/O 模型。
⽐较传统的⽅式是使⽤多进程/线程模型,每来⼀个客户端连接,就分配⼀个进程/线程,然后后续的读写都在对应的进程/线程,这种⽅式处理 100 个客户端没问题,但是当客户端增⼤到 10000 个时,10000 个进程/线程的调度、上下⽂切换以及它们占⽤的内存,都会成为瓶颈。

为了解决上⾯这个问题,就出现了 I/O 的多路复⽤,可以只在⼀个进程⾥处理多个⽂件的 I/O,Linux 下有三种提供 I/O 多路复⽤的 API,分别是: select、poll、epoll。

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 只⽀持⽔平触发,⼀般⽽⾔,边缘触发的⽅式会⽐⽔平触发的效率⾼。

三、异步IO

真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷⻉到⽤户态」这两个过程都不⽤等待。
当我们发起 aio_read 之后,就⽴即返回,内核⾃动将数据从内核空间拷⻉到应⽤程序空间,这个拷⻉过程同样是异步的,内核⾃动完成的,和前⾯的同步操作不⼀样,应⽤程序并不需要主动发起拷⻉动作。
过程如下图:
image.png

四、IO模型的关系

几种IO模型的关系图:
image.png
在前⾯我们知道了,I/O 是分为两个过程的:
1. 数据准备的过程
2. 数据从内核空间拷⻉到⽤户进程缓冲区的过程
阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,⽽⾮阻塞 I/O 和基于⾮阻塞 I/O 的多路复⽤只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。
异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。

⽤故事去理解这⼏种 I/O 模型:
举个你去饭堂吃饭的例⼦,你好⽐⽤户程序,饭堂好⽐操作系统。

阻塞 I/O 好⽐,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就⼀直在那⾥等啊等,等了好⻓⼀段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒⾥(⽤户空间),经历完这两个过程,你才可以离开。

⾮阻塞 I/O 好⽐,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过⼏⼗分钟,你⼜来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒⾥,这个过程你是得等待的。

基于⾮阻塞的 I/O 多路复⽤好⽐,你去饭堂吃饭,发现有⼀排窗⼝,饭堂阿姨告诉你这些窗⼝都还没做好菜,等做好了再通知你,于是等啊等( select 调⽤中),过了⼀会阿姨通知你菜做好了,但是不知道哪个窗⼝的菜做好了,你⾃⼰看吧。于是你只能⼀个⼀个窗⼝去确认,后⾯发现 5 号窗⼝菜做好了,于是你让 5 号窗⼝的阿姨帮你打菜到饭盒⾥,这个打菜的过程你是要等待的,虽然时间不⻓。打完菜后,你⾃然就可以离开了。

异步 I/O 好⽐,你让饭堂阿姨将菜做好并把菜打到饭盒⾥后,把饭盒送到你⾯前,整个过程你都不需要任何待。