Java并发编程学习——《Java Concurrency in Practice》学习笔记 6.任务执行

来源:互联网 发布:龙布峰针贴淘宝有卖吗 编辑:程序博客网 时间:2024/05/22 16:00

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

6.1 在线程中执行任务

当围绕 任务执行 来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想情况下,各个任务之间是相互独立的:任务不依赖于其他任务的状态、结果或边界效应。

正常负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。当负荷过载时,应用程序的性能应该是主键降低,而不是直接失败。要实现这个目标,应该选择清晰的任务边界以及明确的任务执行策略。

大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界。

6.1.1 串行的执行任务

最简单的策略就是在单个线程中串行的执行各项任务。但在服务器应用程序中,串行处理机制通常都无法提供高吞吐率或快速响应性。

6.1.2 显式的为任务创建线程

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

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

6.1.3 无限制创建线程的不足

在生产环境中,为每个任务分配一个线程 这种方法存在一些缺陷,尤其是当需要创建大量的线程时:
- 线程声明周期的开销非常高 线程的创建与销毁是有一定代价的,如果请求的到达率非常高且请求的处理过程是轻量级的,那么为每个请求创建一个新线程将消耗大量的计算资源。
- 资源消耗 如果已经拥有足够多的线程使所有CPU保持忙碌状态,那么再创建更多的线程反而会降低性能。
- 稳定性 不同平台针对可创建线程的数量上都存在着限制。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,要想从这种错误中恢复过来是非常危险的

在一定的范围内,增加线程可以提高系统的吞吐率,但如果超出了这个范围,再创建更多的线程只会降低程序的执行速度

6.2 Executor 框架

在Java类库中,任务执行的主要抽象不是Thread,而是Executor。

Executor接口

public interface Executor {    void execute(Runnable command);}

Executor为灵活且强大的异步任务执行框架提供了基础,该框架能支持多种不同类型的任务执行策略。它提供了一种标准的方法将任务的提交过程与执行过程解耦,并用Runnable表示任务。Executor的实现还提供了对声明周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。

Executor基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程相当于消费者。如果要在程序中实现一个生产者-消费者的设计,那么最简单的方式通常就是使用Executor。

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

固定长度线程池

class TaskExecutionWebServer {    private static final int NTHREADS = 100;    private static final Executor exec = Executors.newFixedThreadPool(NTHREADS);    public static void main(String[] args) throws IOException {        ServerSocket socket = new ServerSocket(80);        while(true) {            final Socket connection = socket.accept();            Runnable task = new Runnable() {                public void run() P                handleRequest(connection);            };            exec.execute(task);        }    }}

为每个请求启动一个新线程的Executor

public class ThreadPerTaskExecutor implements Executor {    public void execute(Runnalbe r) {        new Thread(r).start();    }}

以同步方式执行每个任务的Executor

public class WithinThreadExecutor implements Executor {    public void execute(Runnable r) {        r.run();    }}

6.2.2 执行策略

在执行策略中定义了任务执行的几个方面:
- 在什么线程中执行任务
- 任务按照什么顺序执行
- 有多少个任务能并发执行
- 在队列中有多少个任务在等待执行
- 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个任务,如何通知应用程序有任务被拒绝
- 在执行一个任务之前或之后,应该进行哪些动作。

通过将任务的提交与任务的执行策略分离开来,有助于在部署阶段选择与可用硬件资源最匹配的执行策略。

每当看到new Thread(runnable).start()形式的代码时,如果希望获得一种更灵活的执行策略,考虑使用Executor来代替Thread。

6.2.3 线程池

线程池,是指管理一组同构工作线程的资源池。线程池与工作队列 Work Queue 密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者线程 Worker Thread 从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。

可以通过调用Executors中的静态工厂方法之一来创建一个线程池:

newFixedThreadPool

创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,线程池的规模将不再变化。如果某个线程由于发生了未预期的Exception而结束。

newCachedThreadPool

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

newSingleThreadExecutor

单线程Executor,创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保一招任务在队列中的顺序来执行。该Executor还提供了大量的内部同步机制,从而确保了任务执行的任何内存写入操作对于后续任务来说都是可见的。这意味着,即使这个线程会不时的被另一个线程替代,但对象总是可以安全的封闭在 任务线程 中。

newScheduledThreadPool

创建一个固定长度的线程池,以掩饰或定时的方式来执行任务,类似于Timer。

6.2.4 Executor的生命周期

Executor的实现通常会创建线程来执行任务。但JVM只有在所有非守护线程全部终止后才会退出。因此,如果无法正确的关闭Executor,那么JVM将无法结束。

由于Executor以异步方式来执行任务,因此在任何时刻,之前提交任务的状态不是立即可见的。既然Executor是为应用程序提供服务的,因而它们也是可关闭的,并将在关闭操作中受影响的任务的状态反馈给应用程序。

为了解决执行服务的声明周期问题,Executor扩展了ExecutorService接口,添加了一些用于声明周期管理的方法。

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

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

6.2.5 延迟任务与周期任务

Timer类负责管理延迟任务和周期任务。然后,Timer类存在一些缺陷,因此应该考虑使用ScheduledThreadPoolExecutor来代替。可以通过ScheduledThreadPoolExecutor的构造函数或newScheduledThreadPool工厂方法来创建该类对象。

Timer的缺陷
Timer支持基于绝对时间而不是相对时间的调度机制,因此任务的执行对系统时钟变化很敏感,而ScheduledThreadPoolExecutor。

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

Timer线程并不捕获异常,如果TimerTask抛出了一个未受检的异常,那么将终止定时线程。这种情况下,Timer不会恢复线程的运行,而是会错误的认为整个Timer都被取消了。因此,已经被调度但尚未执行的TimerTask将不会执行,新的任务也不能被调度。

在Java5.0之后的版本,将很少使用Timer

如果要构建自己的调度服务,可以使用DelayQueue,它实现了BlockingQueue,并为ScheduledThreadPoolExecutor提供调度功能。DelayQueue管理一组Delayed对象。每个Delayed对象都有一个相应的延迟时间:在DelayQueue中,只有某个元素与气候,才能从DelayQueue中执行take操作。从DelayQueue中返回的对象将根据它们的延迟时间进行排序。

6.3 找出可利用的并行性

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

6.3.2 携带结果的任务Callable与Future

Executor框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的抽象,它不能返回一个值或抛出一个受检查的异常。

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

Callable

Runnable和Callable描述的都是抽象的计算任务。这些任务通常是有范围的,都有一个明确的起始点,最终会结束。Executor执行的任务由4个声明周期阶段:创建、提交、开始、完成。Executor框架中,已提交但尚未开始的任务可以取消,但对于已经开始执行的任务,只有当它们能响应中断时,才能取消。取消一个已经完成的任务不会有任何影响。

Future

Future表示一个任务的生命周期,并提供了相应的方法判断是否已经完成或取消,以及获取任务的结果和取消任务。Future规范中包含的隐含意义是,任务的生命周期只能前进,不能后退。当某个任务完成后,就永远停留在 完成 状态上。

get方法的行为取决于任务的状态。已完成的任务,get会立刻返回或抛出Exception。任务没完成,get将阻塞到任务完成。任务抛出异常时,get将该异常封装为ExecutionException重新抛出。任务取消时get抛出CancellationException。如果get抛出ExecutionException,可以通过getCause来获得被封装的初始异常

创建Future的方法

ExecutorService中的所有submit方法都返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一个Future用来获得任务的执行结果或取消任务。还可以显式的为某个指定的Runnable或Callable实例化一个FutureTask。FutureTask实现了Runnable,可以被提交给Executor执行,或直接调用run()方法

Java6开始,ExecutorService实现可以改写AbstractExecutorService中的newTaskFor方法,从而根据已提交的Runnable或Callable来控制Future的实例化过程。在默认实现中仅创建了一个新的FutureTask.

在将Runnable或Callable提交到Executor的过程中,包含了一个安全发布过程,即将Runnable或Callable从提交线程发布到最终执行任务的线程。在设置Future结果的过程中也包含了一个安全发布,即将这个结果从计算它的线程发布到任何通过get获得它的线程。

get方法拥有“状态依赖”的内在特性,因而调用者不需要知道任务的状态,此外在任务提交和获得结果中包含的安全发布属性也确保了这个方法是线程安全的。Future.get的异常处理代码将处理两个可能的问题:任务遇到一个Exception,或者调用get的线程在获得结果之前被中断。

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

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

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

6.3.5 CompletionService:Executor与BlockingQueue

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

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

多个ExecutorCompletionService可以共享一个Executor,因此可以创建一个对于特定计算私有,又能共享一个公共Executor的ExecutorCompletionService。因此,CompletionService的作用就相当于一组计算的句柄,这与Future作为单个计算的句柄类似

6.3.7 为任务设置时限

有时,如果某个任务无法在指定时间内完成,那么将不再需要它的结果,此时可以放弃这个任务。

在支持时间限制的Future.get中支持这种需求:当结果可用时,它将立即返回,如果在指定时限内没有计算出结果,将抛出TimeoutException。

在使用限时任务是需要注意,当这些任务超时后应该立即停止,从而避免为计算一个不再使用的结果而浪费计算资源。要实现这个功能,可以由任务本身来管理它的限时事件,并在超时后终止执行或取消任务。此时可再次使用Future,如果一个限时的get方法抛出了TimeoutException,那么可以通过Future来取消任务。如果编写的任务是可取消的,那么可以提前终止它,以免消耗过多的资源。

Page renderPageWithAd() throws InterruptedException {    long endNanos = System.nanotime() + TIME_BUDGET;    Future<Ad> f = exec.submit(new FetchAdTask());    Page page = renderPageBody();    Ad ad;    try {        long timeLeft = endNanos - System.nanoTime();        ad = f.get(timeLeft,NANOSECONDES);    } catch (ExecutionException e) {        ad = DEFAULT_AD;    } catch (TimeoutException e) {        ad = DEFAULT_AD;        f.cancel(true);    }    page.setAd(ad);    return page;}

InvokeAll

invokeAll方法的参数为一组任务,返回一组Future。这两个集合有着相同的结构。invokeAll按照任务集合中迭代器的顺序将所有的Future添加到返回的集合中,从而使调用者能将Future与其表示的Callable关联起来。当所有任务都执行完毕、或调用线程被中断、或超时时,invokeAll将返回。当超时后,任何还未完成的任务都会取消。invokeAll返回后,每个任务要么正常的完成,要么被取消,客户端代码可以调用get或isCancelled判断情况。

public List<TravelQuote> getRankedTravelQuotes(        TravelInfo travelInfo,         Set<TravelCompana> companies,        Comparator<TravelQuote> ranking,        long time,        TimeUnit unit) throws InterruptedException {        List<QuoteTask> tasks = new ArrayList<>();        for(TravleCompana company : companies) {            tasks.add(new QuoteTask(company,travelInfo));        }        List<Future<TravelQuote>> futures = exec.invokeAll(tasks,time,unit);        List<TravelQuote> quotes = new ArrayList<>(tasks.size);        Iterator<QuoteTask> taskIter = tasks.iterator();        for(Future<TravelQuote> f : futures) {            QuoteTask task = taskIter.netx();            try {                quotes.add(f.get());            } catch (ExecutionException e) {                quotes.add(task.getFaulureQuote(e.getCause()));            } catch (CancellationException e) {                quotes.add(task.getTimeoutQuote(e));            }        }        Collections.sort(quotes,ranking);        return quotes;}

小结

通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor框架将任务提交与执行策略解耦,支持多种不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用Executor。要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些应用程序中则需要进一步分析才能揭示出粒度更细的并行性。

阅读全文
0 0
原创粉丝点击