五、任务执行

一、在线程中执行任务

当围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想 情况下,各个任务之间是相互独立的:任务并不依赖于其他任务的状态、结果或边界效应。独 立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执 行。为了在调度与负载均衡等过程中实现更高的灵活性,每项任务还应该表示应用程序的一小部分处理能力。
在正常的负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。应用程 序提供商希望程序支持尽可能多的用户,从而降低每个用户的服务成本,而用户则希望获得尽 快的响应。而且,当负荷过载时,应用程序的性能应该是逐渐降低,而不是直接失败。要实现 上述目标,应该选择清晰的任务边界以及明确的任务执行策略
大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界。 Web服务器、邮件服务器、文件服务器、EJB容器以及数据库服务器等,这些服务器都能通过 网络接受远程客户的连接请求。将独立的请求作为任务边界,既可以实现任务的独立性,又可
以实现合理的任务规模。例如,在向邮件服务器提交一个消息后得到的结果,并不会受其他正 在处理的消息影响,而且在处理单个消息时通常只需要服务器总处理能力的很小一部分。

  • 串行地执行任务

在应用程序中可以通过多种策略来调度任务,而其中一些策略能够更好地利用潜 在的并发性。最简单的策略就是在单个线程中串行地执行各项任务。程序清单中的SingleThreddWebServer将串行地处理它的任务(即通过80端口接收到的HTTP请求)。至于如 何处理请求的细节问题,在这里并不重要,我们感兴趣的是如何表征不同调度策略的同步特性。

class SingleThreadWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        
        while (true) {
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }
}

SingleThreadWebServer很简单,且在理论上是正确的,但在实际生产环境中的执行性能却 很糟糕,因为它每次只能处理一个请求。主线程在接受连接与处理相关请求等操作之间不断地 交替运行。当服务器正在处理请求时,新到来的连接必须等待直到请求处理完成,然后服务器 将再次调用accept。如果处理请求的速度很快并且handleRequest可以立即返回,那么这种方法 是可行的,但现实世界中的Web服务器的情况却并非如此。

  • 显示地为任务创建线程

通过为每个请求创建一个新的线程来提供服务,从而实现更高的响应性,如程序清单中的ThreadPerTaskWebServer 所示。

class ThreadPerTaskWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);

        while (true) {
            final Socket connection = socket.accept();
            Runnable runnable = new Runnable() {
                @Override
                public void run() {
                    handleRequest(connection);
                }
            };

            new Thread(runnable).start();
        }
    }
}
  • 无线创建线程的不足

在生产环境中,“为每个任务分配一个线程”这种方法存在一些缺陷,尤其是当需要创建大量的线程时:

  • 线程生命周期的开销非常高。线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也有所不同,但线程的创建过程都会需要时间,延迟处理的请求,并且需要JVM和操作系统提供一些辅助操作。如果请求的到达率非常高且请求的处理过程是轻量级的,例如大 多数服务器应用程序就是这种情况,那么为每个请求创建一个新线程将消耗大量的计算资源。
  • 咨源消耗。活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多于可用 处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压 力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。如果你已经拥有足够多的线程 使所有CPU保持忙碌状态,那么再创建更多的线程反而会降低性能。
  • 稳定性。在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并 且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,要想从这种错误中恢复过来是非常危险的,更简单的办法是通过构造程序来避免超出这些限制
    在一定的范围内,增加线程可以提高系统的吞吐率,但如果超出了这个范围,再创建更多的线程只会降低程序的执行速度,并且如果过多地创建一个线程,那么整个应用程序将崩溃。 要想避免这种危险,就应该对应用程序可以创建的线程数量进行限制,并且全面地测试应用程 序,从而确保在线程数量达到限制时,程序也不会耗尽资源。
    “为每个任务分配一个线程”这种方法的问题在于,它没有限制可创建线程的数量,只限 制了远程用户提交HTTP请求的速率。与其他的并发危险一样,在原型设计和开发阶段,无限 制地创建线程或许还能较好地运行,但在应用程序部署后并处于高负载下运行时,才会有问题 不断地暴露出来。因此,某个恶意的用户或者过多的用户,都会使Web服务器的负载达到某个 阈值,从而使服务器崩溃。如果服务器需要提供髙可用性,并且在高负载情况下能平缓地降低 性能,那么这将是一个严重的故障。
二、Executor框架

任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。我们已经分析了两种通过 线程来执行任务的策略,即把所有任务放在单个线程中串行执行,以及将每个任务放在各自的 线程中执行。这两种方式都存在一些严格的限制:串行执行的问题在于其糟糕的响应性和吞吐 量,而“为每个任务分配一个线程”的问题在于资源管理的复杂性。
线程池简 化了线程的管理工作,并且java.util.concurrent提供了一种灵活的线程池实现作为Executor框 架的一部分。在Java类库中,任务执行的主要抽象不是Thread,而是Executor,如程序清单所示。

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

虽然Executor是个简单的接口,但它却为灵活且强大的异步任务执行框架提供了基础,该 框架能支持多种不同类型的任务执行策略。它提供了一种标准的方法将任务的提交过程与执行 过程解耦开来,并用Runnable来表示任务。Executor的实现还提供了对生命周期的支持,以及 统计信息收集、应用程序管理机制和性能监视等机制。
Executor基于生产者-消费者模式,提交任务的操作相当于生产者(生成待完成的工作单 元),执行任务的线程则相当于消费者(执行完这些工作单元)。如果要在程序中实现一个生产者-消费者的设计,那么最简单的方式通常就是使用Executor。

  • 示列:基于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 runnable = () -> handleRequest(connection);

            exec.execute(runnable); // 基于线程池执行
        }
    }
}
  • 执行策略

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

  • 在什么(What)线程中执行仟各?
  • 任务按照什么(What)顺序执行(FIFO、LIFO、优先级)?
  • 有多少个(HowManv)仟务能并发执行?
  • 在队列中有多少个(HowMany)任务在等待执行?
  • 如果系统由于过载而霊要柜绝一个任务,那么应该选择哪一个(Which)任务?另外,如 何(How)通知应用程序有任务被拒绝?
  • 在执行一个任务之前或之后,应该进行哪些(What)动作?
    各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的
    需求。通过限制并发任务的数量,可以确保应用程序不会由于资源耗尽而失败,或者由于在稀 缺资源上发生竞争而严重影响性能。通过将任务的提交与任务的执行策略分离开来,有助于 在部署阶段选择与可用硬件资源最匹配的执行策略。

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

new Thread(runnable).start()

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

  • 线程池

线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队 列(Work Queue)密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者线程 (Worker Thread)的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并 等待下一个任务。
在线程池中执行任务”比“为每个任务分配一个线程”优势更多。通过重用现有的线程 而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另 一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。通过适当调整线程池的大小,可以创建足够多的线程以 便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存 或失败。
类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用Executors中的静 态工厂方法之一来创建一个线程池:

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

Executor的实现通常会 创建线程来执行任务。但JVM只有在所有(非守护)线程全部终止后才会退出。因此,如果 无法正确地关闭Executor,那么JVM将无法结束。
为了解决执行服务的生命周期问题,Executor扩展了 ExecutorService接口,添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法)。在程序清单给出了 ExecutorService中的生命周期管理方法。

public interface ExecutorService extends Executor {
	void shutdown();
	List<Runnable> shutdownNow();
	boolean isShutdown();
	boolean isTerminated();
	boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
	<T> Future<T> submit(Callable<T> task);
	<T> Future<T> submit(Runnable task, T result);
	Future<?> submit(Runnable task);
	........
}

ExecutorService的生命周期有3种状态:运行、关闭和已终止。ExecutorService在初始创 建时处于运行状态。shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经 提交的任务执行完成~包括那些还未开始执行的任务。shutdownNow方法将执行粗暴的关闭 过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
在ExecutorService关闭后提交的任务将由“拒绝执行处理器(Rejected Execution Handler)” 来处理,它会抛弃任务,或者使得execute方法抛出一个未检查的Rejected- ExecutionException。等所有任务都完成后,ExecutorService将转入终止状态。可以调用 awaitTermination来等待ExecutorService到达终止状态,或者通过调用isTerminated来轮询 ExecutorService是否已经终止。通常在调用awaitTermination之后会立即调用shutdown,从而 产生同步地关闭ExecutorService的效果。

  • 延迟任务与周期任务

Timer类负责管理延迟任务(“在100ms后执行该任务”)以及周期任务(“每10ms执行一 次该任务”)。然而,Timer存在一些缺陷,因此应该考虑使用ScheduledThreadPoolExecutor来代替它。可以通过 ScheduledThreadPoolExecutor 的构造函数或 newScheduledThreadPool 工厂方法来创建该类的对象。
Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间过长,那么将 破坏其他TimerTask的定时精确性。例如某个周期TimerTask需要每10ms执行一次,而另一个 TimerTask需要执行40ms,那么这个周期任务或者在40ms任务执行完成后快速连续地调用4 次,或者彻底“丢失”4次调用(取决于它是基于固定速率来调度还是基于固定延时来调度)。 线程池能弥补这个觖陷,它可以提供多个线程来执行延时任务和周期任务。
Timer的另一个问题是,如果TimerTask抛出了一个未检査的异常,那么Timer将表现出 糟糕的行为。Timer线程并不捕获异常,因此当TimerTask抛出未检査的异常时将终止定时线 程。这种情况下,Timer也不会恢复线程的执行,而是会错误地认为整个Timer都被取消了。 因此,已经被调度但尚未执行的TimerTask将不会再执行,新的任务也不能被调度。(这个问题 称之为“线程泄漏[Thread Leakage]”。)

三、找出可利用的并行性

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

  • 携带结果的任务Callable与Future

Executor框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的 抽象,虽然run能写入到日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值 或抛出一个受检查的异常。
许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable是一种更好的抽象:它认为主入口点(即call)将返 回一个值,并可能抛出一个异常。在Executor中包含了一些辅助方法能将其他类型的任务封装为一个 Callable,例如 Runnable 和 java.security.PrivilegedAction。
Runnable和Callable描述的都是抽象的计算任务。这些任务通常是有范围的,即都有 一个明确的起始点,并且最终会结束。Executor执行的任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能要执行很长的时间,因此通常希望能够取消这些任务。在 Executor框架中,已提交但尚未开始的任务可以取消,但对于那些已经开始执行的任务,只有 当它们能响应中断时,才能取消。取消一个已经完成的任务不会有任何影响。
Future表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。在程序清单中给出了 Callable和Future。在Future规范中 包含的隐含意义是,任务的生命周期只能前进,不能后退,就像ExecutorService的生命周期一 样。.当某个任务完成后,它就永远停留在“完成”状态上。
get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已经完成,那么get会立即返回或者抛出一个Exception,如果任务没有完成,那么get将阻塞并直到任务完成。如果任务抛出了异常,那么get将该异常封装为ExecutionException并重新抛出。如果 任务被取消,那么 get 将抛出CancellationException。如果 get 抛出了 ExecutionException,那 么可以通过getCause来获得被封装的初始异常。

public interface Callable<V> {
    V call() throws Exception;
}

public interface Future<V> {
    boolean cancel(boolean var1);

    boolean isCancelled();

    boolean isDone();

    V get() throws InterruptedException, ExecutionException;

    V get(long var1, TimeUnit var3) throws InterruptedException, ExecutionException, TimeoutException;
}

可以通过许多神方法创建一个Future来描述任务。ExecutorService中的所有submit方法 都将返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一个Future用 来获得任务的执行结果或者取消任务。还可以显式地为某个指定的Rmmable或Callable实例化 一个FutureTask。(由于FutureTask实现了 Runnable,因此可以将它提交给Executor来执行, 或者直接调用它的nm方法。)
从 Java 6开始,ExecutorService 实现可以改写 AbstractExecutorService 中的 newTaskFor 方法,从而根据已提交的Runnable或Callable来控制Future的实例化过程。在默认实现中仅 创建了一个新的FutureTask。

  • CompletionService、Executor 与 BlockingQueue

如果向Executor提交了一组计算任务,并且希望在计算完成后获得结果,那么可以保留与 每个任务关联的Future,然后反复使用get方法,同时将参数timeout指定为0,从而通过轮询 来判断任务是否完成。这种方法虽然可行,但却有些繁琐。幸运的是,还有一种更好的方法: 完成服务(CompletionService)。
CompletionService 将 Executor 和 BlockingQueue 的功能融合在一起。你可以将 Callable 任 务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,而这 些结果会在完成时将被封装为 Future。ExecutorCompletionService 实现了 CompletionService,并将计算部分委托给一个Executor。

  • 为任务设置时限

有时候,如果某个任务无法在指定时间内完成,那么将不再需要它的结果,此时可以放弃 这个任务。例如,某个Web应用程序从外部的广告服务器上获取广告信息,但如果该应用程序 在两秒钟内得不到响应,那么将显示一个默认的广告,这样即使不能获得广告信息,也不会降 低站点的响应性能。类似地,一个门户网站可以从多个数据源并行地获取数据,但可能只会在 指定的时间内等待数据,如果超出了等待时间,那么只显示已经获得的数据。
在有限时间内执行任务的主要困难在于,要确保得到答案的时间不会超过限定的时间,或 者在限定的时间内无法获得答案。在支持时间限制的Future.get中支持这种需求:当结果可用 时,它将立即返回,如果在指定时限内没有计算出结果,那么将抛出TimeoutException。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

书香水墨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值