作者:星巴刻
一、Netty 内核组
Netty 运行时包含了多个内核。在服务端程序中,需要分别创建 parent 和 child 两种内核: 1 个 parent 内核和 16 个 child 内核( 8 核 CPU系统下的默认数)。为简便起见,以下简单以 16 来代替实际的 child 内核数。因此,用如下大图来概括 Netty 内核组:中间是 17 个内核组成的内核组,左边是操作系统,右边是应用程序:
每一个 Channel 都必须属于且仅属于某一个内核。系统中代表服务端侦听端口的 NioServerSocketChannel 属于 parent 。代表不同客户端连接的 NioSocketChannel 属于 16 个child 中的一个。当 Channel 创建后,Netty 需要安排一个内核来负责它,这个过程称为 register (注册)。注册过程就是调用 NioEventLoopGroup.next() 方法返回一个 NioEventLoop,调用 NioEventLoop 的 register(channel) 方法完成。
二、端口侦听内核
1、初始化
端口侦听内核的创建与初始化属于服务端整体初始化的一部分。这一部分可通过 ServerBootstrap 完成。对照上图,可以清晰地看出,初始化工作主要要构建如下对象,并把它们「串在一起」:
1. 创建一个 NioServerSocketChannel ,用于表示用于接收接受客户端连接的端口
2. 打开一个 Selector,用于发现 NioServerSocketChannel 上有新的客户端连接
3. 创建一个 NioEventLoop 线程以及内部队列,让执行端口绑定、客户端连接到来等程序在这个线程执行
4. 创建一个 ServerBootstrapAcceptor ,接受新来的客户端连接,并初始化后续处理它的对象。
应用程序创建 NioEventLoopGroup 时,NioEventLoopGroup 内部将根据指定的参数自动创建 NioEventLoop 实例。NioEventLoop 随之把其内部线程、队列创建起来,并把打开一个新的 Selector 选择器。这样,内核线程、内部队列、Selector 选择器就天然地属于这个 NioEventLoop 了。
应用程序调用 ServerBootstrap.bind() 方法时,ServerBootstrap 将创建 NioServerSocketChannel 对象及其 ServerBootstrapAcceptor 实例放入 NioServerSocketChannel 的 pipeline 中去。随后将创建的 NioServerSocketChannel 注册到 NioEventLoop 中,由 NioEventLoop 在其内部线程中执行将 NioServerSocketChannel 注册到 Selector 选择器的代码:
至此,端口侦听内核的所有对象都创建完毕,内部对象已经关联起来只差把内核绑定到操作系统中。
2、注册 OP_ACCEPT
侦听内核为了能够感知有新的客户端到来,必须注册对 OP_ACCEPT 事件的兴趣,这个工作在上面的初始化中完成,这里单独列出来说明。在 NioServerSocketChannel 注册到内核工作完成后, DefaultPipeline.channelActive 方法除了通知 channel 已经打开,紧接着马上调用 channel.read() ,在 Netty 中,channel.read 不是真正要去从系统缓冲区读取信息,而是表示要注册一个读取事件。因此,channel.read() 的调用通过 pipeline 后,最终将调用到 channel 自身的 doBeginRead() 方法,将 selectionKey 的 interestOps 属性增加 OP_ACCEPT 值。相关源代码如下:
3、端口绑定
应用程序调用 ServerBootstrap.bind() 完成相关的初始化工作后,最后就是将整个内核和操作系统关联起来,也就是真正将 NioServerSocketChannel 绑定到指定的端口上。类似 register,将 NioServerSocketChannel bind 到操作系统上,需要调用 Java Nio 的 ServerSocketChannel 的 bind 方法,这个工作在 Netty 内核下,也将在 NioEventLoop 内部线程来实际执行:
至此,Netty 已经可以接收客户端连接了。
4、接受连接
对照《端口侦听内核图》,当有新的客户端连接到来时,NioEventLoop 调用选择器选择当前发生的 I/O 事件时,将得到含有 OP_ACCEPT 事件的 selectionKey。NioEventLoop 的 processSelectedKey 方法一一处理这些 I/O 事件,对于 OP_ACCEPT 事件, NioServerSocketChannel 的 doReadMessages 方法将封装出一个 NioSocketChannel:
这个 NioSocketChannel 对象将被之前初始化时创建到 pipeline 中的 ServerBootstrapAcceptor 获得,在里面将新的客户端连接安排到某个 child 内核实例中:
至此,就可以进行客户端连接的读写了。
三、连接的读写
客户端和服务端之间连接上的信息读写以及处理,在 Netty 中使用如下统一的内核来完成。在服务端程序中,由于多了一个侦听端口的组,此内核在服务端中归为 child 组;但在客户端中,就只有这个组,此时它归为客户端中的 parent 组。这样的分组稍微拗口,我们完全可以简单地直接称为它「端口读写内核」,以区别服务端程序特有的「端口侦听内核」。
如前所述,一般地,服务端程序中会有 16 个连接读写内核,典型的客户端通常只有 1 个。这主要是因为,客户端往往只和服务端建立 1 个或少数几个连接,而服务端则要同时维护数量庞大的客户端连接。好在,1 个或多个,对 Netty 来说其内核架构是统一的,我们可以统一来理解,不用分开看。
端口读写内核中,一个内核负责多个 NioSocketChannel 连接,这些连接注册到选择器中,以便通过选择器发现该 channel 的 I/O事件,其中 OP_READ 是最关键的 I/O 事件。NioEventLoop 的内部线程调用选择器进行选择,当注册到选择器中的 NioSocketChannel 有新的 OP_READ 等 I/O 事件时,完成底层操作后(比如将信息读入 ByteBuf),NioEventLoop 将调用和该 channel 一一对应的 ChannelPipeline 中的 ChannelInboundHandler 的 channelRead 等方法进行处理,最终使得最右边的应用程序逻辑得到执行。
每个 NioSocketChannel 都有自己的 ChannelPipeline 对象。对照上面的内核图中 pipeline 的部分,左边是它的 head,右边是它的 tail。每个 ChannelPipeline 可以简单地看做有 2 行,上面行是处理来自内核发出的事件(简称处理 InboundEvent ),底下行处理来自应用程序发出的动作(简称处理 OutboundEvent )。每一行都可以包含不限制个数的 ChannelHandler 模块。
Netty 内核是在其内核线程中调用 ChannelPipleline 的方法提交处理 InboundEvent 或 OutboundEvent,但并不意味着 ChannelPipeline 中的 ChannelHandler 的 channelRead 等方法一定是在 Netty 的内核线程中执行的。这主要 bootstrap 中,ChannelInitializer.initChannel 方法中是如何调用 pipeline 的,以调用 addLast 为例子,如果调用的是 addLast(EventExecutorGroup, ChannelHandler...handlers),即在第 1 个参数指定了一个 EventExecutorGroup,那么 handlers 中的方法将由这个 EventExecutorGroup 提供的一个 EventExecutor 执行,并且之后这个 handlers 的执行一直都由这个 EventExecutor 执行,不再在 Netty 的内核线程了!这个特性的使用需要精心去了解、适时使用,它对性能有重大帮助或影响。
四、总结
虽然 Netty 为网络开发提供了高性能的能力,以及简便的开发框架和各种开箱套件。但要写出良好的 Netty 程序,花点时间看下 Netty 的要点还是值得的。如果只是模模糊糊地使用 Netty 也总能被坑。
本文是市面上 第一个提出 Netty 内核 概念的文章,希望借此有助于理解 Netty 的核心要点。Netty 的要点在其内核体现了 Reactor 编码架构,并根据实际需要进行了扩展。
以服务端程序为例,一个应用程序会包含一个 端口侦听内核 以及 16 个连接读写内核(8 核 CPU下的默认设置)。这些内核具有同构性。内核包含一个内部线程和队列。代表服务端端口的 NioServerSocketChannel 或者代表客户端的 NioSocketChannel 必须选择注册到某一个内核中,内核通过选择器发现新的 I/O 事件的到来,进行初步加工,然后交给各自 channel 对应的生产线去 pipeline 处理。应用程序也会主动发起一些工作,这些被称为 Outbound 事件,比如往 channel 写入信息或者关闭 channel。这些 Outbound 事件也会由经过 pipleline ,最终再进入内核处理。
ChannelPipeline 处理 Inbound 由内核发起,处理 Outbound 事件由应用程序发起,但是 pipeline 中的每个处理器在处理事件时,都可以事先通过 EventExecutorGroup 获得的一个 EventExecutor 执行。对于那些耗时的工作,比如调用数据库、远程服务的处理模块,设置一个独立于内核线程的 EventExecutorGroup 是有绝对必要的。
2017-11-22