Loading

[04] 模块组件

1. Bootstrap

Bootstrap 意思是引导,一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件。有两个启动器,分别应用在服务器和客户端:(1)Bootstrap 是客户端程序的启动引导类;(2)ServerBootstrap 是服务端启动引导类。

2. Channel&Selector

在 Netty 中,Channel 是一个 Socket 连接的抽象,它为用户提供了关于底层 Socket 状态(是否是连接还是断开)以及对 Socket 的读写等操作。

每当 Netty 建立了一个连接后,都会有一个对应的 Channel 实例。Channel 为用户提供:

  • 当前网络连接的通道的状态(例如是否打开?是否已连接?)
    -- 是否打开: true 表示可用,false 表示已经关闭(不可用)
    boolean isOpen();
    -- 是否注册到一个EventLoop
    boolean isRegistered();
    -- 是否激活: ServerSocketChannel 表示已经绑定到端口; SocketChannel 表示 Channel 可用且已经连接到对端
    boolean isActive();
    -- 查询写操作是否可以立即被 IO 线程处理
    boolean isWritable();
    
  • 网络连接的配置参数(例如接收缓冲区大小)
  • 提供异步的网络 I/O 操作(如建立连接、读写、绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成(于是 Netty 基于 Jdk 原生的 Future 进行了封装,读写操作会返回 ChannelFuture 对象,实现自动通知 IO 操作已完成);
  • 调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以在 I/O 操作成功、失败或取消时回调通知调用方;
  • 支持关联 I/O 操作与对应的处理程序;
  • 不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应,下面是一些常用的 Channel 类型(这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO):
    NioSocketChannel            异步的客户端 TCP Socket 连接
    NioServerSocketChannel      异步的服务器端 TCP Socket 连接
    NioDatagramChannel          异步的 UDP 连接
    NioSctpChannel              异步的客户端 Sctp 连接
    NioSctpServerChannel        异步的 Sctp 服务器端连接
    OioSocketChannel            同步的客户端 TCP Socket 连接
    OioServerSocketChannel      同步的服务器端 TCP Socket 连接
    OioDatagramChannel          同步的 UDP 连接
    OioSctpChannel              同步的 Sctp 服务器端连接
    OioSctpServerChannel        同步的客户端 TCP Socket 连接
    

父子 Channel:服务器连接监听的 Channel 也叫 Parent Channel,对应于每一个 Socket 连接的 Channel 也叫 Child Channel。

// 创建客户端 channel 时,会把服务端的 Channel 设置成自己的 parent
客户端的 channel.parent() = 服务端的 channel;
服务端的 channel.parent() = null;

Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector,一个线程可以监听多个连接的 Channel 事件。

当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(select)这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读、可写、网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel。

3. EventLoop

3.1 简述

在 Netty 中,每一个 Channel 绑定了一个 Thread 线程,而这一个 Thread 是被封装到一个 EventLoop 中的。反过来说,EventLoop 相当于一个处理线程,是 Netty 接收请求和处理 IO 请求的线程。

EventLoop 的主要作用实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作(读写)的处理。Channel 为 Netty 网络操作(读写等操作)抽象类,EventLoop 负责处理注册到其上的 Channel 的 I/O 操作,两者配合进行 I/O 操作。

EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护着一个 Selector 实例。

下图是 Netty NIO 模型对应的 EventLoop 模型。通过这个图可以将 EventLoopGroup、EventLoop、 Channel 三者联系起来。

EventLoopGroup 包含多个 EventLoop(每一个 EventLoop 通常内部包含一个线程),它管理着所有的 EventLoop 的生命周期。并且,EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。

EventLoopGroup 提供 next() 接口,可以从组里面按照一定规则获取其中一个 EventLoop 来处理任务。在 Netty 服务器端编程中,我们一般都需要提供两个 EventLoopGroup:BossEventLoopGroup 和 WorkerEventLoopGroup。

  • BossEventLoopGroup 通常只包含一个 EventLoop(即单线程),EventLoop 维护着一个注册了 ServerSocketChannel 的 Selector 实例。BossEventLoop 不断轮询 Selector 将连接事件(OP_ACCEPT)分离出来,然后将接收到的 SocketChannel 交给 WorkerEventLoopGroup;
  • WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop 来将这个 SocketChannel 注册到其维护的 Selector 并对其后续的 IO 事件进行处理。

3.2 NioEventLoop

NioEventLoop 中维护了一个线程和任务队列,支持异步提交执行任务,线程启动时会调用 NioEventLoop#run 方法,执行 I/O 任务和非 I/O 任务:

  • 【I/O 任务】即 selectionKey 中 ready 的事件,如 accept、connect、read、write 等,由 processSelectedKeys 方法触发;
  • 【非 I/O 任务】添加到 taskQueue 中的任务,如 register0、bind0 等任务,由 runAllTasks 方法触发。

两种任务的执行时间比由变量 ioRatio 控制,默认为 50,则表示允许非 IO 任务执行的时间与 IO 任务的执行时间相等。

Server/Client 端 NioEventLoop 处理的事件:

  • Server 端 NioEventLoop 处理的事件
  • Client 端 NioEventLoop 处理的事件

小结:

  • 一个 NioEventLoopGroup 下包含多个 NioEventLoop;
  • 每个 NioEventLoop 中包含有一个 Selector、一个 taskQueue、一个 delayedTaskQueue;
  • 每个 NioEventLoop 的 Selector 上可以注册监听多个 AbstractNioChannel;
  • 每个 AbstractNioChannel 只会绑定在唯一的 NioEventLoop 上;
  • 每个 AbstractNioChannel 都绑定一个自己的 DefaultChannelPipeline。

3.3 NioEventLoopGroup

EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护着一个 Selector 实例。

EventLoopGroup 提供 next 接口,可以从组里面按照一定规则获取其中一个 EventLoop 来处理任务。在 Netty 服务器端编程中,我们一般都需要提供两个 EventLoopGroup:BossEventLoopGroup、WorkerEventLoopGroup。

通常一个服务端口即一个 ServerSocketChannel 对应一个 Selector 和一个 EventLoop 线程。BossEventLoop 负责接收客户端的连接并将 SocketChannel 交给 WorkerEventLoopGroup 来进行 IO 处理。

NioEventLoopGroup 主要管理 EventLoop 的生命周期,可以理解为一个线程池,内部维护了一组线程,每个线程(NioEventLoop)负责处理多个 Channel 上的事件,而一个 Channel 只对应于一个线程。

3.4 线程执行过程

a. 轮询监听的 IO 事件

(1)Netty 的轮询注册机制

Netty 将 AbstractNioChannel 内部的 JDK 类 SelectableChannel 对象注册到 NioEventLoopGroup 中的 JDK 类 Selector 对象上去,并且将 AbstractNioChannel 作为 SelectableChannel 对象的一个 attachment 附属上。

这样在 Selector 轮询到某个 SelectableChannel 有 IO 事件发生时,就可以直接取出 IO 事件对应的 AbstractNioChannel 进行后续操作。

(2)循环执行阻塞 selector.select(timeoutMillis) 操作直到以下条件产生

  • 轮询到了 IO 事件 selectedKey != 0
  • oldWakenUp 参数为 true
  • 任务队列里面有待处理任务 hasTasks()
  • 第一个定时任务即将要被执行 hasScheduledTasks()
  • 用户主动唤醒 wakenUp.get() == true

(3)解决 JDK 的 NIO epoll bug

该 bug 会导致 Selector 一直空轮询,最终导致 CPU 100%!

在每次 selector.select(timeoutMillis) 后,如果没有监听到就绪 IO 事件,会记录此次 select 的耗时。如果耗时不足 timeoutMillis,说明 select 操作没有阻塞那么长时间,可能触发了空轮询,进行一次计数。

计数累积超过阈值(默认 512)后,开始进行 Selector 重建:

  1. 拿到有效的 selectionKey 集合;
  2. 取消该 selectionKey 在旧的 selector 上的事件注册;
  3. 将该 selectionKey 对应的 Channel 注册到新的 selector 上,生成新的 selectionKey;
  4. 重新绑定 Channel 和新的 selectionKey 的关系

(4)Netty 优化了 sun.nio.ch.SelectorImpl 类中的 selectedKeys 和 publicSelectedKeys 这两个 field 的实现

Netty 通过反射将这两个 filed 替换掉,替换后的 field 采用数组实现。

这样每次在轮询到 NIO 事件的时候,Netty 只需要 O(1) 的时间复杂度就能将 SelectionKey 塞到 set 中去,而 JDK 原有 field 底层使用的 HashSet 需要 O(lgN) 的时间复杂度。

b. 处理 IO 事件

(1)对于 Boss NioEventLoop 来说,轮询到的是基本上是连接事件(OP_ACCEPT)

  • socketChannel = ch.accept();
  • 将 socketChannel 绑定到 Worker NioEventLoop 上;
  • socketChannel 在 Worker NioEventLoop 上创建 register0 任务;
  • pipeline.fireChannelReadComplete();

(2)对于 Worker NioEventLoop 来说,轮询到的基本上是 IO 读写事件(以 OP_READ 为例)

  • ByteBuffer.allocateDirect(capacity);
  • socketChannel.read(dst);
  • pipeline.fireChannelRead();
  • pipeline.fireChannelReadComplete();

c. 处理任务队列

(1)处理用户产生的普通任务

NioEventLoop 中的 Queue<Runnable> taskQueue 被用来承载用户产生的普通 Task。

taskQueue 被实现为 Netty 的 mpscQueue,即多生产者单消费者队列。Netty 使用该队列将外部用户线程产生的 Task 聚集,并在 Reactor 线程内部用单线程的方式串行执行队列中的 Task。

当用户在非 IO 线程调用 Channel 的各种方法执行 Channel 相关的操作时,比如 channel.write()、channel.flush() 等,Netty 会将相关操作封装成一个 Task 并放入 taskQueue 中,保证相关操作在 IO 线程中串行执行。

(2)处理用户产生的定时任务

NioEventLoop 中的 Queue<ScheduledFutureTask<?>> delayedTaskQueue = new PriorityQueue() 被用来承载用户产生的定时 Task。

当用户在非 IO 线程需要产生定时操作时,Netty 将用户的定时操作封装成 ScheduledFutureTask,即一个 Netty 内部的定时 Task,并将定时 Task 放入 delayedTaskQueue 中等待对应 Channel 的 IO 线程串行执行。

为了解决多线程并发写入 delayedTaskQueue 的问题,Netty 将添加 ScheduledFutureTask 到 delayedTaskQueue 中的操作封装成普通 Task,放入 taskQueue 中,通过 NioEventLoop 的 IO 线程对 delayedTaskQueue 进行单线程写操作。

(3)处理任务队列的逻辑

  • 将已到期的定时 Task 从 delayedTaskQueue 中转移到 taskQueue 中;
  • 计算本次循环执行的截止时间
  • 循环执行 taskQueue 中的任务,每隔 64 个任务检查一下是否已过截止时间,直到 taskQueue 中任务全部执行完或者超过执行截止时间。

4. Pipeline

4.1 ChannelPipline

保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作。

ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个 ChannelHandler 如何相互交互。

下图引用 Netty 的 Javadoc4.1 中 ChannelPipline 的说明,描述了 ChannelPipeline 中 ChannelHandler 通常如何处理 I/O 事件。

I/O 事件由 ChannelInboundHandler 或 ChannelOutboundHandler 处理,并通过调用 ChannelHandlerContext 中定义的事件传播方法(例如 ChannelHandlerContext.fireChannelRead(Object)ChannelOutboundInvoker.write(Object))转发到其最近的处理程序。

                                                  I/O Request
                                     via Channel or ChannelHandlerContext
                                                      |
  +---------------------------------------------------+---------------+
  |                           ChannelPipeline         |               |
  |                                                  \|/              |
  |    +---------------------+            +-----------+----------+    |
  |    | Inbound Handler  N  |            | Outbound Handler  1  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  |               |                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler N-1 |            | Outbound Handler  2  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  .               |
  |               .                                   .               |
  | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
  |        [ method call]                       [method call]         |
  |               .                                   .               |
  |               .                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler  2  |            | Outbound Handler M-1 |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  |               |                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler  1  |            | Outbound Handler  M  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  +---------------+-----------------------------------+---------------+
                  |                                  \|/
  +---------------+-----------------------------------+---------------+
  |               |                                   |               |
  |       [ Socket.read() ]                    [ Socket.write() ]     |
  |                                                                   |
  |        Netty Internal I/O Threads (Transport Implementation)      |
  +-------------------------------------------------------------------+
  • 入站事件由自下而上方向的入站处理程序处理,如左侧所示。入站 Handler 处理程序通常处理由图底部的 I/O 线程生成的入站数据。通常通过实际输入操作(例如 SocketChannel.read(ByteBuffer))从远程读取入站数据;
  • 出站事件则是由上至下处理,如右侧所示。出站 Handler 处理程序通常会生成或转换出站传输,例如 write 请求。I/O 线程通常执行实际的输出操作,例如 SocketChannel.write(ByteBuffer)

在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下:

  • 一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler;
  • 入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的 handler 互不干扰。

4.2 ChannelHandler

ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。

ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间可以继承它的子类:

  • ChannelInboundHandler 用于处理入站 I/O 事件
  • ChannelOutboundHandler 用于处理出站 I/O 操作

或者使用以下适配器类:

  • ChannelInboundHandlerAdapter 用于处理入站 I/O 事件
  • ChannelOutboundHandlerAdapter 用于处理出站 I/O 操作
  • ChannelDuplexHandler 用于处理入站和出站事件

NettyDoc 上罗列的实现类:

4.3 ChannelHandlerContext

保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象。

即 ChannelHandlerContext 中包含一个具体的事件处理器 ChannelHandler,同时 ChannelHandlerContext 中也绑定了对应的 Pipeline 和 Channel 的信息,方便对其进行调用。

5. ByteBuf

5.1 ByteBuf 结构

  1. ByteBuf 是一个字节容器,容器里面的数据分为 3 部分:(1)已经丢弃的字节,这部分数据是无效的;(2)可读字节,这部分数据是 ByteBuf 的主体,从 ByteBuf 里读取的数据都来自这一部分;(3)可写字节,所有写到 ByteBuf 的数据都会写到这一段。后面的虚线部分表示该 ByteBuf最多还能扩容多少容量;
  2. 以上三部分内容是被两个指针划分出来的,从左到右依次是读指针(readerIndex)和写指针(writerIndex)。还有一个变量 capacity,表示 ByteBuf 底层内存的总容量;
  3. 从 ByteBuf 中每读取一字节,readerIndex 自增 1,ByteBuf 里总共有 writerIndex-readerIndex 字节可读。由此可以知道,当 readerIndex 与 writerIndex 相等的时候,ByteBuf 不可读;
  4. 写数据是从 writerIndex 指向的部分开始写的,每写 1 字节,writerIndex 自增 1,直到增到 capacity。这个时候,表示 ByteBuf 已经不可写;
  5. ByteBuf 里其实还有一个参数 maxCapacity。当向 ByteBuf 写数据的时候,如果容量不足,则可以进行扩容,直到 capacity 扩容到 maxCapacity,超过 maxCapacity 就会报错。

Netty 使用 ByteBuf 这个数据结构可以有效地区分可读数据和可写数据,读写之间相互没有冲突。当然,ByteBuf 只是对二进制数据的抽象,具体底层的实现后面说。在这里,只需要知道 Netty 关于数据读写只认 ByteBuf。

5.2 常用 API

a. 容量 API

capacity()

表示 ByteBuf 底层占用了多少字节的内存(包括丢弃的字节、可读字节、可写字节),不同的底层实现机制有不同的计算方式。

maxCapacity()

表示 ByteBuf 底层最大能够占用多少字节的内存,当向 ByteBuf 中写数据的时候,如果发现容量不足,则进行扩容,直到扩容到 maxCapacity,超过这个数,就抛出异常。

readableBytes()、isReadable()

readableBytes() 表示 ByteBuf 当前可读的字节数,它的值等于 writerIndex-readerIndex,如果两者相等,则不可读,isReadable() 方法返回 false。

writableBytes()、isWritable()、maxWritableBytes()

writableBytes() 表示 ByteBuf 当前可写的字节数,它的值等于 capacity-writerIndex,如果两者相等,则表示不可写,isWritable() 返回 false,但是这个时候,并不代表不能往 ByteBuf 写数据了。如果发现往 ByteBuf 写数据写不进去,Netty 会自动扩容 ByteBuf,直到底层的内存大小为 maxCapacity,而 maxWritableBytes 就表示可写的最大字节数,它的值等于 maxCapacitywriterIndex。

b. 读写指针 API

readerIndex()、readerIndex(int)

前者表示返回当前的读指针 readerIndex,后者表示设置读指针。

writerIndex()、writeIndex(int)

前者表示返回当前的写指针 writerIndex,后者表示设置写指针。

markReaderIndex()、resetReaderIndex()

前者表示把当前的读指针保存起来,后者表示把当前的读指针恢复到之前保存的值。下面两段代码是等价的。

// [1]
int readerIndex = buffer.readerIndex();
// ...
buffer.readerIndex(readerIndex);

// [2]
buffer.markReaderIndex();
// ...
buffer.resetReaderIndex();

多使用 [2] 这种方式,不需要自己定义变量。无论 Buffer 被当作参数传递到哪里,调用 resetReaderIndex() 都可以恢复到之前的状态,在解析自定义协议的数据包时非常常见,推荐大家使用这一对 API。

c. 读写 API

本质上,关于 ByteBuf 的读写都可以看做从指针开始的地方开始读写数据。

writeByte(byte[] src)、buffer.readBytes(byte[] dst)

writeBytes() 表示把字节数组 src 里的数据全部写到 ByteBuf,而 readBytes() 表示 ByteBuf 里的数据全部读取到 dst。这里 dst 字节数组的大小通常等于 readableBytes(),而 src 字节数组大小的长度通常小于 writableBytes()。

writeByte(byte b)、buffer.readByte()

writeByte() 表示往 ByteBuf 中写一字节,而 buffer.readByte() 表示从 ByteBuf 中读取一字节,类似的 API 还有 writeBoolean()、writeChar()、writeShort()、writeInt()、writeLong()、writeFloat()、writeDouble(),以及 readBoolean()、readChar()、...,这里不再赘述。

getBytes()、getByte()、setBytes()、setByte()

get/set 不会改变读写指针,而 read/write 会改变读写指针,这一点在解析数据的时候千万要注意。

release()、retain()

由于 Netty 使用了堆外内存,而堆外内存是不被 JVM 直接管理的。也就是说,申请到的内存无法被垃圾回收器直接回收,所以需要我们手动回收。这有点类似 C 语言里,申请到的内存必须手工释放,否则会造成内存泄露。

Netty 的 ByteBuf 是通过「引用计数」的方式管理的,如果一个 ByteBuf 没有地方被引用到,则需要回收底层内存。在默认情况下,当创建完一个 ByteBuf 时,它的引用为 1,然后每次调用 retain() 方法,它的引用就加 1,release() 方法的原理是将引用计数减一,减完之后如果发现引用计数为 0,则直接回收 ByteBuf 底层的内存。

slice()、duplicate()、copy()

在通常情况下,这三个方法会被放到一起比较,三者的返回值分别是一个新的 ByteBuf 对象。

  1. slice() 方法从原始 ByteBuf 中截取一段,这段数据是从 readerIndex 到 writeIndex 的,同时,返回的新的 ByteBuf 的最大容量 maxCapacity 为原始 ByteBuf 的readableBytes();
  2. duplicate() 方法把整个 ByteBuf 都截取出来,包括所有的数据、指针信息;
  3. slice() 方法与 duplicate() 方法的相同点是:底层内存及引用计数与原始 ByteBuf 共享,也就是说,经过 slice() / duplicate() 方法返回的 ByteBuf 调用 write 系列方法都会影响到原始 ByteBuf,但是它们都维持着与原始 ByteBuf 相同的内存引用计数和不同的读写指针;
  4. slice() 方法与 duplicate 方法的不同点是:slice() 方法只截取从 readerIndex 到 writerIndex 之间的数据,它返回的 ByteBuf 的最大容量被限制到原始 ByteBuf 的readableBytes(),而 duplicate() 方法是把整个 ByteBuf 都与原始 ByteBuf 共享;
  5. slice() / duplicate() 不会复制数据,它们只是通过改变读写指针来改变读写的行为,而最后一个方法 copy() 会直接从原始 ByteBuf 中复制所有的信息,包括读写指针及底层对应的数据,因此,往 copy() 方法返回的 ByteBuf 中写数据不会影响原始 ByteBuf;
  6. slice() / duplicate() 方法不会改变 ByteBuf 的引用计数,所以原始 ByteBuf 调用 release() 方法之后发现引用计数为 0,就开始释放内存,调用这两个方法返回的 ByteBuf 也会被释放,这时候如果再对他们进行读写,就会报错。因此,我们可以通过调用一次 retain() 方法来增加引用,表示它们对应的底层内存多了一次引用,引用计数为 2。在释放内存的时候,需要调用两次 release() 方法,将引用计数降到 0,才会释放内存;
  7. 这三个方法均为护着自己的读写指针,与原始 ByteBuf 的读写指针无关,相互之间不受影响。

retainedSlice()、retainedDuplicate()

在截取内存片段的同时,增加内存的引用计数,分别与下面两段代码等价。

// retainedSlice ~
slice().retain();

// retainedDuplicate() ~
duplicate().retain();

使用 slice() 和 duplicate() 方法的时候,千万要理清内存共享、引用计数共享、读写指针不共享等概念。

一定要记住,只要增加了引用计数(包括 ByteBuf 的创建和手动调用 retain() 方法),就必须调用 release() 方法,防止内存泄漏。

【小结】

  1. 首先分析了 Netty 对二级制数据的抽象 ByteBuf 的结构,本质上他的原理就是,引用了一段内存,这段内存可以是堆内的,也可以是堆外的,然后用引用计数来控制这段内存是否需要被释放。使用读写指针来控制 ByteBuf 的读写,可以理解为是外观模式的一种使用;
  2. 基于读写指针和容量、最大可扩容容量,延伸出一系列读写方法,要注意 read、write 与 get、set 的区别;
  3. 多个 ByteBuf 可以引用同一段内存,通过引用计数来控制内存的释放,遵循谁 retain 谁 release 的原则。

5.3 三种使用模式

  1. 堆缓冲区(HeapByteBuf):内存分配在 JVM 堆,分配和回收速度比较快,可以被 JVM 自动回收,缺点是,如果进行 Socket 的 IO 读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核 Channel 中,性能会有一定程度的下降。由于在堆上被 JVM 管理,在不被使用时可以快速释放。可以通过 ByteBuf.array() 来获取 byte[] 数据。
  2. 直接缓冲区(DirectByteBuf):内存分配的是堆外内存(系统内存),相比堆内存,它的分配和回收速度会慢一些,但是将它写入或从 Socket Channel 中读取时,由于减少了一次内存拷贝,速度比堆内存块。
  3. 复合缓冲区(CompositeByteBuf):顾名思义就是将两个不同的缓冲区从逻辑上合并,让使用更加方便。

Netty 默认使用的是 DirectByteBuf。使用 ByteBuf 分配器来创建:ByteBufAllocator.DEFAULT.ioBuffer(),ioBuffer() 方法会返回适配 IO 读写相关的内存,他会尽可能创建一个直接内存。直接内存可以理解为不受 JVM 堆管理的内存空间,写到 IO 缓冲区的效果更好。

设置 HeapByteBuf 模式,但 ByteBuf 的分配器 ByteBufAllocator 要设置为非池化,否则不能切换到堆缓冲器模式。

// 如果需要使用 HeapByteBuf 模式,则需要进行系统参数的设置。
System.setProperty("io.netty.noUnsafe", "true")

5.4 ByteBuf 分配器

Netty 提供了两种 ByteBufAllocator 的实现,分别是:

  • PooledByteBufAllocator:实现了 ByteBuf 的对象的池化,提高性能减少并最大限度地减少内存碎片,池化思想通过预先申请一块专用内存地址作为内存池进行管理,从而不需要每次都进行分配和释放;
  • UnpooledByteBufAllocator:没有实现对象的池化,每次会生成新的对象实例。

Netty 默认使用了 PooledByteBufAllocator,但可以通过引导类设置非池化模式:

// 引导类中设置非池化模式
bootstrap.childOption(ChannelOption.ALLOCATOR, UnpooledByteBufAllocator.DEFAULT);

// 或者通过系统参数设置
System.setProperty("io.netty.allocator.type", "pooled");
System.setProperty("io.netty.allocator.type", "unpooled");

对于 Pooled 类型的 ByteBuf,不管是 PooledDirectByteBuf 还是 PooledHeapByteBuf 都只能由 Netty 内部自己使用(构造是私有和受保护的),开发者可以使用 Unpooled 类型的 ByteBuf。

Unpooled:Creates a new ByteBuf by allocating new space or by wrapping or copying existing byte arrays, byte buffers and a string.

Netty 提供 Unpooled 工具类创建的 ByteBuf 都是 Unpooled 类型,默认采用的 Allocator 是 Direct 类型;当然用户可以自己选择创建 UnpooledDirectByteBuf 和 UnpooledHeapByteBuf。

5.5 ByteBuf 释放

ByteBuf 如果采用的是「堆缓冲区模式」的话,可以由 GC 回收,但是如果采用的是「直接缓冲区模式」,就不受 GC 的管理,就得手动释放,否则会发生内存泄露,Netty 自身引入了引用计数,提供了 ReferenceCounted 接口,当对象的引用计数 > 0 时要保证对象不被释放,当为 0 时需要被释放。

关于 ByteBuf 的释放,分为手动释放与自动释放:

手动释放,就是在使用完成后,调用 ReferenceCountUtil.release(byteBuf) 进行释放,这种方式的弊端就是一旦忘记释放就可能会造成内存泄露。

自动释放有三种方式,分别是:入站的 TailHandler(TailContext)、继承 SimpleChannelInboundHandler、HeadHandler(HeadContext)的出站释放。

  1. TailContext:Inbound 流水线的末端,如果前面的 handler 都把消息向后传递最终由 TailContext 释放该消息,需要注意的是,如果没有进行向下传递,是不会进行释放操作的。
  2. SimpleChannelInboundHandler:自定义的 InboundHandler 继承自 SimpleChannelInboundHandler,在 SimpleChannelInboundHandler 中自动释放。
  3. HeadContext:Outbound 流水线的末端,出站消息一般是由应用所申请,到达最后一站时,经过一轮复杂的调用,在 flush 完成后终将被 release 掉。

【小结】

对于入站消息:

  • 对原消息不做处理,依次调用 ctx.fireChannelRead(msg) 把原消息往下传,如果能到 TailContext,那不用做什么释放,它会自动释放;
  • 将原消息转化为新的消息并调用 ctx.fireChannelRead(newMsg) 往下传,那需要将原消息 release 掉;
  • 如果已经不再调用 ctx.fireChannelRead(msg) 传递任何消息,需要把原消息 release 掉。

对于出站消息:则无需用户关心,消息最终都会走到 HeadContext,flush 之后会自动释放。

6. Handler 入站/出站

6.1 Pipeline 模型

Netty 的 Pipeline 模型用的是责任链设计模式,当 Boss 线程监控到绑定端口上有 accept 事件,此时会为该 socket 连接实例化 Pipeline,并将 InboundHandler 和 OutboundHandler 按序加载到 Pipeline 中,然后将该 socket 连接(也就是 Channel 对象)挂载到 Selector 上。一个 Selector 对应一个线程,该线程会轮询所有挂载在它身上的 socket 连接有没有 read/write 事件,然后通过线程池去执行 Pipeline 的业务流。 Selector 如何查询哪些 socket 连接有 read/write 事件,主要取决于调用操作系统的哪种 IO 多路复用内核,如果是 select(注意,此处的 select 是指操作系统内核的 select IO 多路复用,不是 Netty 的 Selector 对象),那么将会遍历所有 socket 连接,依次询问是否有 read/write 事件,最终操作系统内核将所有 IO 事件的 socket 连接返回给 Netty 进程,当有很多 socket 连接时,这种方式将会大大降低性能,因为存在大量 socket 连接的遍历和内核内存的拷贝。如果是 epoll,性能将会大幅提升,因为他基于完成端口事件,已经维护好有 IO 事件的 socket 连接列表,Selector 直接取走,无需遍历,也少掉内核内存拷贝带来的性能损耗。

Pipeline 的责任链是通过 ChannelHandlerContext 对象串联的,ChannelHandlerContext 对象里封装了 ChannelHandler 对象,通过 prev 和 next 节点实现双向链表。Pipeline 的首尾节点分别是 head 和 tail,当 Selector 轮询到 socket 有 read 事件时,将会触发 Pipeline 责任链,从 head 开始调起第一个 InboundHandler 的 ChannelRead 事件,接着通过 fire 方法依次触发 Pipeline 上的下一个 ChannelHandler,如下图:

ChannelHandler 分为 InbounHandler 和 OutboundHandler,InboundHandler 用来处理接收消息,OutboundHandler 用来处理发送消息。head 的 ChannelHandler 既是 InboundHandler 又是 OutboundHandler,无论是 read 还是 write 都会经过 head,所以 head 封装了 unsafe 方法,用来操作 socket 的 read/write。tail 的 ChannelHandler 只是 InboundHandler,read 的 Pipleline 处理将会最终到达 tail。

ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter 分别实现了两大子接口的所有功能,在默认情况下会把读/写事件传播到下一个 Handler。

6.2 handler 执行顺序

https://www.cnblogs.com/tianzhiliang/p/11739372.html

这里只说文章的几个结论:

(1)在 InboundHandler 中不触发 fire 方法,后续的 InboundHandler 还能顺序执行吗?

InboundHandler 是通过 fire 事件决定是否要执行下一个 InboundHandler,如果哪个 InboundHandler 没有调用 fire 事件,那么往后的 Pipeline 就断掉了。

(2)InboundHandler 和 OutboundHandler 的执行顺序是什么?

InboundHandler1 → InboundHandler2(有写数据操作) → OutboundHandler1 → OutboundHander2 → OutboundHandler3 → InboundHandler3

所以,我们得到以下几个结论:

  • InboundHandler 是按照 Pipleline 的加载顺序,顺序执行;
  • OutboundHandler 是按照 Pipeline 的加载顺序,逆序执行。

(3)如果把 OutboundHandler 放在 InboundHandler 的后面,OutboundHandler 会执行吗?

由此可见,OutboundHandler 没有执行,为什么呢?

因为 Pipleline 是执行完所有有效的 InboundHandler,再返回执行在最后一个 InboundHandler 之前的 OutboundHandler。

注意,有效的 InboundHandler 是指 fire 事件触达到的 InboundHandler,如果某个 InboundHandler 没有调用 fire 事件,后面的 InboundHandler 都是无效的 InboundHandler。

(4)把其中一个 OutboundHandler 放在最后一个有效的 InboundHandler 之前,看看这唯一的一个 OutboundHandler 是否会执行,其他 OutboundHandler 是否不会执行。

结果:只执行了 OutboundHandler1,其他 OutboundHandler 没有被执行。

所以,我们得到以下几个结论:

  • 有效的 InboundHandler 是指通过 fire 事件能触达到的最后一个 InboundHander;
  • 如果想让所有的 OutboundHandler 都能被执行到,那么必须把 OutboundHandler 放在最后一个有效的 InboundHandler 之前;
  • 推荐的做法是通过 addFirst 加载所有 OutboundHandler,再通过 addLast 加载所有 InboundHandler。

(5)如果其中一个 OutboundHandler 没有执行 write 方法,那么消息会不会发送出去?

我们把 OutboundHandler2 中 ctx.write(msg); 行注掉后再运行,可以看到 OutboundHandler3 并没有被执行到,另外,客户端也没有收到发送的消息。

所以,我们得到以下几个结论:

  • OutboundHandler 是通过 write 方法实现 Pipeline 的串联的;
  • 如果 OutboundHandler 在 Pipeline 的处理链上,其中一个 OutboundHandler 没有调用 write 方法,最终消息将不会发送出去。

(6)ctx.writeAndFlush 的 OutboundHandler 的执行顺序是什么?

我们设定 ChannelHandler 在 Pipeline中 的加载顺序如下:OutboundHandler3 → InboundHandler1 → OutboundHandler2 → InboundHandler2 → OutboundHandler1 → InboundHandler3

在 InboundHander2 中调用 ctx.writeAndFlush:

结果:依次执行了 OutboundHandler2 和 OutboundHandler3,为什么会这样呢?因为 ctx.writeAndFlush 是从当前的 ChannelHandler 开始,向前依次执行 OutboundHandler 的 write 方法,所以分别执行了 OutboundHandler2 和 OutboundHandler3:

OutboundHandler3 → InboundHandler1 → OutboundHandler2 → InboundHandler2 → OutboundHandler1 → InboundHandler3

所以,我们得到如下结论:

  • ctx.writeAndFlush 是从当前 ChannelHandler 开始,逆序向前执行 OutboundHandler;
  • ctx.writeAndFlush 所在 ChannelHandler 后面的 OutboundHandler 将不会被执行。

(7)ctx.channel().writeAndFlush 的 OutboundHandler 的执行顺序是什么?

结果:所有 OutboundHandler 都执行了,由此我们得到结论:

  • ctx.channel().writeAndFlush() 是从最后一个 OutboundHandler 开始,依次逆序向前执行其他 OutboundHandler,即使最后一个 ChannelHandler 是 OutboundHandler,在 InboundHandler 之前,也会执行该 OutbondHandler;
  • 千万不要在 OutboundHandler 的 write 方法里执行 ctx.channel().writeAndFlush,否则就死循环了!

【小结】

  1. InboundHandler 是通过 fire 事件决定是否要执行下一个 InboundHandler,如果哪个 InboundHandler 没有调用 fire 事件,那么往后的 Pipeline 就断掉了。
  2. InboundHandler 是按照 Pipleline 的加载顺序,顺序执行;OutboundHandler 是按照 Pipeline 的加载顺序,逆序执行;
  3. 有效的 InboundHandler 是指通过 fire 事件能触达到的最后一个 InboundHander;如果想让所有的 OutboundHandler 都能被执行到,那么必须把 OutboundHandler 放在最后一个有效的 InboundHandler 之前;
  4. 推荐的做法是通过 addFirst 加载所有 OutboundHandler,再通过 addLast 加载所有 InboundHandler;
  5. OutboundHandler 是通过 write 方法实现 Pipeline 的串联的。如果 OutboundHandler 在 Pipeline 的处理链上,其中一个 OutboundHandler 没有调用 write 方法,最终消息将不会发送出去;
  6. ctx.writeAndFlush 是从当前 ChannelHandler 开始,逆序向前执行 OutboundHandler。ctx.writeAndFlush 所在 ChannelHandler 后面的 OutboundHandler 将不会被执行;
  7. ctx.channel().writeAndFlush 是从最后一个 OutboundHandler 开始,依次逆序向前执行其他 OutboundHandler,即使最后一个 ChannelHandler 是 OutboundHandler,在 InboundHandler 之前,也会执行该 OutbondHandler;
  8. 千万不要在 OutboundHandler 的 write 方法里执行 ctx.channel().writeAndFlush,否则就死循环了!

6.3 writeAndFlush

【结论】ctx 的 writeAndFlush 是从当前 handler 直接发出这个消息,而 channel 的 writeAndFlush 是从整个 Pipline 最后一个 OutHandler 发出。

ctx.writeAndFlush(Unpooled.copiedBuffer("...", CharsetUtil.UTF_8));

如果是 EchoInHandler1 → EchoInHandler2 → EchoOutHandler1 → EchoOutHandler2,那么一开始入站执行了 EchoInHandler1 → EchoInHandler2,因为 do-while 循环跳出,ctx 留在了 EchoInHandler2 的位置,在出站的时候,在 EchoInHandler2 的位置反向遍历,只会遍历 EchoInHandler2 → EchoInHandler1,那么自然就不会去读取 EchoOutHandler1 → EchoOutHandler2 了。

相反,如果是 EchoOutHandler1 → EchoOutHandler2 → EchoInHandler1 → EchoInHandler2 的顺序,一开始入站 ctx 到了 EchoInHandler2 的位置,反向遍历就会经过 EchoInHandler2 → EchoInHandler1 → EchoOutHandler2 → EchoOutHandler1。

ctx.channel().writeAndFlush(Unpooled.copiedBuffer("...", CharsetUtil.UTF_8));

截自:https://blog.csdn.net/FishSeeker/article/details/78447684

posted @ 2022-03-29 22:56  tree6x7  阅读(69)  评论(0编辑  收藏  举报