简单来说,线程模型规定了在操作系统、编程语言、框架或应用程序中线程管理的关键方面。如何以及何时创建线程,显然线程模型对应用代码的执行有重大影响,因此开发人员需要了解与不同模型相关的权衡。无论他们是自己选择模型还是通过采用语言或框架自带的模型。
线程池模型
首先我们来看看一个基本的线程池模型:
线程池,即用来存放“线程”的对象池。线程的生命周期包括:创建,活动和销毁。每个步骤都会占用一定的cpu时间,当线程执行任务的时间短,且执行的次数比较频繁,那么服务器将处于不停的创建线程和销毁线程的状态。当创建和销毁线程占据了线程总周期的总cpu很大一部分额度后,这样的一笔开销是不容忽视的。
线程池的出现正是主要用来处理减少线程自身所带来的上述开销。线程池采用预创建技术,在应用程序启动后,将立即创建一定数量的线程,并将创建的线程放入空闲队列中,这些线程都处于阻塞状态,并不占用cpu时间,但会消耗一定的内存空间。
线程池通常由1个值守线程和n个任务线程组成,n是线程池的最小尺寸,数值根据服务的特点估算。在实际应用中,线程池的尺寸是需要动态调整的,高峰期线程池通过增加线程来尽可能满足任务的需要,空闲期线程池再缩减至最小尺寸。当然线程池的最小尺寸也无需按预置的固定尺寸,通常可以根据一定时期内任务队列的平均大小来获得一个统计量,进行调整。
值守线程的功能是监视任务队列和维护线程池的尺寸,当任务队列中有任务项目时,每次摘除一个任务并将之投放到线程池的空闲线程中去,当线程池中没有空闲线程时,值守线程负责创建新的线程加入到池中。在任务空闲状态,值守线程销毁超过线程池最小尺寸的空闲线程,以释放系统资源。
任务线程是完成具体应用服务的工作线程,未被任务占据的线程称为空闲状态,在此状态下,我们当然不希望它无效地空转,因为那样同样会消耗CPU的时间,解决办法是它阻塞在后台。任务线程的有效运行是在投放了任务之后,被投放任务的线程在任务完成之前不能再被其他任务占据,我们称之为运行状态。在服务进程退出之前,我们希望任务线程都能够自然终结。鉴于以上原因,我们必须定义一些状态量来控制任务线程的有序运行,可以用如下状态量:空闲态、运行态、终结态,并且为每一个任务线程定义一个任务信号,以下我们再讨论下任务线程状态的转移过程。
值守线程创建新的任务线程,将其状态置为空闲态,并加入到线程池中,处于空闲态的任务线程在等待任务信号的过程中阻塞。当值守线程从任务队列中摘取任务项目后,从线程池中攫取一个空闲状态的线程,把任务项目投放到该线程上,置其状态为运行态,并激活任务信号,这样,阻塞的任务线程恢复运行,执行任务。当任务线程完成任务后,重置任务信号,并将自身状态置为空闲态,回归到线程池中。当值守线程收到服务进程的退出宣告后,将池中的空闲线程的状态置为终结态,投放空任务(NULL),并激活任务信号,阻塞的任务线程恢复运行,探测到自身状态为终结态后,执行退出,线程自然终结。
EventLoop(事件循环)
事件循环的意思就是:它运行在一个循环中,直到它停止。网络框架需要需要在一个循环中为一个特定的连接运行事件,所以这符合网络框架的设计。
Netty 的内部实现使其线程模型表现优异,它会检查正在执行的 Thread 是否是已分配给实际 Channel (和 EventLoop),在 Channel 的生命周期内,EventLoop 负责处理所有的事件。
在 Netty 的 EventLoop 线程中,这个线程主要需要处理 IO 事件和其他两种任务,分别为定时任务和一般任务。Netty 提供可一个参数 ioRatio 用于用户调整单线程对于 IO 处理时间和任务处理时间的分配的比率。这样根据实际应用场景用户可以对这个值进行调整,默认值是 50,也就是这个线程会将处理 IO 的时间和处理任务的时间控制为 1:1。
这样尽管一个 EventLoop 会关联多个 Channel,这些 Channel 在单个线程下并不会出现并发问题,同时对于异步任务的处理也一样,Netty 这样设计即免去了并发问题的烦恼,有减少了多线程上下文切换带来的性能损耗,同时基于 EventLoopGroup 实现的有限的线程数能够充分利用 CPU 处理能力。
如果 Thread 是相同的 EventLoop 中的一个,讨论的代码块被执行;如果线程不同,它安排一个任务并在一个内部队列后执行。通常是通过 EventLoop 的 Channel 只执行一次下一个事件,这允许直接从任何线程与通道交互,同时还确保所有的 ChannelHandler 是线程安全,不需要担心并发访问问题。
- 在 EventLoop 中执行的任务
- 任务传递到执行方法后,执行检查来检测调用线程是否是与分配给 EventLoop 是一样的
- 线程是一样的,说明你在 EventLoop 里,这意味着可以直接执行的任务
- 线程与 EventLoop 分配的不一样。当 EventLoop 事件执行时,队列的任务再次执行一次。
我们每隔一段时间就需要调度任务执行,或许你想要注册一个任务在客户端完成连接5分钟后执行,一个比较常见的用例是将一个信息发送给远端,看下远端是否有反应,如果没有就可以关闭通道(连接)并且释放资源。
Netty 使用一个包含 EventLoop 的 EventLoopGroup 为 Channel 的 I/O 和事件服务。EventLoop 创建并分配方式不同基于传输的实现。异步实现使用只有少数 EventLoop (和 Threads)共享于 Channel 之间 。这允许最小线程数服务多个 Channel ,不需要为他们每个人都有一个专门的 Thread。
- 所有的 EventLoop 由 EventLoopGroup 分配。这里它将使用三个 EventLoop 实例
- 这个 EventLoop 处理所有分配给它管道的事件和任务。每个 EventLoop 绑定到一个 Thread
- 管道绑定到 EventLoop ,所以所有操作总是被同一个线程在 Channel 的生命周期执行。一个管道属于一个连接
当然你也可以创建多个 EventLoop。
如图所述,使用有 3个 EventLoop (每个都有一个 Thread ) EventLoopGroup 。EventLoop (同时也是 Thread )直接当 EventLoopGroup 创建时分配。这样保证资源是可以使用的。
这三个 EventLoop 实例将会分配给每个新创建的 Channel。这是通过 EventLoopGroup 实现,管理 EventLoop 实例。实际实现会照顾所有 EventLoop 实例上均匀的创建 Channel (同样是不同的 Thread)。
一旦 Channel 是分配给一个 EventLoop,它将使用这个 EventLoop 在它的生命周期里和同样的线程。你可以,也应该,依靠这个,因为它可以确保你不需要担心同步(包括线程安全、可见性和同步)在你 ChannelHandler 实现。
但是这也会影响使用 ThreadLocal ,例如,经常使用的应用程序。因为一个EventLoop 通常影响多个
Channel,ThreadLocal 将相同的 Channel 分配给 EventLoop。因此,它适合状态跟踪等等。它仍然可以用于共享重或昂贵的对象之间的 Channel ,不再需要保持状态,因此它可以用于每个事件,而不需要依赖于先前 ThreadLocal 的状态。
你可能会注意到这里,一个 EventLoop (也是一个 Thread )创建每个 Channel。你可能被用来从开发网络应用程序是基于常规阻塞I/O在使用java.io.* 包。但即使语义变化在这种情况下,有一件事仍然是相同的:每个 I/O 通道将由一次只有一个线程来处理,这是一个线程增强 Channel 的 EventLoop。
Netty 基于单线程设计的 EventLoop 能够同时处理成千上万的客户端连接的 IO 事件,缺点是单线程不能够处理时间过长的任务,这样会阻塞使得 IO 事件的处理被阻塞,严重的时候回造成 IO 事件堆积,服务不能够高效响应客户端请求。所谓时间过长的任务通常是占用 CPU 资源比较长的任务,也即 CPU 密集型,对于业务应用也可能是业务代码的耗时。这点和 Node 是极其相似的,我可以认为这是基于单线程的 EventLoop 模型的通病,我们不能够将过长的任务交给这个单线程来处理,也就是不适合 CPU 密集型应用。那么问题怎么解决呢,参照 Node 的解决方案,当我们遇到需要处理时间很长的任务的时候,我们可以将它交给子线程来处理,主线程继续去 EventLoop,当子线程计算完毕再讲结果交给主线程。这也是通常基于 Netty 的应用的解决方案,通常业务代码执行时间比较长,我们不能够把业务逻辑交给这个单线程来处理,因此我们需要额外的线程池来分配线程资源来专门处理耗时较长的业务逻辑,这是比较通用的设计方案。
Vert.x 线程模型
Vert.x 是基于 netty 的 eventloop 设计的。
Vert.x 的线程模型设计的非常巧妙。总的来说,Vert.x 中主要有两种线程:Event Loop 线程 和 Worker 线程。其中,Event Loop 线程结合了 Netty 的 EventLoop
,用于处理事件。每一个 EventLoop
都与唯一的线程相绑定,这个线程就叫 Event Loop 线程。Event Loop 线程不能被阻塞,否则事件将无法被处理。
Worker线程用于执行阻塞任务,这样既可以执行阻塞任务而又不阻塞Event Loop线程。
如果像Node.js一样只有单个 Event Loop 的话就不能充分利用多核 CPU 的性能了。为了充分利用多核 CPU 的性能,Vert.x中提供了一组 Event Loop 线程。每个 Event Loop 线程都可以处理事件。为了保证线程安全,防止资源争用,Vert.x 保证了某一个 Handle
总是被同一个 Event Loop 线程执行,这样不仅可以保证线程安全,而且还可以在底层对锁进行优化提升性能。所以,只要开发者遵循Vert.x的线程模型,开发者就不需要再担心线程安全的问题,这是非常方便的。
Vert.x 线程模型中最重要的一点就是:永远不要阻塞 Event Loop 线程。因为一旦处理事件的线程被阻塞了,事件就会一直积压着不能被处理,整个应用也就不能正常工作了。
Vert.x 中内置一种用于检测Event Loop是否阻塞的线程:vertx-blocked-thread-checker
。一旦 Event Loop 处理某个事件的时间超过一定阈值(默认为2000ms)就会警告,如果阻塞的时间过长就会抛出异常。Block Checker 的实现原理比较简单,底层借助了JUC的 TimerTask
,定时计算每个Event Loop线程的处理事件消耗的时间,如果超时就进行相应的警告。
那么这个时候就会有个一个问题,阻塞的代码要去哪里运行呢?好在 Vert.x 提供了个一个叫 Worker Context 用于跑阻塞任务。与 Event Loop 相似,每一个 Handler 都只会跑在固定的 Worker 线程下。Worker 本质上也是个 Fixed Thread Pool 线程池。
当在 eventloop 中运行阻塞代码时,本质上就是利用 Worker 的线程池运行阻塞的代码。
Vert.x 还提供了一个名为 verticle 的并发模型,类似于Actor 系统。Verticle 隔离其状态和行为以提供线程安全的环境。与之通信的唯一方法是通过事件总线。
但是在 Docker 盛行的今天,其实我们只需要在一个 Docker 容器中创建一个 Verticle 用于保证在单容器下的线程安全即可。