技多不压身,本文会对一些常见的 Netty 最佳实践进行简单的概述,让你对其有一个简单的理解,更深入的话就需要自己去动手学习啦🤓
Netty 是基于 Reactor 线程模型实现的,默认情况下,Netty 在启动的时候会开启 2 倍的 cpu 核数个 NIO 线程,而通常情况下我们单机会有几万或者十几万的连接,因此,一条 NIO 线程会管理着几千或几万个连接,在 ChannelPipeline 传播事件的过程中,单条 NIO 线程的处理逻辑可以抽象成以下一个步骤:
List<Channel> channelList = 已有数据可读的 channel
for (Channel channel in channelist) {
for (ChannelHandler handler in channel.pipeline()) {
handler.channelRead0();
}
}
如果其中任何一个 ChannelHandler 处理器需要执行耗时的操作,其中那么 I/O 线程就会出现阻塞,甚至整个系统都会被拖垮。
线程池添加有两种策略:
-
用户自定义线程池执行业务ChannelHandler
-
通过Netty的EventExecutorGroup机制来并行执行ChannelHandler。
Netty的EventExecutorGroup机制
对于某个客户端连接Channel,同一时间只会绑定到一个EventLoop线程中,用于处理网络的I/O操作。业务的ChannelHandler指定了运行EventExecutorGroup线程池后,创建ChannelHandlerContext上下文的时候也会选择其中一个EventExecutor来绑定。当客户端比较多的时候,就会有N个Channel并行执行。
但并不是说我设置了EventExecutorGroup(100),就会100个线程去执行。如下:
private static final EventExecutorGroup EXECUTOR_GROUP = new DefaultEventExecutorGroup(10);
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast("decoder", decoder.getClass().newInstance());
pipeline.addLast(EXECUTOR_GROUP, "handler", handler);
pipeline.addLast(new TcpOutHandler());
}
ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler)方法使用指定的EventExecutorGroup,该线程池将为该ChannelHandler提供执行线程。这意味着ChannelHandler将在指定的线程池中执行,而不是在EventLoop所在的线程中执行。
业务ChannelHandler无法并发执行问题
业务通过Netty的DefaultEventExecutorGroup并行执行Handler,做性能测试时发现服务端的处理能力非常差
无法并行执行的EventExecutorGroup
对源码进行分析,首先查看绑定 DefaultEventExecutorGroup 到业务 ChannelHandler的代码,如下所示(DefaultChannelPipeline类):
创建DefaultChannelHandlerContext
,调用childExecutor(group)
方法,从EventExecutorGroup
中选择一个EventExecutor
绑定到DefaultChannelHandlerContext
,相关代码如下:
通过group.next()
方法,从EventExecutorGroup
中选择一个EventExecutor
,存放到EventExecutor Map
中,选择EventExecutor
的具体实现是调用GenericEventExecutorChooser
的next()
方法,代码如下(GenericEventExecutorChooser类):
通过分析以上代码,我们发现对于某个具体的TCP连接,绑定到业务ChannelHandler
实例上的线程池为DefaultEventExecutor
, 由于DefaultEventExecutor
继承自SingleThreadEventExecutor
,所以执行 execute 方法就是把 Runnable 放入任务队列由单线程执行
通过以上分析得知,对于某个Channel对应的业务ChannelHandler实例,无论消费端有多少个线程来并发压测某条链路,对于服务端都只有一个DefaultEventExecutor线程来运行业务 ChannelHandler,无法实现并行调用,业务 ChannelHandler 的线程调度模型如图所示。
异步线程安全问题
在多线程异步执行过程中,如果某ChannelHandler的成员变量共享给其他ChannelHandler,那么多个被多个线程并发访问和修改就存在并发问题,如图:
自定义线程池执行业务ChannelHandler
在 ChannelHandler 处理器中自定义新的业务线程池,将耗时的操作提交到业务线程池中执行。 以 RPC 框架为例,在服务提供者处理 RPC 请求调用时就是将 RPC 请求提交到自定义的业务线程池中执行,如下所示:
-
注:这里的自定义业务线程池也可以使用
DefaultEventExecutorGroup
,Netty也是建议使用它提供的业务线程池。
ThreadPool threadPool = xxx;
protected void channelRead0(ChannelHandlerContext ctx, T packet) {
threadPool.submit(new Runnable() {
})
}
线程池隔离
建议根据业务逻辑的核心等级拆分出多个业务线程池,如果某类业务逻辑出现异常造成线程池资源耗尽,也不会影响到其他业务逻辑,从而提高应用程序整体可用率。对于 Netty I/O 线程来说,每个 EventLoop 可以与某类业务线程池绑定,避免出现多线程锁竞争。如下图所示:
我们经常使用以下 new HandlerXXX() 的方式进行 Channel 初始化,在每建立一个新连接的时候会初始化新的 HandlerA 和 HandlerB,如果系统承载了 1w 个连接,那么就会初始化 2w 个处理器,造成非常大的内存浪费。
public class InitHandler extends ChannelInitializer<NioSocketChannel> {
@Override
protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {
System.out.println(this);
nioSocketChannel.pipeline()
.addLast(new AHandler())
.addLast(new BHandler());
}
}
为了解决上述问题,Netty 提供了 @Sharable 注解用于修饰 ChannelHandler,标识该 ChannelHandler 全局只有一个实例,而且会被多个 ChannelPipeline 共享。所以我们必须要注意的是, @Sharable 修饰的 ChannelHandler 必须都是无状态的,这样才能保证线程安全。
@Sharable原理
而加上 @ChannelHandler.Sharable
注解的作用并不是代表nettry就会自动复用实例对象,而是防止没有@sharable注解的实例被当成单例使用,示例如下
public class InitHandler extends ChannelInitializer<NioSocketChannel> {
private AHandler aHandler = new AHandler();
@Override
protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {
System.out.println(this);
nioSocketChannel.pipeline()
.addLast(aHandler)
.addLast(new BHandler());
}
}
此时AHandler如果没有被标注@sharable注解就会检查不通过,源码如下
private static void checkMultiplicity(ChannelHandler handler) {
if (handler instanceof ChannelHandlerAdapter) {
ChannelHandlerAdapter h = (ChannelHandlerAdapter) handler;
if (!h.isSharable() && h.added) {
throw new ChannelPipelineException(
h.getClass().getName() +
" is not a @Sharable handler, so can't be added or removed multiple times.");
}
h.added = true;
}
}
很明显,判断handler是不是共享的,然后是不是首次添加,不满足其一,直接抛异常。
合并编解码器
Netty 内部提供了一个类,叫做MessageToMessageCodec
,使用它可以让我们的编解码操作放到一个类里面去实现,原理:MessageToMessageCodec extends ChannelDuplexHandler
而ChannelDuplexHandler
又是怎么样的呢?
public class ChannelDuplexHandler extends ChannelInboundHandlerAdapter implements ChannelOutboundHandler
****这个 handler 可以直接移到 pipeline的最 前面去,一般放在第二位,第一位用来判断报文是否是完整的
合并平行 handler
对我们这个应用程序来说,每次 decode 出来一个指令对象之后,其实只会在一个指令 handler 上进行处理,因此,我们其实可以把这么多的指令 handler 压缩为一个 handler
类似如下:
@ChannelHandler.Sharable
public class IMHandler extends SimpleChannelInboundHandler<Packet> {
public static final IMHandler INSTANCE = new IMHandler();
private Map<Byte, SimpleChannelInboundHandler<? extends Packet>> handlerMap;
private IMHandler() {
handlerMap = new HashMap<>();
handlerMap.put(MESSAGE_REQUEST, MessageRequestHandler.INSTANCE);
handlerMap.put(CREATE_GROUP_REQUEST, CreateGroupRequestHandler.INSTANCE);
handlerMap.put(JOIN_GROUP_REQUEST, JoinGroupRequestHandler.INSTANCE);
handlerMap.put(QUIT_GROUP_REQUEST, QuitGroupRequestHandler.INSTANCE);
handlerMap.put(LIST_GROUP_MEMBERS_REQUEST, ListGroupMembersRequestHandler.INSTANCE);
handlerMap.put(GROUP_MESSAGE_REQUEST, GroupMessageRequestHandler.INSTANCE);
handlerMap.put(LOGOUT_REQUEST, LogoutRequestHandler.INSTANCE);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Packet packet) throws Exception {
handlerMap.get(packet.getCommand()).channelRead(ctx, packet);
}
}
-
首先,IMHandler 是无状态的,依然是可以写成一个单例模式的类。
-
我们定义一个 map,存放指令到各个指令处理器的映射。
-
每次回调到 IMHandler 的
channelRead0()
方法的时候,我们通过指令找到具体的 handler,然后调用指令 handler 的channelRead
,他内部会做指令类型转换,最终调用到每个指令 handler 的channelRead0()
方法。
注意: 如果你对性能要求没这么高,大可不必搞得这么复杂,还是按照多个handler的方式来实现即可,比如,我们的客户端多数情况下是单连接的,其实并不需要搞得如此复杂,还是保持原样即可。
更改事件传播源
如果你的 outBound 类型的 handler 较多,在写数据的时候能用ctx.writeAndFlush()
就用这个方法。
如上图,在某个 inBound 类型的 handler 处理完逻辑之后,调用ctx.channel().writeAndFlush()
,对象会从最后一个 outBound 类型的 handler 开始,逐个往前进行传播,路径是要比ctx.writeAndFlush()
要长的。
因为writeAndFlush()
这个方法如果在非netty 的 NIO 线程(这里,我们其实是在业务线程中调用了该方法)中执行,它是一个异步的操作,调用之后,其实是会立即返回的,剩下的所有的操作,都是 Netty 内部有一个任务队列异步执行的
因此,这里的writeAndFlush()
执行完毕之后,并不能代表相关的逻辑,比如事件传播、编码等逻辑执行完毕,只是表示 Netty 接收了这个任务,那么如何才能判断writeAndFlush()
执行完毕呢?
protected void channelRead0(ChannelHandlerContext ctx, T packet) {
threadPool.submit(new Runnable() {
long begin = System.currentTimeMillis();
xxx.writeAndFlush().addListener(future -> {
if (future.isDone()) {
long time = System.currentTimeMillis() - begin;
}
});
})
}
writeAndFlush()
方法会返回一个ChannelFuture
对象,我们给这个对象添加一个监听器,然后在回调方法里面,我们可以监听这个方法执行的结果,进而再执行其他逻辑,最后统计耗时,这样统计出来的耗时才是最准确的。
最后,需要提出的一点就是,Netty 里面很多方法都是异步的操作,在业务线程中如果要统计这部分操作的时间,都需要使用监听器回调的方式来统计耗时,如果在 NIO 线程中调用,就不需要这么干。
本文主要讨论了Netty的线程池必要性,介绍了如何通过Netty的EventExecutorGroup机制并行执行ChannelHandler,同时阐述了合并编解码器和减少事件传播路径的方法,例如合并平行处理器、更改事件传播源,以及如何准确统计处理时长等。同时还解析了@Sharable注解的原理以及如何实现ChannelHandler的单例。这些操作可以更好地优化Netty性能,提高程序运行稳定性和效率。