Netty 是一个 异步 事件驱动 的网络应用框架,用于快速开发高性能、可扩展协议的服务器和客户端
无论是 C++ 还是 Java 编写的网络框架,大多数都是基于 Reactor 模式进行设计和开发,Reactor 模式基于事件驱动,特别适合处理海量的 I/O 事件。
Reactor 单线程模型,指的是所有的 IO 操作都在同一个 NIO 线程上面完成,NIO 线程的职责如下:
- 作为 NIO 服务端,接收客户端的 TCP 连接;
- 作为 NIO 客户端,向服务端发起 TCP 连接;
- 读取通信对端的请求或者应答消息;
- 向通信对端发送消息请求或者应答消息。
由于 Reactor
模式使用的是异步非阻塞 IO
,所有的 IO
操作都不会导致阻塞,理论上一个线程可以独立处理所有 IO
相关的操作。从架构层面看,一个 NIO
线程确实可以完成其承担的职责。例如,通过 Acceptor
类接收客户端的 TCP 连接请求消息,链路建立成功之后,通过 Dispatch
将对应的 ByteBuffer
派发到指定的 Handler
上进行消息解码。用户线程可以通过消息编码通过 NIO
线程将消息发送给客户端。
对于一些小容量应用场景,可以使用单线程模型。但是 对于高负载、大并发的应用场景却不合适。
Rector 多线程模型与单线程模型最大的区别就是有一组 NIO 线程处理 IO 操作,它的原理图如下:
Reactor 多线程模型的特点:
- 有专门一个
NIO
线程Acceptor
线程用于监听服务端,接收客户端的TCP
连接请求; - 网络
IO
操作 - 读、写等由一个NIO
线程池负责,线程池可以采用标准的JDK
线程池实现,它包含一个任务队列和N
个可用的线程,由这些NIO
线程负责消息的读取、解码、编码和发送; - 1 个
NIO
线程可以同时处理 N 条链路,但是 1 个链路只对应 1 个NIO
线程,防止发生并发操作问题。
主从 Reactor
线程模型的特点是:服务端用于接收客户端连接的不再是个 1
个单独的 NIO
线程,而是一个独立的 NIO
线程池。 Acceptor
接收到客户端 TCP
连接请求处理完成后(可能包含接入认证等),将新创建的 SocketChannel
注册到 IO 线程池(sub reactor
线程池)的某个 IO 线程上,由它负责 SocketChannel
的读写和编解码工作。 Acceptor
线程池仅仅只用于客户端的登陆、握手和安全认证,一旦链路建立成功,就将链路注册到后端 subReactor
线程池的 IO
线程上,由 IO
线程负责后续的 IO
操作。
它的工作流程总结如下:
- 从主线程池中随机选择一个
Reactor
线程作为Acceptor
线程,用于绑定监听端口,接收客户端连接; - Acceptor 线程接收客户端连接请求之后创建新的
SocketChannel
,将其注册到主线程池的其它 Reactor 线程上,由其负责接入认证、IP 黑白名单过滤、握手等操作; - 步骤 2 完成之后,业务层的链路正式建立,将
SocketChannel
从主线程池的Reactor
线程的多路复用器上摘除,重新注册到Sub
线程池的线程上,用于处理 I/O 的读写操作。
- 多路复用,并在 NIO 的基础上进行更高层次的抽象
- 事件机制
- 功能强大,预置了多种编解码功能,支持多种主流协议
- 定制能力强,可以通过ChannelHandler对通信框架进行灵活的扩展
- 纯异步:
Reactor
线程模型 - IO 多路复用
- GC 优化:更少的分配内存、池化(Pooling)、复用、选择性的使用
sun.misc.Unsafe
- 更多的硬件相关优化(mechanical sympathy)
- 内存泄漏检测
- "Zero Copy"
Netty 的 Zero-copy 体现在如下几个个方面:
Netty
提供了CompositeByteBuf
类, 它可以将多个ByteBuf
合并为一个逻辑上的ByteBuf
, 避免了各个ByteBuf
之间的拷贝.- 通过
wrap
操作, 我们可以将byte[]
数组、ByteBuf
、ByteBuffer
等包装成一个Netty ByteBuf
对象, 进而避免了拷贝操作. ByteBuf
支持slice
操作, 因此可以将ByteBuf
分解为多个共享同一个存储区域的ByteBuf
, 避免了内存的拷贝.- 通过
FileRegion
包装的FileChannel.tranferTo
实现文件传输, 可以直接将文件缓冲区的数据发送到目标Channel
, 避免了传统通过循环write
方式导致的内存拷贝问题.
- ByteBuf 扩容采用先倍增后步进的方式
在执行网络IO或者文件IO时,如果是使用 DirectBuffer
就会少一次内存拷贝。如果是非 DirectBuffer
,JDK 会先创建一个 DirectBuffer
,再去执行真正的写操作。这是因为,当我们把一个地址通过 JNI
传递给底层的C库的时候,有一个基本的要求,就是这个地址上的内容不能失效。然而,在 GC
管理下的对象是会在 Java
堆中移动的。也就是说,有可能我把一个地址传给底层的 write
,但是这段内存却因为 GC
整理内存而失效了。所以我必须要把待发送的数据放到一个 GC
管不着的地方。这就是调用 native
方法之前,数据一定要在堆外内存的原因。
- 正常情况下,
selector.select()
操作是阻塞的,只有被监听的fd
有读写操作时,才被唤醒 - 但是,在这个
bug
中,没有任何fd
有读写请求,但是select()
操作依旧被唤醒 - 很显然,这种情况下,
selectedKeys()
返回的是个空数组 - 然后按照逻辑执行到
while(true)
处,循环执行,导致死循环。
Netty 解决方案:
long currentTimeNanos = System.nanoTime();
for (;;) {
// 1.定时任务截止事时间快到了,中断本次轮询
...
// 2.轮询过程中发现有任务加入,中断本次轮询
...
// 3.阻塞式select操作
selector.select(timeoutMillis);
// 4.解决jdk的nio bug
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
rebuildSelector();
selector = this.selector;
selector.selectNow();
selectCnt = 1;
break;
}
currentTimeNanos = time;
...
}
netty
会在每次进行 selector.select(timeoutMillis)
之前记录一下开始时间 currentTimeNanos
,在 select
之后记录一下结束时间,判断 select
操作是否至少持续了 timeoutMillis
秒。如果持续的时间大于等于 timeoutMillis
,说明就是一次有效的轮询,重置 selectCnt
标志,否则,表明该阻塞方法并没有阻塞这么长时间,可能触发了 jdk
的空轮询 bug
,当空轮询的次数超过一个阀值的时候,默认是 512
,就开始重建 selector