java并发之任务执行

1,224 阅读13分钟

任务执行

大多数并发应用程序都是围绕‘任务执行’来构造的:通常是一个抽象且离散的工作单元,通过把应用程序的工作分解到多个任务中。

在线程中执行任务

当围绕‘任务执行’设计应用程序结构,第一步就是找出清晰的任务边界,理想情况下,各个任务是独立的:任务并不依赖其他任务的状态或结果。这些独立的任务都可以并发执行。
大多数服务器应用都提供了一种自然的任务边界:以独立的客户请求为边界。

串行的执行任务

在应用程序中可以通过多种策略来调度任务,最简单的策略就是在单个线程中串行的执行各项任务

image.png SingleThreadWebServer很简单,且理论上是正确的,但在实际生产中性能却非常差。在web请求中可能会因为网络拥塞和处理IO或数据库请求等陷入阻塞,但单线程中阻塞不但会延迟当前请求的完成时间,还会彻底阻止等待中的请求被处理。用户将认为服务器是不可用的,因为服务器看似失去了响应。

显示的为任务创建线程

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

image.png 对于每个连接,主循环都创建一个新的线程处理请求。

  1. 任务处理过程从主线程分离出来。
  2. 任务可以并行的处理
  3. 任务处理代码必须是线程安全的,因为会有多个任务并发的调用这段代码

无限制创建线程的不足

  • 线程生命周期的开销非常高,线程的创建和销毁并不是没有代价的,线程的创建会需要时间,会延迟处理的请求,如果请求频率非常高,且请求处理过程是轻量级的,那么为每个请求创建一个新线程会消耗大量的计算资源
  • 资源消耗。活跃的线程会消耗系统资源,尤其是内存,如果可运行的线程多余可用处理器的数量,那么有些线程会被闲置,大量的空闲线程会占用许多内存,并且大量的线程在竞争CPU资源,还将产生其他的性能开销,如果有了足够多的线程,那么再创建反而会降低性能
  • 稳定性。在可创建线程的数量上存在一个限制,破坏了这个限制可能抛出oom异常,过多的创建线程,那么整个应用也会崩溃。避免这个危险,应该对可创建的线程进行限制。

Executor

任务是一组逻辑工作单元,而线程是任务异步执行的机制。上面已经分析或串行执行和无限制分配新线程的方式,都存在一些严重的缺陷。java中提供了一种灵活的线程池作为Executor框架的一部分,在java类库中,任务执行的主要抽象不是Thread,而是Executor。

image.png Executor框架能够支持多种不同类型执行策略,它将任务提交的过程与执行的过程解耦开,并用Runnable表示任务,Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。

image.png 通过使用Executor,将请求处理任务的提交与任务的实际执行解耦开,并且只需采用另一种不同Executor的实现,就可以使用不同的执行策略。 比如:

image.png

image.png 可以很容易的修改它的执行策略。

执行策略

通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务指定和 修改执行策略。在执行策略中定义了任务执行的“What、Where、When、How”等方面,包括:

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

线程池

线程池:管理一组工作线程的资源池。线程池与工作队列密切相关,工作队列保存着所以等待执行的任务,工作线程,从队列中获取一个任务,执行任务,返回线程池并等待下一个任务。
线程池的优势:

  • 可以复用老的线程而不是创建新线程。
  • 当请求到达时,线程已经存在,因此不会有等待线程创建而延迟任务的执行,提高了响应性。
  • 可以限制线程池的大小,不会过多的创建线程

类库提供了一些默认的线程池

  • newFixedThreadPool:newFixedThreadPool将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。
  • newCachedThreadPool:newCachedThreadPool将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
  • newSingleThreadExecutor:newSingleThreadExecutor是一个单线程的 Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)。
  • newScheduledThreadPool:newScheduledThreadPool创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务。

Executor的生命周期

为了解决服务执行生命周期的问题,Executor扩展了ExecutorService接口,提供了一些管理生命周期的方法。

image.png ExecutorService的生命周期有3种状态:运行、关闭和已终止。ExecutorService在初始创建时处于运行状态。

shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成-包括那些还未开始执行的任务。

shutdownNow方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。

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

可以调用awaitTermination来等待ExecutorService到达终止状态,或者通过调用isTerminated来轮询 ExecutorService是否已经终止。通常在调用 awaitTermination之后会立即调用shutdown,从而产生同步地关闭ExecutorService的效果。

延迟任务与周期任务

Timer类负责管理延迟任务(延迟几秒执行)和周期任务(隔几秒执行一次)。然而它存在着一些缺陷,应该使用ScheduledThreadPoolExecutor来替代它。 Timer存在的问题:

  • Timer在执行所以定时任务都只会创建一个线程,如果其中某个任务执行时间过长,那么会破坏其他任务定时精确性。例如某个周期TimerTask需要每10ms执行一次,而另一个TimerTask需要执行40ms,那么这个周期任务或者在40ms任务执行完成后快速连续地调用4次,或者彻底“丢失”4次调用(取决于它是基于固定速率来调度还是基于固定延时来调度)。线程池能弥补这个缺陷,它可以提供多个线程来执行延时任务和周期任务。
  • 如果TimerTask抛出了一个未检查的异常,那么Timer将表现出糟糕的行为。Timer线程并不捕获异常,因此当TimerTask 抛出未检查的异常时将终止定时线程。这种情况下,Timer也不会恢复线程的执行,而是会错误地认为整个Timer都被取消了。因此,已经被调度但尚未执行的TimerTask将不会再执行,新的任务也不能被调度。(这个问题称之为“线程泄漏”)

示例:串行的页面渲染器

image.png 页面的渲染分为文字和图片,图片下载都是在登录IO操作执行完成,这段时间CPU不做任何操作,使得响应时间很长,可以将任务分解成并行执行,提高响应度。

携带结果的Future和CallAble

Executor使用Runnable作为一个基本任务,但是它没有返回值,或者抛出一个受检异常。

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

Future 表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。在程序清单6-11中给出了Callable 和Future。在Future规范中包含的隐含意义是,任务的生命周期只能前进,不能后退,就像ExecutorService的生命周期一样。当某个任务完成后,它就永远停留在“完成”状态上。

image.png get方法

  • 任务完成:返回结果或者抛出一个Exception。
  • 任务没有完成:get一直阻塞,直到任务完成。
  • 任务抛出异常:将异常封装成ExecutionException并抛出,可以通过getCause来获取被封装的异常。 ExecutorService的submit方法会返回一个Future,用于获取任务的返回,或者取消任务。
Future<Object> future = Executors.newFixedThreadPool(1).submit(new Callable<Object>() {
    @Override
    public Object call() {
        return null;
    }
});

FutureTask

FutureTask实现了RunnableFuture,RunnableFuture又继承了Runnable, Future。

image.png

image.png 即可以通过Runnable接口实现线程,也可以通过Future取得线程执行完后的结果),因此FutureTask也可以直接提交给Executor执行。

Callable<String> call = new Callable<String>() {
    @Override
    public String call() throws Exception {
        return "";
    }
};
FutureTask<String> futureTask = new FutureTask<String>(call);
Executors.newFixedThreadPool(1).submit(futureTask);
futureTask.get();

使用Future实现页面渲染器

image.png 上图虽然将渲染分成了2个任务,渲染文字和下载图片。但一般来说,渲染文本的速度远远快于下载图片的速度。那么程序提升的性能不大(因为下载图片还是在串行下载),但是代码的复杂度却变高了。只有大量独立且同构的任务可以并发执行时,性能才能真正提升。很明显这里的下载图片是相互独立的。

CompletionService

如果向Executor提交了一组计算任务,并且希望在计算完成后获得结果,那么可以保留与每个任务关联的Future,然后反复使用get方法,同时将参数timeout指定为0,从而通过轮询来判断任务是否完成。这种方法虽然可行,但却有些繁琐。幸运的是,还有一种更好的方法:完成服务(CompletionService)。

CompletionService 将Executor和BlockingQueue的功能融合在一起。你可以将Callable 任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,而这些结果会在完成时将被封装为Future image.png CompletionService的实现非常简单:

image.png

image.png 提交某一个任务时,封装成一个QueueingFuture,QueueingFuture其实就是继承了FutureTask重写了done方法(任务完成时,回调该方法)。BlockingQueue<Future> completionQueue 用来存储返回结果。

为任务设置时限

某个任务如不能在一定时间内完成,那么将不再需要它的结果。如一个门户网站从多个数据源获取显示的数据,如果超出了指定时间,那么只显示已经获取的数据。Future.get支持这种需求,在指定时限内没有获取结果,抛出一个TimeoutExecption。如果一个Future抛出了TimeoutExecption,那么可以通过Future取消任务。

示例:旅行预订门户网站

一个旅行门户网站,用户输入日期和其他要求,门店获取并展示来自多条航线,多个旅馆的报价。在获取报价的过程中,如果指定时间内没有响应,那么就不予显示。

从一个公司获得报价的过程与从其他公司获得报价的过程无关,因此可以将获取报价的过程当成一个独立的任务,从而使获得报价的过程能并发执行。创建n个任务,将其提交到一个线程池,保留n个Future,并使用限时的get方法通过Future 串行地获取每一个结果,这一切都很简单,但还有一个更简单的方法-invokeAll。

private class QuoteTask implements Callable<TravelQuote> {
    private final TravelCompany company;
    private final TravelInfo travelInfo;
    ...

    public TravelQuote call() throws Exception {
        return company.solicitQuote(travelInfo);
    }

    public List<TravelQuote> getRankedTrave1Quotes(
            TravelInfo travelInfo, Set<TravelCompany> companies,
            Comparator<TravelQuote> ranking, long time, TimeUnit unit)
            throws InterruptedException {
        List<QuoteTask> tasks = new ArrayList<QuoteTask>();
        for (TravelCompany company : companies) {
            tasks.add(new QuoteTask(company, travelInfo));
        }

        List<Future<TravelQuote>> futures = exec.invokeA11(tasks, time, unit);
        List<TravelQuote> quotes = new ArrayList<TravelQuote>(tasks.size());
        Iterator<QuoteTask> taskIter = tasks.iterator();
        for (Future<TravelQuote> f : futures) {
            QuoteTask task = taskIter.next();
            try {
                quotes.add(f.get());
            } catch (ExecutionException e) {
                quotes.add(task.getFailureQuote (e.getCause()));
            }catch (CancellationException e) {
                quotes.add(task.getTimeoutQuote(e));
            }
        }
        return quotes;
    }

}

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