Java并发编程实战读书笔记——第六章 任务执行

任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。

6.1 在线程中执行任务

第一步就是要找出清晰的任务边界。独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。

服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。应用程序提供商希望程序支持尽可能多的用户,从而降低每个用户的服务成本,而用户则希望获得尽快的响应。

自然的任务边界选择方式:以独立的客户请求为边界:实现任务的独立性,又可以实现合理的任务规模。

6.1.1 串行地执行任务SingleThreadWebServer

在web请求的处理中包含了一组不同的运算和I/O操作。服务器必须处理套接字I/O以读取请求和写回响应,这些操作通常会由于网络拥塞或者连通性问题而被阻塞。此外,服务器还可能处理I/O或者数据库请求,这些操作同样会阻塞。在单线程的服务器中,也用过不仅会推迟当前请求的完成时间,而且还将彻底阻止等待中的请求被处理。请求也用过的时间过长,用户认为服务器是不可用的。同时,服务器的资源利用率非常低,因此当单线程在等待I/O操作完成时,CPU处于空闲状态。

串行处理机制通常都无法提供高吞吐率或快速响应性。也有一些例外:当任务数量很少且执行时间很长,或者当服务器只为单个用户提供服务,并且该用户每次只发出一个请求时。

6.1.2 显式地为任务创建线程ThreadPerTaskWebServer

通过为每个请求创建一个新的线程来提供服务,从而实现更高的响应性。

主线程仍然不断地交替执行接受外部连接与分发请求等操作。区别在于,对于每个连接,主循环都将创建一个新线程来处理请求,而不是在主循环中进行处理:

  1. 任务处理过程从主线程中分离出来,使得主循环能够更快地先更新等待下一个到来的连接。提高响应性。
  2. 任务可以并行处理,从而能同时服务多个请求。如果有多个处理器,或者任务由于某种原因被阻塞,例如等待I/O完成、获取锁或者资源的可用性等,程序的吞吐量将得到提高。
  3. 任务处理代码必须是线程安全的,因此当有多个任务时会并发地调用这段代码。

在正常负载情况下,为每个任务分配一个线程的方法能提升串行执行的性能。只要请求的到达率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更高的吞吐率。

6.1.3 无限制创建线程的不足

线程生命周期的开销非常高

线程的创建与销毁需要时间,会延迟请求的处理,并且需要JVM和操作系统提供一些辅助操作。如果请求的到达率非常高且请求的处理过程是轻量级的,例如大多数服务器应用程序,那么为每个请求创建一个新线程将消耗大量的计算资源。

资源消耗

活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用这么多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。
使所有CPU保持忙碌状态,再创建反而会降低性能。

稳定性

在创建线程的数量上存在一个限制:JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出OOM异常。

在一定的范围内,增加线程可以提高系统的吞吐率,但如果超出了这个范围,再创建更多的线程只会降低程序的执行速度,并且如果过多的创建一个线程,那么整个是应用程序将崩溃。

6.2 Executor框架

在java.util.concurrent提供了一种灵活的线程池实现作为Executor框架的一部分,在java类库中,任务执行的主要抽象不是Thread,而是Executor。

Executor框架提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并用Runnabler来表示任务。Executor的实现还提供了对生命周期的支持,以及统计你信息收集、应用程序管理机制和性能监视等机制。

Executor基于生产者-消费者模式,提交任务相当于生产者,执行任务的线程相当于消费者。

6.2.1 示例:基于Executor的Web服务器

Java并发编程实战读书笔记——第六章 任务执行_第1张图片

通过使用Executor,将请求处理任务的提交与任务的实际执行解耦开来,并且只需采用另一种不同的Executor实现,就可以改变服务器的行为。

6.2.2 执行策略

通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务指定和修改执行策略。包括:

  • 在什么(What)线程中执行任务?
  • 任务按照什么(What)顺序执行(FIFO、LIFO、优先级)?
  • 有多少个(HOW MANY)任务能并发执行?
  • 在队列中有多少个(HOW MANY)任务在等待执行?
  • 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个(Which)任务?另外,如何(HOW)通知应用程序有任务被拒绝?
  • 在执行一个任务之前或之后,应该执行哪些(What)操作?

各个执行策略都是一种资源管理工具,具佳策略取决于可用的计算资源以及对服务质量的需求。通过限制并发任务的数量,可以确保应用程序不会由于资源耗尽而失败,或者由于稀缺资源上发生竞争而严重影响性能。

每当看到下面这种形式的代码时:

new Thread(runnable).start()

并且你希望获得一种更灵活的执行策略时,请考虑使用Executor来代替Thread。

6.2.3 线程池

字面含义:管理一组同构工作线程的资源池。线程池与工作队列密切相关,其中工作队列中保存了所有等待执行的任务。工作者线程的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。

在线程池中执行任务比为每个任务分配一个线程优势更多:1通过重用现有线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。2不会有由于等待创建线程而延迟任务的执行,从而提高了响应性。

通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使用应用程序耗尽内存或失败。

类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过Executors中的静态工厂方法之一来创建一个线程池:

  • newFixedThreadPool 创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量

  • newCachedThreadPool 将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任务限制。

  • newSingleThreadExecutor 是一个单线程的Executor,它能确保依照任务在队列中的顺序来串行执行(FIFO、FIFO、优先级)

  • newScheduledThreadPool 创建一个固定长度的线程池,而且以迟时或定时的方式来执行任务,类似于Timer

6.2.4 Executor的生命周期

Java并发编程实战读书笔记——第六章 任务执行_第2张图片

ExecutorService的生命周期有3种状态:运行、关闭和已终止。ExecutorService在初始创建时牌运行状态。shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。shutdownNow方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。

在ExecutorService关闭后提交的任务将由拒绝执行处理器(Rejected Execution Handler)来处理,它会抛弃任务,或者使得execute方法抛出一个未检查的RejectedExecutionExecption。等所有任务都完成后,ExecutorService将转入终止状态。可以调用awaitTermination来等待ExecutorService到达终止状态。

6.2.5 延迟任务与周期任务

Timer类负责管理延迟任务(在100ms后执行任务)以及周期任务(每10ms执行一次该任务)。然而,Timer存在一些缺陷,基于绝对时间来调度,因此应该考虑使用ScheduledThreadPoolExecutor来代替它。

Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间过长,那么将破坏其他TimerTask的定时精确性。线程池可以弥补这个缺陷,它可以提供多个线程来执行延时任务和周期任务。

Timer的另一个问题是,如果TimerTask抛出一个未检查的异常,那么TImer将出现线程泄漏问题(Thread Leakage)。Timer线程不捕获异常,因此当TimerTask抛出未检查异常时将终止定时线程。这种情况下,Timer不会恢复线程的执行,而是会错误地认为整个Timer都被取消了。因此,已经被调度但未执行的TimerTask将不会再执行,新的任务也不能被调度。

如果需要构建自己的调度服务,那么可以使用DelayQueue,它实现了BlockingQueue,并为ScheduledThreadPoolExecutor提供调度功能。

6.3 找出可利用的并行性

Executor框架帮助指定执行策略,但如果要使用Executor,必须将任务表述为一个Runnable。在大多数服务器应用程序中都存在一个明显的任务边界:单个客户请求。但有时候,任务边界并厚厚是显而易见的,在单个客户请求中仍可能存在可发掘的并行性,例如数据库服务器。

6.3.2 携带结果的任务Callable与Future

Executor框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的抽象,虽然run能写入日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值或者抛出一个异常。

对于存在延迟计算的任务,Callable是一种更好的抽象 :它认为主入口点(即call)将返回一个值,并可能抛出一个异常。在Executor中包含了一些辅助方法能将其他类型的任务封闭为一个Callbable。

Executor执行的任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能要执行很长的时间,因此通常希望能够取消这些任务。在Executor框架中,已提交但尚未开始的任务可以取消,但对于那些已经开始执行的任务,只有当它们能响应中断时,都能取消。取消一个已完成的任务不会有任务影响。

Future表示一个任务的生命周期,并提供了一相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。

get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已完成,那么get会立即返回或者抛出一个Exception,如果任务没有完成,那么get将也阻塞并直到任务完成。如果任务报出了异常,那么get将该异常封闭为ExecutionException并先重新抛出。如果任务被取消,那么get将抛出CancellationException。

6.3.4 在异构任务并行化中存在的局限

通过异构任务进行并行化来获取重大的性能提升是很困难的。

两个人可以很好地分担洗碗的工作:其中一个人负责清洗,而另一个人负责烘干。然而要将不同类型的任务平均分配给每个工人却并不容易;还有一个问题就是各个任务的大小可能完全不同;以及任务协调开销。

只有当大量相互独立且同构的任务可以并发进行处理时,都能体现出将程序的工作 负载分配到多个任务中带来的真正性能提升。

6.3.5 CompletionService:Executor与BlockingQueue

CompletionService将Executor和BlockingQueue的功能整合在一起。你可以将Callable任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,而这些结果会在完成时将被封闭成为Future。ExecutorCompletionService实现了CompletionService并将计算部分委托给一个Executor。

ExecutorCompletionService的实现非常简单。在构造函数中创建一个BlockingQueue来保存计算完成的结果。当计算完成时,调用FutureTask中的done方法。当提交某个任务时,该任务将首先包装为一个QueueingFuture,这是FutureTask的一个子类,然后再改写子类的done方法,并将结果放入BlockingQueue中。take和poll方法委托给了BlockingQueue,这些方法会在得出结果之前阻塞。

Java并发编程实战读书笔记——第六章 任务执行_第3张图片

6.3.6 示例:使用CompletionService实现页面渲染器

可以通过CompletionService从两个方面来提高页面渲染器的性能:缩短总运行时间以及提高响应性为每一幅图像的下载都创建一个独立任务,并在线程池中执行它们,从而将串行的下载过程转换为并行的过程:这将减少下载所有图像的总时间。此外,通过从CompletionService中获取结果以及使每张图片在下载完成后立刻显示 出来,能使用户获得一个更加动态和更高响应的用户界面。

6.3.7 为任务设置时限

有时候,如果某个任务无法在指定时间内完成,那么将不再需要它的结果,此时可以放弃这个任务。在有限时间内执行任务的主要困难在于,要确保得到答案的时间不会超过限定的时间,或者在限定的时间内无法获得答案。在支持时间限制的Future.get中支持这种需求:当结果可用时,它将立即返回,如果在指定时限内没有计算出结果,那么将抛出TimeoutException。

在使用限时任务时需要注意,当这些任务超时后应该立即停止,从而避免为继续计算一个不再使用的结果而浪费计算资源。此时可以再次使用Future,如果一个限时的get方法抛出了TimeoutException,那么可以通过Future来取消任务,前提任务是可取消的。

6.3.8 示例:旅行预定门户网站

invokeAll,将多个任务提交到一个ExecutorService并获得结果。InvokeAll方法的参数为一组任务,并返回一组Future。这两个集合有相同的结构 。invokeAll按照任务集合中迭代器的顺序将所有的Future添加到返回的集合中,从而使用调用者能将各个Future与其表示的Callable关联起来。当所有任务都执行完成时,或者调用线程被中断,又或者超过指定时限时,invokeAll将返回,任何还未完成的任务都会被取消。当invokeAll返回后,每个任务要么正常地完成,要么被取消,而客户端代码可以调用get或isCancelled来判断究竟是何种情况。

小结

通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor框架将任务提交与执行策略解耦开来,同时还支持多种不同类型的执行策略。要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。

你可能感兴趣的:(Java,并发,Java)