[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 底层发生了什么呢?
上图中,上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作。下面说说他们的步骤:
- read 调用导致〈用户态〉到〈内核态〉的一次变化,同时,第 1 次复制开始:DMA(Direct Memory Access,直接内存存取,即不使用 CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存,用于解放 CPU) 引擎从磁盘读取 index.html 文件,并将数据放入到「内核缓冲区」;
- 发生第 2 次数据拷贝,即:将「内核缓冲区」的数据拷贝到「用户缓冲区」,同时,发生了一次〈内核态〉到〈用户态〉的上下文切换;
- 发生第 3 次数据拷贝,我们调用 write 方法,系统将「用户缓冲区」的数据拷贝到「Socket 缓冲区」。此时,又发生了一次〈用户态〉到〈内核态〉的上下文切换;
- 第 4 次拷贝,数据异步地从「Socket 缓冲区」使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换;
- write 方法返回,再次从〈内核态〉切换到〈用户态〉。
如你所见,复制拷贝操作太多了。如何优化这些流程?
b. mmap
先来介绍一下虚拟内存:
现代操作系统使用虚拟内存,即虚拟地址取代物理地址,使用虚拟内存可以有 2 个好处:
- 虚拟内存空间可以远远大于物理内存空间
- 多个虚拟内存可以指向同一个物理地址
正是多个虚拟内存可以指向同一个物理地址,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,这样的话,就可以减少 IO 的数据拷贝次数啦,示意图如下:
可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,从而减少数据拷贝次数!mmap 就是用了虚拟内存这个特点,它将内核中的读缓冲区与用户空间的缓冲区进行映射,所有的 IO 都在内核中完成。
- ⽤户进程通过「mmap ⽅法」向操作系统内核发起 IO 调⽤,上下⽂从⽤户态切换为内核态。
- CPU 利⽤ DMA 控制器把数据从硬盘中拷⻉到内核缓冲区。
- 上下⽂从内核态切换回⽤户态,mmap ⽅法返回。
- ⽤户进程通过 write ⽅法向操作系统内核发起 IO 调⽤,上下⽂从⽤户态切换为内核态。
- CPU 将内核缓冲区的数据拷⻉到的 socket 缓冲区。
- 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,避免了数据从内核缓冲区和⽤户缓冲区之间的拷⻉操作。同时,由于和用户态完全无关,就减少了一次上下文切换。
- ⽤户进程发起 sendfile 系统调⽤,上下⽂(切换 1)从⽤户态转向内核态。
- CPU 利用 DMA 控制器把数据从硬盘中拷⻉到内核缓冲区。
- CPU 将读缓冲区中数据拷⻉到 socket 缓冲区。
- DMA 控制器异步地把数据从 socket 缓冲区拷⻉到⽹卡。
- 上下⽂(切换 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 拷⻉。
- ⽤户进程发起 sendfile 系统调⽤,上下⽂(切换 1)从⽤户态转向内核态。
- DMA 控制器把数据从硬盘中拷⻉到内核缓冲区。
- CPU 把内核缓冲区中的⽂件描述符信息(包括内核缓冲区的内存地址和偏移量)发送到 socket 缓冲区。
- DMA 控制器根据⽂件描述符信息,直接把数据从内核缓冲区拷⻉到⽹卡。
- 上下⽂(切换 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 的区别
- mmap 适合小数据量读写,sendFile 适合大文件传输;
- mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝;
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区);
- 在这个选择上,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 进行了封装,解决了上述问题)。
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):实现业务逻辑,处理完成后会发出事件,触发下一步操作。
可以看出,相对传统轮询模式,事件驱动有如下优点:
- 可扩展性好,分布式的异步架构,事件处理器之间高度解耦,可以方便扩展事件处理逻辑;
- 高性能,基于队列暂存事件,能方便并行异步处理事件。
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. 工作原理
- Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发;
- 如果是建立连接请求事件,则由 Acceptor 通过 accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理;
- 如果不是建立连接事件,则 Reactor 会分发请求调用连接对应的 Handler 来响应;
- 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. 工作原理
- Reactor 对象通过 select 监控客户端请求事件,收到事件后通过 dispatch 进行分发;
- 如果是建立连接请求事件,则由 Acceptor 通过 accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后续的各种事件;
- 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
- Handler 只负责响应事件,不做具体业务处理,通过 read 读取数据后,会分发给后面的「Worker 线程池」进行业务处理;
- Worker 线程池会分配独立的线程完成真正的业务处理,将响应结果发给 Handler 进行处理;
- Handler 收到响应结果后通过 send 将响应结果返回给 Client。
b. 优缺点分析
- 优点:可以充分利用多核 CPU 的处理能力;
- 缺点:多线程数据共享和访问比较复杂;Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。
4.6 — 主从 Reactor 多线程
a. 工作原理
- Reactor 主线程 MainReactor 通过 select 监听连接事件,收到事件后通过 Acceptor 处理建立连接事件;
- Acceptor 处理建立连接事件后(与客户端建立好 Socket 连接),MainReactor 将连接分配给 Reactor 子线程 SubReactor 进行处理(即:MainReactor 只负责监听客户端连接请求,和客户端建立连接之后将连接交由 SubReactor 监听后面的 IO 事件);
- SubReactor 将连接加入到连接队列进行监听,并创建一个 Handler 用于处理各种连接后的请求事件;
- 当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应;
- Handler 先通过 read 读取数据,然后会分发给后面的 Worker 线程池进行业务处理;
- Worker 线程池会分配独立的线程完成真正的业务处理,然后将响应结果发给 Handler 进行处理;Handler 收到响应结果后通过 send 将响应结果返回给 Client;
- 一个 MainReactor 可以对应多个 SubReactor,即一个 MainReactor 线程可以对应多个 SubReactor 线程。
b. 优缺点分析
优点:
- MainReactor 线程与 SubReactor 线程的数据交互简单职责明确,MainReactor 线程只需要接收新连接,SubReactor 线程完成后续的业务处理。
- MainReactor 线程与 SubReactor 线程的数据交互简单, MainReactor 线程只需要把新连接传给 SubReactor 线程,SubReactor 线程无需返回数据。
- 多个 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