Netty4实战第十五章:选择正确的线程模型

本章主要内容:

  • 线程模型的知识
  • EventLoop
  • 并发
  • 任务执行器
  • 任务定时执行
  线程模型决定了应用或框架如何执行代码,所以选择正确的线程模型是很重要的事情。Netty提供了一个简单的但是功能强大的线程模型帮助开发者简化代码,因为Netty核心部分处理了所有需要同步的地方。所有的ChannelHandler,包括业务逻辑,在指定的Channel时或保证同时只执行在一个线程中。不过并不是说Netty不能使用多线程,它设计成限制一个连接对应一个线程,对于非阻塞更好一些。也就是说使用Netty不需要考虑多线程环境中经常遇见的问题,例如ConcurrentModificationException,也不用担心状态数据、锁等等麻烦的问题,Netty已经帮我们解决了。
  学习完本章的知识,我们就会了解Netty的线程模型,并且会知道Netty的团队为什么选择这种线程模型。使用这些知识,也可以让你的应用发挥出最佳的性能。除此之外,它还可以帮助你编写简洁的代码,这种线程模型让我们更容易编写出干净简单的代码。还可以学习Netty团队的经验,过去使用的线程模型和现在使用的线程模型如何使Netty更加强大。
  虽然本章学习的主要是Netty的知识,不过你也可以融会贯通线程模型的知识,它可以帮助你如何为你的应用选择一个合适的线程模型。
  本章还假设读者有以下知识:
  • 理解什么是线程并且有一定的使用经验。如果还没使用过线程的,最好去了解一下线程的基础,再来学习本章
  • 理解多线程应用,包括线程安全方面的知识
  • 最好理解并使用过java.util.concurrent包里面的ExecutorService和ScheduledExecutorService

一、线程模型

  在这一小节,我们会学习什么是线程模型,Netty4使用的是什么线程模型,Netty4之前的版本使用的是什么样的线程模型。会更好地理解不同线程模型之间的优缺点。
  其实仔细想一想,大家在实际生活中其实都使用了线程模型。为了更好的理解什么是线程模型,我们使用一个现实生活中的例子进行类比。
  比如大家有一个饭店,厨房要做好饭菜然后送到顾客桌子上。一个顾客进来点了菜,你就需要告诉厨房要做什么菜。你的厨房可以使用不同的方式处理这些订单-每一种方式类似一个线程模型如何执行任务。

  • 一个厨师一次只做一道菜,这类似单线程模型,也就是一个时间点只有一个任务在执行。这个任务完成了,再去执行下一个任务
  • 多个厨师,哪个厨师没事做就去做客人点的菜。这类似与多线程模型,多个线程执行多个任务,也就是说那些任务可以同时执行
  • 多个厨师,但是进行了分组,这组炒菜,这组煮面,那组蒸米饭。这也是多线程模型,不过有额外的限制。虽然多个任务也是同时执行,但是不同类型的任务分在了不同的组执行,如炒菜,面条,米饭
  日常的很多事情都可以与线程模型联系起来。但是Netty是什么样的呢?不幸的是,它并不简单,Netty的核心是多线程模型但是对于开发者来说隐藏了很多细节。Netty使用了多线程完成所有的任务,但是暴露给开发者的却是一个单线程模型。
  在学习更底层的知识之前,我们先通过其他大部分应用的做法来理解线程模型。
  大多数现代应用程序都会使用多个线程来分发工作,因此可以有效的利用系统的资源。在早起的Java版本中,如果需要并行的处理任务,是通过创建线程来做到的。
  但很快大家发现这种方式并不完美,因为创建线程然后回收资源是有不小的系统开销的。然后到了Java 5有了线程池技术,使用了同一个接口Executor。Java 5提供了很多线程池的实现,虽然它们的内部结构都不一样,但是它们的思想是一样的。当需要多线程执行任务时,线程池创建线程并尽可能重用线程。这样就可以尽可能降低创建与销毁线程的系统开销。
  下图展示了线程池如何使用多线程执行任务的。任务提交之后由空闲线程去执行,任务执行完成之后,就会释放掉线程。

Netty4实战第十五章:选择正确的线程模型_第1张图片

  线程池的技术,解决了线程创建与销毁的系统开销,因为对于每一个新任务,它不需要去创建线程,只需要使用线程池中的空闲线程。但这也只解决了问题的一半,后面再详细讲解。
  为什么不在所有情况下都使用多线程呢?毕竟现在有了ExecutorService这种线程池技术帮我们降低了创建回收线程的系统资源开销。
  使用多线程除了创建回收线程的系统开销,还会带来其他的副作用,如资源管理,上下文切换等。随着线程数量和任务数量的增加,这些副作用就会越大。刚使用多线程的时候可能不会出现什么问题,但是到真正在大型系统中使用多线程就可能出现很大的问题。

  除了上面说的这些技术限制和问题外,使用多线程技术另一个大问题就是项目或框架的维护成本。可以说应用的并发特性会导致项目复杂很多。总结起来就是很简单的一句话:编写多线程应用是一个很难的工作。我们能做什么来解决这些问题呢?因为实际项目中很多地方都需要多线程场景。我们来看看Netty是如何解决这些问题的。

二、EventLoop

  EventLoop这个名字取的很形象。意思就是事件执行在一个循环中直到被终止。这种设计很适合网络框架,因为它需要在一个循环中为指定的连接执行事件逻辑。这并不是Netty新发明的,其他项目和框架很早以前就使用了这种设计。

  Netty的事件循环代表是EventLoop接口。EventLoop继承了EventExecutor,EventExecutor继承了ScheduledExecutorService,也就是任务可以直接交给EventLoop来执行。EventExecutor的继承关系图如下。

Netty4实战第十五章:选择正确的线程模型_第2张图片

  因为EventLoop会指定给Channel,所以Channel的事件就可以交给EventLoop执行,类似通常使用ExecutorService执行多线程任务。

2.1、使用EventLoop

  下面的代码展示了如何使用指定给Channel的EventLoop执行任务。

        Channel ch = ...
        Future future = ch.eventLoop().execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("Run in the EventLoop");
            }
        });
  直接使用 EventLoop的execute方法就可以执行线程任务了,并且不需要担心一些同步问题。因为一个Channel相关的任务都会在同一个线程里执行。这就是Netty需要的线程模型。

  可以利用返回的Future来检查任务是否执行完成。

        Channel channel = ...
        Futurefuture = channel.eventLoop().submit(…);
        if (future.isDone()) {
            System.out.println("Task complete");
        } else {
            System.out.println("Task not complete yet");
        }
  还可以通过检查任务是否在 EventLoop中来确定任务是否将被直接执行,如下。

        Channel ch = ...
        if (ch.eventLoop().inEventLoop()) {
            System.out.println("In the EventLoop");
        } else {
            System.out.println("Outside the EventLoop");
        }
  只有在确定没有其他 EventLoop使用线程池时再去关闭线程池,否则则会出现一些不明确的影响。

2.2、Netty4的IO操作

  上一小节实现的线程模型非常强大,Netty就是使用它处理IO事件的,也就是触发Socket上的读写操作。这些读写操作是Java和底层操作系统提供的网络API的一部分。

  下图展示了Netty的进出数据操作是如何在EventLoop上下文中执行的。如果执行线程已经绑定到EventLoop则操作就会直接执行;如果没有则会放到队列里,待EventLoop准备好后再执行。

Netty4实战第十五章:选择正确的线程模型_第3张图片

  具体处理什么事件取决于事件的性质。通常是将网络栈中的数据读或传输到应用中,其他时候则是方向相反的同样操作,例如将应用中的数据传输到网络栈中用来发送给对端。但并不只限用于这种类型,重要的是它的逻辑是比较通用和灵活的,可以用来处理各种各样的情况。

  另外就是Netty并不是一直使用上面说的EventLoop线程模型。下一小节就会介绍Netty3使用的线程模型。这样也能帮助我们更容易理解Netty4的线程模型为什么更好。

2.3、Netty3的IO操作

  Netty3的线程模型和Netty4的有些不同。Netty3只保证读操作事件在IO线程中执行。写操作事件是通过调用线程来处理的。听起来这也是个好主意但结果证实这很容易出错。因为它需要同步ChannelHandler来处理事件,因为它不保证同一时间只有一个线程去操作。一个Channel在同一时间写多次数据就会出现这种问题,例如,在同时在不同线程调用Channel.write(..)方法。

Netty4实战第十五章:选择正确的线程模型_第4张图片

  除了需要同步ChannelHandler的副作用,Netty3的线程模型另外一个副作用就是处理发送数据事件时会触发收到消息事件。这是很有可能的,例如,当你使用Channel.write(..)方法时出现了异常。这个时候,exceptionCaught事件就会产生并被触发。咋一看这貌似也不是个问题,不过exceptionCaught设计的是一个收到消息事件,这样就会出现问题。实际上问题就是你的代码在调用的业务线程里面执行,但是exceptioncaught事件要交给工作线程去处理。通常如果能正确处理也没什么问题,但是如果忘记交给工作线程,就会导致线程模型失效,可能会带来很多竞争条件,使用一个线程去处理收到消息事件就不再可行了。

  当然Netty3的线程模型也就是优点的,在某些情况它有更低的延迟,毕竟读写操作是分在了不同线程,但是它带来的复杂性远远大于它的优点。事实上,大多数应用程序也不能明显看出延迟的差异,因为这还依赖一些其他因素:

  • 网络速度
  • 实际的I/O线程是否繁忙
  • 上下文切换
  可以看到影响整体延迟的因素还是很多的。
  现在大家已经知道了在EventLoop中可以执行任务,我们来看看Netty内部是如何使用这些功能的。

2.4、Netty线程模型详情

  Netty内部的线程模型性能如此优秀的诀窍就是通过检查执行线程是否是分配给Channel和EventLoop的那一个。在EventLoop的生命周期中,它负责处理Channel的所有事件。
  如果线程和EventLoop的相同,则代码块就会直接执行。如果不相同,就产生一个调度任务放到内部的队列中,稍后再执行。通常EventLoop处理了下一个Channel的事件就会被执行。这样就可以在任意线程中与Channel交互,而且能确保所有的ChannelHandler都是顺序执行的,不用担心并发问题。
  下图展示了Netty的线程模型中EventLoop执行任务的逻辑。
Netty4实战第十五章:选择正确的线程模型_第5张图片
  还有,这种设计有一个非常重要的需要注意的地方,就是不能将耗时比较长的任务交给EventLoop的执行队列,因为在同一个线程中执行耗时的任务就会阻塞线程中其他任务的执行。这对整个系统有多大影响取决于不同传输方式的EventLoop的具体实现。
  实际项目中难免会遇到不修改任何代码而去切换传输方式的情况,所以最重要的还是记住黄金法则:永远不要去阻塞I/O线程。但是肯定会有耗时的任务需要去执行,这时就需要使用专用的EventExecutor和EventExecutorGroup,前面第六章也说过这方面的知识。
  下一小节会讲述应用程序常用的另一个特性,就是常见的定时调度任务和定期执行任务。Java本身就提供了开箱即用的方案处理这种需求,但你会学习到Netty提供的几个更为先进的实现。

三、延迟执行的调度任务

  实际项目中会经常遇到延迟执行的调度任务,例如你需要在客户端连接成功五分钟后检查连接是否断开。一个常用的方式就是不停发送心跳检测消息给对端来检查连接是否还存活着。如果对端没有响应,那么你就知道了连接已经断开了,就可以关闭Channel释放资源了。
  例如我们生活中经常会打电话,沉默一段时间后,你可能就会问对方还能听到你的声音不。如果对方没有回应,就说明已经挂断了或者信号不好连接断开了,不管什么情况只要你没得到回应你就可以挂断电话了,因为你等下去也没什么意义了。挂断电话你就可以干其他事情了,相当于释放了资源。
  下一小节将会介绍如何使用Netty强大的EventLoop实现去执行调度任务。也会花一点时间介绍一下如何使用Java内置的API去执行调度任务,这样大家更容易对比Netty的实现和Java API的优缺点。除此之外,你还可以更加了解Netty内部的实现的优缺点。

3.1、Java API执行调度任务

  使用Java执行调度任务比较常用的就是ScheduledExecutorService。不过也不一定,因为在JDK5之前没有它,一般都是使用java.util.Timer,不过它与普通线程的问题是一样的,所以JDK5发布了ScheduledExecutorService。
  下表列出了java.util.concurrent.Executors提供的创建ScheduledExecutorService实例的静态方法。

方法

描述

newScheduledThreadPool(int corePoolSize)


newScheduledThreadPool(int corePoolSize,ThreadFactorythreadFactory)

创建指定线程数量的调度任务执行器

newSingleThreadScheduledExecutor()


newSingleThreadScheduledExecutor(ThreadFactorythreadFactory)

创建单个线程的调度任务执行器

  可能你觉得上面的方法提供的不是很多,但是大部分情况下都是够用的了。现在我们来看看如何使用 ScheduledExecutorService执行一个60秒后的任务。
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
        ScheduledFuture future = executor.schedule((Runnable) () -> {
            System.out.println("Now it is 60 seconds later");
        }, 60, TimeUnit.SECONDS);
        executor.shutdown();
  可以看到使用 ScheduledExecutorService是很容易发起一个调度任务的。
  我们已经了解的JDK的API是如何发起调度任务的,等会也会学习Netty的,Netty的API和JDK很类似,但是使用的是一种更加有效的方式。

3.2、使用EventLoop发起调度任务

  可能你在实际项目中经常使用ScheduledExecutorService发起调度任务,而且它也工作的很好,然而它还是有一些限制的,例如它的任务是执行在其他线程中的。如果启动了非常多的调度任务会带来大量的资源负载。这种大量的资源负载在网络框架例如Netty是不能接受的。但如果确实有大量调度任务需要执行该怎么办呢?幸运的是Netty已经免费提供了大部分核心API。
  Netty通过指定给Channel的EventLoop来处理这种场景。
        Channel ch = ...
        ScheduledFuture future = ch.eventLoop().schedule(() -> System.out.println("Now its 60 seconds later"),
                60, TimeUnit.SECONDS);
  如上面的代码所示,60秒后将由 EventLoop去执行。
  前面说过,EventLoop继承了ScheduledExecutorService,也就是说如果你习惯ScheduledExecutorService的API,那么使用EventLoop还是同样的API。
  也可以使用EventLoop执行循环调度任务,例如每60秒就去执行一次。
        Channel ch = ...
        ScheduledFuture future = ch.eventLoop()
                .scheduleAtFixedRate((Runnable) () -> System.out.println("Run every 60 seconds"),
                        60, 60, TimeUnit.SECONDS);
  如果想取消任务,可以利用返回的 ScheduledFuture。ScheduledFuture提供了一些方法用于取消调度任务或者检查调度任务的状态。
        ScheduledFuture future = ch.eventLoop().scheduleAtFixedRate(..);
        future.cancel(false);
  更多 ScheduledExecutorService的相关API可以去查看JDK文档。
  现在我们了解一下Netty的ScheduledExecutorService究竟和其他的实现有什么不同,为什么它的实现比较优秀。

3.3、调度任务的内部实现

  实际上Netty的实现是参考了George Varghese的论文“Hashed and hierarchical timing wheels: Data structures to efficiently implement timer facility”,利用一种定时轮算法来管理大量的调度任务。这个算法只能保证任务执行时间是大致准确的,也就是说任务并不能保证100%准时执行。不过在实际中已经证明这个不会对系统造成很大影响,一般的应用程序都是可以容忍的。也就是说它可以管理大量调度任务,但是并不完美;如果你的应用一定要保证100%准时执行,那最好还是换一种方式。
  按照如下方式来更好的理解它是如何工作的:

  • 创建一个调度任务
  • 将调度任务插入EventLoop的任务执行队列
  • 任务需要执行时由EventLoop去检查
  • 检查通过则任务会立即执行并将任务从队列中移除
  • 检查不通过等待下次再执行
  因为这种设计任务的执行时间不是100%准确的,但比较适合Netty中的使用场景。但如果必须准确执行呢?很简单,别使用Netty实现的ScheduledExecutorService即可,例如可以直接使用JDK的实现。不过也要记住,如果不实用Netty的线程模型,那么就要记得处理好你的并发问题,需要同步的地方要同步。不到万不得已,还是别采用这种方式。

四、I/O线程分配详情

  前面我们学习了如何使用EventLoop执行任务或调度任务,是时候了解Netty是如何分配线程的了。
  Netty使用了一个线程池来处理Channel相关的I/O事件。分配线程的方式依赖于具体的传输方式。一个异步的实现就是在多个Channel之间共享几个线程,也就是一个线程服务多个连接,不需要为每个连接分配一个线程。
  Netty4实战第十五章:选择正确的线程模型_第6张图片
  如上图所以,线程池中有三个线程,每个线程服务多个Channel,这样就能保证系统资源在需要时是可用的。这三个线程会指定给每一个新创建的连接。这是通过EventLoopGroup的实现做到的,它也是通过线程池来管理资源的。尽可能的保证新创建的Channel能平均分配给每一个线程。具体实现是通过循环的方式进行分配的,可能并不是100%准确,但大部分情况下还是可信赖的。
  一旦一个Channel指定给了一个线程,那Channel的整个生命周期会都会只使用这个线程。这个情况也许会改变,不过暂时不用管这个。不会变化的就是同一个时间一个线程只会处理一个指定的Channel的IO操作。你可以而且应该相信这一点,因为它确保你不用担心并发同步问题。
  阻塞的传输方式在实现上会有一点点不同,如下图。
Netty4实战第十五章:选择正确的线程模型_第7张图片
  很明显,阻塞传输的时候一个线程只服务一个Channel,也就是只服务一个连接。过去大家可能使用了java.io.*包下面的类开发了基于阻塞API的网络应用。虽然Netty的语义和JDK的有些不太一样,但最关键的一点是不变的:指定给Channel的线程在同一时间只处理一个Channel的I/O操作。根据这个规定,使用Netty编写的应用程序可以很容易和其他网络框架比较。

五、总结

  本章我们主要学习了Netty使用了什么样的线程模型。了解了那些线程模型的优点和缺点,以及Netty的线程模型如何简化我们的应用开发工作。
  除了框架内部的任务,我们还学习了如何使用Netty的EventLoop执行自定义的任务。还学习了如何执行调度任务,以及Netty如何实现执行大量调度任务的。并且知道了如何查看调度任务的状态以及如何取消调度任务。
  本章我们还了解的Netty3使用的线程模型,通过与Netty4的线程模型对比,我们学习了Netty4是如何改善之前使用的线程模型以及现在使用的线程模型的优缺点。
  这些知识都是为了大家能更好的了解Netty的线程模型,以及帮助大家使用最少的代码获取最大的性能。如果想学习更多关于线程池和并发的知识,可以参考Brian Goetz的书Java Concurrency in Practic》。这本书能帮助大家更深入的学习多线程知识,已经使用多线程技术开发非常复杂的应用
  






 

  




  


  
  

你可能感兴趣的:(netty学习)