Java进阶知识点5:服务端高并发的基石 - NIO与Reactor模式以及AIO与Proactor模式

1、背景

要提高服务器的并发处理能力,一般有两大方向的思路。编程

一、系统架构层面。好比负载均衡、多级缓存、单元化部署等等。后端

二、单节点优化层面。好比修复代码级别的性能Bug、JVM参数调优、IO优化等等。缓存

通常来讲,系统架构的合理程度,决定了系统在总体性能上的伸缩性(高伸缩性,简而言之就是能够很任性,性能不行就加机器,加到性能足够为止);而单节点在性能上的优化程度,决定了单个请求的时延,以及要达到指望的性能,所需集群规模的大小。二者左右开弓,才能快速构建出性能良好的系统。性能优化

今天,咱们就聊聊在单节点优化层面最重要的IO优化。之因此IO优化最重要,是由于IO速度远低于CPU和内存,而不够良好的软件设计,经常致使CPU和内存被IO所拖累,如何摆脱IO的束缚,充分发挥CPU和内存的潜力,是性能优化的核心内容。服务器

而CPU和内存又是如何被IO所拖累的呢?这就从Java中几种典型的IO操做模式提及。网络

2、Java中的典型IO操做模式

2.1 同步阻塞模式

Java中的BIO风格的API,都是该模式,例如:架构

Socket socket = getSocket();
socket.getInputStream().read(); //读不到数据誓不返回

该模式下,最直观的感觉就是若是IO设备暂时没有数据可供读取,调用API就卡住了,若是数据一直不来就一直卡住。并发

2.2 同步非阻塞模式

Java中的NIO风格的API,都是该模式,例如:负载均衡

SocketChannel socketChannel = getSocketChannel(); //获取non-blocking状态的Channel
socketChannel.read(ByteBuffer.allocate(4)); //读不到数据就算了,当即返回0告诉你没有读到

该模式下,一般须要不断调用API,直至读取到数据,不过好在函数调用不会卡住,我想继续尝试读取或者先去作点其余事情再来读取均可以。框架

2.3 异步非阻塞模式

Java中的AIO风格的API,都是该模式,例如:

AsynchronousSocketChannel asynchronousSocketChannel = getAsynchronousSocketChannel();
asynchronousSocketChannel.read(ByteBuffer.allocate(4), null, new CompletionHandler<Integer, Object>() {
    @Override
    public void completed(Integer result, Object attachment) {
        //读不到数据不会触发该回调来烦你,只有确实读取到数据,且把数据已经存在ByteBuffer中了,API才会经过此回调接口主动通知您
    }
    @Override
    public void failed(Throwable exc, Object attachment) {
    }
});

该模式服务最到位,除了会让编程变的相对复杂之外,几乎无可挑剔。

 2.4 小结

对于IO操做而言,同步和异步的本质区别在于API是否会将IO就绪(好比有数据可读)的状态主动通知你。同步意味着想要知道IO是否就绪,必须发起一次询问,典型的一问一答,若是回答是没有就绪,那你还得本身不断询问,直到答案是就绪为止。异步意味着,IO就绪后,API将主动通知你,无需你不断发起询问,这一般要求调用API时传入通知的回调接口。

阻塞和非阻塞的本质区别在于IO操做因IO未就绪不能当即完成时,API是否会将当前线程挂起。阻塞意味着API会一直等待IO就绪后,完成本次IO操做才返回,在此以前调用该API的用户线程将一直挂起,没法进行其余计算处理。非阻塞意味着API会当即返回,而不是等待IO就绪,用户能够当即再次得到线程的控制权,可使用该线程进行其余计算处理。

那有没有异步阻塞模式呢?若是API支持异步,至关于API说:“你玩去吧,我准备好了通知你”,可是你仍是傻乎乎地不去玩,原地等待API作完后的通知。这一般是由于本次IO操做很重要,拿不到结果业务流程根本没法继续,因此为了编程上的简单起见,仍是乖乖等吧。可见异步阻塞模式更多的是出于业务流程控制和简化编码难度的考虑,由业务代码自主造成的,Java语言不会特别为你准备异步阻塞IO的API。

3、分离快与慢

3.1 BIO的局限

CPU和内存是高速设备,磁盘、网络等IO设备是低速设备,在Java编程语言中,对CPU和内存的使用被抽象为对线程、栈、堆的使用,对IO设备的使用被抽象为IO相关的API调用。

显然,若是使用BIO风格的IO API,因为其同步阻塞特性,会致使IO设备未就绪时,线程挂起,该线程没法继续使用CPU和内存,直至IO就绪。因为IO设备的速度远低于CPU和内存,因此使用BIO风格的API时,有极大的几率会让当前线程长时间挂起,这就造成了CPU和内存资源被IO所拖累的状况。

做为服务端应用,会面临大量客户端向服务端发起链接请求的场景,每一个链接对服务端而言,都意味着须要进行后续的网络IO读取,IO读取完成后,才能得到完整的请求内容,进而才能再进行一些列相关计算处理得到请求结果,最后还要将结果经过网络IO回写给客户端。使用BIO的编码风格,一般是同一个线程全程负责一个链接的IO读取、数据处理和IO回写,该线程绝大部分时间均可能在等待IO就绪,只有极少时间在真正利用CPU资源。

而此时服务器要想同时处理大量客户端链接,后端就同时开启与并发链接数量相应的线程。线程是操做系统的宝贵资源,并且每开启一个操做系统线程,Java还会消耗-Xss指定的线程堆栈大小的堆外内存,若是同时存在大量线程,操做系统调度线程的开销也会显著增长,致使服务器性能快速降低。因此此时服务器想要支持上万乃至几十万的高并发链接,可谓难上加难。

3.2 NIO的突破

3.2.1 突破思路

因为NIO的非阻塞特性,决定了IO未就绪时,线程能够没必要挂起,继续处理其余事情。这就为分离快与慢提供了可能,高速的CPU和内存能够没必要苦等IO交互,一个线程也没必要局限于只为一个IO链接服务。这样,就让用少许的线程处理海量IO链接成为了可能。

3.2.2 思路落地

虽然咱们看到了曙光,可是要将这个思路落地还需解决掉一些实际的问题。

a)当IO未就绪时,线程就释放出来,转而为其余链接服务,那谁去监控这个被抛弃IO的就绪事件呢?

b)IO就绪了,谁又去负责将这个IO分配给合适的线程继续处理呢?

为了解决第一个问题,操做系统提供了IO多路复用器(好比Linux下的select、poll和epoll),Java对这些多路复用器进行了封装(通常选用性能最好的epoll),也提供了相应的IO多路复用API。NIO的多路复用API典型编程模式以下:

// 开启一个ServerSocketChannel,在8080端口上监听
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress("0.0.0.0", 8080));
// 建立一个多路复用器
Selector selector = Selector.open();
// 将ServerSocketChannel注册到多路复用器上,并声明关注其ACCEPT就绪事件
server.register(selector, SelectionKey.OP_ACCEPT);
while (selector.select() != 0) {
    // 遍历全部就绪的Channel关联的SelectionKey
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
    while (iterator.hasNext()) {
        SelectionKey key = iterator.next();
        // 若是这个Channel是READ就绪
        if (key.isReadable()) {
            // 读取该Channel
            ((SocketChannel) key.channel()).read(ByteBuffer.allocate(10));
        }
        if (key.isWritable()) {
            //... ...
        }
        // 若是这个Channel是ACCEPT就绪
        if (key.isAcceptable()) {
            // 接收新的客户端链接
            SocketChannel accept = ((ServerSocketChannel) key.channel()).accept();
            // 将新的Channel注册到多路复用器上,并声明关注其READ/WRITE就绪事件
            accept.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
        }
        // 删除已经处理过的SelectionKey
        iterator.remove();
    }
}

IO多路复用API能够实现用一个线程,去监控全部IO链接的IO就绪事件。

第二个问题在上面的代码中其实也获得了“解决”,可是上面的代码是使用监控IO就绪事件的线程来完成IO的具体操做,若是IO操做耗时较大(好比读操做就绪后,有大量数据须要读取),那么会致使监控线程长时间为某个具体的IO服务,从而致使整个系统长时间没法感知其余IO的就绪事件并分派IO处理任务。因此生产环境中,通常使用一个Boss线程专门用于监控IO就绪事件,一个Work线程池负责具体的IO读写处理。Boss线程检测到新的IO就绪事件后,根据事件类型,完成IO操做任务的分配,并将具体的操做交由Work线程处理。这其实就是Reactor模式的核心思想。

3.2.3 Reactor模式

如上所述,Reactor模式的核心理念在于:

a)依赖于非阻塞IO。

b)使用多路复用器监管海量IO的就绪事件。

c)使用Boss线程和Work线程池分离IO事件的监测与IO事件的处理。

Reactor模式中有以下三类角色:

a)Acceptor。用户处理客户端链接请求。Acceptor角色映射到Java代码中,即为SocketServerChannel。

b)Reactor。用于分派IO就绪事件的处理任务。Reactor角色映射到Java代码中,即为使用多路复用器的Boss线程。

c)Handler。用于处理具体的IO就绪事件。(好比读取并处理数据等)。Handler角色映射到Java代码中,即为Worker线程池中的每一个线程。

Acceptor的链接就绪事件,也是交由Reactor监管的,有些地方为了分离链接的创建和对链接的处理,为将Reactor分离为一个主Reactor,专门用户监管链接相关事件(即SelectionKey.OP_ACCEPT),一个从Reactor,专门用户监管链接上的数据相关事件(即SelectionKey.OP_READ 和SelectionKey.OP_WRITE)。

关于Reactor的模型图,网上一搜一大把,我就不献丑了。相信理解了它的核心思想,图天然在心中。关于Reactor模式的应用,能够参见著名NIO编程框架Netty,其实有了Netty以后,通常都直接使用Netty框架进行服务端NIO编程。

3.3 AIO的更进一步

3.3.1 AIO得天独厚的优点

你很容易发现,若是使用AIO,NIO突破时所面临的落地问题彷佛自然就不存在了。由于每个IO操做均可以注册回调函数,自然就不须要专门有一个多路复用器去监听IO就绪事件,也不须要一个Boss线程去分配事件,全部IO操做只要一完成,就自然会经过回调进入本身的下一步处理。

并且,更让人惊喜的是,经过AIO,连NIO中Work线程去读写数据的操做均可以省略了,由于AIO是保证数据真正读取/写入完成后,才触发回调函数,用户都没必要关注IO操做自己,只需关注拿到IO中的数据后,应该进行的业务逻辑。

简而言之,NIO的多路复用器,是通知你IO就绪事件,AIO的回调是通知你IO完成事件。AIO作的更加完全一些。这样在某些平台上也会带来性能上的提高,由于AIO的IO读写操做能够交由操做系统内核完成,充分发挥内核潜能,减小了IO系统调用时用户态与内核态间的上下文转换,效率更高。

(不过遗憾的是,Linux内核的AIO实现有不少问题(不在本文讨论范畴),性能在某些场景下还不如NIO,连Linux上的Java都是用epoll来模拟AIO,因此Linux上使用Java的AIO API,只是能体验到异步IO的编程风格,但并不会比NIO高效。综上,Linux平台上的Java服务端编程,目前主流依然采用NIO模型。)

使用AIO API典型编程模式以下:

//建立一个Group,相似于一个线程池,用于处理IO完成事件
AsynchronousChannelGroup group = AsynchronousChannelGroup.withCachedThreadPool(Executors.newCachedThreadPool(), 32);
//开启一个AsynchronousServerSocketChannel,在8080端口上监听
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group);
server.bind(new InetSocketAddress("0.0.0.0", 8080));
//接收到新链接
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
    //新链接就绪事件的处理函数
    @Override
    public void completed(AsynchronousSocketChannel result, Object attachment) {
        result.read(ByteBuffer.allocate(4), attachment, new CompletionHandler<Integer, Object>() {
            //读取完成事件的处理函数
            @Override
            public void completed(Integer result, Object attachment) {
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
            }
        });
    }
    @Override
    public void failed(Throwable exc, Object attachment) {
    }
});

3.3.2 Proactor模式

Java的AIO API其实就是Proactor模式的应用。

也Reactor模式相似,Proactor模式也能够抽象出三类角色:

a)Acceptor。用户处理客户端链接请求。Acceptor角色映射到Java代码中,即为AsynchronousServerSocketChannel。

b)Proactor。用于分派IO完成事件的处理任务。Proactor角色映射到Java代码中,即为API方法中添加回调参数。

c)Handler。用于处理具体的IO完成事件。(好比处理读取到的数据等)。Handler角色映射到Java代码中,即为AsynchronousChannelGroup 中的每一个线程。

可见,Proactor与Reactor最大的区别在于:

a)无需使用多路复用器。

b)Handler无需执行具体的IO操做(好比读取数据或写入数据),而是只执行IO数据的业务处理。

4、总结

一、Java中的IO有同步阻塞、同步非阻塞、异步非阻塞三种操做模式,分别对应BIO、NIO、AIO三类API风格。

二、BIO须要保证一个链接一个线程,因为线程是操做系统宝贵资源,不可开过多,因此BIO严重限制了服务端可承载的并发链接数量。

三、使用NIO特性,辅以Reactor编程模式,是Java在Linux下实现服务器端高并发能力的主流方式。

四、使用AIO特性,辅以Proactor编程模式,在其余平台上(好比Windows)可以得到比NIO更高的性能。

相关文章
相关标签/搜索