这章包涵以下内容
- 线程模型概览
- 事件循环概念和实现
- 任务调度
- 实现细节
简单地说,线程模型指定了OS、编程语言、框架或应用程序的上下文中的线程管理的关键方面。线程创造的方式和时间明显对于应用程序代码的执行有着重大的影响,所以开发人员有必要去理解与不同模型相关的权衡。无论他们自己选择模型还是通过采用框架语言隐式获取模型,都是无可厚非的。
这一章我们将仔细研究Netty的线程模型。它是功能强大而易于使用的,正和Netty一样,致力于简化你的应用程序代码并使其性能和可维护性尽可能最大化。同时我们将探讨引导我们选择当前模型的经历。
如果你对于Java并发API(java.util.concurrent)有着融会贯通的理解,你应该发现这一章的探讨是简单明了的。如果你对这些概念是陌生的或者你需要重新唤起你对它们的记忆,Brian Goetz等人所著的Java Concurrency in Practice(Addison-Wesley Professional,2006)是一个相当不错的资源。
7.1 线程模型概览
这一节我们将从整体上介绍线程模型,然后探讨Netty过去和现在的线程模型,回顾它们的优点和缺陷。
正如我们在这章开头提及的,线程模型指定代码如何被执行。因为我们必须一直监视并发执行可能产生的副作用,理解所应用模型的含义是非常重要的(单线程模型也一样)。如果你忽略这些问题且仅仅希冀于最好的,无异于赌博——毫无疑问对你不利。
因为多核或多个CPUs的电脑随处可见,大多数现代应用程序使用复杂的多线程技术来充分利用系统资源。相比而言,我们在Java早期的多线程方案不外乎按需创建和开启新的线程来执行工作的并发单元,这是一个重负载下工作效率低下的原始方案。Java 5随之引入了Executor API,此API中的线程池通过Thread缓存和重用极大提高了性能。
基础的线程池模式可以被描述为:
- 从线程池空闲列表选择Thread然后把它分配去运行一个提交的任务(Runnable接口的实现)。
- 当任务完成,Thread返回线程池空闲列表且变成可重用的。
图7.1阐明了这一模式。
图 7.1 Executor执行逻辑
线程池和可重用线程相比于每个任务创建和销毁线程是一个改进,但是它并没有消除上下文切换的成本,当线程数量增加到处于极度重负载时,这种成本消耗愈加明显。此外,在项目的生命周期过程中仅仅因为应用程序的整体复杂性或并发需求,其他线程相关的问题可能出现。
简而言之, 多线程是复杂的。下一节我们将发现Netty如何帮助简化多线程。
EventLoop 接口
运行任务来处理在连接的生命周期过程中发生的事件,这是任何网络框架的基本方法。相应的编码结构通常被称为事件循环,Netty从接口io.netty.channel.EventLoop采用的术语。
以下代码清单阐明了事件循环基本的想法,每一个任务是一个Runnable实例(如图7.1所示)。
码单 7.1 以事件循环方式执行任务
while (!terminated) {
//blocks until there are events that are ready to run
List<Runnable> readyEvents = blockUntilEventsReady();
for (Runnable ev: readyEvents) {
ev.run();
}
}
Netty的EventLoop是协作设计的一部分,此设计采用两个基础的APIs:并发和网络。首先,包io.netty.util.concurrent建立在JDK包java.util.concurrent上提供线程执行器。其次,为了连接Channel事件,包io.netty.channel中的类继承这些执行器。最终的类层次结构如图7.2所示。
在这个模型中,一个EventLoop恰好被一个Thread持有,并且不会改变,任务(Runnable或Callable)可以直接提交到EventLoop实现以立即执行或调度执行。根据配置和可用内核,为了使资源利用最大化,多个EventLoops可能被创建,且单个EventLoop可能被分配去服务多个Channels。
注意Netty的这个EventLoop,当它继承ScheduledExecutorService,仅仅定义一个方法parent()。这个显示在以下代码块中的方法,目的是返回当前EventLoop实现实例所属的EventLoopGroup的引用。
public interface EventLoop extends EventExecutor, EventLoopGroup {
@Override
EventLoopGroup parent();
}
图 7.2 EventLoop类层次结构
事件/任务执行顺序事件和任务以FIFO(先进先出)顺序执行。通过保证字节内容以正确顺序处理消除数据损坏的可能性。
7.2.1 Netty 4中I/O和事件处理
正如我们在第六章详细描述的,I/O操作流通已经建立一个或多个ChannelHandlers的ChannelPipeline会触发事件。传播这些事件的方法调用可以被ChannelHandlers拦截并按需处理事件。
事件的性质通常决定它被处理的方式;它有可能从网络堆栈传送数据到你的应用程序,相反,也可能做一些完全不同的事。但是事件处理的逻辑必须是通用的且足够复杂以处理所有可能的用例。因此,在Netty 4所有I/o操作和事件被已经分配给EventLoop的线程处理。
这与Netty 3中使用的模型不同。下一节我们将讨论更早的模型和它为什么被替换。
7.2.2 Netty 3中的I/O操作
在先前版本使用的线程模型仅仅保证入站(先前叫做上游)事件在所谓的I/O线程(与Netty 4的EventLoop对应)中会被执行。所有出站(下游)事件被调用的线程处理,此线程可能是I/O线程或者任何其他线程。起初这看起来是一个好主意,但由于ChannelHandlers中出站事件的仔细同步的需要而被发现是有问题的。简而言之,不可能保证多线程不会在同一时间尝试去访问入站事件。这是可能发生的,举个例子,如果你通过在不同线程中调用Channel.write(),触发同一个Channel的同时发生的下游事件。
当入站事件被触发导致出站事件时,另外一个消极放方面的影响出现。当Channel.write()导致异常,你有必要去生成并触发exceptionCaught事件。但在Netty 3模型中,因为这是入站事件,你最终在调用线程中执行代码,然后通过执行I/O线程处理事件,随之而来的是多余的上下文切换。
Netty 4中采用的线程模型,通过处理发生在同一线程所给定的EventLoop的每件事,解决了这些问题。此模型提供了一个更简便的执行架构且消除了ChannelHandlers中的同步需要(除了多个Channels之间可能共享的部分)。
既然你理解了EventLoop的角色,让我们看看任务是如何调度执行的。
7.3 任务调度
有时候你有必要去调度一个任务更迟的(延期的)或定期的执行。举个例子,你可能想去注册一个任务,并在客户端已经连接5分钟后触发它。一个普遍的用例是发送心态信息给远端,以此检查连接是否仍然活跃。如果没有回应,你就知道你可以关闭通道了。
在下一节中,我们将向你展示如何用核心的Java API和Netty的EventLoop调度任务。然后,我们将检查Netty的内部实现并讨论它的优缺点。
7.3.1 JDK调度API
在Java 5之前,任务调度以java.util.Timer为基础,它使用一个后台线程,且有和标准的线程相同的限制。随后,JDK提供了包java.util.concurrent,它定义了接口ScheduledExecutorService。表7.1展示了java.util.concurrent.Executors的相关工厂方法。
表 7.1 java.util.concurrent.Executors工厂方法
方法 | 描述 |
---|---|
newScheduledThreadPool(int corePoolSize) / newScheduledThreadPool(int corePoolSize,ThreadFactorythreadFactory) | 创建一个可以调度命令在延迟后运行或定期执行的ScheduledThreadExecutorService。它使用参数corePoolSize来计算线程数量。 |
newSingleThreadScheduledExecutor() / newSingleThreadScheduledExecutor(ThreadFactorythreadFactory) | 创建一个可以调度命令在延迟后运行或定期执行的ScheduledThreadExecutorService。它使用一个线程执行调度的任务。 |
虽然选择不是很多,但对于大多数用例来说这些提供的选择已经足够了。以下代码清单展示了如何使用ScheduledExecutorService在60s延迟后运行任务。
码单 7.2 用ScheduledExecutorService调度任务
ScheduledExecutorService executor =
Executors.newScheduledThreadPool(10);
ScheduledFuture<?> future = executor.schedule(
new Runnable() {
@Override
public void run() {
System.out.println("60 seconds later");
}
}, 60, TimeUnit.SECONDS);
...
executor.shutdown();
虽然ScheduledExecutorService API简单明了,但是重负载下它能带来性能成本。下一节我们将看到Netty如何用更高的效率提供相同的功能。
7.3.2 使用EventLoop调度任务
ScheduledExecutorService实现有不足之处,比如额外的线程被创建为线程池管理的一部分。如果任务没有被积极地调用,这可能成为一个性能瓶颈。Netty通过使用通道的EventLoop实现调度来解决这一问题,正如以下代码清单所示。
码单 7.3 用EventLoop调度任务
Channel ch = ...
ScheduledFuture<?> future = ch.eventLoop().schedule(
new Runnable() {
@Override
public void run() {
System.out.println("60 seconds later");
}
}, 60, TimeUnit.SECONDS);
60s过后,Runnable实例将被分配给通道的EventLoop执行。如果要调度一个任务使其每间隔60s执行,使用scheduleAtFixedRate(),如下所示。
码单 7.4 用EventLoop调度循环任务
Channel ch = ...
ScheduledFuture<?> future = ch.eventLoop().scheduleAtFixedRate(
new Runnable() {
@Override
public void run() {
System.out.println("Run every 60 seconds");
}
}, 60, 60, TimeUnit.Seconds);
正如我们之前提到的,Netty的EventLoop继承ScheduledExecutorService(看图7.2),所以它提供了JDK实现的所有可用方法,包括上述例子中用到的schedule()和scheduleAtFixedRate()。所有操作的完整清单可以在关于ScheduledExecutorService的Javadocs中找到。
使用每一个异步操作返回的ScheduledFuture来取消或者检查执行状态。以下代码清单占了一个简单的取消操作。
码单 7.5 使用ScheduledFuture取消任务
ScheduledFuture<?> future = ch.eventLoop().scheduleAtFixedRate(...);
// Some other code that runs...
boolean mayInterruptIfRunning = false;
//cancels the task, which prevents it from running again
future.cancel(mayInterruptIfRunning);
这些例子阐明性能可以通过充分利用Netty的调度能力进行获取。反过来,这些依赖于底层线程模型,接下来我们将讨论它。
7.4 实现细节
这一节进一步详细讨论Netty线程模型和调度实现的主要因素。我们还会提到需要注意的缺陷,以及持续发展的领域。
7.4.1 线程管理
Netty线程模型的优越性能取决于确定当前正在执行的Thread的标识;也就是说,不论它被分配给当前Channel和它的EventLoop。(回想一下,EventLoop有责任为Channel处理其生命周期期间所有的事件。)
如果调用的线程是EventLoop的,有问题的代码被执行。否则,EventLoop调度任务来延迟执行并把它放到一个内部的队列中。当EventLoop接着执行它的事件,它将执行在队列中的这些事件。这阐释了在ChannelHandlers没有取得同步的情况下,Thread如何直接与Channel相互作用的。
注意每一个EventLoop有它自己的任务队列,并不依赖于任何其他EventLoop。图7.3展示了EventLoop用来调度任务的执行逻辑。这是Netty线程模型的一个关键的组件。
我们之前讲了不要阻塞当前I/O线程的重要性。我们用另一种方式再陈述一遍:“千万不要将长期运行的线程放到执行队列中,因为它会阻塞任何其他的任务在同一线程执行。”如果你必须阻塞调用或者执行长期运行的任务,我们建议使用专用的EventExecutor。(查看侧边栏章节6.2.1中的“ChannelHandler执行和阻塞”)
图 7.3 EventLoop执行逻辑
暂且不说这种极限情况,线程模型在使用中可以强烈影响排队任务对整体系统性能的影响。正如所使用的传输事件处理实现。(和我们在第四章看到的一样,Netty在不借助于修改你的代码库的情况下,可以方便地切换传输。)
7.4.2 EventLoop/线程配置
服务Channels的I/O和事件的EventLoops被包含在一个EventLoopGroup中。EventLoops被创建和分配的方式根据传输实现变化。
异步传输
异步实现仅仅使用很少的EventLoops(和它们相关的线程),并在当前模型中这些可以在Channels之间共享。这允许很多Channels由尽可能少的Threads服务,而不是每一个Channel分配一个Thread。
图7.4显示了一个EventLoopGroup,其大小固定为三个EventLoopsge(每个由一个线程驱动)。当EventLoopGroup被创建时,EventLoops(和它们的线程)直接被分配,以此确保它们在被需要的时候是可用的。
EventLoopGroup负责分配EventLoop给每一个新创建的Channel。在目前的实现中,使用循环方法实现均衡分布,且同样的EventLoop会分配到多个Channels。(这一点在未来的版本中可能会改善。)
图7.4 非阻塞传输的EventLoop分配(比如NIO和AIO)
一旦一个Channel已经被分配给一个EventLoop,它将使用这个EventLoop(和相关的线程)贯穿其生命周期。记住这一点,因为它使你不用担心在你的ChannelHandler实现中的线程安全和同步。
同样,请注意EventLoop分配对ThreadLocal使用的影响。因为一个EventLoop通常驱动不止一个Channel,ThreadLocal对于所有相关的Channels都是一样的。这使得实现一个方法,比如状态跟踪,不是一个很好的选择。然而,在一个无状态的上下文中,对于共享它仍然可用于Channels之间共享繁重或昂贵的对象,甚至事件。
阻塞传输
其他传输的设计比如IOI(古老的阻塞I/O)是有一点不同的,如图7.5所阐明的。
图7.5 阻塞传输的EventLoop 分配(比如OIO)
这里一个EventLoop(和它的线程)分配给一个Channel。如果你已经使用java.io包中的阻塞I/O来开发应用程序,你可能已经遇到这种模式。
但使用这种模式之前,你必须保证每个Channel的I/O事件仅仅被一个驱动此Channel的EventLoop的线程处理。这是Netty的一致性设计的另一例子,并且它对Netty的可靠性和易用性有很大贡献。
7.5 总结
在这章中你学习了通常的线程模型和特别的Netty线程模型,我们详细讨论了后者的性能和一致性优点。
你明白了如何用EventLoop(I/O Thread)去执行你自己的任务,正如框架自身所做的。你学会了如何去调度延迟执行的任务,并且我们研究了重负载下的可扩展性问题。你也知道如何去验证一个任何是否已经执行和如何去取消它。
这些由我们对框架实现细节的研究拓展的信息,将帮助你使得你的应用程序性能最大化同时简化它的代码库。更多关于线程池和并发编程的信息,我们推荐Brian Goetz所著的Java Concurrency in Practice(java并发实战)。这本书将给你对最复杂多线程用例的深度理解。
我们已经到了一个激动人心的时刻——下一章我们将讨论Bootstrapping,一个为你的应用程序带来生命的配置和连接所有Netty组件的处理过程。