一文读懂阻塞、非阻塞、同步、异步IO

介绍

    在谈及网络IO的时候总避不开阻塞、非阻塞、同步、异步、IO多路复用、select、poll、epoll等这几个词语。在面试的时候也会被常常问到这几个的区别。本文就来说一下这几个词语的含义、区别以及使用方式。
Unix网络编程一书中做者给出了五种IO模型:
一、BlockingIO - 阻塞IO
二、NoneBlockingIO - 非阻塞IO
三、IO multiplexing - IO多路复用
四、signal driven IO - 信号驱动IO
五、asynchronous IO - 异步IO
这五种IO模型中前四个都是同步的IO,只有最后一个是异步IO。信号驱动IO使用的比较少,重点介绍其余几种IO以及在Java中的应用。react

阻塞、非阻塞、同步、异步以及IO多路复用

    在进行网络IO的时候会涉及到用户态和内核态,而且在用户态和内核态之间会发生数据交换,从这个角度来讲咱们能够把IO抽象成两个阶段:一、用户态等待内核态数据准备好,二、将数据从内核态拷贝到用户态。之因此会有同步、异步、阻塞和非阻塞这几种说法就是根据程序在这两个阶段的处理方式不一样而产生的。nginx

同步阻塞

        
    当在用户态调用read操做的时候,若是这时候kernel尚未准备好数据,那么用户态会一直阻塞等待,直到有数据返回。当kernel准备好数据以后,用户态继续等待kernel把数据从内核态拷贝到用户态以后才可使用。这里会发生两种等待:一个是用户态等待kernel有数据能够读,另一个是当有数据可读时用户态等待kernel把数据拷贝到用户态。
    在Java中同步阻塞的实现对应的是传统的文件IO操做以及Socket的accept的过程。在Socket调用accept的时候,程序会一直等待知道有描述符就绪,而且把就绪的数据拷贝到用户态,而后程序中就能够拿到对应的数据。程序员

同步非阻塞

        
        对比第一张同步阻塞IO的图就会发现,在同步非阻塞模型下第一个阶段是不等待的,不管有没有数据准备好,都是当即返回。第二个阶段仍然是须要等待的,用户态须要等待内核态把数据拷贝过来才能使用。对于同步非阻塞模式的处理,须要每隔一段时间就去询问一下内核数据是否是能够读了,若是内核说能够,那么就开始第二阶段等待。面试

IO多路复用

    IO多路复用也是同步的。
        
    IO多路复用的方式看起来跟同步阻塞是同样的,两个阶段都是阻塞的,可是IO多路复用能够实现以较小的代价同时监听多个IO。一般状况下是经过一个线程来同时监听多个描述符,只要任何一个知足就绪条件,那么内核态就返回。IO多路复用使得传统的每请求每线程的处理方式获得解耦,一个线程能够同时处理多个IO请求,而后交到后面的线程池里处理,这也是netty等框架的处理方式,所谓的reactor模式。IO多路复用的实现依赖于操做系统的select、poll和epoll,后面会详细介绍这几个系统调用。
    IO多路复用在Java中的实现方式是在Socket编程中使用非阻塞模式,而后配置感兴趣的事件,经过调用select函数来实现。select函数就是对应的第一个阶段。若是给select配置了超时参数,在指定时间内没有感兴趣事件发生的话,select调用也会返回,这也是为何要作非阻塞模式下运行。编程

异步IO

        
        异步模式下,前面提到的两个阶段都不会等待。使用异步模式,用户态调用read方法的时候,至关于告诉内核数据发送给我以后告诉我一声我先去干别的事情了。在这两个阶段都不会等待,只须要在内核态通知数据准备好以后使用便可。一般状况下使用异步模式都会使用callback,当数据可用以后执行callback函数。数组

IO多路复用

    如今用Java开发的网络服务器一般采用IO多路复用的方式来加快网络IO操做,例如Netty、Tomcat等。IO多路复用的基础是select、poll和epoll。这三个函数是从操做系统的角度上支持的IO多路复用的操做,下面就分别来看一下这三个函数。服务器

select

函数签名以下:微信

int select(int maxfdp1, fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)网络

maxfdp1为指定的待监听的描述符的个数,由于描述符是从0开始的,因此须要加1
readset为要监听的读描述符
writeset为要监听的写描述符
exceptset为要监听的异常描述符
timeout监听没有准备好的描述符的话,多久能够返回,支持按照秒或者毫秒来配置时间
    select操做的逻辑是首先将要监听的读、写以及异常描述符拷贝到内核空间,而后遍历全部的描述符,若是有感兴趣的事件发生,那么就返回。
select在使用的过程当中有三个问题:
一、被监控的fds(描述符)集合限制为1024,1024过小了
二、须要将描述符集合从用户空间拷贝到内核空间
三、当有描述符可操做的时候都须要遍历一下整个描述符集合才能知道哪一个是可操做的,效率很低。app

poll

函数签名以下:

  int poll(struct pollfd[] fds, unsigned int nfds, int timeout);

 poll操做与select操做相似,仍旧避免不了描述符从用户空间拷贝到内核空间,可是poll再也不有1024个描述符的限制。对于事件的触发通知仍是使用遍历全部描述符的方式,所以在大量链接的状况下也存在遍历低效的问题。poll函数在传递参数的时候统一的将要监听的描述符和事件封装在了pollfd结构体数组中。

epoll

    epoll有三个方法:epoll_create、epoll_ctl和epoll_wait。epoll_create是建立一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。 经过这三个方法epoll解决了select的三个问题。
一、1024数量限制的问题
经过epoll_create方法来建立一个epoll句柄,这个句柄监听的描述符的数量再也不有限制。
二、文件描述符频繁从用户空间拷贝到内核空间的问题
经过观察select的操做会发现描述符从用户空间到内核空间拷贝发生在调用select方法的时候,只要没有注册新的事件或者取消注册事件,每次拷贝的描述符都是同样的。所以epoll引入了epoll_ctl调用,该方法用于注册新事件和取消注册事件。而在epoll_wait的时候并不会拷贝描述符,描述符始终存在于内核空间,当须要修改的时候只要调用epoll_ctl修改一下内核的描述符便可。如此一来便省去了描述符来回拷贝的开销。
三、文件描述符可操做的时候遍历整个描述符集合的问题
在调用epoll_ctl注册感兴趣的事件的时候,实际上会为设置的事件添加一个回调函数,当对应的感兴趣的事件发生的时候,回调函数就会触发,而后将本身加到一个链表中。epoll_wait函数的做用就是去查看这个链表中有没有已经准备就绪的事件,若是有的话就通知应用程序处理,如此操做epoll_wait只须要遍历就绪的事件描述符便可。

epoll在Java中的使用

    目前针对Java服务器的非阻塞编程基本都是基于epoll的。在进行非阻塞编程的时候有两个步骤:一、注册感兴趣的事情;二、调用select方法,查找感兴趣的事件。

注册感兴趣的事件

    咱们在编写Socket的非阻塞代码的时候须要在Selector上注册感兴趣的事情,一般写法是serverSocketChannel.register(selector, SelectionKey.XXX)。来看一下这行代码背后的执行逻辑是什么样的。
        
注册的时候实际执行的是EPollSelectorImp。该方法主要有如下三步:
一、implRegister方法。在fdToKey的Map中插入channel对应的文件描述法和SelectionKey的映射,当作注册Channel、关闭Channel、取消注册等操做是都是操做此Map。
二、往pollWrapper[Epoll实例]中放入channel实例。
三、往keys[HashSet]中放入SelectionKey

select方法

    经过Java的Selector.select方法来获取准备好的键的时候实际执行的代码以下:
        
首先调用EPollArrayWrapper的poll方法,该方法作两件事:一、调用epollCtl方法向epoll中注册感兴趣的事件;二、调用epollWait方法返回已就绪的文件描述符集合
而后调用updateSelectedKeys方法调用把epoll中就绪的文件描述符加到ready队列中等待上层应用处理, updateSelectedKeys经过fdToKey查找文件描述符对应的SelectionKey,并在SelectionKey对应的channel中添加对应的事件到ready队列。

水平触发LT与边缘触发ET

    epoll支持两种触发模式,分别是水平触发和边缘触发。
    LT是缺省的工做方式,而且同时支持block和no-block socket。在这种作法中,内核告诉你一个文件描述符是否就绪了,而后你能够对这个就绪的fd进行IO操做。若是你不做任何操做,内核仍是会继续通知你的。
    ET是高速工做方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核会通知你一次,而且除非你作了某些操做致使那个文件描述符再也不为就绪状态了,不然不会再次发送通知。
    能够看到,原本内核在被DMA中断,捕获到IO设备来数据后,只须要查找这个数据属于哪一个文件描述符,进而通知线程里等待的函数便可,可是,LT要求内核在通知阶段还要继续再扫描一次刚才所创建的内核fd和io对应的那个数组,由于应用程序可能没有真正去读上次通知有数据后的那些fd,这种沟通方式效率是很低下的,只是方便编程而已;

    JDK并无实现边缘触发,关于边缘触发和水平触发的差别简单列举以下,边缘触发的性能更高,但编程难度也更高,netty就从新实现了Epoll机制,采用边缘触发方式;另外像nginx等也采用的是边缘触发。

 

----------------------------------------------------------------

欢迎关注个人微信公众号:yunxi-talk,分享Java干货,进阶Java程序员必备。

相关文章
相关标签/搜索