相信很多人知道石中剑这个典故,在此典故中,天命注定的亚瑟很容易的就拔出了这把石中剑,但是由于资历不被其他人认可,所以他颇费了一番周折才成为了真正意义上的英格兰全境之王,亚瑟王。
说道这把剑,剑身上铭刻着这样一句话:ONLY THE KING CAN TAKE THE SWORD FROM THE STONE。
虽然典故中的 the king 是指英明之主亚瑟王,但是在本章中,这个 king 就是读者自己。
我们今天不仅要从百万并发基石上拔出这把 epoll 之剑,也就是 Netty,而且要利用这把剑大杀四方,一如当年的亚瑟王凭借此剑统一了英格兰全境一样。
说到石中剑 Netty,我们知道他极其强悍的性能以及纯异步模型,释放出了极强的生产力,内置的各种编解码编排,心跳包检测,粘包拆包处理等,高效且易于使用,以至于很多耳熟能详的组件都在使用,比如 Hadoop,Dubbo 等。
但是他是如何做到这些的呢?本章将会以庖丁解牛的方式,一步一步的来拔出此剑。
Netty 的异步模型
说起 Netty 的异步模型,我相信大多数人,只要是写过服务端的话,都是耳熟能详的,bossGroup 和 workerGroup 被 ServerBootstrap 所驱动,用起来简直是如虎添翼。
再加上各种配置化的 handler 加持,组装起来也是行云流水,俯拾即是。但是,任何一个好的架构,都不是一蹴而就实现的,那她经历了怎样的心路历程呢?
①经典的多线程模型
此模型中,服务端起来后,客户端连接到服务端,服务端会为每个客户端开启一个线程来进行后续的读写操作。
客户端少的时候,整体性能和功能还是可以的,但是如果客户端非常多的时候,线程的创建将会导致内存的急剧飙升从而导致服务端的性能下降,严重者会导致新客户端连接不上来,更有甚者,服务器直接宕机。
此模型虽然简单,但是由于其简单粗暴,所以难堪大用,建议在写服务端的时候,要彻底的避免此种写法。
②经典的 Reactor 模型
由于多线程模型难堪大用,所以更好的模型一直在研究之中,Reactor 模型,作为天选之子,也被引入了进来,由于其强大的基于事件处理的特性,使得其成为异步模型的不二之选。
Reactor 模型由于是基于事件处理的,所以一旦有事件被触发,将会派发到对应的 event handler 中进行处理。
所以在此模型中,有两个最重要的参与者,列举如下:
-
Reactor:主要用来将 IO 事件派发到相对应的 handler 中,可以将其想象为打电话时候的分发总机,你先打电话到总机号码,然后通过总机,你可以分拨到各个分机号码。
-
Handlers:主要用来处理 IO 事件相关的具体业务,可以将其想象为拨通分机号码后,实际上为你处理事件的员工。
上图为 Reactor 模型的描述图,具体来说一下:
Initiation Dispatcher 其实扮演的就是 Reactor 的角色,主要进行 Event Demultiplexer,即事件派发。
而其内部一般都有一个 Acceptor,用于通过对系统资源的操纵来获取资源句柄,然后交由 Reactor,通过 handle_events 方法派发至具体的 EventHandler 的。
Synchronous Event Demultiplexer 其实就是 Acceptor 的角色,此角色内部通过调用系统的方法来进行资源操作。
比如说,假如客户端连接上来,那么将会获得当前连接,假如需要删除文件,那么将会获得当前待操作的文件句柄等等。
这些句柄实际上是要返回给 Reactor 的,然后经由 Reactor 派发下放给具体的 EventHandler。
Event Handler 这里,其实就是具体的事件操作了。其内部针对不同的业务逻辑,拥有不同的操作方法。
比如说,鉴权 EventHandler 会检测传入的连接,验证其是否在白名单,心跳包 EventHanler 会检测管道是否空闲。
业务 EventHandler 会进行具体的业务处理,编解码 EventHandler 会对当前连接传输的内容进行编码解码操作等等。
由于 Netty 是 Reactor 模型的具体实现,所以在编码的时候,我们可以非常清楚明白的理解 Reactor 的具体使用方式,这里暂时不讲,后面会提到。
由于 Doug Lea 写过一篇关于 NIO 的文章,整体总结的极好,所以这里我们就结合他的文章来详细分析一下 Reactor 模型的演化过程。
上图模型为单线程 Reator 模型,Reactor 模型会利用给定的 selectionKeys 进行派发操作,派发到给定的 handler。
之后当有客户端连接上来的时候,acceptor 会进行 accept 接收操作,之后将接收到的连接和之前派发的 handler 进行组合并启动。
上图模型为池化 Reactor 模型,此模型将读操作和写操作解耦了出来,当有数据过来的时候,将 handler 的系列操作扔到线程池中来进行,极大的提到了整体的吞吐量和处理速度。
上图模型为多 Reactor 模型,此模型中,将原本单个 Reactor 一分为二,分别为 mainReactor 和 subReactor。
其中 mainReactor 主要进行客户端连接方面的处理,客户端 accept 后发送给 subReactor 进行后续处理处理。
这种模型的好处就是整体职责更加明确,同时对于多 CPU 的机器,系统资源的利用更加高一些。
从 Netty 写的 server 端,就可以看出,boss worker group 对应的正是主副 Reactor。
之后 ServerBootstrap 进行 Reactor 的创建操作,里面的 group,channel,option 等进行初始化操作。
而设置的 childHandler 则是具体的业务操作,其底层的事件分发器则通过调用 Linux 系统级接口 epoll 来实现连接并将其传给 Reactor。
石中剑 Netty 强悍的原理(JNI)
Netty 之剑之所以锋利,不仅仅因为其纯异步的编排模型,避免了各种阻塞式的操作,同时其内部各种设计精良的组件,终成一统。
且不说让人眼前一亮的缓冲池设计,读写标随心而动,摒弃了繁冗复杂的边界检测,用起来着实舒服之极。
原生的流控和高低水位设计,让流速控制真的是随心所欲,铸就了一道相当坚固的护城河。
齐全的粘包拆包处理方式,让每一笔数据都能够清晰明了;而高效的空闲检测机制,则让心跳包和断线重连等设计方案变得如此俯拾即是。
上层的设计如此优秀,其性能又怎能甘居下风。由于底层通讯方式完全是 C 语言编写,然后利用 JNI 机制进行处理,所以整体的性能可以说是达到了原生 C 语言性能的强悍程度。
说道 JNI,这里我觉得有必要详细说一下,他是我们利用 Java 直接调用 C 语言原生代码的关键。
JNI,全称为Java Native Interface,翻译过来就是 Java 本地接口,他是 Java 调用 C 语言的一套规范。具体来看看怎么做的吧。
步骤一,先来写一个简单的 Java 调用函数:
/**
* @author shichaoyang
* @Description: 数据同步器
* @date 2020-10-14 19:41
*/
public class DataSynchronizer {
/**
* 加载本地底层C实现库
*/
static {
System.loadLibrary("synchronizer");
}
/**
* 底层数据同步方法
*/
private native String syncData(String status);
/**
* 程序启动,调用底层数据同步方法
*
* @param args
*/
public static void main(String... args) {
String rst = new DataSynchronizer().syncData("ProcessStep2");
System.out.println("The execute result from C is : " + rst);
}
}
可以看出,是一个非常简单的 Java 类,此类中,syncData 方法前面带了 native 修饰,代表此方法最终将会调用底层 C 语言实现。main 方法是启动类,将 C 语言执行的结果接收并打印出来。
然后,打开我们的 Linux 环境,这里由于我用的是 linux mint,依次执行如下命令来设置环境:
执行apt install default-jdk 安装java环境,安装完毕。
通过update-alternatives --list java 获取java安装路径,这里为:/usr/lib/jvm/java-11-openjdk-amd64
设置java环境变量 export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
环境设置完毕之后,就可以开始进行下一步了。
步骤二,编译,首先,进入到代码 DataSynchronizer.c 所在的目录,然后运行如下命令来编译 Java 源码:
javac -h . DataSynchronizer.java
编译完毕之后,可以看到当前目录出现了如下几个文件:
其中 DataSynchronizer.h 是生成的头文件,这个文件尽量不要修改,整体内容如下:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for