网管与当前Netty框架的相适性

笔者最近重温《Netty In Action》这本书时,引发了一些思考,主要针对于目前网管与Netty框架的相适性与网管对Netty的使用是否正确,此处的相适指Netty是否适合目前网管。顺便本篇笔记补充了一些Netty的细节知识,便于使用Netty的开发人员能对Netty有个更深的理解。

@author 殷华盛

@verion 1.0

1. Netty线程模型

想了解开头提到的问题,我们需要先从Netty的线程模型说起:

image-20220413215838377

上图即为我们熟知的Netty的线程模型(此处只说非阻塞IO模型),这也是人们经常说到的IO多路复用或Reactor模型。

网上这张图流传的比较广,首先Netty会创建多个EventLoop(一个EventLoop即一个线程),而一个EventLoop会处理多个Channel,也即可以通过一个线程处理多个连接,这一技术很大程度上缓解了为每个连接创建一个线程导致的浪费与线程过多时上下文切换带来的耗时影响等,因此很大程度的提高了并发。

但很多人都忽略了一个问题:这是一个Netty Server端的线程模型。当然我们常说的IO多路复用或Reactor本身也是Server端技术。

很少有人会提及Netty客户端的线程模型,根本原因在于大家觉得客户端不需要考虑多线程的情况,大部分情况下一个客户端只会连接一个Server,所以不会像Server端需要处理多个Client然后创建多个线程的现象。对于Netty客户端而言,每一个Client都是一个单独的线程,也即每一个连接到Server的Channel,都由一个单独的线程在维护,我们将这种Channel暂称为ClientChannel,如下图是利用Netty创建一个客户端代码。

image-20220414111450995

一般我们在创建netty客户端时都会写类似这样一段的代码,但这段代码却有其问题,在执行

EventLoopGroup group = new NioEventLoopGroup();

时,NioEventLoopGroup默认的构造方法会创建当前CPU核心数*2的具体的NioEventLoop,我们知道一个Channel只会绑定到一个具体的EventLoop上,因此上述代码代表一个NioEventLoopGroup内只有一个NioEventLoop会被实际用到,而另外7个会被浪费。即使我们指定NioEventLoopGroup创建NioEventLoop的个数,如:

EventLoopGroup group = new NioEventLoopGroup(1);

这依然不可避免的为每个CleintChannel分配了一个EventLoop,即一个单独的线程。

网管的问题在于,每添加一个设备,就需要创建一个ClientChannel,也就需要一个单独的线程维护这个Channel。假设我们现在有1w台设备,那也就代表有1w个线程,即使大部分时间我们和这些设备不交互。

虽然按目前网管的体量与公司的设备数,远不会到达1w个设备那么夸张,最多也不过是百十台设备。但如果从高效性考虑,这种线程模型性能还是很低的。

通常来说,Netty是一个Server端技术,网管相对于设备是客户端,我们和设备通信时使用了Netty框架,主要使用的并非是Netty优秀的线程模型,而更多考虑的是如下几点:

  1. 创建一个连接简单。只需要配置好,那么就会自动创建一个线程维护连接
  2. Netty是事件驱动的。所谓事件驱动可以简单理解为可以监听到所有感兴趣的事件并作处理。因此我们可以监听长时间未读写事件,此时发一个心跳包,可以监听连接断开事件,此时做一个重连。
  3. Netty的ChannelHandler责任链机制做的很棒,且内置了很多编解码器,可以直接拿来用。

因此对于网管而言,目前来看我们与Netty框架的相适性并不是很好。针对网管这一通信模型,也即一个进程要和成百上千的Server端通信的模型,笔者一开始想也许通过一些中间件来解决可能会更好,比如常见的消息队列中间件。

这种中间件的一大好处是简化了网管与设备的通信,由以前的Client - Server模式改为了生产 - 消费模式,且网管与设备都同时是生产者和消费者。也即由

image-20220413222624829

改为了

image-20220413223817758

这一好处在于网管无需和每个设备都建立一个连接,并要用一个线程维护这个连接。第二种模式下网管只需要一个与中间件的连接即可。

但第二种模式也存在一个明显的弊端,即它模糊了一次request和response的概念,这可能在上下行匹配时稍微麻烦,但仔细想想还是能实现的。

另外现在设备层对于中间件的使用经验不足,这一方案很难在现今推广开。

幸运的是在读《Netty In Action》书籍时作者提供了另一个案例,这给了笔者一定的启发:

假设你的服务器正在处理一个客户端的请求,这个请求需要它充当第三方系统的客户端。当一个应用程序(如一个代理服务器)必须要和组织现有的系统(如 Web 服务或者数据库)集成时,就可能发生这种情况。在这种情况下,将需要从已经被接受的子Channel 中引导一个客户端Channel。 你可以按照 8.2.1 节中所描述的方式创建新的 Bootstrap 实例,但是这并不是最高效的解决方案,因为它将要求你为每个新创建的客户端 Channel 定义另一个 EventLoop。这会产生额外的线程,以及在已被接受的子Channel 和客户端 Channel 之间交换数据时不可避免的上下文切换。 一个更好的解决方案是:通过将已被接受的子Channel 的EventLoop 传递给Bootstrap的group()方法来共享该EventLoop。因为分配给EventLoop 的所有Channel 都使用同一个线程,所以这避免了额外的线程创建,以及前面所提到的相关的上下文切换。这个共享的解决方案如图8-4 所示。

image-20220413223614524

代码如下:

image-20220413223727873

作者描述了这一现象:假设身为服务端的你正在接受一个请求(你的服务端是Netty写的),在处理这个请求的时候你需要向其他服务端拿数据(比如向数据库要数据),那么你往往需要建立一个向拿数据的服务端的连接,Netty创建连接需要传一个EventLoop,也即一个用来维护这个连接的线程,如果此时你用一个新的EventLoop,且用这个EventLoop发送请求,那么会很明显的发生上下文的切换。

作者给出了一个解决方案:用当前Server端为你创建的线程,也即你的Channel绑定的那个EventLoop作为新创建的客户端的EventLoop,这样我们就可以在同一个线程中做请求,而无需额外创建线程。

这一示例给出了一个很重要的信息:EventLoop可以复用。虽然在之前Netty线程模型的图中,我们知道多个Channel共用一个EventLoop这本身就是EventLoop复用的体现,但之前的Channel与EventLoop都是Netty帮你创建的,现在我们知道了EventLoop就是一个线程资源,多个Channel的构建可以用同一个EventLoop。

那么我们回到之前说的网管的问题上:Netty的客户端线程模型决定了每一个ClientChannel都需要一个线程维护,如果网管添加过多的设备势必会导致创建过多的线程。

但现在我们知道,虽然每一个ClientChannel都需要一个线程,也即EventLoop维护,但不代表每次创建ClientChannel都单独再创建一个EventLoop。一种合理的做法为:

我们将EventLoop池化,其本质和线程池一样(实际上EventLoop本身就是一个单线程池),每次创建一个ClientChannel(即面向设备的连接)时都从EventLoop池中选择一个EventLoop作为当前ClientChannel的维护线程。

假设我们现在EventLoop池有10个EventLoop,但是有100个设备,那么每次添加设备创建连接时,都从池中选择一个EventLoop,这样就做到了10个EventLoop管理100个设备。也即10个线程管理100个ClientChannel,大大减少了线程的创建,同时也减少了上下文切换的开销。

其实如果有对二期网管后端有足够了解的同学,不难发现二期网管后端采用的就是这一方案:

private  EventLoopGroup eventLoopGroup = new NioEventLoopGroup(50);
public void initSockManagement() {
   if (optionMap != null && !optionMap.isEmpty()) {
      Iterator<Entry<Object, Object>> dataIterator = optionMap.entrySet().iterator();
      bootStrap.group(eventLoopGroup);
      while (dataIterator.hasNext()) {
         Entry<Object, Object> entry = dataIterator.next();
         bootStrap.option((ChannelOption<Object>) entry.getKey(), entry.getValue());
      }
   }

}

二期后端虽然比较巧妙的利用了这一思想,但处理的也比较粗糙和暴力,无论当前有多少设备,都会创建50个NioEventLoop。

这会导致在设备过少时NioEventLoop的浪费,与设备过多时线程池不够用导致IO线程阻塞现象,这也并不符合我们对池化资源的正常管理。

因此池化EventLoop也会带来两个问题:

第一个问题是:池化的数量是多少,每一个EventLoop绑定多少ClientChannel合适。

对于这一问题,还需要进行实际的测试才行,我们期望的是在不影响各个ClientChannel正常工作的前提下尽可能让一个EventLoop绑定更多ClientChannel。

第二个问题是:一个EventLoop对应多个ClientChannel,如果其中一个Channel在事件处理时阻塞,其他Channel也会受影响。针对这一问题,我们需要了解Netty的IO线程与EventLoop本身。

2. EventLoop

2.1 EventLoop

我们先来简单说下EventLoop是什么:

image-20220414090113997

上图即为EventLoop的继承关系,通过上图不难看出Netty的EventLoop继承自Java的ScheduledExecutorServiceExecutorService接口,这两个接口对于经常用线程池的同学不陌生,从这里我们也可以看出EventLoop本身支持类似线程池的所有操作,比如执行一个提交的任务或定时执行任务或周期执行任务等。EventLoop的一种实现类为SingleThreadEventLoop,其中我们的ThreadPerChannelEventLoopNioEventLoop又继承自SingleThreadEventLoop,因此在这里也不难看出我们经常使用的EventLoop实现类是一个单线程池。网上对于EventLoop的理解更多是EventLoop是一个线程,这种描述是有些不恰当的。EventLoop绑定了一个线程,但EventLoop比线程能提供更丰富的功能,如任务的调度(其实质是一个单线程池而非线程)。

既然EventLoop支持任务的调度,那么我们就可以将一些定时或周期执行的任务交给EventLoop去做,最经典的场景就是断线重连:

 public void connect() throws Exception {
     //启动客户端去连接服务器端
     ChannelFuture cf = bootstrap.connect(host, port);
     cf.addListener(new ChannelFutureListener() {
         @Override
         public void operationComplete(ChannelFuture future) throws Exception {
             if (!future.isSuccess()) {
                 //重连交给后端线程执行
                 future.channel().eventLoop().schedule(() -> {
                     try {
                         connect();
                     } catch (Exception e) {
                         e.printStackTrace();
                     }
                 }, 3000, TimeUnit.MILLISECONDS);
             } else {
                 System.out.println("服务端连接成功...");
             }
         }
     });
 }

可以看到我们在连接函数中监听连接的结果,如果连接失败,则会执行一个定时事件:3s后再调用一次连接,也即重连。

而对于这种定时或周期事件的调用,我们只是获取到EventLoop,然后像使用线程池一样向它提交任务。

future.channel().eventLoop().schedule(() -> {
    try {
        connect();
    } catch (Exception e) {
        e.printStackTrace();
    }
}, 3000, TimeUnit.MILLISECONDS);

2.2 Channel,ChannelPipeline,ChannelHandler,ChannelHandlerContext

了解了EventLoop是什么后,我们再来说第二件事:Netty经常使用的组件是Channel,ChannelPipeline,ChannelHandler和ChannelHandlerContext,这些组件和EventLoop的关系是什么?

2.2.1 EventLoop与Channel

首先通过本文一开始的Netty线程模型一图中了解到EventLoop与Channel的关系是一对多的关系,也即一个EventLoop可以监听多个Channel,同时《Netty In Action》书籍中给出了更具体的解释:

一个 EventLoop 将由一个永远都不会改变的 Thread 驱动。

一旦一个 Channel 被分配给一个 EventLoop,它将在它的整个生命周期中都使用这个EventLoop(以及相关联的Thread)。

2.2.2 ChannelPipeline与ChannelHandler

然后我们再来理解ChannelHandler与ChannelPipeline的关系

image-20220414094415581

可以看到一个ChannelPipeline中包含多个ChannelHandler。ChannelPipeline类似于一个管道流,而每一个ChannelHandler是分布在这个管道上的各个处理节点。拿生产线举例:一个ChannelPipeline就是一条生产线,而ChannelHandler则是这条生产线上的各个环节,第一个环节处理完后会将处理结果交给第二个环节,而这种模式也是大家津津乐道的责任链模式。

同样ChannelHandler往往又分为入站的Handler和出站的Handler(可以既是入站也是出站),因此当有入站事件触发时,事件会经由ChannelPipeline上的所有的入站处理器,同理出站事件触发时,事件会经由ChannelPipeline上的所有的出站处理器。

2.2.3 ChannelHandler与ChannelHandlerContext

ChannelHandler与ChannelHandlerContext的关系是一一对应或者说彼此绑定的关系

image-20220414095357730

我们之前说过ChannelHandler是用来处理事件的,其分布在ChannelPipeline中。也即每当有事件到来的时候,ChannelPipeline会一个接一个的调用ChannelHandler的方法。但其实这一说法并不准确:ChannelPipeline并不会一个接一个的调用ChannelHandler,责任链机制要求的是前置节点调用后置节点,也即ChannelHandler的调用是由其前置ChannelHandler调用的。那么这就存在一个问题?ChannelHandler是如何知道其后置节点是什么?答案就是通过ChannelHandlerContext。

我们以一个ExceptionCaught事件的传播为例:

我们知道在编写自己的ChannelHandler时,可以继承父类实现exceptionCaught方法,该方法保证了在ChannelPipeline中有任何异常时都会调用这个exceptionCaught方法来捕获异常。

假设我们此时的ChannelPipeline是如下样子:

image-20220414100820826

其中ChannelHandler3实现了exceptionCaught方法,而事件的传播顺序是1->2->3。那么如果当前ChannelPipeline有异常发生时,会先经过ChannelHandler1,由于ChannelHandler1未实现exceptionCaught,那么会调用父类,也即ChannelInboundHandlerAdapterexceptionCaught方法,其源码如下:

public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    ctx.fireExceptionCaught(cause);
}

可以看到,默认的exceptionCaught调用了ChannelHandlerContextfireExceptionCaught,其中fireExceptionCaught源码如下:

public ChannelHandlerContext fireExceptionCaught(Throwable cause) {
    invokeExceptionCaught(this.next, cause);
    return this;
}
static void invokeExceptionCaught(final AbstractChannelHandlerContext next, final Throwable cause) {
    ObjectUtil.checkNotNull(cause, "cause");
    EventExecutor executor = next.executor();
    if (executor.inEventLoop()) {
        next.invokeExceptionCaught(cause);
    } else {
        try {
            executor.execute(new Runnable() {
                public void run() {
                    next.invokeExceptionCaught(cause);
                }
            });
        } catch (Throwable var4) {
            if (logger.isWarnEnabled()) {
                logger.warn("Failed to submit an exceptionCaught() event.", var4);
                logger.warn("The exceptionCaught() event that was failed to submit was:", cause);
            }
        }
    }

}
private void invokeExceptionCaught(Throwable cause) {
    if (this.invokeHandler()) {
        try {
            this.handler().exceptionCaught(this, cause);
        } catch (Throwable var3) {
            if (logger.isDebugEnabled()) {
                logger.debug("An exception {}was thrown by a user handler's exceptionCaught() method while handling the following exception:", ThrowableUtil.stackTraceToString(var3), cause);
            } else if (logger.isWarnEnabled()) {
                logger.warn("An exception '{}' [enable DEBUG level for full stacktrace] was thrown by a user handler's exceptionCaught() method while handling the following exception:", var3, cause);
            }
        }
    } else {
        this.fireExceptionCaught(cause);
    }

}

其中this.next即为当前ChannelHandler的后继节点的ChannelHandlerContext,我们之前说过ChannelHandlerContext与ChannelHandler是一一绑定的。我们先不关心invokeExceptionCaught方法的的executor.inEventLoop()是什么意思,我们假设会走进这个if,那么可以看到当调用默认的exceptionCaught时,实际上会直接获得当前节点的后继ChannelHandlerContext,然后通过后继的ChannelHandlerContext拿到其对应的ChannelHandler,再调用这个ChannelHandler的exceptionCaught方法,这就完成了事件的传递。

拿上面的ChannelHandler1,2,3举例,异常发生后会首先调用ChannelHandler1的exceptionCaught,ChannelHandler1未实现自己的exceptionCaught,因此会走默认的exceptionCaught,那么也就会调继任节点Handler的exceptionCaught,即ChannelHandler2的exceptionCaught,同理ChannelHandler2也未实现自己的exceptionCaught,那么就调用ChannelHandler3的exceptionCaught,ChannelHandler3实现了exceptionCaught,因此异常发生就会执行我们自己写的exceptionCaught方法内。如果现在还有一个ChannelHandler4也实现了exceptionCaught,且希望ChannelHandler3处理完后将这个异常也传播给ChannelHandler4处理,那么我们只需要在ChannelHandler3exceptionCaught方法的末尾加上

ctx.fireExceptionCaught(cause);

通过上面的示例和源码我们已经发现,ChannelPipeline内的ChannelHandler是由其绑定的ChannelHandlerContext串联起来的,一般一个ChannelHandler会有很多可以实现的方法(也即可以监听很多感兴趣的事件),但一般在一个ChannelHandler中我们只会实现一至两个方法,而将不同的事件监听放到不同的Handler中,对于我们当前Handler不感兴趣的事件,也即未实现的方法,往往父类的默认实现就是 ctx.firexxxx(),由ChannelHandlerContext来调用责任链中的下一个ChannelHandler。

因此一般来说ChannelHandler每有一个感兴趣可监听的事件,那么ChannelHandlerContext也会有一个对应的firexxx方法,firexxx方法为默认实现,其就是将事件传播给下一个ChannelHandler。

我们可以通过《Netty In Action》证实这一推测:

其中ChannelInboundHandler的事件如下:

image-20220414103037774

ChannelOutboundHandler的事件如下:

image-20220414103104313

而ChannelHandlerContext得事件如下:

image-20220414103153335

另外通过ChannelInboundHandlerAdapter源码也可以证实这一情况:

public class ChannelInboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelInboundHandler {
    public ChannelInboundHandlerAdapter() {
    }

    public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
        ctx.fireChannelRegistered();
    }

    public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
        ctx.fireChannelUnregistered();
    }

    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ctx.fireChannelActive();
    }

    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        ctx.fireChannelInactive();
    }

    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        ctx.fireChannelRead(msg);
    }

    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        ctx.fireChannelReadComplete();
    }

    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        ctx.fireUserEventTriggered(evt);
    }

    public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
        ctx.fireChannelWritabilityChanged();
    }

    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.fireExceptionCaught(cause);
    }
}

2.2.4 Channel与ChannelPipeline

Channel与ChannelPipeline的关系也很简单,一个Channel内有且唯有一个ChannelPipeline。这很好理解,因为Channel就是连接,而ChannelPipeline就是处理连接的管道。

2.2.5 总结

由此我们知道了他们之间的关系,也知道了Netty责任链的调用逻辑,总结一下便是:

一个EventLoop内包含多个Channel,而一个Channel有一个ChannelPipeline,一个ChannelPipeline有多个ChannelHandler且每个ChannelHandler都有一个和其彼此绑定的ChannelHandlerContext,ChannelHandlerContext保证了责任链的顺序调用。

2.3 IO线程与非IO线程

Netty中有两个重要的概念:IO线程和非IO线程。我们2.2.3节中贴源码时,看到了这样一行代码if (executor.inEventLoop())

这就是在判断当前调用线程是否是IO线程。

所谓IO线程即当前Channel绑定的那个EventLoop线程,而非IO线程自然就是非当前Channel绑定的EventLoop的线程。

在了解这个概念有什么用之前,我们先来看下正常流程下的一个Channel线程执行情况:

首先一个Channel会被绑定于一个EventLoop,而一个EventLoop又唯一对应一个线程,这个线程我们就称为IO线程。因此当当前Channel有事件发生时,IO线程会响应这个事件,然后调用我们实现的事件处理方法,如当有数据进来时会调用我们的read函数。

再换言之,我们在这个Channel里写的每一个Handler其实都是由IO线程调用的(但不绝对,我们一会再说为什么),我们又知道一个EventLoop可以绑定多个Channel,也即多个Channel下的多个Handler都是由一个IO线程执行的

image-20220414113020824

倘若某个Channel的某个Handler阻塞了,那与这个Channel同EventLoop的其他Channel也会被阻塞,因为一直都是只有一个线程在调用处理这些事件。这也就解释了我们在第一章说的问题2:

一个EventLoop对应多个ClientChannel,如果其中一个Channel在事件处理时阻塞,其他Channel也会受影响。

因此在实际的Netty处理中,我们往往会把耗时的业务交给别的线程做,比如用自定义的线程池。

我们假设这样一种业务场景:现在我们是Server端,在接收到客户端请求后进行协议解析(解码),一般这种协议解析是CPU操作不会很耗时,然后将解析到的请求,交给业务方法处理,往往业务方法会进行一些耗时的查库操作,如果我们业务方法也用EventLoop线程,那么势必会造成短时间的阻塞,鉴于此我们往往会将业务处理交由单独的线程做。在做完业务处理后,我们可能要将处理的结果写回给客户端,也即调用Netty Channel的write方法。但这里就会有一个问题:当前调用write方法的是我们的业务线程,也即我们自己的线程,不再是IO线程,针对IO线程与非IO线程,netty的处理方法完全不同,《Netty In Action》中给出了解释:

如果(当前)调用线程正是支撑EventLoop 的线程,那么所提交的代码块将会被(直接)执行。否则,EventLoop 将调度该任务以便稍后执行,并将它放入到内部队列中。当EventLoop下次处理它的事件时,它会执行队列中的那些任务/事件

换句话说当我们在非IO线程中执行事件,这个事件会被放进EventLoop的任务队列内,后续EventLoop会从队列内取这个任务来执行。我们通过之前的源码也可以证实这一结论:

if (executor.inEventLoop()) {
    next.invokeExceptionCaught(cause);
} else {
    try {
        executor.execute(new Runnable() {
            public void run() {
                next.invokeExceptionCaught(cause);
            }
        });
    } catch (Throwable var4) {
        if (logger.isWarnEnabled()) {
            logger.warn("Failed to submit an exceptionCaught() event.", var4);
            logger.warn("The exceptionCaught() event that was failed to submit was:", cause);
        }
    }
}

其中如果是IO线程,会直接调用invokeExceptionCaught执行,但如果是非IO线程则会调用executor.execute的方法,对线程池源码有一定了解的同学应该知道executor.execute方法并不代表执行任务,而代表将任务交给线程池内的任务队列。

说了那么多,Netty这样设计有什么用?用处太大了。首先一个Channel内的事件是由一个EventLoop的线程来执行,既然是一个线程,这就保证了线程安全性。如果是其他线程调用Channel内的事件,那并不由这个调用线程执行,而是会将这个事件提交到EventLoop的任务队列内,后续还是由EventLoop来执行,这就保证了一个Channel内的ChannelHandler是线程安全的,因为只有一个线程在执行它们。但是Netty提供了@Sharable注解,这个注解允许一个ChanelHandler被多个Channel共享,这时如果这两个Channel属于不同的EventLoop,那么这个ChannelHandler就会被两个EventLoop线程执行,因此Netty要求使用@Sharable注解的ChannelHandler需开发人员自己保证线程安全性。

之前我们说Channel里写的每一个Handler其实都是由IO线程调用的,并补充了说明但不绝对。为什么那么说,这是因为Netty其实对于耗时的任务也提供了一种解决方案,《Netty In Action》中是这样描述的:

通常 ChannelPipeline 中的每一个 ChannelHandler 都是通过它的 EventLoop(I/O 线程)来处理传递给它的事件的。所以至关重要的是不要阻塞这个线程,因为这会对整体的 I/O 处理产生负面的影响。 但有时可能需要与那些使用阻塞 API 的遗留代码进行交互。对于这种情况,ChannelPipeline 有一些接受一个EventExecutorGroup 的 add()方法。如果一个事件被传递给一个自定义的 EventExecutorGroup,它将被包含在这个 EventExecutorGroup 中的某个 EventExecutor 所处理,从而被从该
Channel 本身的 EventLoop 中移除。对于这种用例,Netty 提供了一个叫 DefaultEventExecutorGroup 的默认实现。

Netty允许用户将自定义的一组线程池传给ChannelPipeline,其其中一个API如下 :

ChannelPipeline addLast(EventExecutorGroup group, ChannelHandler... handlers);

也即添加ChannelHandler时为其添加一个EventExecutorGroup。

那么对于耗时的事件可以由这个传入的线程池来处理,这其实与我们自定义线程池来处理耗时事件没什么两样。

3. 事件驱动

很多人会将Netty与NIO或IO多路复用对比,认为其只是在此基础上的一层封装。但其实不然,Netty真正强大的地方在于异步和事件驱动。在Netty官网第一行是这样描述的:

Netty is an asynchronous event-driven network application framework
for rapid development of maintainable high performance protocol servers & clients.

官方也很自豪于其异步和事件驱动。我们在这一章简单讲讲事件驱动。

事件驱动是一个很抽象的概念,很多框架都会用这个概念,什么是事件驱动?

简而言之我们将所有关心的内容称为事件,一旦这个内容发生,就代表一次事件的发生,那么就会执行一次这个事件的处理。

以Netty举例,我们在创建一个Server端的时候,往往比较关心读写事件。以读事件为例,既然我们关心读事件,那么Netty就给我们提供了接口,这个接口规定了当读事件发生时,我们可以做的事。我们继承这个接口然后实现了read方法。那么一旦当有客户端给你发了消息,就代表一次读事件的发生,此时Netty就会调用你的之前的read方法。看到了没有,你的read方法都是每次在读事件发生的时候调用的,这就是事件驱动。

你无需关心这些事件是如何发生的,Netty只是告诉了你有哪些你可以关心的事件,那么如果你关心哪个事件就写一个Handler实现这个事件的处理,一旦这个事件真的发生,Netty就会调用你的这个处理函数。至于Netty中有哪些可以关心的事件,不同的组件不太相同,大家可以在《Netty In Action》一书中第六章自行查看。

4. 网管与Netty的相适性

上面我们介绍了网管和设备通信时,网管作为客户端对Netty的使用相适性,笔者也在第一章中对这种情况下网管使用Netty的目的,也即提出了客户端模式下单线程管理多Channel的可行性方案。

这里其实很多同学可能会问,网管使用Netty其实不仅是和设备通信,还和57S通信,57S会频繁发大量的请求,此时网管是身为服务端的,这不正是netty的使用场景吗?

如果你是那么认为的,那说明你对netty,以及对本文上面所讲的内容基本没有弄明白。

我们之前说过一个channel会唯一绑定到一个EventLoop上。你server端创建的再好,用主从reactor,然后subreactor由CPU2的EventLoop,但那又有什么用?57S模拟器我们认为是我们的客户端,现场实际使用的时候,一般只会有一个57S模拟器客户端,不会同时有很多很多的57s模拟器与你交互的。也即现场一般只会有一个channel与我们的server连接,我们创建的CPU 2个EventLoop也是浪费。即使你57s客户端发的再频繁,1s发1w条消息,依然永远都是一个EventLoop,也即一个线程来处理这个Channel,其他Eventloop是不会帮忙的。因此即使是三方接口的模式下,网管作为服务端,但我们依然完全没有用到IO多路复用的特性。

因此对于Netty提供的IO多路复用,无论是网管作为客户端还是网管作为服务端,我们目前都完全没有用到或者说没有很好的用到这一特性。网管目前对于netty的使用,也仅仅是写一些handler,用netty的事件驱动来回调自己的业务代码,仅仅如此。

这其实主要跟网管的业务类型与使用场景相关,netty更适用于作为server端处理高并发的场景,比如你可以用netty写一个tomcat,那么你就可以完全充分的利用netty的IO多路复用这一高效特性。

netty是一个很好的网络框架,只不过我们对其的使用很浅并且受限于业务场景,网管身为服务端并没有高并发的场景,因此没有这样的业务需求,我们自然也无法使用和发挥netty的高并发性能。

最后修改:2022 年 05 月 15 日
如果觉得我的文章对你有用,请随意赞赏