爆肝整理:Netty 优化的一些小技巧

本文介绍了如何通过Netty的EventExecutorGroup并行处理ChannelHandler,避免阻塞和单线程限制,以及如何合并编解码器、减少事件传播路径和利用@Sharable注解实现高效性能。
摘要由CSDN通过智能技术生成

技多不压身,本文会对一些常见的 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 线程就会出现阻塞,甚至整个系统都会被拖垮。

线程池添加有两种策略:

  1. 用户自定义线程池执行业务ChannelHandler

  2. 通过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的具体实现是调用GenericEventExecutorChoosernext()方法,代码如下(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);
    }
}

  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() 执行完毕呢?

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性能,提高程序运行稳定性和效率。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术小羊

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值