第六章 任务执行 Java并发编程实战 阅读总结

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

6.1 在线程中执行任务

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

6.1.1 串行地执行任务

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

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

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

        在服务器应用程序中, 串行处理机制通常都无法提供高吞吐率或快速响应性。也有一些例外, 例如, 当任务数量很少且执行时间很长时, 或者当服务器只为单个用户提供服务, 并且该客户每次只发出一个请求时——但大多数服务器应用程序并不是按照这种方式来工作的e

6.1.2 显式地为任务创建线程

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

        ThreadPerTask WebServer在结构上类似千前面的单线程版本-主线程仍然不断地交替执行“接受外部连接” 与“分发请求” 等操作。区别在于, 对于每个连接, 主循环都将创建一个新线程来处理请求, 而不是在主循环中进行处理。由此可得出3个主要结论:
·任务处理过程从主线程中分离出来, 使得主循环能够更快地重新等待下一个到来的连接。这使得程序在完成前面的请求之前可以接受新的请求, 从而提高响应性。
·任务可以并行处理, 从而能同时服务多个请求。如果有多个处理器, 或者任务由千某种原因被阻塞, 例如等待I/0完成、获取锁或者资源可用性等, 程序的吞吐量将得到提高。

·任务处理代码必须是线程安全的, 因为当有多个任务时会并发地调用这段代码。在正常负载情况下, “为每个任务分配一个线程” 的方法能提升串行执行的性能。只要请求的到达速率不超出服务器的请求处理能力, 那么这种方法可以同时带来更快的响应性和更高的吞吐率。

6.1.3 无限制创建线程的不足

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

        “为每个任务分配一个线程” 这种方法的问题在于, 它没有限制可创建线程的数量, 只限制了远程用户提交HTTP 请求的速率。与其他的并发危险一样, 在原型设计和开发阶段, 无限制地创建线程或许还能较好地运行, 但在应用程序部署后井处于高负载下运行时, 才会有问题不断地暴露出来。因此, 某个恶意的用户或者过多的用户, 都会使Web 服务器的负载达到某个阀值, 从而使服务器崩溃。如果服务器需要提供高可用性, 并且在高负载情况下能平缓地降低性能, 那么这将是一个严重的故障。

6.2 Executor框架

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

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

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

扫描二维码关注公众号,回复: 1552147 查看本文章

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

        基于Executor来构建Web服务器是非常容易的。在程序清单6-4中 用Executor 代替了硬编码的线程创建过程。在这种情况下使用了 一种标准的Executor实现 ,即一个固定长度的线程 池,可以容纳100个线程 。

public 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() {
                @Override
                public void run() {
                //    handleRequest(connection);
                }
            };exec.execute(task);
        }

    }
}
        在TaskExecutionWeb Server中, 通过使用Executor, 将请求处理任务的提交与任务的实际 执行解耦开来,并且只需采用另一种不同的Executor实现, 就可以改变服务器的行为。 改变 Executor实现或配置所带来的影响要远远小于改变任务 提交方式带来的影响。 通常,Executor 的配置是一次性的, 因此在部署阶段可以完成, 而提交任务的代码却会不断地扩散到整个程序中,增加了修改的难度 。

        我们可以很容易地将TaskExecutionWebServer修改为类似ThreadPerTaskWebServer的行 为, 只需使用一个为每个请求都创建新线程的Executor。 编写这样的Executor很简单, 如程序清单6-5中的ThreadPerTaskExecutor所示。

       同样, 还可以编写一个Executor使TaskExecutionWebServer的行为类似于单线程的行为即以同步的方式执行每个任务,然后再返回, 如程序清单6-6中的WithinThreadExecutor所示。                                      

6.2.2执行策略

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

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

· 在执行一个任务之前或之后,应该进行哪些(What)动作?

        各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。 通过限制并发任务的数量, 可以确保应用程序不会由千资源耗尽而失败, 或者由于在稀缺资源上发生竞争而严重影响性能。通过将任务的提交与任务的执行策略分离开来, 有助于在部署阶段选择与可用硬件资源最匹配的执行策略。

        每当看到下面这种形式的代码时: new Thread(runnable).start()并且你希望获得一种更灵活的执行策略时,请考虑使用Executor来代替Thread。

6.2.3 线程池

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

        “ 在线程池中执行任务” 比“为每个任务分配一个线程” 优势更多。通过重用现有的线程而不是创建新线程, 可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是, 当请求到达时, 工作线程通常已经存在, 因此不会由于等待创建线程而延迟任务的执行, 从而提高了响应性。通过适当调整线程池的大小, 可以创建足够多的线程以便使处理器保持忙碌状态, 同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。

        类库提供了一个灵活的线程池以及一些有用的默认配置。 可以通过调用Executors中的静 态工厂方法之一来创建一个线程池:
        newFixedThreadPool。newFixedThreadPool将创建一个固定长度的线程池, 每当提交一 个任务时就创建一个线程, 直到达到线程池的最大数量, 这时线程池的规模将不再变化(如果 某个线程由千发生了未预期的Exception而结束, 那么线程池会补充一个新的线程)。
        newCached Thread Pool。 newCachedThreadPool将创建一个可缓存的线程池, 如果线程池 的当前规模超过了处理需求时, 那么将回收空闲的线程, 而当需求增加时, 则可以添加新的线 程, 线程池的规模不存在任何 限制。
        newSingle Thread Executor。newSingleThreadExecutor是一个单线程的Executor, 它创建单个工 作者线程来执行任务, 如果这个线程异常结束, 会创建另一个线程来替代。newSingleThreadExecutor 能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、 优先级)。e
        newScheduledThreadPool. newSched uledThreadPool创建了一个固定长度的线程池, 而 且以延迟或定时的方式来执行任务, 类似千Timer (参见6.2.5节)。
        newFixedThreadPool和newCachedThreadPool这两个工厂方法返回通用的ThreadPool­Executor 实例,这些实例可以直接用来构造专门用途的 executor。我们将在第8章中深入讨论线程池的各个配置选项。

        TaskExecution WebServer中的Web服务器使用了一个带有有界线程池的Executor。通过 execute方法将任务提交到工作队列中, 工作线程反复地从工作队列中取出任务并执行它们。

       Web服务器不会再在高负载情况下失败。由于服务器不会创建数千个线程来争夺有限的CPU和内存资源, 因此服务器的性能将平缓地降低。通过使用Executor, 可以实现各种调优、 管理、 监视、 记录日志、 错误报告和其他功能 ,如果不使用任务执行框架, 那么要增加 这些功能是非常困难的。

6.2.4 Executor的生命周期

        我们已经知道如何创建一个Executor, 但并没有讨论如何关闭它 。Executor 的实现通常会 创建线程来执行任务。 但JVM只有在所有(非守护) 线程全部终止后才会退出。 因此 ,如果无法正确地关闭Executor, 那么JVM将无法结束。

        由于Executor 以异步方式来执行任务, 因此在任何时刻, 之前提交任务的状态不是立即可见的。有些任务可能已经完成, 有些可能正在运行, 而其他的任务可能在队列中等待执行。当关闭应用程序时,可能采用最平缓的关闭形式(完成所有已经启动的任务, 并且不再接受任何新的任务), 也可能采用最粗暴的关闭形式(直接关掉机房的电源), 以及其他各种可能的形式。既然Executor 是为应用程序提供服务的, 因而它们也是可关闭的(无论采用平缓的方式还是粗暴的方式),并将在关闭操作中受影响的任务的状态反馈给应用程序。

        为了解决执行服务的生命周期问题, Executor 扩展了ExecutorService 接口, 添加了一些用期管理的方法(同时还有一些用于任务提交的便利方法)。在程序清单6-7 中给出ExecutorService 中的生命周期管理方法。                                                  

ExecutorService 的生命周期有3 种状态:运行、关闭和已终止。ExecutorService 在初始创建时处于运行状态。shutdown 方法将执行平缓的关闭过程: 不再接受新的任务, 同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。shutdownNow 方法将执行粗暴的关闭过程: 它将尝试取消所有运行中的任务, 并且不再启动队列中尚未开始执行的任务。
        在ExecutorService 关闭后提交的任务将由“ 拒绝执行处理器(Rejected Execution Handler)"来处理(请参见8.3.3 节), 它会抛弃任务, 或者使得execute 方法抛出一个未检查的RejectedExecutionException。等所有任务都完成后, ExecutorService 将转人终止状态。可以调用awaitTermination 来等待Execut

猜你喜欢

转载自blog.csdn.net/weixin_40243947/article/details/80642879
今日推荐