io多路复用的原理和实现_IO模型和基于事件驱动的IO多路复用模式

1.IO多路复用与事件驱动模型

传统的服务器(如Apache,2.4版本前)IO模型是采用为每个请求创建一个子线程来处理,这种模式在并发量小的情况下可以正常支撑业务,但是在高并发场景下,机器资源很快就会耗尽。

现今常见的高吞吐高并发系统往往是基于事件驱动的IO多路复用模式设计,这种模式将所有的请求交给一个单独的线程管理,此线程被称之为事件循环线程,当事件等待的系统资源就绪时会及时进行处理,而不是为每个连接生成一个OS线程(这里大家可能会有疑问操作系统是如何识别IO事件以及事件就绪的,我们后面再聊)。这种事件驱动的异步模型大幅度提升了服务器的吞吐能力,在相同配置的服务器能接受更多的并发请求。

事件驱动模型的应用十分广泛,Redis就是一个典型的单线程基于事件驱动的内存数据库,node.js、nginx、netty等也都是基于这种方式来实现高吞吐性能。但Redis是单线程的,它是如何做到基于事件多路复用的呢?本文会带着大家从Java语言应用层面到内核层面介绍各种IO模型,了解事件驱动模型背后的原理。

2.如何做到IO非阻塞

一般高性能的背后都需要底层的实现,IO多路复用高性能的背后同样也要操作系统内核、TCP/IP协议栈底层的支持才行。

当我们调用套接字读写方法时,默认它们都是阻塞的,比如read方法要传递进去一个参数n,表示最多读取n个字节后再返回,如果这时一个字节都没有,线程就会阻塞,直到新的数据到来或者连接关闭read方法才会返回。write方法如果内核套接字分配的写缓冲区已经满了,这时也会阻塞。下图是套接字读写流程。

746ef2e2419fb4380013351fc260a8d9.png

那么,底层socket是如何支持非阻塞的呢?原来,非阻塞套接字对象提供了一个选项Non_Blocking,当这个选项打开时,读写方法不会阻塞,而是能读多少读多少,能写多少写多少,调用读写方法不阻塞,且通过返回值告知程序实际读写的字节。有了非阻塞意味着读写IO可以不必阻塞,读写可以瞬间完成,线程就可以继续干别的事情。

非阻塞IO有一个问题,如果读没读够,或者写入只写入不一半,何时可以继续读或写呢?这里需要用到操作系统给用户线程提供的API。这部分内容在第五节IO多路复用原理我们再进一步分析。

3.五种IO模型

IO的两个阶段

在Linux 操作系统系统中几乎所有IO操作都是以“文件”的形式管理的(一切皆文件),对“文件”的读写一般都要经过内核态和用户态的切换,以read为例,对于一次IO访问,会经历两个阶段:

  1. 调用操作系统的read方法,并开始阻塞等待,等待数据准备好,此时数据正在拷贝到内核缓冲区。
  2. 将数据从内核缓冲区拷贝到应用进程,应用进程进行处理。

其实,从IO的两个阶段可以看出,数据需要从拷贝到内核缓冲区,再从内核缓冲区拷贝到应用进程,存在数据的反复拷贝,因此肯定是有优化空间的,另外,这两个阶段都存在阻塞等待,是否每次用户线程都必须在这两个阶段阻塞等待呢?带着这两个问题,我们继续分析5种IO模型。

五种IO模型分别是:阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO。

下图是5种IO模型的对比图。

e4f2c1896bf830908d09b3f56085086f.png

阻塞IO

阻塞IO(blocking IO)模式下,用户进程在发起系统调用直到数据到达且被拷贝到用户空间的两个阶段都处于阻塞状态。这个过程会产生1次系统调用recvfrom

非阻塞IO

非阻塞IO模式下,用户进程持续非阻塞地询问内核数据准备好了没有,轮询是是非阻塞的,如果数据还没准备好,用户进程不会被block住,内核会立即返回error给用户进程,直到数据准备就绪后,应用进程需同步等待数据从内核空间拷贝到用户空间。虽然数据拷贝到内核缓冲区这个阶段用户线程是非阻塞的,但轮询也导致了CPU空转。

IO多路复用

IO多路复用(IO multiplexing)模式也称作事件驱动IO。用户进程会阻塞在select(或poll、epoll)系统调用上,内核会不断的轮询所负责的所有socket(或其他文件描述符),当某个socket有数据到达了,就通知用户进程,select调用就返回,这时候用户进程再同步等待数据从内核空间拷贝到用户空间。

从上图中看,IO多路复用和阻塞IO看起来貌似没有什么区别,因为两个阶段都是阻塞的,事实上还更差一些,因为这里需要使用两个系统调用(select和recvfrom),而阻塞IO只调用了一个系统调用(recvfrom)。但是,用select的优势在于它可以同时处理多个连接。

所以,如果处理的连接数不是很高的话,使用select/poll/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

select/epoll优势在于处理更多的连接,而不是处理得更快!

信号驱动IO

信号驱动IO(signal-driven IO),使用信号机制,让内核在描述符就绪时发送SIGIO信号通知用户进程。整个过程是先通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,用户进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号,我们随后可以在信号处理函数中调用recvfrom读取内核空间准备好的数据。特点:第一阶段(等待数据报到达期间)进程不被阻塞。

从上图中可以看出,信号驱动IO第一步非阻塞,通过sigaction系统调用达到目标。

异步IO

异步IO(asynchronous IO)的工作机制是:告知内核开启某个操作,并让内核在整个操作完成后通知用户进程,两个阶段都不会被阻塞。

与信号驱动IO模式的区别:信号驱动IO由内核通知我们何时启动一个IO操作,异步IO由内核告诉我们IO操作何时完成。

5种IO模型的前四种,阻塞式I/O、非阻塞式I/O、I/O复用、信号驱动式I/O 在操作系统层面都是同步IO,它们都会阻塞在数据从内核空间复制到用户空间的缓冲区;异步IO模型在两个阶段都不会阻塞调用进程,在操作系统层面实现真正的异步IO。

下面我们从BIO、NIO再到AIO是如何演进来提高效率的。

4.BIO、NIO、AIO演进

Java 中的 BIO、NIO和 AIO 本质上是 Java 语言对操作系统层面的各种 IO 模型的封装。

BIO是JDK1.4之前的传统IO模型,属于阻塞模式,服务器采用每个连接由独立线程维护的方式,即服务器收到客户端的连接请求时,会启动一个新的线程进行处理。在高并发场景下,机器资源很快会被耗尽,即使通过线程池来优化,也无法改变阻塞IO的根本问题,即每个线程在IO执行的上述两个阶段都被阻塞,系统的吞吐量也自然很难提升。

JDK1.4发布了Java NIO,可以支持非阻塞IO。NIO采用了多路复用的IO模式,多路复用的模式在Linux底层可以基于select、poll或epoll实现(本质都是一些系统函数),这种机制下可以用单个线程监视多个fd(文件描述符),当其中一个fd读写就绪,会通知用户线程进行IO操作。

阻塞IO模式下阻塞的是每一个用户线程,与之不同的是,多路复用只需要阻塞一个用户线程即可,这个用户线程通常我们叫它Selector,其实底层调用的是内核的select,只要任何一个IO操作就绪,就可以唤醒select,然后交由用户线程处理。用户线程读取数据这个过程仍然是阻塞的,多路复用技术只是在第一个阶段可以变为非阻塞调用,但在第二个阶段拷贝数据到用户空间,其实还是阻塞的,多路复用技术的最大特点是使用一个线程就可以处理很多的socket连接,尽管性能上不一定提升,但并发能力和吞吐量却大大增强了。

AIO,也被称为NIO2.0,在JDK1.7版本中发布,提供了AIO的功能,支持文件的异步IO操作。从NIO中可以看到,对于IO的两个阶段的阻塞,只是对于第一个阶段有所改善,对于第二个阶段在NIO里面仍然是阻塞的。而理想的异步非阻塞IO要做的就是,将IO操作的两个阶段都全部交给内核系统完成,用户线程只需要告诉内核,我要读取一块数据,请你帮我读取,读取完了放在我给你的地址里面,然后告诉我一声就可以了。从操作系统层面来看,AIO算是真正的实现了异步非阻塞,操作系统层面的异步需要系统原生提供支持,目前windows基于IOCP(Input/Output Completion Port)技术实现,在Linux上,目前有很多开源的异步IO库,例如libevent、libev、libuv,都不是基于操作系统的异步IO实现的,底层均是基于epoll实现的。

上面简单地介绍了下Java中BIO、NIO、AIO的演进,为了更好的理解其中的底层IO模型,接下来会先介绍下Unix下的几种IO模型,然后重点说下被广泛应用的IO多路复用的底层实现。

http://5.IO多路复用原理

操作系统提供了事件轮询API(select、poll和epoll系统调用)来支持IO多路复用模式,那么这里有一个问题IO多路复用到底复用的是什么?IO多路复用复用的是一个用户线程。通过一个select就可以检查多个文件描述符,它们能够同时检查多个文件描述符,看这些文件描述符是否处于就绪状态(对文件的IO系统调用能否非阻塞地执行)。文件描述符就绪状态的转化是通过一些 I/O 事件来触发的,比如输入数据到达,套接字连接建立完成,或者是之前满载的套接字发送缓冲区在 TCP 将队列中的数据传送到对端之后有了剩余空间。事件轮询API在Jdk中包装后就是NIO,但多路复用并不是Java特有的,在其他语言中它们不叫NIO而已。下面我们来学习一下事件轮询API。

select

系统调用select()会一直阻塞,所以从操作系统角度上看,select也是同步阻塞的,包括poll/epoll都是如此,直到一个或多个文件描述符集合成为就绪状态。在 select中提供三个集合readfds、writefds、exceptfds,在每个集合中标明我们感兴趣的文件描述符,这三个集合分别代表着感兴趣的事件是输入就绪、输出就绪、异常发生:

int 

输入是读写描述符列表readfds和write_fds,输出是与之对应的事件。

我们以select为例来说明一下select系统调用过程,代码如下。

read_events

poll

系统调用 poll()执行的任务同 select()很相似。两者间主要的区别在于我们要如何指定待检查的文件描述符。在 select()中,我们提供三个集合,在每个集合中标明我们感兴趣的文件描述符,而在 poll()中我们提供一列文件描述符,并在每个文件描述符上标明我们感兴趣的事件:

int 

当检查大量的文件描述符的时候,select和poll的性能会大幅下降,主要的原因有:

  1. 每次调用 select()或 poll()时,程序都必须传递一个表示所有需要被检查的文件描述符的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。当检查大量文件描述符时,从用户空间和内核空间来回拷贝这个数据结构将占用较大量的 CPU 时间。
    1. 对于 select()来说,必须在每次调用前初始化这个数据结构。
    2. 对于 poll()来说,随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。
  2. select()或 poll()调用完成后,应用进程必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态了。

所以随着待检查的文件描述符数量的增加,select()和 poll()所占用的CPU 时间也会随之增加;造成上述现象的本质原因是这两个API的局限性:即使应用进程传递的感兴趣的文件描述符集合是相同的,但是内核并不会在每次调用成功后记录下它们,所以每次调用都需要重复拷贝。当需要检查大量的文件描述符,epoll表现出更好的性能,原因之一是内核会记录下进程中感兴趣的文件描述符,通过这种机制消除了 select()和 poll()的局限性。也就是说,epoll可以让内核记录文件描述符,所以性能更好。

epoll

epoll API 由以下 3 个系统调用组成:

  • 系统调用 epoll_create()创建一个 epoll 实例,返回代表该实例的文件描述符。epoll实例记录了在进程中声明过的感兴趣的文件描述符列表— interest list(感兴趣列表)并维护了处于 I/O 就绪态的文件描述符列表— ready list(就绪列表)。
int 
  • 系统调用 epoll_ctl()操作同 epoll 实例相关联的感兴趣列表。通过 epoll_ctl(),我们可以增加新的描述符到列表中,将已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的位掩码。
int 
  • 系统调用 epoll_wait()的目的就是让内核负责监视打开的文件描述,它返回的是与 epoll 实例相关联的就绪列表中的成员。单个 epoll_wait()调用能返回多个就绪态文件描述符的信息,存放在数组 evlist 中。
int 

这里再总结下,在监视大量的文件描述符下,epoll性能比select、poll好的几个关键点:

  • 每次调用 select()或 poll()时,应用进程传递一个标记了所有待监视的文件描述符的数据结构给内核,调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给应用进程,拷贝这个数据结构将占用大量的 CPU 时间。与之相反,在 epoll 中我们使用 epoll_ctl()在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个数据结构建立完成,以后每次调用 epoll_wait()时就不需要再传递任何与文件描述符有关的信息给内核了。
  • epoll_wait()调用返回的信息中只包含那些已经处于就绪态的描述符,不必像select和poll那样,应用进程还必须检查返回的数据结构中的每个元素来确定哪些文件描述符是处于就绪状态的。(客户端不用检查哪些处理就绪态,内核已经做了筛选)。

实际上,现代操作系统的多路复用API已经不再使用select系统调用,改用epoll(在Linux中改用epoll)和kqueue(FreeBSD和macosx),原因就是select和poll在描述符特别多时,性能较差。

6.Redis服务器如何实现IO多路复用的

Redis是单线程的,对于每个客户端套接字都关联一个指令队列,客户端的指令通过队列排队进行顺序处理。Redis也同样会为每个客户端套接字关联一个响应队列,通过响应队列将指令的返回结果回复给客户端。Redis除了要响应IO事件外,还要处理其他事情,如定时任务,但是如何线程阻塞在select等系统调用上,定时任务将无法获得准时调度,那如何解决这个问题呢?

Redis定时任务会记录在一个最小堆的数据结构中,最快要执行的任务排在堆顶。并记录最快执行的间隔时间点,然后在调用select时,将这个时间点设置成select调用的timeout方法。

总结

本文从IO多路复用技术和事件驱动模型开头,介绍了五种IO模型,最后介绍了IO多路复用计数原理以及epoll相对select、poll性能上的改进。文章最后简单介绍了Redis是如何单线程实现IO多路复用的。

The end.

转载请注明来源,否则严禁转载。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值