技多不压身,Netty 优化的一些小技巧

技多不压身,本文会对一些常见的 Netty 最佳实践进行简单的概述,让你对其有一个简单的理解,更深入的话就需要自己去动手学习啦🤓

业务线程池的必要性

Netty 是基于 Reactor 线程模型实现的,默认情况下,Netty 在启动的时候会开启 2 倍的 cpu 核数个 NIO 线程,而通常情况下我们单机会有几万或者十几万的连接,因此,一条 NIO 线程会管理着几千或几万个连接,在 ChannelPipeline 传播事件的过程中,单条 NIO 线程的处理逻辑可以抽象成以下一个步骤:

java 复制代码
List<Channel> channelList = 已有数据可读的 channel
for (Channel channel in channelist) {
   for (ChannelHandler handler in channel.pipeline()) {
       handler.channelRead0();
   } 
}

如果其中任何一个 ChannelHandler 处理器需要执行耗时的操作,其中那么 I/O 线程就会出现阻塞,甚至整个系统都会被拖垮。

线程池添加有两种策略:

  1. 用户自定义线程池执行业务ChannelHandler
  2. 通过Netty的EventExecutorGroup机制来并行执行ChannelHandler。

Netty的EventExecutorGroup机制

对于某个客户端连接Channel,同一时间只会绑定到一个EventLoop线程中,用于处理网络的I/O操作。业务的ChannelHandler指定了运行EventExecutorGroup线程池后,创建ChannelHandlerContext上下文的时候也会选择其中一个EventExecutor来绑定。当客户端比较多的时候,就会有N个Channel并行执行。

但并不是说我设置了EventExecutorGroup(100),就会100个线程去执行。如下:

java 复制代码
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的具体实现是调用GenericEventExecutorChoosernext()方法,代码如下(GenericEventExecutorChooser类):

通过分析以上代码,我们发现对于某个具体的TCP连接,绑定到业务 ChannelHandler实例上的线程池为 DefaultEventExecutor 由于DefaultEventExecutor继承自SingleThreadEventExecutor,所以执行 execute 方法就是把 Runnable 放入任务队列由单线程执行

通过以上分析得知,对于某个Channel对应的业务ChannelHandler实例,无论消费端有多少个线程来并发压测某条链路,对于服务端都只有一个DefaultEventExecutor线程来运行业务 ChannelHandler,无法实现并行调用,业务 ChannelHandler 的线程调度模型如图所示。

异步线程安全问题

在多线程异步执行过程中,如果某ChannelHandler的成员变量共享给其他ChannelHandler,那么多个被多个线程并发访问和修改就存在并发问题,如图:

自定义线程池执行业务ChannelHandler

在 ChannelHandler 处理器中自定义新的业务线程池,将耗时的操作提交到业务线程池中执行。 以 RPC 框架为例,在服务提供者处理 RPC 请求调用时就是将 RPC 请求提交到自定义的业务线程池中执行,如下所示:

  • 注:这里的自定义业务线程池也可以使用DefaultEventExecutorGroup ,Netty也是建议使用它提供的业务线程池。
java 复制代码
ThreadPool threadPool = xxx;

protected void channelRead0(ChannelHandlerContext ctx, T packet) {
    threadPool.submit(new Runnable() {
        // 1. balabala 一些逻辑
        // 2. 数据库或者网络等一些耗时的操作
        // 3. writeAndFlush()
        // 4. balabala 其他的逻辑
    })
}

线程池隔离

建议根据业务逻辑的核心等级拆分出多个业务线程池,如果某类业务逻辑出现异常造成线程池资源耗尽,也不会影响到其他业务逻辑,从而提高应用程序整体可用率。对于 Netty I/O 线程来说,每个 EventLoop 可以与某类业务线程池绑定,避免出现多线程锁竞争。如下图所示:

共享 handler,实现ChannelHandler的单例

我们经常使用以下 new HandlerXXX() 的方式进行 Channel 初始化,在每建立一个新连接的时候会初始化新的 HandlerA 和 HandlerB,如果系统承载了 1w 个连接,那么就会初始化 2w 个处理器,造成非常大的内存浪费。

java 复制代码
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注解的实例被当成单例使用,示例如下

java 复制代码
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注解就会检查不通过,源码如下

java 复制代码
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是不是共享的,然后是不是首次添加,不满足其一,直接抛异常。

压缩Handler

合并编解码器

Netty 内部提供了一个类,叫做MessageToMessageCodec,使用它可以让我们的编解码操作放到一个类里面去实现,原理:MessageToMessageCodec extends ChannelDuplexHandler

ChannelDuplexHandler又是怎么样的呢?

java 复制代码
public class ChannelDuplexHandler extends ChannelInboundHandlerAdapter implements ChannelOutboundHandler

****这个 handler 可以直接移到 pipeline的最 前面去,一般放在第二位,第一位用来判断报文是否是完整的

缩短事件传播路径

合并平行 handler

对我们这个应用程序来说,每次 decode 出来一个指令对象之后,其实只会在一个指令 handler 上进行处理,因此,我们其实可以把这么多的指令 handler 压缩为一个 handler

类似如下:

java 复制代码
@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);
    }
}
  1. 首先,IMHandler 是无状态的,依然是可以写成一个单例模式的类。
  2. 我们定义一个 map,存放指令到各个指令处理器的映射。
  3. 每次回调到 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() 执行完毕呢?

java 复制代码
protected void channelRead0(ChannelHandlerContext ctx, T packet) {
    threadPool.submit(new Runnable() {
        long begin = System.currentTimeMillis();
        // 1. balabala 一些逻辑
        // 2. 数据库或者网络等一些耗时的操作
        
        // 3. writeAndFlush
        xxx.writeAndFlush().addListener(future -> {
            if (future.isDone()) {
                // 4. balabala 其他的逻辑
                long time =  System.currentTimeMillis() - begin;
            }
        });
    })
}

writeAndFlush() 方法会返回一个ChannelFuture对象,我们给这个对象添加一个监听器,然后在回调方法里面,我们可以监听这个方法执行的结果,进而再执行其他逻辑,最后统计耗时,这样统计出来的耗时才是最准确的。

最后,需要提出的一点就是,Netty 里面很多方法都是异步的操作,在业务线程中如果要统计这部分操作的时间,都需要使用监听器回调的方式来统计耗时,如果在 NIO 线程中调用,就不需要这么干。

总结

本文主要讨论了Netty的线程池必要性,介绍了如何通过Netty的EventExecutorGroup机制并行执行ChannelHandler,同时阐述了合并编解码器和减少事件传播路径的方法,例如合并平行处理器、更改事件传播源,以及如何准确统计处理时长等。同时还解析了@Sharable注解的原理以及如何实现ChannelHandler的单例。这些操作可以更好地优化Netty性能,提高程序运行稳定性和效率。

相关推荐
学c真好玩几秒前
Django创建的应用目录详细解释以及如何操作数据库自动创建表
后端·python·django
Asthenia0412几秒前
GenericObjectPool——重用你的对象
后端
Piper蛋窝11 分钟前
Go 1.18 相比 Go 1.17 有哪些值得注意的改动?
后端
excel25 分钟前
招幕技术人员
前端·javascript·后端
盖世英雄酱5813642 分钟前
什么是MCP
后端·程序员
淋一遍下雨天44 分钟前
Spark Streaming核心编程总结(四)
java·开发语言·数据库
琢磨先生David1 小时前
重构数字信任基石:Java 24 网络安全特性的全维度革新与未来防御体系构建
java·web安全·密码学
程序修理员1 小时前
技能点总结
java
Jennifer33K2 小时前
报错_NoSuchMethodException: cn.mvc.entity.User.<init>()
java
爱吃烤鸡翅的酸菜鱼2 小时前
【SpringMVC】概念引入与连接
java·开发语言·mysql