文章目录
一、前言
本系列虽说本意是作为 《Netty4 核心原理》一书的读书笔记,但在实际阅读记录过程中加入了大量个人阅读的理解和内容,因此对书中内容存在大量删改。
本篇涉及内容 :第六章 Netty 高性能之路
本系列内容基于 Netty 4.1.73.Final 版本,如下:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.73.Final</version>
</dependency>
系列文章目录:
二、传统RPC调用的“三宗罪”
第一宗罪:网络传输方式存在弊端。 传统 RPC 框架或者基于 RMI 等方式的远程调用都是采用 BIO,当客户端并发压力或网络延迟增大时,BIO会因为频繁 “wait” 线程导致 IO 线程经常出现阻塞情况,由于线程本身无法高效工作,IO处理能力自然会下降。
采用BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端连接,接收到客户端连接之后为客户端连接创建一个新的线程请求消息,处理完成后发挥应答消息给客户端后,线程销毁。这种架构设计最大的问题是无法进行弹性伸缩,当用户访问量剧增时,服务端线程个数与并发数成正比。所以随着并发量的增加,系统的性能也会急剧下降,可能会发生句柄溢出和线程堆栈溢出等问题。
第二宗罪:序列化方式存在弊端。Java 序列化存在下面几个典型的问题:
- Java 序列化是 Java 内部针对对象设计的编解码技术,无法跨语言使用。
- 相较于其他开源序列化框架,Java 序列化之后的字节码流占用空间太大。
- 序列化性能较差,在编解码过程中要占用更高的CPU资源。
第三宗罪:线程模型存在弊端。传统 RPC 框架采用 BIO模型,使得每个 TCP 连接都需要分配一个线程,当 IO 读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降,甚至会导致虚拟机无法创建新的线程。
三、Netty 高性能法宝
1. 异步非阻塞通信
在 IO 编程过程中,当需要同时处理多个客户端接入请求时,便可以通过多线程或多路复用技术来实现。多路复用IO就是把多个 IO 的阻塞复用到同一个 Selector 的阻塞从而达到系统在单线程的情况下也可以同时处理多个客户端的请求的目的。与传统多线程模型相比,多路复用IO 最大优势是系统开销小。
从 JDK 1.5 开始 使用 epoll 模型替代传统的 select/poll 模型。
Netty 服务端 API 的通信步骤
Netty 客户端 API 的通信步骤
通过上面的序列图,可以看到 Netty 的 IO 线程 NioEventLoop 聚合了 Selector,可以同时并发处理成百上千个客户端 Channel,而且他的读写操作都是非阻塞的,这可以大幅度提高单个线程的运行效率,避免由于频繁 IO阻塞导致的线程挂起。另外由于 Netty 采用的是异步通信模式,单个 IO 线程也可以并发处理多个客户端连接和读写操作,使得整个系统的性能、弹性伸缩和可好性都得到了极大提升。
2. 零拷贝
Netty 的零拷贝主要体现在下面三个方面:
-
Netty 接收和发送消息的 ByteBuffer 采用 DirectBuffer,使用堆外直接内存进行 Socket读写,不需要进行字节缓冲区二次拷贝,如果使用传统的堆内存(Heap Buffer)进行 Socket 读写,那么 JVM 会将堆内存数据拷贝一份到直接内存中,然后才写入 Socket。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
在 NioEventLoop#processSelectedKey 方法中有如下代码
// (readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 检查 SelectionKey 的就绪操作位中是否包含 OP_READ 或 OP_ACCEPT,分别表示通道可以进行读取操作或服务器套接字通道可以接受新连接。 if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) { // 调用 unsafe.read() 进行读取操作 unsafe.read(); }
上面会调用 AbstractNioByteChannel.NioByteUnsafe#read 方法,该方法会通过如下代码分配内存:
byteBuf = allocHandle.allocate(allocator);
这里实际调用的是 DefaultMaxMessagesRecvByteBufAllocator.MaxMessageHandle#allocate 方法,相当于每循环读取一次消息,就通过 ByteBufAllocator#ByteBufAllocator 方法获取 ByteBuf 对象
当Socket 进行 IO读写时,为了避免从堆内存拷贝一份副本到直接内存,Netty 的ByteBuffer 分配器直接创建非堆内存避免缓冲区的二次拷贝,通过零拷贝来提升读写性能。
-
Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以向操作一个Buffer 那样方便的堆组合 Buffer 进行操作,避免了传统的通过内存拷贝的方式将几个小 Buffer 合并成一个大 Buffer 的繁琐操作。
零拷贝组合 Buffer 的实现类是 io.netty.buffer.CompositeByteBuf,io.netty.buffer.CompositeByteBuf 实际上就是一个 ByteBuf 的包装器,他将多个 ByteBuf 组合成一个集合,然后堆外提供统一的 ByteBuf 接口。
-
Netty 中文件传输采用了 transferTo 方法,它可以将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
Netty 文件传输时通过 DefaultFileRegion#transferTo 方法将文件发送到目标 Channel 中,而在 DefaultFileRegion#transferTo 方法中会调用 FileChannel#transferTo 方法。而 FileChannel#transferTo 在调用时会将接收文件缓冲区的内容直接发送给目标 Channel,而不需要从内核再拷贝到应用程序内存,就已经实现了零拷贝。
3. 内存池
由于对于堆外直接内存堆分配和回收时一个非常耗时的操作,为了尽量重复利用缓冲区内存, Netty 设计了一套基于内存池的缓冲区重用机制。
与堆内内存不同的是,堆外内存其内存位于 Java 堆外,Java 虚拟机无法直接对其进行垃圾回收。当直接缓冲区不再被引用时,需要通过 Cleaner 机制来触发操作系统的内存释放操作。如果大量创建直接缓冲区而不及时释放,可能会导致本地内存泄漏
3.1 简单介绍
Netty 提供了多种内存管理策略,通过在启动辅助类中配置相关参数,可以实现差异化的个性定制。简化类图如下:
ByteBuf 存在一个子类 PooledByteBuf, 继承该类的ByteBuf 可以认定为使用了内存池技术。PooledByteBuf 是 Netty 中的一个核心类,用于实现内存池化的字节缓冲区。与普通的字节缓冲区(如 JDK 自带的ByteBuffer)相比,PooledByteBuf 借助内存池机制显著提升了性能,尤其在高并发场景下优势明显。
PooledByteBuf 内存池化原理
- 对象复用:Netty 的内存池维护了多个不同大小的内存块,PooledByteBuf 从这些内存块中获取空间,使用完毕后再将其归还内存池,而非每次都创建新的缓冲区。这减少了内存分配和垃圾回收的开销,因为频繁创建和销毁缓冲区会加重 JVM 的负担。
- 内存分配策略:采用分级的内存分配策略。例如,在 PooledByteBufAllocator 中,会根据请求的内存大小,从不同级别的内存池中分配内存。小内存(如小于 8K)可能从 tiny 或 small 内存池获取,大内存则从 normal 内存池获取,这样可以更高效地利用内存,减少内存碎片。
如下,通过内存池分配创建直接缓冲区 和 非堆内存分配器创建直接缓冲器的速度差异
public static void main(String[] args) {
// 在本机上输出:
// 内存池分配创建直接缓冲区 : 9594
// 非堆内存分配器创建直接缓冲器耗时 : 16335
createByPool();
createByUnPool();
}
private static void createByPool() {
byte[] data = new byte[1024];
ByteBuf byteBuf = null;
StopWatch stopWatch = new StopWatch();
stopWatch.start();
for (int i = 0; i < 100000000; i++) {
byteBuf = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
byteBuf.writeBytes(data);
byteBuf.release();
}
stopWatch.stop();
System.out.println("内存池分配创建直接缓冲区 : " + stopWatch.getTotal(TimeUnit.MILLISECONDS));
}
private static void createByUnPool() {
byte[] data = new byte[1024];
ByteBuf byteBuf = null;
StopWatch stopWatch = new StopWatch();
stopWatch.start();
for (int i = 0; i < 100000000; i++) {
byteBuf = Unpooled.directBuffer(1024);
byteBuf.writeBytes(data);
byteBuf.release();
}
stopWatch.stop();
System.out.println("非堆内存分配器创建直接缓冲器耗时 : " + stopWatch.getTotal(TimeUnit.MILLISECONDS));
}
由此可见,使用内存池的 ByteBuf 相比于非内存池的 ByteBuf 性能明显提升。
3.2 源码分析
以上面的代码为例,我们简单分析下 Netty 内存池的内存分配。
3.2.1 AbstractByteBufAllocator#directBuffer
上面代码我们通过 PooledByteBufAllocator.DEFAULT.directBuffer(1024)
来进行内存分配,而这句代码会调用 AbstractByteBufAllocator#directBuffer(int, int)
方法,该方法的实现如下:
@Override
public ByteBuf directBuffer(int initialCapacity, int maxCapacity) {
if (initialCapacity == 0 && maxCapacity == 0) {
return emptyBuf;
}
// 合法性校验
validate(initialCapacity, maxCapacity);
// newDirectBuffer 是一个抽象方法,供子类实现。目的是 创建一个直接内存类型的 ByteBuf 对象。
return newDirectBuffer(initialCapacity, maxCapacity);
}
这里的 newDirectBuffer(initialCapacity, maxCapacity)
调用的是 AbstractByteBufAllocator#newDirectBuffer,该方法是一个抽象方法,供子类实现。而该类有两个实现类:
- PooledByteBufAllocator:池化字节缓冲区分配器,使用内存池技术来管理和分配 ByteBuf。通过复用已分配的内存块,减少了频繁的内存分配和回收操作,从而提高了性能和内存利用率。
- UnpooledByteBufAllocator:非池化字节缓冲区分配器,每次分配 ByteBuf 时都会创建一个新的内存块,使用完后直接释放该内存块,不进行复用。
由于我们这里是看内存池的 ByteBuf ,所以这里的实现是 PooledByteBufAllocator#newDirectBuffer,其代码如下:
@Override
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
// 从线程本地缓存(ThreadLocal)中获取当前线程对应的 PoolThreadCache 对象。PoolThreadCache 是一个线程私有的缓存,用于存储和复用 ByteBuf 对象,减少频繁的内存分配和释放操作,提高性能。
PoolThreadCache cache = threadCache.get();
// 从 PoolThreadCache 中获取直接内存的分配器 PoolArena。
// directArena 不为空的条件是 :PooledByteBufAllocator 构造入参中的 useCacheForAllThreads = true 或 当前线程是 FastThreadLocalThread 类型
PoolArena<ByteBuffer> directArena = cache.directArena;
final ByteBuf buf;
// 如果 directArena 不为 null,说明存在可用的直接内存分配器,调用 directArena.allocate(cache, initialCapacity, maxCapacity) 方法从内存池中分配一个 ByteBuf 对象。这种方式利用了内存池的复用机制,能够减少内存分配和垃圾回收的开销
if (directArena != null) {
buf = directArena.allocate(cache, initialCapacity, maxCapacity);
} else {
// 如果 directArena 为 null,则需要直接创建一个新的 ByteBuf 对象。这里会根据当前平台是否支持 Unsafe 操作来选择不同的创建方式
buf = PlatformDependent.hasUnsafe() ?
UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
}
// 返回带有内存泄漏检测的 ByteBuf
// 将创建好的 ByteBuf 对象包装成带有内存泄漏检测功能的 ByteBuf。Netty 提供了内存泄漏检测机制,通过包装 ByteBuf 可以在开发和测试阶段帮助开发者及时发现内存泄漏问题。
return toLeakAwareBuffer(buf);
}
上面的代码注释很清楚,可以看到 directArena != null
时才会使用内存分配器来分配,而 directArena
则是 PoolThreadCache 对象的 directArena 属性,因此这里我们需要搞清楚 PoolThreadCache#directArena 在何时才不为空。
在 PooledByteBufAllocator 构造函数中会创建对应的 PoolThreadLocalCache 对象,如下:
public PooledByteBufAllocator(boolean preferDirect, int nHeapArena, int nDirectArena, int pageSize, int maxOrder,
int smallCacheSize, int normalCacheSize,
boolean useCacheForAllThreads, int directMemoryCacheAlignment) {
...
threadCache = new PoolThreadLocalCache(useCacheForAllThreads);
...
}
而在 PoolThreadLocalCache#initialValue 方法中创建 PoolThreadCache 对象,会将 PooledByteBufAllocator#directArenas 属性处理后作为 PoolThreadCache#directArena 的属性。而 PooledByteBufAllocator#directArenas 属性的赋值则是在 PooledByteBufAllocator 的静态代码块中,如下:
static {
...
DEFAULT_NUM_DIRECT_ARENA = Math.max(0,
SystemPropertyUtil.getInt(
"io.netty.allocator.numDirectArenas",
(int) Math.min(
defaultMinNumArena,
PlatformDependent.maxDirectMemory() / defaultChunkSize / 2 / 3)));
...
}
该代码块会根据系统属性和当前 JVM 的直接内存情况,计算出一个合适的直接内存分配区域数量。这样做的好处是可以根据不同的运行环境动态调整 Netty 的内存分配策略,避免过度分配或分配不足,从而提高系统的性能和稳定性。例如,在直接内存有限的环境中,会适当减少分配区域的数量,以避免内存溢出;而在直接内存充足的环境中,可以增加分配区域的数量,提高并发处理能力。
3.2.2 PoolArena#allocate
上面代码中 directArena.allocate(cache, initialCapacity, maxCapacity);
调用的是 PoolArena#allocate,其方法如下:
// PoolThreadCache 是 Netty 为每个线程维护的一个本地缓存,用于存储一些常用的内存块,以减少从主内存池分配内存的次数,提高分配效率
// reqCapacity 表示请求分配的内存容量,即用户期望这个 PooledByteBuf 能够存储的字节数
PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
// 抽象方法,供子类实现。PooledByteBuf 是 Netty 中实现内存池化的字节缓冲区类,通过内存池化可以提高内存分配和回收的效率,减少内存碎片。
PooledByteBuf<T> buf = newByteBuf(maxCapacity);
// 这里调用了另一个重载的 allocate 方法,该方法会根据 PoolThreadCache(线程本地缓存)和请求的容量 reqCapacity 对之前创建的 PooledByteBuf 实例 buf 进行具体的内存分配操作
allocate(cache, buf, reqCapacity);
return buf;
}
PoolArena 同样是一个抽象类,有两个子类 DirectArena 和 HeapArena。如果使用堆外内存则实现类是 DirectArena ,如果使用堆内存则实现类是 HeapArena。因此我们这里直接来看 DirectArena#newByteBuf 的实现,如下:
@Override
protected PooledByteBuf<ByteBuffer> newByteBuf(int maxCapacity) {
// HAS_UNSAFE 是一个布尔类型的静态常量,用于表示当前运行环境是否支持 Java 的 Unsafe 类。
// Unsafe 类提供了一些底层操作的能力,如直接内存访问、内存分配和释放等,使用 Unsafe 可以在某些场景下提高性能,但也需要谨慎使用,因为它绕过了 Java 的一些安全机制。
if (HAS_UNSAFE) {
// 利用 Unsafe 类进行直接内存的操作,能够提供更高的性能。
return PooledUnsafeDirectByteBuf.newInstance(maxCapacity);
} else {
// 不使用 Unsafe 类,而是通过 Java 的标准 API 进行内存操作。
return PooledDirectByteBuf.newInstance(maxCapacity);
}
}
上面两个方法的作用是:创建一个新的或复用一个已有的 PooledUnsafeDirectByteBuf 或 PooledDirectByteBuf 实例, 其实现如下:
/******************************** PooledUnsafeDirectByteBuf ***************************************************/
private static final ObjectPool<PooledUnsafeDirectByteBuf> RECYCLER = ObjectPool.newPool(
new ObjectCreator<PooledUnsafeDirectByteBuf>() {
@Override
public PooledUnsafeDirectByteBuf newObject(Handle<PooledUnsafeDirectByteBuf> handle) {
return new PooledUnsafeDirectByteBuf(handle, 0);
}
});
// PooledUnsafeDirectByteBuf#newInstance
static PooledUnsafeDirectByteBuf newInstance(int maxCapacity) {
// 尝试从回收器中获取一个已经被回收的 PooledUnsafeDirectByteBuf 实例。如果回收器中有可用的实例,会直接返回该实例;如果没有,则会创建一个新的 PooledUnsafeDirectByteBuf 实例。
PooledUnsafeDirectByteBuf buf = RECYCLER.get();
// 复用 PooledUnsafeDirectByteBuf 实例
buf.reuse(maxCapacity);
return buf;
}
/******************************** PooledDirectByteBuf ***************************************************/
private static final ObjectPool<PooledDirectByteBuf> RECYCLER = ObjectPool.newPool(
new ObjectCreator<PooledDirectByteBuf>() {
@Override
public PooledDirectByteBuf newObject(Handle<PooledDirectByteBuf> handle) {
return new PooledDirectByteBuf(handle, 0);
}
});
// PooledDirectByteBuf#newInstance
static PooledDirectByteBuf newInstance(int maxCapacity) {
// 尝试从回收器中获取一个已经被回收的 PooledDirectByteBuf 实例。如果回收器中有可用的实例,会直接返回该实例;如果没有,则会创建一个新的 PooledDirectByteBuf 实例。
PooledDirectByteBuf buf = RECYCLER.get();
// 复用 PooledDirectByteBuf 实例
buf.reuse(maxCapacity);
return buf;
}
我们这里以 PooledUnsafeDirectByteBuf
为例,主要来看两点:
-
RECYCLER.get()
: 获取循环使用的 ByteBuf 对象,判断如果是非内存池实现,则直接创建一个新的 ByteBuf 对象。从缓冲区获取 ByteBufRECYCLER 是 ObjectPool 包装类,ObjectPool 是 Netty 提供的一个对象池实现,用于管理对象的创建、获取和回收。对象池的核心思想是预先创建一定数量的对象并存储在池中,当需要使用对象时,从池中获取;使用完毕后,将对象归还到池中,而不是直接销毁,以便后续再次使用。
在使用 RECYCLER 对象池时,可以通过 RECYCLER.get() 方法从对象池中获取一个 PooledDirectByteBuf 对象,使用完毕后,调用 PooledDirectByteBuf 对象的 release() 方法将其归还到对象池中。
-
buf.reuse(maxCapacity);
: 因为通过RECYCLER.get()
获取到的 ByteBuf 之前可能被使用过,所以还需要调用 PooledByteBuf#reuse 重置引用计数器,用于对象 引用计数和内存回收等以便重新使用。这个方法是父类 PooledByteBuf 实现的,因此我们这里直接来看 PooledByteBuf#reuse, 如下:final void reuse(int maxCapacity) { // 1. 设置最大容量 : 这个方法会更新 ByteBuf 内部的最大容量属性,确保后续的操作不会超过这个最大限制。 maxCapacity(maxCapacity); // 2. 重置引用计数 : ByteBuf 使用引用计数机制来管理其生命周期。当一个 ByteBuf 被创建或被引用时,引用计数会增加;当不再被引用时,引用计数会减少。当引用计数为 0 时,ByteBuf 会被释放回内存池。 // 在复用 ByteBuf 时,需要将引用计数重置为初始值(通常为 1),以便重新开始对其引用计数的管理。 resetRefCnt(); // 3. 重置读写索引 :这里将读写索引都设置为 0,表示将 ByteBuf 的读写位置重置到起始位置。 // 在复用 ByteBuf 之前,其内部可能已经存在之前使用时的读写状态,通过将读写索引重置为 0,可以确保新的使用场景下从 ByteBuf 的起始位置开始读写数据。 setIndex0(0, 0); // 4. 丢弃标记 : discardMarks 方法用于丢弃 ByteBuf 中的标记。在 ByteBuf 的使用过程中,可以使用 markReaderIndex 和 markWriterIndex 方法设置读写标记,方便后续通过 resetReaderIndex 和 resetWriterIndex 方法恢复到标记位置。 // 在复用 ByteBuf 时,之前设置的标记可能不再适用,因此需要调用 discardMarks 方法将这些标记丢弃,避免对新的使用场景产生干扰。 discardMarks(); }
综上:这里可以简单总结 。
PooledByteBufAllocator.DEFAULT.directBuffer(1024)
会调用 AbstractByteBufAllocator#newDirectBuffer 方法来分配指定大小的直接内存。而 AbstractByteBufAllocator#newDirectBuffer 方法是一个抽象方法,会根据是否需要池化内存有不同的实现类,我们这里调用的则是 PooledByteBufAllocator#newDirectBuffer 。- PooledByteBufAllocator#newDirectBuffer 优先从线程本地缓存的直接内存分配器中分配池化的直接内存 ByteBuf,如果分配器不可用,则根据平台特性选择合适的方式创建非池化的直接内存 ByteBuf。
- 从线程本地缓存的直接内存分配器中分配池化的直接内存 ByteBuf 会调用 PoolArena#allocate 方法,该方法会通过 RECYCLER 的 get 方法获取循环使用的 ByteBuf 对象,判断如果是非内存池实现,则直接创建一个新的 ByteBuf 对象。从缓冲区获取 ByteBuf 之后,调用 PooledByteBuf#reuse 重置引用计数器,用于对象 引用计数和内存回收。
4. Reactor 线程模型
常用的 Reactor 线程模型有三种:
- Reactor 单线程模型
- Reactor 多线程模型
- 主从 Reactor 多线程模型
4.1 Reactor 单线程模型
Reactor 单线程模型,指的是所有 IO 操作都在同一个 NIO 线程中完成,NIO 线程的职责如下:
- 作为 NIO 服务端,接收客户端 TCP 连接
- 作为 NIO 客户端,向服务端发起 TCP 连接。
- 读取通信对端的请求或应答消息
- 向通信对端发送消息请求或应答消息。
Reactor 单线程模型的工作方式如下图:
由于 Reactor 模式使用的是 NIO,所有的 IO 操作都不会阻塞,理论上一个线程可以独立处理所有 IO 相关的操作。从架构层面看,一个 NIO 线程确实可以完成其承担的职责。从上图中看到 Acceptor 负责接收客户端的TCP 连接请求消息,链路建立成功之后,通过 Dispatcher 将 对应的 ByteBuffer 派发到指定的 Handler 上进行消息解码。用户 Handler 通过 NIO 现成将消息发送给客户端。
对于并发量较小的业务场景,可以使用单线程模型。但单线程模型不适用于高负载、高并发的场景,原因如下:
- 一个 NIO 线程如果同时处理成百上千个链路,则机器在性能上无法满足,即便是 NIO 线程的 CPU 负载达到 100%,也无法满足海量消息的编码、解码、读取和发送。
- 如果 NIO 线程负载过重,处理速度将变慢,从而导致大量客户端连接超时,超时之后往往会进行重发,这更加重 NIO 线程的负载,最终会导致大量消息积压和处理超时, NIO 线程就会成为系统的性能瓶颈。
- 一旦 NIO 线程发生意外或者进入死循环状态,就会导致整个系统通信模块不可用,从而不能接收和处理外部消息,造成节点故障。
4.2 Reactor 多线程模型
Reactor 多线程模型就是为了解决以上问题而被设计出来的,与 Reactor 单线程模型最大的区别就是设计了一个 NIO 线程池处理 IO 操作,原理如下图:
Reactor 多线程模型有以下特点:
- 有一个专门的 NIO 线程 Acceptor 用于监听服务端、接收客户端的 TCP 连接请求。
- 网络 IO 的读、写等操作只由一个 NIO 线程池负责,他包含一个任务队列和多个可用的线程,由这些 NIO 线程负责消息的读取、解码、编码和发送。
- 一个 NIO 线程可以同时处理多条请求链路,但是一条链路只对应一个 NIO线程,防止发生并发串行。
在大多数场景下,Reactor 多线程模型都可以满足性能要求,但在特殊的应用场景中,一个 NIO 线程负责监听和处理所有的客户端连接也可能会存在性能问题,如百万客户端并发连接,或服务端需要对客户端的握手消息进行安全认证,认证本身消耗性能很大。在这类场景中单个 Acceptor 线程可能会存在性能不足的问题,为了解决性能不足的问题,就出现了主从 Reactor 多线程模型。
4.3 主从 Reactor 多线性模型
主从 Reactor 多线性模型的特点是:服务端用户接收客户端的不再是单个 NIO 线程,而是分配了一个独立的 NIO 线程池。Acceptor 接收到客户端 TCP 连接请求并处理完成后(可能包含接入认证等),将新创建的 SocketChannel 注册到 IO 线程池(Sub Reactor 子线程池)的某个 IO线程上,由他负责 SocketChannel 的读写和编解码工作。Acceptor 线程池仅仅用户客户端的登录,握手和安全认证,一旦链路建立成功,就将链路注册到后端 Sub Reactor 子线程池的 IO 线程,再由 IO 线程负责后续的 IO 操作,其线程模型原理如下图:
利用主从 Reactor 多线程模型可以解决一个服务端监听线程无法有效处理所有客户端连接的性能不足的问题。因此在 Netty 的官方 Demo 汇总,推荐使用该线程模型。
事实上,Netty 的线程模型并非固定不变,通过在启动辅助类中创建不同的EventLoopGroup 实例并通过适当的参数配置,就可以自由选择上述三种 Reactor 线程模型。正是因为 Netty 对 Reactor 线程模型的支持提供了灵活的定制能力,所以可以满足不同业务场景下的性能需求。
5. 无锁化的串行理念
在大多数场景下,并发多线程处理可以提升系统的并发性能。但是,如果对共享资源的并发访问处理不当,就会造成严重的锁竞争,最终导致系统性能的下降。为了尽可能避免锁竞争带来的性能损耗,可以通过串行化设计来避免多线程竞争和同步锁,即消息的处理尽可能在同一个线程内完成,不进行线程切换。
在 Netty 源码中很多场景可以看到 通过 io.netty.util.concurrent.EventExecutor#inEventLoop() 方法判断是否是在 任务线程中执行任务,如果是则直接执行,否则启动一个 任务线程来处理任务。如:AbstractChannelHandlerContext#invokeChannelRegistered、AbstractChannelHandlerContext#invokeChannelUnregistered 、AbstractChannelHandlerContext#invokeChannelActive 等方法。
为了尽可能提升性能, Netty 采用无锁化串行设计,在 IO 线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,似乎串行设计的 CPU 利用率不高,并发程度不高,但通过调整 NIO 线程池的线程参数,可以同时启动多个串行的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程的模型性能更优。
有些时候可以考虑到这种设计模式并加以利用, 无锁化串行 与 队列->多个工作线程 模式
Netty 的 NioEventLoop 读取消息后,直接调用 ChannelPipeline 的 fireChannelRead(Object msg),只要用户不主动切换线程, NioEventLoop 就会调用用户的 Handler,期间不进行线程切换,这种串行处理方式避免了多线程操作导致的锁竞争,从性能角度看是最优的。
6. 其他
6.1 高效的并发编程
- volatile 关键字大量正确的使用
- CAS 和 原子类的广泛使用
- 线程安全容器的使用
- 通过读写锁提升并发性能
6.2 对高性能序列化框架的支持
影响序列化性能的关键因素如下
- 序列化后的码流大小(网络带宽的占用)
- 序列化、反序列化的性能(CPU 资源占用)
- 是否支持跨语言(异构系统的对接和开发语言切换)
除此之前, Netty 还具备灵活的 TCP 参数配置等方面以保障其高性能,这里就不过多赘述了。
四、参考内容
- 《Netty4 核心原理》
- 豆包