4. Reactor反应器模式

Reactor反应器模式是高性能网络编程在设计和架构层面的基础模式。为什么呢?只有彻底了解反应器的原理,才能真正构建好高性能的网络应用,才能轻松地学习和掌握Netty框架。同时,反应器模式也是BAT级别大公司必不可少的面试题。

4.1 Reactor 反应器模式为何如此重要

在详细介绍什么是Reactor反应器模式之前,首先说明一下它的重要性。

到目前为止,高性能网络编程都绕不开反应器模式。很多著名的服务器软件或者中间件都是基于反应器模式实现的。

比如说,“ 全宇宙最有名的、最高性能”的Web服务器Nginx,就是基于反应器模式的;如雷贯耳的Redis,作为最高性能的缓存服务器之一, 也是基于反应器模式的;目前火得“一塌糊涂”、在开源项目中应用极为广泛的高性能通信中间件Netty,更是基于反应器模式的。

从开发的角度来说,如果要完成和胜任高性能的服务器开发,反应器模式是必须学会和掌握的。从学习的角度来说,反应器模式相当于高性能、高并发的一项非常重要的基础知识,只有掌握了它,才能真正掌握Nginx、Redis、 Netty 等这些大名鼎鼎的中间件技术。正因为如此,在大的互联网公司如阿里、腾讯、京东的面试过程中,反应器模式相关的问题是经常出现的面试问题。

总之,反应器模式是高性能网络编程的必知、必会的模式。

4.1.1 为什么首先学习Reactor反应器模式

本书的目标,是学习基于Netty的开发高性能通信服务器。为什么在学习Netty之前,首先要学习Reactor反应器模式呢?

写多了代码的程序员都知道,Java程序不是按照顺序执行的逻辑来组织的。代码中所用到的设计模式,在一定程度上已经演变成了代码的组织方式。越是高水平的Java代码,抽象的层次越高,到处都是高度抽象和面向接口的调用,大量用到继承、多态的设计模式。

在阅读别人的源代码时,如果不了解代码所使用的设计模式,往往会晕头转向,不知身在何处,很难读懂别人的代码,对代码跟踪很成问题。反过来,如果先了解代码的设计模式,再去看代码,就会阅读得很轻松,不会那么难懂了。

当然,在写代码时,不了解设计模式,也很难写出高水平的Java代码。

本书的重要使命之一,就是帮助大家学习和掌握Netty。Netty本身很抽象,大量应用了设计模式。学习像Netty这样的“精品中的精品”,肯定也是需要先从设计模式入手的。而Netty的整体架构,就是基于这个著名反应器模式。

总之,反应器模式非常重要。首先学习和掌握反应器模式,对于学习Netty的人来说,一定是磨刀不误砍柴工。

4.1.2 Reactor反应器模式简介

什么是Reactor反应器模式呢?本文站在巨人的肩膀上,引用一下Doug Lea(那是一位让人无限景仰的大师,Java中Concurrent并发包的重要作者之一)在文章《Scalable IO in Java》中对反应器模式的定义,具体如下:

反应器模式由Reactor反应器线程、Handlers处理器两大角色组成:

  1. Reactor反应器线程的职责:负责响应IO事件,并且分发到Handlers处理器。
  2. Handlers处理器的职责:非阻塞的执行业务处理逻辑。

在这里,为了方便大家学习,将Doug Lea著名的文章《Scalable IO in Java》的链接地址贴出来:http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf,建议大家去阅读一下,提升自己的基础知识,开阔下眼界。

从上面的反应器模式定义,看不出这种模式有什么神奇的地方。当然,从简单到复杂,反应器模式也有很多版本。根据前面的定义,仅仅是最为简单的一个版本。

如果需要彻底了解反应器模式,还得从最原始的OIO编程开始讲起。

4.1.3 多线程OIO的致命缺陷

在Java的OIO编程中,最初和最原始的网络服务器程序,是用一个while循环,不断地监听端口是否有新的连接。如果有,那么就调用一个处理函数来处理,示例代码如下:

while(true){
    socket = accept(); //阻塞,接收连接
    handle(socket) ;   //读取数据、业务处理、写入结果
}

这种方法的最大问题是:如果前一个网络连接的handle(socket)没有处理完,那么后面的连接请求没法被接收,于是后面的请求通通会被阻塞住,服务器的吞吐量就太低了。对于服务器来说,这是一个严重的问题。

为了解决这个严重的连接阻塞问题,出现了一个极为经典模式:Connection Per Thread(一个线程处理一个连接)模式。示例代码如下:

//...省略: 导入的Java类
class ConnectionPerThread implements Runnable {
    public void run() {
        try {
            //服务器监听socket
            ServerSocketserverSocket =
                    new ServerSocket(NioDemoConfig.SOCKET_SERVER_PORT);
            while (!Thread.interrupted()) {
                Socket socket = serverSocket.accept();
                //接收一个连接后,为socket连接,新建一个专属的处理器对象
                Handler handler = new Handler(socket);
                //创建新线程,专门负责一个连接的处理
                new Thread(handler).start();
            }
        } catch (IOException ex) { /* 处理异常 */ }
    }
    //处理器对象
    static class Handler implements Runnable {
        final Socket socket;
        Handler(Socket s) {
            socket = s;
        }
        public void run() {
            while (true) {
                try {
                    byte[] input = new byte[NioDemoConfig.SERVER_BUFFER_SIZE];
                    /* 读取数据 */
                    socket.getInputStream().read(input);
                    /* 处理业务逻辑,获取处理结果*/
                    byte[] output =null;
                    /* 写入结果 */
                    socket.getOutputStream().write(output);
                } catch (IOException ex) { /*处理异常*/ }
            }
        }
    }
}

对于每一个新的网络连接都分配给一个线程。每个线程都独自处理自己负责的输入和输出。当然,服务器的监听线程也是独立的,任何的socket连接的输入和输出处理,不会阻塞到后面新socket连接的监听和建立。早期版本的Tomcat服务器,就是这样实现的。

Connection Per Thread模式(一个线程处理一个连接)的优点是:解决了前面的新连接被严重阻塞的问题,在一定程度上,极大地提高了服务器的吞吐量。

这里有个问题:如果一个线程同时负责处理多个socket连接的输入和输入,行不行呢?

看上去,没有什么不可以。但是,实际上没有用。为什么?传统OIO编程中每一个socket的IO读写操作,都是阻塞的。在同一时刻,一个线程里只能处理一个socket,前一个socket被阻塞了,后面连接的IO操作是无法被并发执行的。所以,不论怎么处理,OIO中一个线程也只能是处理一个连接的IO操作。

Connection Per Thread模式的缺点是:对应于大量的连接,需要耗费大量的线程资源,对线程资源要求太高。在系统中,线程是比较昂贵的系统资源。如果线程数太多,系统无法承受。而且,线程的反复创建、销毁、线程的切换也需要代价。因此,在高并发的应用场景下,多线程OIO的缺陷是致命的。

如何解决Connection Per Thread模式的巨大缺陷呢?一个有效路径是:使用Reactor反应器模式。用反应器模式对线程的数量进行控制,做到一个线程处理大量的连接。它是如何做到呢?首先来看简单的版本——单线程的Reactor反应器模式。

4.2 单线程Reactor反应器模式

总体来说,Reactor反应器模式有点儿类似事件驱动模式。

在事件驱动模式中,当有事件触发时,事件源会将事件dispatch分发到handler处理器进行事件处理。反应器模式中的反应器角色,类似于事件驱动模式中的dispatcher事件分发器角色。

前面已经提到,在反应器模式中,有Reactor反应器和Handler处理器两个重要的组件:

  1. Reactor反应器:负责查询IO事件,当检测到一个IO事件,将其发送给相应的Handler处理器去处理。这里的IO事件,就是NIO中选择器监控的通道IO事件。
  2. Handler处理器:与IO事件(或者选择键)绑定,负责IO事件的处理。完成真正的连接建立、通道的读取、处理业务逻辑、负责将结果写出到通道等。

4.2.1 什么是单线程Reactor反应器

什么是单线程版本的Reactor反应器模式呢?简单地说,Reactor反应器和Handers处理器处于一个线程中执行。它是最简单的反应器模型,如图4-1所示。

图4-1 单线程Reactor反应器模式

基于Java NIO,如何实现简单的单线程版本的反应器模式呢?需要用到SelectionKey选择键的几个重要的成员方法:

方法一:void attach(Object o)

此方法可以将任何的Java POJO对象,作为附件添加到SelectionKey实例,相当于附件属性的setter方法。这方法非常重要,因为在单线程版本的反应器模式中,需要将Handler处理器实例,作为附件添加到SelectionKey实例。

方法二:Object attachment()

此方法的作用是取出之前通过attach(Object o)添加到SelectionKey选择键实例的附件,相当于附件属性的getter方法,与attach(Object o)配套使用。

这个方法同样非常重要,当IO事件发生,选择键被select方法选到,可以直接将事件的附件取出,也就是之前绑定的Handler处理器实例,通过该Handler,完成相应的处理。

总之,在反应器模式中,需要进行attach和attachment结合使用:在选择键注册完成之后,调用attach方法,将Handler处理器绑定到选择键;当事件发生时,调用attachment方法,可以从选择键取出Handler处理器,将事件分发到Handler处理器中,完成业务处理。

4.2.2 单线程Reactor反应器的参考代码

Doug Lea在《Scalable IO in Java》中,实现了一个单线程Reactor反应器模式的参考代码。这里,我们站在巨人的肩膀上,借鉴Doug Lea的实现,对其进行介绍。为了方便说明,对Doug Lea的参考代码进行一些适当的修改。具体的参考代码如下:

//...
class Reactor implements Runnable {
    Selector selector;
    ServerSocketChannelserverSocket;
    EchoServerReactor() throws IOException {
        //....省略:打开选择器、serverSocket连接监听通道
        //注册serverSocket的accept事件
        SelectionKeysk = serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        //将新连接处理器作为附件,绑定到sk选择键
        sk.attach(new AcceptorHandler());
    }
    public void run() {
        //选择器轮询
        try {
            while (!Thread.interrupted()) {
                selector.select();
                Set selected = selector.selectedKeys();
                Iterator it = selected.iterator();
                while (it.hasNext()) {
                //反应器负责dispatch收到的事件
                    SelectionKeysk=it.next();
                    dispatch(sk);
                }
                selected.clear();
            }
        } catch (IOException ex) { ex.printStackTrace(); }
    }
    //反应器的分发方法
    void dispatch(SelectionKey k) {
        Runnable handler = (Runnable) (k.attachment());
        //调用之前绑定到选择键的handler处理器对象
        if (handler != null) {
            handler.run();
        }
    }
    // 新连接处理器
    class AcceptorHandler implements Runnable {
        public void run() {
            //接受新连接
            //需要为新连接,创建一个输入输出的handler处理器
        }
    }
    //….
}

在上面的代码中,设计了一个Handler处理器,叫作AcceptorHandler处理器,它是一个内部类。在注册serverSocket服务监听连接的接受事件之后,创建一个AcceptorHandler新连接处理器的实例,作为附件,被设置(attach)到了SelectionKey中。

//注册serverSocket的接受(accept)事件
SelectionKeysk = serverSocket.register(selector, SelectionKey.OP_ACCEPT);
//将新连接处理器作为附件,绑定到sk选择键
sk.attach(new AcceptorHandler());

当新连接事件发生后,取出了之前attach到SelectionKey中的Handler业务处理器,进行socket的各种IO处理

void dispatch(SelectionKey k) {
        Runnable r = (Runnable) (k.attachment());
        //调用之前绑定到选择键的处理器对象
        if (r != null) {
           r.run();
        }
    }

AcceptorHandler处理器的两大职责:一是接受新连接,二是在为新连接创建一个输入输出的Handler处理器,称之为IOHandler。

// 新连接处理器
    class AcceptorHandler implements Runnable {
        public void run() {
            // 接受新连接
            // 需要为新连接创建一个输入输出的handler处理器
        }
    }

IOHandler,顾名思义,就是负责socket的数据输入、业务处理、结果输出。示例代码如下:

//...
class IOHandler implements Runnable {
    final SocketChannel channel;
    final SelectionKeysk;
    IOHandler (Selector selector, SocketChannel c) throws IOException {
        channel = c;
        c.configureBlocking(false);
        //仅仅取得选择键,稍候设置感兴趣的IO事件
        sk = channel.register(selector, 0);
        //将Handler处理器作为选择键的附件
        sk.attach(this);
        //注册读写就绪事件
        sk.interestOps(SelectionKey.OP_READ|SelectionKey.OP_WRITE);
    }
    public void run()  {
    //...处理输入和输出
   }
}

在IOHandler的构造器中,有两点比较重要:

  1. 将新的SocketChannel传输通道,注册到了反应器Reactor类的同一个选择器中。这样保证了Reactor类和Handler类在同一个线程中执行。
  2. Channel传输通道注册完成后,将IOHandler自身作为附件,attach到了选择键中。这样,在Reactor类分发事件(选择键)时,能执行到IOHandler的run方法。

如果上面的示例代码比较绕口,不要紧。为了彻底地理解个中妙处,自己动手开发一个可以执行的实例。下面基于反应器模式,实现了一个EchoServer回显服务器实例。仔细阅读和运行这个实例,就可以明白上面这段绕口的程序代码的真正含义了。

4.2.3 一个Reactor反应器版本的EchoServer实践案例

EchoServer回显服务器的功能很简单:读取客户端的输入,回显到客户端,所以也叫回显服务器。基于Reactor反应器模式来实现,设计3个重要的类:

  1. 设计一个反应器类:EchoServerReactor类。
  2. 设计两个处理器类:AcceptorHandler新连接处理器、EchoHandler回显处理器。

反应器类EchoServerReactor的实现思路和前面的示例代码基本上相同,具体如下:

//.....
//反应器
class EchoServerReactor implements Runnable {
    Selector selector;
    ServerSocketChannel serverSocket;
    EchoServerReactor() throws IOException {
         //...获取选择器、开启serverSocket服务监听通道
            //...绑定AcceptorHandler新连接处理器到selectKey
    }
    //轮询和分发事件
    public void run() {
        try {
            while (!Thread.interrupted()) {
                selector.select();
                Set<SelectionKey> selected = selector.selectedKeys();
                Iterator<SelectionKey> it = selected.iterator();
                while (it.hasNext()) {
                    //反应器负责dispatch收到的事件
                    SelectionKey sk = it.next();
                    dispatch(sk);
                }
                selected.clear();
            }
        } catch (IOException ex) {
            ex.printStackTrace();
          }
    }
    void dispatch(SelectionKeysk) {
        Runnable handler = (Runnable) sk.attachment();
        //调用之前attach绑定到选择键的handler处理器对象
        if (handler != null) {
            handler.run();
        }
    }
    // Handler:新连接处理器
    class AcceptorHandler implements Runnable {
        public void run() {
            try {
                SocketChannel channel = serverSocket.accept();
                if (channel != null)
                    new EchoHandler(selector, channel);
            } catch (IOException e) {
                e.printStackTrace();
             }
        }
    }
    public static void main(String[] args) throws IOException {
        new Thread(new EchoServerReactor()).start();
    }
}

EchoHandler回显处理器,主要是完成客户端的内容读取和回显,具体如下:

//...
class EchoHandler implements Runnable {
    final SocketChannel channel;
    final SelectionKeysk;
    final ByteBufferbyteBuffer = ByteBuffer.allocate(1024);
    static final int RECIEVING = 0, SENDING = 1;
    int state = RECIEVING;
    EchoHandler(Selector selector, SocketChannel c) throws IOException {
        channel = c;
        c.configureBlocking(false);
        //取得选择键,再设置感兴趣的IO事件
        sk = channel.register(selector, 0);
        //将Handler自身作为选择键的附件
        sk.attach(this);
        //注册Read就绪事件
        sk.interestOps(SelectionKey.OP_READ);
        selector.wakeup();
    }
    public void run() {
        try {
            if (state == SENDING) {
                //写入通道
                channel.write(byteBuffer);
                //写完后,准备开始从通道读,byteBuffer切换成写入模式
                byteBuffer.clear();
                //写完后,注册read就绪事件
                sk.interestOps(SelectionKey.OP_READ);
                //写完后,进入接收的状态
                state = RECIEVING;
            } else if (state == RECIEVING) {
                //从通道读
                int length = 0;
                while ((length = channel.read(byteBuffer)) &gt; 0) {
                    Logger.info(new String(byteBuffer.array(), 0, length));
                }
                //读完后,准备开始写入通道,byteBuffer切换成读取模式
                byteBuffer.flip();
                //读完后,注册write就绪事件
                sk.interestOps(SelectionKey.OP_WRITE);
                //读完后,进入发送的状态
                state = SENDING;
            }
            //处理结束了, 这里不能关闭select key,需要重复使用
            //sk.cancel();
        } catch (IOException ex) {
            ex.printStackTrace();
          }
    }
}

以上两个类,是一个基于反应器模式的EchoServer回显服务器的完整实现。它是一个单线程版本的反应器模式,Reactor反应器和所有的Handler处理器,都执行在同一条线程中。

运行EchoServerReactor类中的main方法,可以启动回显服务器。如果要看到回显输出,还需要启动客户端。客户端的代码,在同一个包下,类名为EchoClient,负责数据的发送。代码如下:

public class EchoClient {

    public void start() throws IOException {

        InetSocketAddress address =
                new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP,
                        NioDemoConfig.SOCKET_SERVER_PORT);

        // 1、获取通道(channel)
        SocketChannel socketChannel = SocketChannel.open(address);
        // 2、切换成非阻塞模式
        socketChannel.configureBlocking(false);
        //不断的自旋、等待连接完成,或者做一些其他的事情
        while (!socketChannel.finishConnect()) {

        }
        Print.tcfo("客户端启动成功!");

        //启动接受线程
        Processer processer = new Processer(socketChannel);
        new Thread(processer).start();

    }

    static class Processer implements Runnable {
        final Selector selector;
        final SocketChannel channel;

        Processer(SocketChannel channel) throws IOException {
            //Reactor初始化
            selector = Selector.open();
            this.channel = channel;
            channel.register(selector,
                    SelectionKey.OP_READ | SelectionKey.OP_WRITE);
        }

        public void run() {
            try {
                while (!Thread.interrupted()) {
                    selector.select();
                    Set<SelectionKey> selected = selector.selectedKeys();
                    Iterator<SelectionKey> it = selected.iterator();
                    while (it.hasNext()) {
                        SelectionKey sk = it.next();
                        if (sk.isWritable()) {
                            ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);

                            Scanner scanner = new Scanner(System.in);
                            Print.tcfo("请输入发送内容:");
                            if (scanner.hasNext()) {
                                SocketChannel socketChannel = (SocketChannel) sk.channel();
                                String next = scanner.next();
                                buffer.put((Dateutil.getNow() + " >>" + next).getBytes());
                                buffer.flip();
                                // 操作三:通过DatagramChannel数据报通道发送数据
                                socketChannel.write(buffer);
                                buffer.clear();
                            }

                        }
                        if (sk.isReadable()) {
                            // 若选择键的IO事件是“可读”事件,读取数据
                            SocketChannel socketChannel = (SocketChannel) sk.channel();

                            //读取数据
                            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                            int length = 0;
                            while ((length = socketChannel.read(byteBuffer)) > 0) {
                                byteBuffer.flip();
                                Logger.info("server echo:" + new String(byteBuffer.array(), 0, length));
                                byteBuffer.clear();
                            }

                        }
                        //处理结束了, 这里不能关闭select key,需要重复使用
                        //selectionKey.cancel();
                    }
                    selected.clear();
                }
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws IOException {
        new EchoClient().start();
    }
}

代码测试,client类运行结果如下:

[main|EchoClient.start]:客户端启动成功!
[Thread-0|EchoClient$Processer.run]:请输入发送内容:
hello

server类运行结果如下:

[Thread-0|EchoHandler.run] |>  2019-11-19 02:39:51 >>hello 

4.2.4 单线程Reactor反应器模式的缺点

单线程Reactor反应器模式,是基于Java的NIO实现的。相对于传统的多线程OIO,反应器模式不再需要启动成千上万条线程,效率自然是大大提升了。

在单线程反应器模式中,Reactor反应器和Handler处理器,都执行在同一条线程上。这样,带来了一个问题:当其中某个Handler阻塞时,会导致其他所有的Handler都得不到执行。在这种场景下,如果被阻塞的Handler不仅仅负责输入和输出处理的业务,还包括负责连接监听的AcceptorHandler处理器。这个是非常严重的问题。

为什么?一旦AcceptorHandler处理器阻塞,会导致整个服务不能接收新的连接,使得服务器变得不可用。因为这个缺陷,因此单线程反应器模型用得比较少。

另外,目前的服务器都是多核的,单线程反应器模式模型不能充分利用多核资源。总之,在高性能服务器应用场景中,单线程反应器模式实际使用的很少。

4.3 多线程的Reactor反应器模式

既然Reactor反应器和Handler处理器,挤在一个线程会造成非常严重的性能缺陷。那么,可以使用多线程,对基础的反应器模式进行改造和演进。

4.3.1 多线程池Reactor反应器演进

多线程池Reactor反应器的演进,分为两个方面:

  1. 首先是升级Handler处理器。既要使用多线程,又要尽可能的高效率,则可以考虑使用线程池。
  2. 其次是升级Reactor反应器。可以考虑引入多个Selector选择器,提升选择大量通道的能力。

总体来说,多线程池反应器的模式,大致如下:

  1. 将负责输入输出处理的IOHandler处理器的执行,放入独立的线程池中。这样,业务处理线程与负责服务监听和IO事件查询的反应器线程相隔离,避免服务器的连接监听受到阻塞。
  2. 如果服务器为多核的CPU,可以将反应器线程拆分为多个子反应器(SubReactor)线程;同时,引入多个选择器,每一个SubReactor子线程负责一个选择器。这样,充分释放了系统资源的能力;也提高了反应器管理大量连接,提升选择大量通道的能力。

4.3.2 多线程Reactor反应器的实践案例

在前面的“回显服务器”(EchoServer)的基础上,完成多线程Reactor反应器的升级。多线程反应器的实践案例设计如下:

  1. 引入多个选择器。
  2. 设计一个新的子反应器(SubReactor)类,一个子反应器负责查询一个选择器。
  3. 开启多个反应器的处理线程,一个线程负责执行一个子反应器(SubReactor)。

为了提升效率,建议SubReactor的数量和选择器的数量一致。避免多个线程负责一个选择器,导致需要进行线程同步,引起的效率降低。这个实践案例的代码如下:

//....反应器
class MultiThreadEchoServerReactor {
    ServerSocketChannelserverSocket;
    AtomicInteger next = new AtomicInteger(0);
    //选择器集合,引入多个选择器
    Selector[] selectors = new Selector[2];
    //引入多个子反应器
    SubReactor[] subReactors = null;
    MultiThreadEchoServerReactor() throws IOException {
        //初始化多个选择器
        selectors[0] = Selector.open();
        selectors[1] = Selector.open();
        serverSocket = ServerSocketChannel.open();
        InetSocketAddress address =
                new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP,
        NioDemoConfig.SOCKET_SERVER_PORT);
        serverSocket.socket().bind(address);
        //非阻塞
        serverSocket.configureBlocking(false);
        //第一个选择器,负责监控新连接事件
        SelectionKeysk =
                serverSocket.register(selectors[0], SelectionKey.OP_ACCEPT);
        //绑定Handler:attach新连接监控handler处理器到SelectionKey(选择键)
        sk.attach(new AcceptorHandler());
        //第一个子反应器,一子反应器负责一个选择器
        SubReactor subReactor1 = new SubReactor(selectors[0]);
        //第二个子反应器,一子反应器负责一个选择器
        SubReactor subReactor2 = new SubReactor(selectors[1]);
        subReactors = new SubReactor[]{subReactor1, subReactor2};
    }
    private void startService() {
        // 一子反应器对应一个线程
        new Thread(subReactors[0]).start();
        new Thread(subReactors[1]).start();
    }
    //子反应器
    class SubReactor implements Runnable {
        //每个线程负责一个选择器的查询和选择
        final Selector selector;
        public SubReactor(Selector selector) {
            this.selector = selector;
        }
        public void run() {
            try {
                while (!Thread.interrupted()) {
                    selector.select();
                    Set<SelectionKey> keySet = selector.selectedKeys();
                    Iterator<SelectionKey> it = keySet.iterator();
                    while (it.hasNext()) {
                        //反应器负责dispatch收到的事件
                        SelectionKeysk = it.next();
                        dispatch(sk);
                    }
                keySet.clear();
                }
            } catch (IOException ex) {
                ex.printStackTrace();
              }
        }
        void dispatch(SelectionKeysk) {
            Runnable handler = (Runnable) sk.attachment();
            //调用之前attach绑定到选择键的handler处理器对象
            if (handler != null) {
                handler.run();
            }
        }
    }
    // Handler:新连接处理器
    class AcceptorHandler implements Runnable {
        public void run() {
            try {
                SocketChannel channel = serverSocket.accept();
                if (channel != null)
                    new MultiThreadEchoHandler(selectors[next.get()], channel);
            } catch (IOException e) {
                e.printStackTrace();
              }
            if (next.incrementAndGet() == selectors.length) {
                next.set(0);
            }
        }
    }
    public static void main(String[] args) throws IOException {
        MultiThreadEchoServerReactor server =
                   new MultiThreadEchoServerReactor();
        server.startService();
    }
}

上面是反应器的演进代码,再来看看Handler处理器的多线程演进实践。

4.3.3 多线程Handler处理器的实践案例

基于前面的单线程反应器的EchoHandler回显处理器的程序代码,予以改进,新的回显处理器为:MultiThreadEchoHandler。主要的升级是引入了一个线程池(ThreadPool),业务处理的代码执行在自己的线程池中,彻底地做到业务处理线程和反应器IO事件线程的完全隔离。这个实践案例的代码如下:

//...
class MultiThreadEchoHandler implements Runnable {
    final SocketChannel channel;
    final SelectionKeysk;
    final ByteBufferbyteBuffer = ByteBuffer.allocate(1024);
    static final int RECIEVING = 0, SENDING = 1;
    int state = RECIEVING;
    //引入线程池
    static ExecutorService pool = Executors.newFixedThreadPool(4);
    MultiThreadEchoHandler(Selector selector, SocketChannel c) throws IOException {
        channel = c;
        c.configureBlocking(false);
        //取得选择键,、再设置感兴趣的IO事件
        sk = channel.register(selector, 0);
        //将本Handler作为sk选择键的附件,方便事件分发(dispatch)
        sk.attach(this);
        //向sk选择键注册Read就绪事件
        sk.interestOps(SelectionKey.OP_READ);
        selector.wakeup();
    }
    public void run() {
        //异步任务,在独立的线程池中执行
        pool.execute(new AsyncTask());
    }
    //业务处理,不在反应器线程中执行
    public synchronized void asyncRun() {
        try {
            if (state == SENDING) {
                //写入通道
                channel.write(byteBuffer);
                //写完后,准备开始从通道读,byteBuffer切换成写入模式
                byteBuffer.clear();
                //写完后,注册read就绪事件
                sk.interestOps(SelectionKey.OP_READ);
                //写完后,进入接收的状态
                state = RECIEVING;
            } else if (state == RECIEVING) {
                //从通道读
                int length = 0;
                while ((length = channel.read(byteBuffer)) &gt; 0) {
                    Logger.info(new String(byteBuffer.array(), 0, length));
                }
                //读完后,准备开始写入通道,byteBuffer切换成读取模式
                byteBuffer.flip();
                //读完后,注册write就绪事件
                sk.interestOps(SelectionKey.OP_WRITE);
                //读完后,进入发送的状态
                state = SENDING;
            }
            //处理结束了, 这里不能关闭select key,需要重复使用
            //sk.cancel();
        } catch (IOException ex) {
            ex.printStackTrace();
          }
    }
    //异步任务的内部类
    class AsyncTask implements Runnable {
        public void run() {
            MultiThreadEchoHandler.this.asyncRun();
        }
    }
}

代码中设计了一个内部类AsyncTask,是一个简单的异步任务的提交类。它使得异步业务asyncRun方法,可以独立地提交到线程池中。另外,既然业务处理异步执行,需要在asyncRun方法的前面加上synchronized同步修饰符。

至此,多线程版本的反应器模式,实践案例的代码就演示完了。执行新版本的多线程MultiThreadEchoServerReactor服务器,可以使用之前的EchoClient客户端与之配置,完成整个回显(echo)的通信演示。

演示的输出和之前单线程版本的EchoServer回显服务器示例,是一模一样的。

客户端演示结果:

[main|EchoClient.start]:客户端启动成功!
[Thread-0|EchoClient$Processer.run]:请输入发送内容:
Multi helloworld

服务器端演示结果:

[pool-1-thread-1|MultiThreadEchoHandler.asyncRun] |>  2019-11-19 03:13:28 >>Multi 

4.4 Reactor反应器模式小结

在总结反应器模式前,首先看看和其他模式的对比,加强一下对它的理解。

  1. 反应器模式和生产者消费者模式对比

相似之处:在一定程度上,反应器模式有点类似生产者消费者模式。在生产者消费者模式中,一个或多个生产者将事件加入到一个队列中,一个或多个消费者主动地从这个队列中提取(Pull)事件来处理。

不同之处在于:反应器模式是基于查询的,没有专门的队列去缓冲存储IO事件,查询到IO事件之后,反应器会根据不同IO选择键(事件)将其分发给对应的Handler处理器来处理。

  1. 反应器模式和观察者模式(Observer Pattern)对比

相似之处在于:在反应器模式中,当查询到IO事件后,服务处理程序使用单路/多路分发(Dispatch)策略,同步地分发这些IO事件。观察者模式(Observer Pattern)也被称作发布/订阅模式,它定义了一种依赖关系,让多个观察者同时监听某一个主题(Topic)。这个主题对象在状态发生变化时,会通知所有观察者,它们能够执行相应的处理。

不同之处在于:在反应器模式中,Handler处理器实例和IO事件(选择键)的订阅关系,基本上是一个事件绑定到一个Handler处理器;每一个IO事件(选择键)被查询后,反应器会将事件分发给所绑定的Handler处理器;而在观察者模式中,同一个时刻,同一个主题可以被订阅过的多个观察者处理。

最后,总结一下反应器模式的优点和缺点。作为高性能的IO模式,反应器模式的优点如下:

  • 响应快,虽然同一反应器线程本身是同步的,但不会被单个连接的同步IO所阻塞;

  • 编程相对简单,最大程度避免了复杂的多线程同步,也避免了多线程的各个进程之间切换的开销;

  • 可扩展,可以方便地通过增加反应器线程的个数来充分利用CPU资源。

反应器模式的缺点如下:

  • 反应器模式增加了一定的复杂性,因而有一定的门槛,并且不易于调试。

  • 反应器模式需要操作系统底层的IO多路复用的支持,如Linux中的epoll。如果操作系统的底层不支持IO多路复用,反应器模式不会有那么高效。

  • 同一个Handler业务线程中,如果出现一个长时间的数据读写,会影响这个反应器中其他通道的IO处理。例如在大文件传输时,IO操作就会影响其他客户端(Client)的响应时间。因而对于这种操作,还需要进一步对反应器模式进行改进。

4.5 本章小结

反应器(Reactor)模式是高性能网络编程在设计和架构层面的基础模式。同时,反应器模式,也是BAT级别大公司必不可少的面试题。

本章首先从最简单的Connection Per Thread(一个线程处理一个连接)模式入手,介绍了该模式的严重缺陷,从而引出来了单线程的反应器模式。

为了充分利用系统资源,最大限度地减少阻塞,在单线程的反应器模式的基础上,又演进出来了多线程的反应器模式实现。

本章的反应器模式的实现,仅仅是抛砖引玉,在充分利用系统资源、最大限度地减少阻塞两个维度,都有很大的提升空间,建议大家自行尝试。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容