操作系统
介绍下Linux(Unix)操作系统的五种IO模型
在Linux(UNIX)操作系统中,共有五种IO模型,分别是:阻塞IO模型、非阻塞IO模型、IO复用模型、信号驱动IO模型以及异步IO模型。
我们说阻塞IO模型、非阻塞IO模型、IO复用模型和信号驱动IO模型都是同步的IO模型。原因是因为,无论以上那种模型,真正的数据拷贝过程,都是同步进行的。
同步和异步:
从操作系统角度来说,网络IO的数据拷贝主要分为两个阶段,一是数据准备阶段,二是数据从内核拷贝到用户中。
同步IO指的是数据从内核拷贝到用户时。发起该请求的线程会自己来拷贝数据(表现为线程阻塞拷贝)。
PS:一旦涉及到网络 IO必定会发生数据拷贝的阻塞(此阻塞非彼阻塞,这里的阻塞形容的是拷贝数据相对于CPU的速度来说是非常耗时的,看起来像线程阻塞了一样。我们常说的阻塞是线程挂起并让出CPU),只不过阻塞发生在其他的地方(自己来拷贝就是同步的,别人帮我拷贝就是异步的),因为IO必定会用到CPU,即使是零拷贝。
阻塞和非阻塞:
阻塞和非阻塞主要描述的是网络IO数据拷贝的第一个阶段。
阻塞指的是线程一直等待数据准备好,期间什么都不干,但是会让出CPU,这样其他线程可以执行(CPU的利用率比较高),数据准备好之后自己来拷贝数据。
非阻塞指的是在第一阶段,发起网络IO请求的时候会立即返回去干别的事情,但是会不断地进行询问数据是否准备好,这种方式称之为轮询。由于CPU要处理更多的系统调用(每次询问都是系统调用),这种模型的CPU利用率低。
NIO是如何实现同步非阻塞的呢?
Java的NIO实现采用了同步非阻塞IO和IO多路复用,其核心组件是Selector。Selector会不断的主动的询问Channel是否有事件发生,有事件发生,会进行事件处理。
1 | //java nio |
Netty采用的是NIO其对Java的NIO进行了改进,其内部也封装了Selector和Channel,采用串行并发来提高效率。
Java的NIO采用的是Reactor线程模型中的单Reactor单线程模型(前台和服务员是一个人,全程为顾客服务,可以服务多个人),Netty的NIO采用的是主从Reactor模型,是多Reactor多线程模型。
I/O 多路复用
select 的缺点
- 性能开销大
- 调用
select
时会陷入内核,这时需要将参数中的fd_set
从用户空间拷贝到内核空间 - 内核需要遍历传递进来的所有
fd_set
的每一位,不管它们是否就绪
- 调用
- 同时能够监听的文件描述符数量太少。受限于
sizeof(fd_set)
的大小,在编译内核时就确定了且无法更改。一般是 1024,不同的操作系统不相同
poll
poll 和 select 几乎没有区别。poll 采用链表的方式存储文件描述符,没有最大存储数量的限制。
从性能开销上看,poll 和 select 的差别不大。
epoll
epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。
简而言之,epoll 有以下几个特点:
- 使用红黑树存储文件描述符集合
- 使用队列存储就绪的文件描述符
- 每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态
select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create
、epoll_ctl
和 epoll_wait
。
epoll 的优点
一开始说,epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。
对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。
对于“性能开销大”,epoll_ctl
中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select
那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。
相当于时间复杂度从 O(n) 降为 O(1)
此外,每次调用 select
时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl
传递一次,之后 epoll_wait
不需要再次传递。这也大大提高了效率。
水平触发、边缘触发
select
只支持水平触发,epoll
支持水平触发和边缘触发。
水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。
边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。
区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。
水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。
为什么边缘触发必须使用非阻塞 I/O?
关于这个问题的解答,强烈建议阅读这篇文章。下面是一些关键摘要:
- 每次通过
read
系统调用读取数据时,最多只能读取缓冲区大小的字节数;如果某个文件描述符一次性收到的数据超过了缓冲区的大小,那么需要对其read
多次才能全部读取完毕 select
可以使用阻塞 I/O。通过select
获取到所有可读的文件描述符后,遍历每个文件描述符,read
一次数据(见上文 select 示例)- 这些文件描述符都是可读的,因此即使
read
是阻塞 I/O,也一定可以读到数据,不会一直阻塞下去 select
采用水平触发模式,因此如果第一次read
没有读取完全部数据,那么下次调用select
时依然会返回这个文件描述符,可以再次read
select
也可以使用非阻塞 I/O。当遍历某个可读文件描述符时,使用for
循环调用read
多次,直到读取完所有数据为止(返回EWOULDBLOCK
)。这样做会多一次read
调用,但可以减少调用select
的次数
- 这些文件描述符都是可读的,因此即使
- 在
epoll
的边缘触发模式下,只会在文件描述符的可读/可写状态发生切换时,才会收到操作系统的通知- 因此,如果使用
epoll
的边缘触发模式,在收到通知时,必须使用非阻塞 I/O,并且必须循环调用read
或write
多次,直到返回EWOULDBLOCK
为止,然后再调用epoll_wait
等待操作系统的下一次通知 - 如果没有一次性读/写完所有数据,那么在操作系统看来这个文件描述符的状态没有发生改变,将不会再发起通知,调用
epoll_wait
会使得该文件描述符一直等待下去,服务端也会一直等待客户端的响应,业务流程无法走完 - 这样做的好处是每次调用
epoll_wait
都是有效的——保证数据全部读写完毕了,等待下次通知。在水平触发模式下,如果调用epoll_wait
时数据没有读/写完毕,会直接返回,再次通知。因此边缘触发能显著减少事件被触发的次数 - 为什么
epoll
的边缘触发模式不能使用阻塞 I/O?很显然,边缘触发模式需要循环读/写一个文件描述符的所有数据。如果使用阻塞 I/O,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束
- 因此,如果使用
三者对比
select
:调用开销大(需要复制集合);集合大小有限制;需要遍历整个集合找到就绪的描述符poll
:poll 采用链表的方式存储文件描述符,没有最大存储数量的限制,其他方面和 select 没有区别epoll
:调用开销小(不需要复制);集合大小无限制;采用回调机制,不需要遍历整个集合
select
、poll
都是在用户态维护文件描述符集合,因此每次需要将完整集合传给内核;epoll
由操作系统在内核中维护文件描述符集合,因此只需要在创建的时候传入文件描述符。
此外 select
只支持水平触发,epoll
支持边缘触发。
适用场景
当连接数较多并且有很多的不活跃连接时,epoll 的效率比其它两者高很多。当连接数较少并且都十分活跃的情况下,由于 epoll 需要很多回调,因此性能可能低于其它两者。
Redis 的线程模型
Redis 是一个单线程的工作模型,使用 I/O 多路复用来处理客户端的多个连接。为什么 Redis 选择单线程也能效率这么高?
I/O 设备(如磁盘、网络)等速度远远慢于 CPU,因此引入了多线程技术。当一个线程发起 I/O 请求时,先将它挂起,切换到别的线程;当 I/O 设备就绪时,再切换回该线程。总之,多线程技术是为了充分利用 CPU 的计算资源,适用于下层存储慢速的场景。
而 redis 是纯内存操作,读写速度非常快。所有的操作都会在内存中完成,不涉及任何 I/O 操作,因此多线程频繁的上下文切换反而是一种负优化。Redis 选择基于非阻塞 I/O 的 I/O 多路复用机制,在单线程里并发处理客户端的多个连接,减少多线程带来的系统开销,同时也有更好的可维护性,方便开发和调试。
不过 redis 在最新的几个版本中也引入了多线程,目的是:
- 异步处理删除操作。当删除超大键值对的时候,单线程内同步地删除可能会阻塞待处理的任务
- 应对网络 I/O 的场景,网络 I/O 是慢速 I/O。redis6 吞吐量提高了 1 倍