相信很多人知道石中剑这个典故,在此典故中,天命注定的亚瑟很容易的就拔出了这把石中剑,但是由于资历不被其他人认可,所以他颇费了一番周折才成为了真正意义上的英格兰全境之王,亚瑟王。
说道这把剑,剑身上铭刻着这样一句话: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。
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
石中剑 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 class DataSynchronizer */ #ifndef _Included_DataSynchronizer #define _Included_DataSynchronizer #ifdef __cplusplus extern "C" { #endif /* * Class: DataSynchronizer * Method: syncData * Signature: (Ljava/lang/String;)Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData (JNIEnv *, jobject, jstring); #ifdef __cplusplus } #endif #endif
其中 JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData 方法,就是给我们生成的本地 C 语言方法,我们这里只需要创建一个 C 语言文件,名称为 DataSynchronizer.c。
将此头文件加载进来,实现此方法即可:
#include <jni.h> #include <stdio.h> #include "DataSynchronizer.h" JNIEXPORT jstring JNICALL Java_DataSynchronizer_syncData(JNIEnv *env, jobject obj, jstring str) { // Step 1: Convert the JNI String (jstring) into C-String (char*) const char *inCStr = (*env)->GetStringUTFChars(env, str, NULL); if (NULL == inCStr) { return NULL; } // Step 2: Perform its intended operations printf("In C, the received string is: %s\n", inCStr); (*env)->ReleaseStringUTFChars(env, str, inCStr); // release resources // Prompt user for a C-string char outCStr[128]; printf("Enter a String: "); scanf("%s", outCStr); // Step 3: Convert the C-string (char*) into JNI String (jstring) and return return (*env)->NewStringUTF(env, outCStr); }
其中需要注意的是,JNIEnv* 变量,实际上指的是当前的 JNI 环境。而 jobject 变量则类似 Java 中的 this 关键字。
jstring 则是 C 语言层面上的字符串,相当于 Java 中的 String。整体对应如下:
最后,我们来编译一下:
gcc -fPIC -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" -shared -o libsynchronizer.so DataSynchronizer.c
编译完毕后,可以看到当前目录下又多了一个 libsynchronizer.so 文件(这个文件类似 Windows 上编译后生成的 .dll 类库文件):
此时我们可以运行了,运行如下命令进行运行:
java -Djava.library.path=. DataSynchronizer
得到结果如下:
java -Djava.library.path=. DataSynchronizer In C, the received string is: ProcessStep2 Enter a String: sdfsdf The execute result from C is : sdfsdf
从这里看到,我们正确的通过 java jni 技术,调用了 C 语言底层的逻辑,然后获取到结果,打印了出来。
在 Netty 中,也是利用了 jni 的技术,然后通过调用底层的 C 语言逻辑实现,来实现高效的网络通讯的。
感兴趣的同学可以扒拉下 Netty 源码,在 transport-native-epoll 模块中,就可以见到具体的实现方法了。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
IO 多路复用模型
石中剑,之所以能荡平英格兰全境,自然有其最强悍的地方。
相应的,Netty,则也是不遑多让,之所以能够被各大知名的组件所采用,自然也有其最强悍的地方,而本章节的 IO 多路复用模型,则是其强悍的理由之一。
在说 IO 多路复用模型之前,我们先来大致了解下 Linux 文件系统。
在 Linux 系统中,不论是你的鼠标,键盘,还是打印机,甚至于连接到本机的 socket client 端,都是以文件描述符的形式存在于系统中,诸如此类,等等等等。
所以可以这么说,一切皆文件。来看一下系统定义的文件描述符说明:
从上面的列表可以看到,文件描述符 0,1,2 都已经被系统占用了,当系统启动的时候,这三个描述符就存在了。
其中 0 代表标准输入,1 代表标准输出,2 代表错误输出。当我们创建新的文件描述符的时候,就会在 2 的基础上进行递增。
可以这么说,文件描述符是为了管理被打开的文件而创建的系统索引,他代表了文件的身份 ID。对标 Windows 的话,你可以认为和句柄类似,这样就更容易理解一些。
由于网上对 Linux 文件这块的原理描述的文章已经非常多了,所以这里我不再做过多的赘述,感兴趣的同学可以从 Wikipedia 翻阅一下。
由于这块内容比较复杂,不属于本文普及的内容,建议读者另行自研。