Loading

[02] 前置学习(下)

1. NIO 群聊系统

编写一个 NIO 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)。

  • 服务端:可以监测用户上线、离线,并实现消息转发功能;
  • 客户端:通过 Channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(由服务器转发得到)。

a. GroupChatServer

public class GroupChatServer {

  private Selector selector;

  private ServerSocketChannel listener;

  private static final int PORT = 6677;

  public GroupChatServer() {
    try {
        selector = Selector.open();
        listener = ServerSocketChannel.open();
        listener.socket().bind(new InetSocketAddress(PORT));
        listener.configureBlocking(false);
        listener.register(selector, SelectionKey.OP_ACCEPT);
    } catch (Exception e) {
        e.printStackTrace();
    }
  }

  public void listen() {
    try {
      while (true) {
        int count = selector.select();
        if (count > 0) {
            Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                if (key.isAcceptable()) {
                    // 服务端SocketChannel ===<通信管道>=== 客户端SocketChannel
                    SocketChannel sc = listener.accept();
                    sc.configureBlocking(false);
                    // ServerSocketChannel 和 服务端SocketChannel 共用一个 Selector
                    sc.register(selector, SelectionKey.OP_READ);
                    System.out.println("[" + sc.getRemoteAddress() + "] online ...");
                } else if (key.isConnectable()) {
                    // a connection was established with a remote server.
                } else if (key.isReadable()) {
                    // 有客户端消息可读
                    readClientData(key);
                } else if (key.isWritable()) {
                    // a channel is ready for writing
                }
                // 删除当前的 key,防止重复处理
                keyIterator.remove();
            }
        }
      }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {

    }
  }

  private void readClientData(SelectionKey key) {
    SocketChannel channel = null;
    try {
      // 取到关联的 Channel
      channel = (SocketChannel) key.channel();
      // 创建 Buffer
      ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
      // Channel ->[data]-> ByteBuffer # 若客户端中止程序,下面这行会抛出异常
      int read = channel.read(byteBuffer);
      // 根据 read 的值做处理
      if (read > 0) {
        // 把缓冲区数据转成字符串
        String msg = new String(byteBuffer.array());
        // 输出该消息
        System.out.println(msg);
        // 向其他客户端(通道)转发消息
        sendMsg2OtherClients(msg, channel);
      }
    } catch (IOException e) {
      try {
        // [e] java.io.IOException: 远程主机强迫关闭了一个现有的连接。
        System.out.println("[" + channel.getRemoteAddress() + "] offline ...");
        // 取消注册
        key.cancel();
        // 关闭通道
        channel.close();
      } catch (IOException ex) {
        ex.printStackTrace();
      }
    }
  }

  /**
    * 转发消息到其他客户端 => 遍历所有注册到 selector 上的 SocketChannel,并排除 speaker
    * @param msg     消息内容
    * @param speaker 讲这话的人
    */
  private void sendMsg2OtherClients(String msg, SocketChannel speaker) throws IOException {
    System.out.print("[Server->Msg->Client] " + msg);
    for (SelectionKey key : selector.keys()) {
      Channel targetChannel = key.channel();
      // condition1:ServerSocketChannel 也注册在这个 Selector 上,所以得把它给滤掉。
      // condition2:自己发的消息不用再发回一遍给自己,所以要把自己给滤掉。
      if (targetChannel instanceof SocketChannel && targetChannel != speaker) {
        SocketChannel destChannel = (SocketChannel) targetChannel;
        // 将 Msg 存储到 Buffer
        ByteBuffer byteBuffer = ByteBuffer.wrap(msg.getBytes());
        // Buffer ->[data]-> Channel
        destChannel.write(byteBuffer);
      }
    }
  }


  public static void main(String[] args) {
    GroupChatServer chatServer = new GroupChatServer();
    chatServer.listen();
  }

}

b. GroupChatClient

public class GroupChatClient {
  private final String HOST = "127.0.0.1";
  private final int PORT = 6677;
  private Selector selector;
  private SocketChannel socketChannel;
  private String username;

  public GroupChatClient() throws IOException {
    selector = Selector.open();
    socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT));
    socketChannel.configureBlocking(false);
    socketChannel.register(selector, SelectionKey.OP_READ);
    username = socketChannel.getLocalAddress().toString().substring(1);
    System.out.println(username + " is ready!");
  }

  private void sendMsg(String msg) {
    msg = "[" + username + "] " + msg;
    try {
        socketChannel.write(ByteBuffer.wrap(msg.getBytes()));
    } catch (IOException e) {
        e.printStackTrace();
    }
  }

  private void readMsg() {
    try {
        int speakChannelCount = selector.select();
        if (speakChannelCount > 0) {
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                if (key.isReadable()) {
                    // 得到相关的通道
                    SocketChannel speakChannel = (SocketChannel) key.channel();
                    // 得到一个 Buffer
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    // Channel ->[data]-> ByteBuffer
                    speakChannel.read(byteBuffer);
                    // 把读到的缓冲区的数据转成字符串
                    String msg = new String(byteBuffer.array());
                    System.out.println(msg.trim());
                }
            }
            // 删除当前的 key,防止重复处理
            iterator.remove();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
  }

  public static void main(String[] args) throws Exception {
    GroupChatClient chatClient = new GroupChatClient();

    // receive message
    new Thread(() -> {
        while (true) {
            chatClient.readMsg();
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();

    // send message
    Scanner scanner = new Scanner(System.in);
    while (scanner.hasNextLine()) {
        String msg = scanner.nextLine();
        chatClient.sendMsg(msg);
    }
  }
}

IDEA 客户端多开:

2. NIO 与零拷贝

2.1 原理

https://blog.csdn.net/weixin_37782390/article/details/103833306

零拷贝是网络编程的关键,很多性能优化都离不开。

在 Java 程序中,常用的零拷贝有 mmap(内存映射) 和 sendFile。那么,他们在 OS 里到底是怎么样的一个的设计?接下来我们分析 mmap 和 sendFile 这两个零拷贝。

a. 传统 IO

File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

我们会调用 read 方法读取 index.html 的内容 —— 变成字节数组,然后调用 write 方法,将 index.html 字节流写到 socket 中,那我们调用这两个方法,在 OS 底层发生了什么呢?

上图中,上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作。下面说说他们的步骤:

  1. read 调用导致〈用户态〉到〈内核态〉的一次变化,同时,第 1 次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 index.html 文件,并将数据放入到「内核缓冲区」;
  2. 发生第 2 次数据拷贝,即:将「内核缓冲区」的数据拷贝到「用户缓冲区」,同时,发生了一次〈内核态〉到〈用户态〉的上下文切换;
  3. 发生第 3 次数据拷贝,我们调用 write 方法,系统将「用户缓冲区」的数据拷贝到「Socket 缓冲区」。此时,又发生了一次〈用户态〉到〈内核态〉的上下文切换;
  4. 第 4 次拷贝,数据异步地从「Socket 缓冲区」使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换;
  5. write 方法返回,再次从〈内核态〉切换到〈用户态〉。

如你所见,复制拷贝操作太多了。如何优化这些流程?

b. mmap

先来介绍一下虚拟内存:

现代操作系统使用虚拟内存,即虚拟地址取代物理地址,使用虚拟内存可以有 2 个好处:

  1. 虚拟内存空间可以远远大于物理内存空间
  2. 多个虚拟内存可以指向同一个物理地址

正是多个虚拟内存可以指向同一个物理地址,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样的话,就可以减少 IO 的数据拷贝次数啦,示意图如下:

可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,从而减少数据拷贝次数!mmap 就是用了虚拟内存这个特点,它将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的 IO 都在内核中完成。

  1. ⽤户进程通过「mmap ⽅法」向操作系统内核发起 IO 调⽤,上下⽂从⽤户态切换为内核态。
  2. CPU 利⽤ DMA 控制器把数据从硬盘中拷⻉到内核缓冲区。
  3. 上下⽂从内核态切换回⽤户态,mmap ⽅法返回。
  4. ⽤户进程通过 write ⽅法向操作系统内核发起 IO 调⽤,上下⽂从⽤户态切换为内核态。
  5. CPU 将内核缓冲区的数据拷⻉到的 socket 缓冲区。
  6. CPU 利⽤ DMA 控制器,把数据从 socket 缓冲区拷⻉到⽹卡,上下⽂从内核态切换回⽤户态,write 调⽤返回。

可以发现, mmap+write 实现的零拷⻉,I/O 发⽣了 4 次⽤户空间与内核空间的上下⽂切换,以及 3 次数据拷⻉。其中 3 次数据拷⻉中,包括了 2 次 DMA 拷⻉和 1 次 CPU 拷⻉。

mmap 是将读缓冲区的地址和⽤户缓冲区的地址进⾏映射,内核缓冲区和应⽤缓冲区共享,所以节省了⼀次 CPU 拷⻉。并且⽤户进程内存是虚拟的,只是映射到内核的读缓冲区,可以节省⼀半的内存空间。

c. sendFile

那么,我们还能继续优化吗? Linux 2.1 版本提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,避免了数据从内核缓冲区和⽤户缓冲区之间的拷⻉操作。同时,由于和用户态完全无关,就减少了一次上下文切换。

  1. ⽤户进程发起 sendfile 系统调⽤,上下⽂(切换 1)从⽤户态转向内核态。
  2. CPU 利用 DMA 控制器把数据从硬盘中拷⻉到内核缓冲区。
  3. CPU 将读缓冲区中数据拷⻉到 socket 缓冲区。
  4. DMA 控制器异步地把数据从 socket 缓冲区拷⻉到⽹卡。
  5. 上下⽂(切换 2)从内核态切换回⽤户态,sendfile 调⽤返回。

如上图,我们进行 sendFile 系统调用时,数据被 DMA 引擎从文件复制到内核缓冲区,然后调用 write 方法时,从内核缓冲区进入到 Socket,这时,是没有上下文切换的,因为都在内核空间。

可以发现, sendfile 实现的零拷⻉,I/O 发⽣了 2 次⽤户空间与内核空间的上下⽂切换,以及 3 次数据拷⻉。其中 3 次数据拷⻉中,包括了2 次 DMA 拷⻉和 1 次 CPU 拷⻉。那能不能把 CPU 拷⻉的次数减少到 0 次呢?有的,即:带有 DMA 收集拷⻉功能的 sendfile!

实际上,Linux 在 2.4 版本中,对 sendfile 做了优化升级,引⼊ SG-DMA 技术,其实就是对 DMA 拷⻉加⼊了 scatter/gather 操作,它可以直接从内核空间缓冲区中将数据读取到⽹卡(协议栈),避免了从 Kernel buffer 拷贝到 Socket buffer 的操作(内核缓存区只会拷贝一些 offset 和 length 信息到 Socket buffer,基本无消耗)。使⽤这个特点搞零拷⻉,即还可以多省去⼀次 CPU 拷⻉。

  1. ⽤户进程发起 sendfile 系统调⽤,上下⽂(切换 1)从⽤户态转向内核态。
  2. DMA 控制器把数据从硬盘中拷⻉到内核缓冲区。
  3. CPU 把内核缓冲区中的⽂件描述符信息(包括内核缓冲区的内存地址和偏移量)发送到 socket 缓冲区。
  4. DMA 控制器根据⽂件描述符信息,直接把数据从内核缓冲区拷⻉到⽹卡。
  5. 上下⽂(切换 2)从内核态切换回⽤户态,sendfile 调⽤返回。

可以发现, sendfile+DMA scatter/gather 实现的零拷⻉,I/O 发⽣了 2 次⽤户空间与内核空间的上下⽂切换,以及 2 次数据拷⻉。其中 2 次数据拷⻉都是 DMA 拷⻉。这就是真正的零拷⻉(Zero-copy) 技术,全程都没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进⾏传输的。

等一下,不是说零拷贝吗?为什么还是要 2 次拷贝?

首先我们说零拷贝,是从〈操作系统〉的角度来说的,指内核缓冲区之间,没有数据是重复的(只有 Kernel buffer 有一份数据,sendFile 2.1 版本实际上有 2 份数据,算不上零拷贝)。例如我们刚开始的例子,内核缓存区和 Socket 缓冲区的数据就是重复的。

而零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

再稍微说下 mmap 和 sendFile 的区别

  1. mmap 适合小数据量读写,sendFile 适合大文件传输;
  2. mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝;
  3. sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区);
  4. 在这个选择上,RocketMQ 在消费消息时使用了 mmap,Kafka 使用了 sendFile。

2.2 文件传输案例

NewIOServer

public class NewIOServer {
  public static void main(String[] args) throws Exception {
    InetSocketAddress address = new InetSocketAddress(7001);

    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

    ServerSocket serverSocket = serverSocketChannel.socket();

    serverSocket.bind(address);

    ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

    while (true) {
      SocketChannel socketChannel = serverSocketChannel.accept();

      int readCount = 0;
      while (-1 != readCount) {
        try {
          readCount = socketChannel.read(byteBuffer);
        } catch (Exception ex) {
          break;
        }
        // [倒带] Rewinds this buffer. The position is set to zero and the mark is discarded.
        byteBuffer.rewind();
      }
    }
  }
}

NewIOClient

public class NewIOClient {
  public static void main(String[] args) throws Exception {
    SocketChannel socketChannel = SocketChannel.open();
    socketChannel.connect(new InetSocketAddress("localhost", 7001));
    String filename = "XiaMi.png";

    // 得到一个文件 Channel
    FileChannel fileChannel = new FileInputStream(filename).getChannel();

    // 准备发送
    long startTime = System.currentTimeMillis();

    // 在 Linux 下使用一次 transferTo 方法就可以完成传输, 在 Windows 下调用
    // 一次 transferTo 只能发送 8M, 不然就要分段传输文件, 而且要注意传输位置
    // LOOP_COUNT = file.size/8, position = 8M*(LOOP_COUNT-1)
    long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

    System.out.println("发送的总的字节数 =" + transferCount 
            + ", 耗时:" + (System.currentTimeMillis() - startTime));

    fileChannel.close();
  }
}

2.3 实际用例

(1)Kafka 在客户端和 Broker 进行数据传输时,会使用 transferTo 和 transferFrom 方法,即对应 Linux 的 sendFile;

(2)Tomcat 内部在进行文件拷贝的时候,也会使用 transferto 方法;

(3)Tomcat 在处理一下心跳保活时,也会调用该 sendFile 方法。

所以,如果你需要优化网络传输的性能,或者文件读写的速度,请尽量使用零拷贝。它不仅能较少复制拷贝次数,还能较少上下文切换,缓存行污染。

2.4 Java AIO

JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式:Reactor 和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理。

AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。

目前 AIO 还没有广泛应用,Netty 也是基于 NIO,而不是 AIO, 因此我们就不详解 AIO 了,有兴趣的同学可以参考《 Java 新一代网络编程模型 AIO 原理及 Linux 系统 AIO 介绍 · http://www.52im.net/thread-306-1-1.html

3. Netty 简述

原生 NIO 存在的问题:

  • NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等;
  • 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序;
  • 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等;
  • JDK NIO Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%;直到 JDK 1.7 版本该问题仍旧存在,没有被根本解决。

Netty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供异步的、基于事件驱动的网络应用程序框架,用以快速开发高性能、高可靠性的网络 IO 程序(Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题)。

https://netty.io/

Netty 优点

  • 设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池;
  • 使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了;
  • 高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制;
  • 安全:完整的 SSL/TLS 和 StartTLS 支持;
  • 社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入。

Netty 版本说明

Netty 版本分为 Netty3.x、Netty4.x 和 Netty5.x,因为 Netty5 出现重大 bug,已经被官网废弃了,目前推荐使用的是 Netty4.x 的稳定版本。

下载地址:https://bintray.com/netty/downloads/netty

4. Reactor 模型

不同的线程模式,对程序的性能有很大影响,为了搞清 Netty 线程模式,我们来系统的讲解下各个线程模式,最后看看 Netty 线程模型有什么优越性。

目前存在的线程模型有:传统阻塞 I/O 服务模型、Reactor 模型。

根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程

Netty 主要基于主从 Reactor 多线程模型做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor。

4.1 阻塞 I/O 模型

  • 模型特点
    • 采用阻塞 IO 模型获取输入的数据;
    • 每个连接都需要独立的线程完成数据的输入、业务处理、数据返回。
  • 问题分析
    • 当并发数很大,就会创建大量的线程,占用大量的系统资源;
    • 连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在 read 操作,造成线程资源浪费。

4.2 事件驱动模型

通常,我们设计一个事件处理模型的程序有两种思路:

  • 【轮询方式】线程不断轮询访问相关事件发生源有没有发生事件,有发生事件就调用事件处理逻辑;
  • 【事件驱动方式】发生事件,主线程把事件放入事件队列,在另外线程不断循环消费事件列表中的事件,调用事件对应的处理逻辑处理事件。事件驱动方式也被称为消息通知方式,其实是“发布-订阅模式”的思路。

以 GUI 的逻辑处理为例,说明两种逻辑的不同:

  • 【轮询方式】线程不断轮询是否发生按钮点击事件,如果发生,调用处理逻辑;
  • 【事件驱动方式】发生点击事件时把事件放入事件队列,在另外线程消费的事件列表中的事件,根据事件类型调用相关事件处理逻辑。

主要包括 4 个基本组件:

  • 事件队列(Event Queue):接收事件的入口,存储待处理事件;
  • 分发器(Event Mediator):将不同的事件分发到不同的业务逻辑单元;
  • 事件通道(Event Channel):分发器与处理器之间的联系渠道;
  • 事件处理器(Event Processor):实现业务逻辑,处理完成后会发出事件,触发下一步操作。

可以看出,相对传统轮询模式,事件驱动有如下优点:

  1. 可扩展性好,分布式的异步架构,事件处理器之间高度解耦,可以方便扩展事件处理逻辑;
  2. 高性能,基于队列暂存事件,能方便并行异步处理事件。

4.3 Reactor 模型

(1)针对传统阻塞 I/O 服务模型的 2 个缺点并基于事件驱动模型的思想,提出解决方案:I/O 复用结合线程池。

  • 基于 I/O 复用模型:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理;
  • 基于线程池复用线程资源:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程池中的线程进行处理,一个线程可以处理多个连接的业务。

(2)Reactor 模式是一种事件驱动处理模式。

服务端程序处理传入的多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式(即 I/O 多路复用来统一监听客户端的请求#Event,收到请求#Event 后 dispatch 给线程池中某个线程)。

(3)Rector 模式中核心组成

  • Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件作出反应。它就像公司的电话接线员,它接听来自客户的电话并将线路转移给使用的联系人;
  • Handler:处理程序执行 IO 事件实际处理器,类似于客户想要与之交谈的公司中的实际人员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

(4)根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现。

  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程

可以这样理解,Reactor 就是一个执行 while (true) { selector.select(); ... } 循环的线程,会源源不断的产生新的事件,称作“反应堆”很贴切。

(5)三种模式可以用个比喻来理解:餐厅常常雇佣接待员负责迎接顾客,当顾客入坐后,侍应生专门为这张桌子服务。

  • 单 Reactor 单线程,接待员和侍应生是同一个人,全程为顾客服务;
  • 单 Reactor 多线程,1 个接待员,多个侍应生,接待员只负责接待;
  • 主从 Reactor 多线程,多个接待员,多个侍应生。

(6)Reactor 模式优点

  • 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的;
  • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
  • 可扩展性,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源;
  • 可复用性,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。

4.4 — 单 Reactor 单线程

a. 工作原理

  1. Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发;
  2. 如果是建立连接请求事件,则由 Acceptor 通过 accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理;
  3. 如果不是建立连接事件,则 Reactor 会分发请求调用连接对应的 Handler 来响应;
  4. Handler 会完成〈read → 业务处理 → send〉的完整业务流程。

b. 优缺点分析

单 Reactor 单线程模型只是在代码上进行了组件的区分,但是整体操作还是单线程,不能充分利用硬件资源。Handler 业务处理部分没有异步。

服务器端用一个线程通过多路复用搞定了所有的 IO 操作(包括连接、读、写等),编码简单、清晰明了。但是如果客户端连接数量较多,将无法支撑(#1 的群聊 Demo 就属于这种模型,其从头至尾只有 Main 这一个线程在工作)。

  • 优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成;
  • 缺点:
    • 性能问题:只有一个线程,一个线程需要执行处理所有的 accept、read、decode、process、encode、send 事件,处理成百上千的链路时性能上无法支撑;无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈;
    • 可靠性问题:线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。

使用场景:客户端的数量有限,业务处理非常快速,比如 Redis 业务处理的时间复杂度 O(1)。

4.5 — 单 Reactor 多线程

a. 工作原理

  1. Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发;
  2. 如果是建立连接请求事件,则由 Acceptor 通过 accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后续的各种事件;
  3. 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
  4. Handler 只负责响应事件,不做具体业务处理,通过 read 读取数据后,会分发给后面的「Worker 线程池」进行业务处理;
  5. Worker 线程池会分配独立的线程完成真正的业务处理,将响应结果发给 Handler 进行处理;
  6. Handler 收到响应结果后通过 send 将响应结果返回给 Client。

b. 优缺点分析

  • 优点:可以充分利用多核 CPU 的处理能力;
  • 缺点:多线程数据共享和访问比较复杂;Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。

4.6 — 主从 Reactor 多线程

a. 工作原理

  1. Reactor 主线程 MainReactor 通过 select 监听连接事件,收到事件后通过 Acceptor 处理建立连接事件;
  2. Acceptor 处理建立连接事件后(与客户端建立好 Socket 连接),MainReactor 将连接分配给 Reactor 子线程 SubReactor 进行处理(即:MainReactor 只负责监听客户端连接请求,和客户端建立连接之后将连接交由 SubReactor 监听后面的 IO 事件);
  3. SubReactor 将连接加入到连接队列进行监听,并创建一个 Handler 用于处理各种连接后的请求事件;
  4. 当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应;
  5. Handler 先通过 read 读取数据,然后会分发给后面的 Worker 线程池进行业务处理;
  6. Worker 线程池会分配独立的线程完成真正的业务处理,然后将响应结果发给 Handler 进行处理;Handler 收到响应结果后通过 send 将响应结果返回给 Client;
  7. 一个 MainReactor 可以对应多个 SubReactor,即一个 MainReactor 线程可以对应多个 SubReactor 线程。

b. 优缺点分析

优点:

  1. MainReactor 线程与 SubReactor 线程的数据交互简单职责明确,MainReactor 线程只需要接收新连接,SubReactor 线程完成后续的业务处理。
  2. MainReactor 线程与 SubReactor 线程的数据交互简单, MainReactor 线程只需要把新连接传给 SubReactor 线程,SubReactor 线程无需返回数据。
  3. 多个 SubReactor 线程能够应对更高的并发请求。

这种模式的缺点是编程复杂度较高。编程复杂度较高,但是由于其优点明显,所以这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型、Memcached 主从多线程、Netty 主从多线程模型的支持。

这种模式也被叫做服务器的「1+M+N 线程模式」,即使用该模式开发的服务器包含 1 个(或多个,1 只是表示相对较少)连接建立线程 + M 个 IO 线程 + N 个业务处理线程。这是业界成熟的服务器程序设计模式。

【变种】1 * AcceptThread + M * SelectorThread + N * IOThread + K * BusinessThread

4.7 Review Reactor

Reactor 模式思想:分而治之 + 事件驱动

(1)分而治之

一个连接里完整的网络处理过程一般分为 accept、read、decode、process、encode、send 这几步。

Reactor 模式将每个步骤映射为一个 Task,服务端线程执行的最小逻辑单元不再是一次完整的网络请求而是 Task,且采用非阻塞方式执行。

(2)事件驱动

每个 Task 对应特定网络事件。当 Task 准备就绪时,Reactor 收到对应的网络事件通知,并将 Task 分发给绑定了对应网络事件的 Handler 执行。

(3)几个角色

  • Reactor:负责响应事件,将事件分发给绑定了该事件的 Handler 处理;
  • Handler:事件处理器,绑定了某类事件,负责执行对应事件的 Task 对事件进行处理;
  • Acceptor:是 Handler 的一种,绑定了 connect 事件。当客户端发起 connect 请求时,Reactor 会将 accept 事件分发给 Acceptor 处理。

(4)《Scalable IO in Java》Doug Lea

posted @ 2022-03-26 14:47  tree6x7  阅读(42)  评论(0编辑  收藏  举报