博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

netty io线程与业务逻辑线程的分离【好好学学】

Posted on 2016-05-28 17:46  bw_0927  阅读(5787)  评论(0编辑  收藏  举报

 

http://www.infoq.com/cn/articles/the-multithreading-of-netty-cases-part02#anch130405

 

1.1. 问题描述

最近在使用Netty构建推送服务的过程中,遇到了一个问题,想再次请教您:如何正确的处理业务逻辑?问题主要来源于阅读您发表在InfoQ上的文章《Netty系列之Netty线程模型》,文中提到 “2.4Netty线程开发最佳实践中 2.4.2复杂和时间不可控业务建议投递到后端业务线程池统一处理。对于此类业务,不建议直接在业务ChannelHandler中启动线程或者线程池处理,建议将不同的业务统一封装成Task,统一投递到后端的业务线程池中进行处理。”

我不太理解“统一投递到后端的业务线程池中进行处理”具体如何操作?像下面这样做是否可行:

private ExecutorService executorService = 
Executors.newFixedThreadPool(4);
@Override
public void channelRead (final ChannelHandlerContext ctx, final Object msg) 
throws Exception {
executorService.execute(new Runnable() 
{@Override
public void run() {
doSomething();

其实我想了解的是真实生产环境中如何将业务逻辑与Netty网络处理部分很好的作隔离,有没有通用的做法?

 

 

1.2. 答疑解惑

Netty的ChannelHandler链由I/O线程执行,如果在I/O线程做复杂的业务逻辑操作,可能会导致I/O线程无法及时进行read()或者write()操作。所以,比较通用的做法如下:

  • 在ChannelHanlder的Codec中进行编解码,由I/O线程做CodeC;
  • 将数据报反序列化成业务Object对象之后,将业务消息封装到Task中,投递到业务线程池中进行处理,I/O线程返回。

不建议的做法:

 

 图1-1 不推荐业务和I/O线程共用同一个线程

 

推荐做法:

 

 图1-2 建议业务线程和I/O线程隔离

 

1.3. 问题总结

事实上,并不是说业务ChannelHandler一定不能由NioEventLoop线程执行,如果业务ChannelHandler处理逻辑比较简单,执行时间是受控的,业务I/O线程的负载也不重,

在这种应用场景下,业务ChannelHandler可以和I/O操作共享同一个线程。使用这种线程模型会带来两个优势:

  1. 开发简单:开发业务ChannelHandler的不需要关注Netty的线程模型,只负责ChannelHandler的业务逻辑开发和编排即可,对开发人员的技能要求会低一些;
  2. 性能更高:因为减少了一次线程上下文切换,所以性能会更高。

在实际项目开发中,一些开发人员往往喜欢照葫芦画瓢,并不会分析自己的ChannelHandler更适合在哪种线程模型下处理。

如果在ChannelHandler中进行数据库等同步I/O操作,很有可能会导致通信模块被阻塞。

所以,选择什么样的线程模型还需要根据项目的具体情况而定,一种比较好的做法是支持策略配置,例如阿里的Dubbo,支持通过配置化的方式让用户选择业务在I/O线程池还是业务线程池中执行,比较灵活。

2. Netty客户端连接问题

2.1. 问题描述

Netty客户端想同时连接多个服务端,使用如下方式,是否可行,我简单测试了下,暂时没有发现问题。代码如下:

EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group)
             ......代码省略
            // Start the client.
            ChannelFuture f1 = b.connect(HOST, PORT);
            ChannelFuture f2 = b.connect(HOST2, PORT2);
            // Wait until the connection is closed.
            f1.channel().closeFuture().sync();
            f2.channel().closeFuture().sync();
            ......代码省略
}

2.2. 答疑解惑

上述代码没有问题,原因是尽管Bootstrap自身不是线程安全的,但是执行Bootstrap的连接操作是串行执行的,而且connect(String inetHost, int inetPort)方法本身是线程安全的,它会创建一个新的NioSocketChannel,

并从初始构造的EventLoopGroup中选择一个NioEventLoop线程执行真正的Channel连接操作,与执行Bootstrap的线程无关,所以通过一个Bootstrap连续发起多个连接操作是安全的,它的原理如下:

 

 图2-1 Netty BootStrap工作原理

 

2.3. 问题总结

注意事项-资源释放问题: 在同一个Bootstrap中连续创建多个客户端连接,需要注意的是EventLoopGroup是共享的,也就是说这些连接共用一个NIO线程组EventLoopGroup,当某个链路发生异常或者关闭时,只需要关闭并释放Channel本身即可,不能同时销毁Channel所使用的NioEventLoop和所在的线程组EventLoopGroup,例如下面的代码片段就是错误的:

ChannelFuture f1 = b.connect(HOST, PORT);
 ChannelFuture f2 = b.connect(HOST2, PORT2);
 f1.channel().closeFuture().sync();
  } finally {
          group.shutdownGracefully();
  }

线程安全问题: 需要指出的是Bootstrap不是线程安全的,因此在多个线程中并发操作Bootstrap是一件非常危险的事情,Bootstrap是I/O操作工具类,它自身的逻辑处理非常简单,真正的I/O操作都是由EventLoop线程负责的,所以通常多线程操作同一个Bootstrap实例也是没有意义的,而且容易出错,错误代码如下:

Bootstrap b = new Bootstrap();
{
   //多线程执行初始化、连接等操作
}

 

如果复杂业务放到业务池里处理,怎么对客户端进行返回:将Chanel传递到业务线程池中,然后调用Chanel的write方法即可。

=====================

http://blog.kazaff.me/2015/04/01/dubbo%E7%9A%84%E9%80%9A%E4%BF%A1%E6%A8%A1%E5%9E%8B/

https://pianshen.com/article/20245626/

 

netty线程模型的分析中,可以认为netty提供的那些nio网络工作线程主要被用于消息链路的读取、解码、编码和发送。

而dubbo把业务逻辑的执行放在自身维护的线程池中是否就是为了贯彻netty的这一原则呢?

从上面给的链接中可以注意到下面这段话:

Netty是个异步高性能的NIO框架,它并不是个业务运行容器,因此它不需要也不应该提供业务容器和业务线程。

合理的设计模式是Netty只负责提供和管理NIO线程,其它的业务层线程模型由用户自己集成,Netty不应该提供此类功能,只要将分层划分清楚,就会更有利于用户集成和扩展。

正如文中所说,dubbo这么做有利于分离通信层,方便的替换掉netty。至于是否还有更高深的理由,我就不清楚了,希望大牛赐教。

 

 

2.3.3. 聚焦而不是膨胀

Netty是个异步高性能的NIO框架,它并不是个业务运行容器,因此它不需要也不应该提供业务容器和业务线程。合理的设计模式是Netty只负责提供和管理NIO线程,其它的业务层线程模型由用户自己集成,Netty不应该提供此类功能,只要将分层划分清楚,就会更有利于用户集成和扩展。

令人遗憾的是在Netty 3系列版本中,Netty提供了类似Mina异步Filter的ExecutionHandler,它聚合了JDK的线程池java.util.concurrent.Executor,用户异步执行后续的Handler。

ExecutionHandler是为了解决部分用户Handler可能存在执行时间不确定而导致IO线程被意外阻塞或者挂住,从需求合理性角度分析这类需求本身是合理的,但是Netty提供该功能却并不合适。原因总结如下:

1. 它打破了Netty坚持的串行化设计理念,在消息的接收和处理过程中发生了线程切换并引入新的线程池,打破了自身架构坚守的设计原则,实际是一种架构妥协;

2. 潜在的线程并发安全问题,如果异步Handler也操作它前面的用户Handler,而用户Handler又没有进行线程安全保护,这就会导致隐蔽和致命的线程安全问题;

3. 用户开发的复杂性,引入ExecutionHandler,打破了原来的ChannelPipeline串行执行模式,用户需要理解Netty底层的实现细节,关心线程安全等问题,这会导致得不偿失。

鉴于上述原因,Netty的后续版本彻底删除了ExecutionHandler,而且也没有提供类似的相关功能类,把精力聚焦在Netty的IO线程NioEventLoop上,这无疑是一种巨大的进步,

Netty重新开始聚焦在IO线程本身,而不是提供用户相关的业务线程模型

 

2.4. Netty线程开发最佳实践

2.4.1. 时间可控的简单业务直接在IO线程上处理

如果业务非常简单,执行时间非常短,不需要与外部网元交互、访问数据库和磁盘,不需要等待其它资源,则建议直接在业务ChannelHandler中执行,不需要再启业务的线程或者线程池。避免线程上下文切换,也不存在线程并发问题。

2.4.2. 复杂和时间不可控业务建议投递到后端业务线程池统一处理

对于此类业务,不建议直接在业务ChannelHandler中启动线程或者线程池处理,建议将不同的业务统一封装成Task,统一投递到后端的业务线程池中进行处理。

过多的业务ChannelHandler会带来开发效率和可维护性问题,不要把Netty当作业务容器,对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务容器,做好和Netty的架构分层

2.4.3. 业务线程避免直接操作ChannelHandler

对于ChannelHandler,IO线程和业务线程都可能会操作,因为业务通常是多线程模型,这样就会存在多线程操作ChannelHandler。

为了尽量避免多线程并发问题,建议按照Netty自身的做法,通过将操作封装成独立的Task由NioEventLoop统一执行,而不是业务线程直接操作,相关代码如下所示:

 

图2-31 封装成Task防止多线程并发操作

如果你确认并发访问的数据或者并发操作是安全的,则无需多此一举,这个需要根据具体的业务场景进行判断,灵活处理

 

if(ctx.executor().inEventLoop()){    //如果当前线程就是业务线程ctx.executor(),执行任务

}else{

  ctx.executor().execute(new Runnable(){   //否则把任务投递到业务线程里

}

}

 

 

======

对于绝大多数业务,业务逻辑耗时是<<<IO耗时,使用默认配置没有问题。

 

如果业务逻辑比较简单,没有阻塞的情况则可以直接在netty的io线程中处理业务逻辑。
2.如果业务逻辑复杂,耗时长,则建议使用自己实现的线程池。
(注意处理完业务逻辑,封装完返回的rsp后要调用
ctx.executer(new Runable(){ctx.writeAndflush(rsp)}))   //作用是:把resp作为一个任务投递到socket所在的eventloop
调用netty的io线程处理剩下的编码逻辑。这一点排名第一的答案写的比较清楚。
 
最后提醒一下,使用自己的线程池的时候注意限流,不然容易高并发情况下容易引起内存泄露【来不及处理】。
线程池提交任务是异步无阻塞的。高并发情况下可能造成大量的请求积压在线程池的队列里,耗完内存。
tomcat也使用了线程池,但是他有限制连接数。所以使用自己线程池的时候要么也限流,要么实现自己线程池,当任务超过一定量的提交任务时阻塞。



链接:https://www.zhihu.com/question/35487154/answer/89255483

 

http://www.52im.net/thread-184-1-1.html

http://www.infoq.com/cn/articles/netty-version-upgrade-history-thread-part/

 

Netty 3.x 线程模型


Netty 3.X的I/O操作线程模型比较复杂,它的处理模型包括两部分:

  • Inbound:主要包括链路建立事件、链路激活事件、读事件、I/O异常事件、链路关闭事件等;
  • Outbound:主要包括写事件、连接事件、监听绑定事件、刷新事件等。


我们首先分析下Inbound操作的线程模型:

 

 

 

从上图可以看出,Inbound操作的主要处理流程如下:

  • I/O线程(Work线程)将消息从TCP缓冲区读取到SocketChannel的接收缓冲区中;
  • 由I/O线程负责生成相应的事件,触发事件向上执行,调度到ChannelPipeline中;
  • I/O线程调度执行ChannelPipeline中Handler链的对应方法,直到业务实现的Last Handler;
  • Last Handler将消息封装成Runnable,放入到业务线程池中执行,I/O线程返回,继续读/写等I/O操作;
  • 业务线程池从任务队列中弹出消息,并发执行业务逻辑。

通过对Netty 3的Inbound操作进行分析我们可以看出,Inbound的Handler都是由Netty的I/O Work线程负责执行



下面我们继续分析Outbound操作的线程模型:

 


从上图可以看出,Outbound操作的主要处理流程如下:

    • 业务线程发起Channel Write操作,发送消息;
    • Netty将写操作封装成写事件,触发事件向下传播;
    • 写事件被调度到ChannelPipeline中,业务线程按照Handler Chain串行调用支持Downstream事件的Channel Handler;
    • 执行到系统最后一个ChannelHandler,将编码后的消息Push到发送队列中,业务线程返回;
    • Netty的I/O线程从发送消息队列中取出消息,调用SocketChannel的write方法进行消息发送。

 

6.2 Netty 4.X 版本线程模型

相比于Netty 3.X系列版本,Netty 4.X的I/O操作线程模型比较简答,它的原理图如下所示:

 

 

图6-3 Netty 4 Inbound和Outbound操作线程模型

从上图可以看出,Outbound操作的主要处理流程如下:

  1. I/O线程NioEventLoop从SocketChannel中读取数据报,将ByteBuf投递到ChannelPipeline,触发ChannelRead事件;
  2. I/O线程NioEventLoop调用ChannelHandler链,直到将消息投递到业务线程,然后I/O线程返回,继续后续的读写操作;
  3. 业务线程调用ChannelHandlerContext.write(Object msg)方法进行消息发送;
  4. 如果是由业务线程发起的写操作,ChannelHandlerInvoker(这就是业务线程)将发送消息封装成Task,放入到I/O线程NioEventLoop的任务队列中,由NioEventLoop在循环中统一调度和执行。放入任务队列之后,业务线程返回;【执行写操作的业务线程不是socket对应的IO线程,则进行写操作封装并投递给写线程】
  5. I/O线程NioEventLoop调用ChannelHandler链,进行消息发送,处理Outbound事件,直到将消息放入发送队列,然后唤醒Selector,进而执行写操作。

通过流程分析,我们发现Netty 4修改了线程模型,无论是Inbound还是Outbound操作,统一由I/O线程NioEventLoop调度执行

 

6.3. 线程模型对比

在进行新老版本线程模型PK之前,首先还是要熟悉下串行化设计的理念:

我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。

为了解决上述问题,Netty 4采用了串行化设计理念,从消息的读取、编码以及后续Handler的执行,始终都由I/O线程NioEventLoop负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户而言,甚至不需要了解Netty的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:

 

 图6-4 Netty 4的串行化设计理念

 

一个NioEventLoop聚合了一个多路复用器Selector【one loop per thread】,因此可以处理成百上千的客户端连接,Netty的处理策略是每当有一个新的客户端接入,则从NioEventLoop线程组中顺序获取一个可用的NioEventLoop,当到达数组上限之后,重新返回到0,通过这种方式,可以基本保证各个NioEventLoop的负载均衡。一个客户端连接只注册到一个NioEventLoop上,这样就避免了多个I/O线程去并发操作它。

Netty通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。

了解完了Netty 4的串行化设计理念之后,我们继续看Netty 3线程模型存在的问题【netty3提供了业务线程;netty 4专注网络线程】,总结起来,它的主要问题如下:

  1. Inbound和Outbound实质都是I/O相关的操作,它们的线程模型竟然不统一,这给用户带来了更多的学习和使用成本;
  2. Outbound操作由业务线程执行通常业务会使用线程池并行处理业务消息,这就意味着在某一个时刻会有多个业务线程同时操作ChannelHandler,我们需要对ChannelHandler进行并发保护,通常需要加锁。如果同步块的范围不当,可能会导致严重的性能瓶颈,这对开发者的技能要求非常高,降低了开发效率;
  3. Outbound操作过程中,例如消息编码异常,会产生Exception,它会被转换成Inbound的Exception并通知到ChannelPipeline,这就意味着业务线程发起了Inbound操作!它打破了Inbound操作由I/O线程操作的模型,如果开发者按照Inbound操作只会由一个I/O线程执行的约束进行设计,则会发生线程并发访问安全问题。由于该场景只在特定异常时发生,因此错误非常隐蔽!一旦在生产环境中发生此类线程并发问题,定位难度和成本都非常大。

讲了这么多,似乎Netty 4 完胜 Netty 3的线程模型,其实并不尽然。在特定的场景下,Netty 3的性能可能更高,就如本文第4章节所讲,

如果编码和其它Outbound操作非常耗时,由多个业务线程并发执行,性能肯定高于单个NioEventLoop线程。

但是,这种性能优势不是不可逆转的,如果我们修改业务代码,将耗时的Handler操作前置,Outbound操作不做复杂业务逻辑处理,性能同样不输于Netty 3,但是考虑内存池优化、不会反复创建Event、不需要对Handler加锁等Netty 4的优化,整体性能Netty 4版本肯定会更高。

 

 

 

 

使用业务线程池把I/O操作和业务操作的隔离

Netty 4修改了Netty 3的线程模型:

在Netty 3的时候,upstream是在I/O线程里执行的,而downstream是在业务线程里执行【业务线程把处理结果封装成任务,投递给网络线程】

当Netty从网络读取一个数据报投递给业务handler的时候,handler是在I/O线程里执行;

当我们在业务线程中调用write和writeAndFlush向网络发送消息的时候,handler是在业务线程里执行,直到最后一个Header handler将消息写入到发送队列中,业务线程才返回

 

Netty4修改了这一模型,

在Netty 4里inbound(对应Netty 3的upstream)和outbound(对应Netty 3的downstream)都是在NioEventLoop(I/O线程)中执行。

当我们在业务线程里通过ChannelHandlerContext.write发送消息的时候,

Netty 4在将消息发送事件调度到ChannelPipeline的时候,首先将待发送的消息封装成一个Task,然后放到NioEventLoop的任务队列中,由NioEventLoop线程异步执行。

后续所有handler的调度和执行,包括消息的发送、I/O事件的通知,都由NioEventLoop线程负责处理

 

 http://www.52im.net/forum.php?mod=viewthread&tid=181

下面我们分别通过对比Netty 3和Netty 4的消息接收和发送流程,来理解两个版本线程模型的差异:

Netty 3的I/O事件处理流程:

 

 图2-3 Netty 3 I/O事件处理线程模型

 

Netty 4的I/O消息处理流程:

 

 图2-4 Netty 4 I/O事件处理线程模型