Java并发编程-可利用的并行性

Java并发编程-可利用的并行性

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

1. 串行的页面渲染器

最简单的方法就是对 HTML 文档进行串行处理。当遇到文本标签时,将其绘制到图像缓存中。当遇到图像引用时,先通过网络获取它,然后再将其绘制到图像缓存中。这种方式可能会用户感到烦恼,用户需要等待很长时间,直到现实所有文本。

另一种串行执行方法更好一些,它先绘制文本元素,同时为图像预留出矩形的占位空间,在处理完了第一遍文本后,程序再开始下载图像,并将它们绘制到相应的占位空间中。程序如下:

public class SingleThreadRenderer {
   /**
     * 页面渲染
     * @param source 字符串
     */
    void renderPage(CharSequence source) {
        // 绘制文本元素
        renderText(source);
        // 下载图像
        List<ImageData> imageData = new ArrayList<>();
        for (ImageData imageInfo : scanForImageInfo(source)) {
            imageData.add(imageInfo);
        }
        // 绘制图像
        for (ImageData data : imageData) {
            renderImage(data);
        }
    }
}

图像下载过程的大部分时间都是在等待 IO 操作执行完成,在这期间 CPU 几乎不做任何工作。因此,这种串行执行方法没有充分地利用 CPU,使得用户在看到最终页面之前要等待过长的时间。通过将问题分解为多个独立的任务并发执行,能够获得更高的CPU利用率和响应灵敏度。

2. 携带结果的 Callable 与 Future

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

许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable 是一种更好的抽象:它认为主入口点(即 call)将返回–个值,并可能抛出一个异常。在 Executor 中包含了一些辅助方法能将其他类型的任务封装为一个 Callable。

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

Callable 与 Future 接口

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

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
    boolean isCancelled();
    boolean isDone();
    V get() throws InterruptedException, ExecutionException, CancellationException;
    V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, CancellationException, TimeoutException; 
}

get 方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已经完成,那么 get 会立即返回或者抛出一个 Exception,如果任务没有完成,那么 get 将该异常封装为 ExecutionException 并重新抛出,如果任务被取消,那么 get 抛出 CallcellationException。如果 get 抛出了 ExecutionException,那么可以通过 getCause 来获得被封装的初始异常。

可以通过许多种方法创建一个 Future 来描述任务。ExecutorService 中的所有 submit 方法都将返回一个Future,从而将一个 Runnable 或 Callable 提交给 Executor,并得到一个 Future 用来获得任务的执行结果或者取消任务。

3. 使用 Future 实现页面渲染

为了使页面渲染器实现更高的并发性,首先将渲染过程分解为两个任务,一个是渲染所有的文本,另一个是下载所有的图像。(因为其中一个任务是 CPU 密集型,而另一个任务是 I/O密集型,因此这种方法即使在单 CPU 系统上也能提升性能)

public class FutureRenderer {
    private final ExecutorService executor = Executors.newFixedThreadPool(5);
    void renderPage(CharSequence source) {
        final List<ImageInfo> imageInfos = scanForImageInfo(source);
        // 获取图像的任务
        Callable<List<ImageData>> task = () -> {
            List<ImageData> result = new ArrayList<>();
            for (ImageInfo imageInfo : imageInfos) {
                result.add(imageInfo.downloadImage());
            }
            return result;
        };
        // 提交任务,并获取结果
        Future<List<ImageData>> future = executor.submit(task);
        renderText(source);
        try {
            List<ImageData> imageData = future.get();
            for (ImageData data : imageData) {
                renderImage(data);
            }
        } catch (InterruptedException e) { // 程序中断异常
            // 重新设置线程的中断状态
            Thread.currentThread().interrupt();
            // 由于不需要结果,因此取消任务
            future.cancel(true);
        } catch (ExecutionException e) { // 线程异常退出
            // throw launderThrowable(e.getCause());
        }
    }
}

Callable 和 Future 有助于表示这些协同任务之间的交互。在上述程序中,我们创建了一个 Callable 来下载所有的图像,并将提交到一个 ExecutionService。这将返回一个描述任务执行情况的 Future。当主任务需要图像时,他会等待 Future.get 的调用结果。如果幸运的话,当开始请求时,所有图像就已经下载完成了,即使没有,那图像下载任务也已经开始了。

FutureRenderer使得渲染文本任务与下载图像数据的任务并发地执行。当所有图像下载完后,会显示到页面上。这将提升用户体验,不仅使用户更快地看到结果,还有效利用了并行。

4. 任务并行化中存在的局限

FutureRenderer 使用了两个任务,其中一个负责渲染文本,另一个负责下载图像。如果渲染文本的速度远远高于下载图像的速度(可能性很大),那么程序的最终性能与串行执行时的性能差别不大,而代码却变得更复杂了。当使用两个线程时,至多能将速度提高一倍。因此,虽然做了许多工作来并发执行异构任务以提高并发度,但从中获得的并发性却是十分有限的。

5. 使用 CompletionService 实现页面渲染器

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

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

public class Renderer {
    private final ExecutorService executor;
    Renderer(ExecutorService executor) {
        this.executor = executor;
    }
    void renderPage(CharSequence source) {
        List<ImageInfo> info = scanForImageInfo(source);
        CompletionService<ImageData> completionService = 
            new ExecutorCompletionService<>(executor);
        for (final ImageInfo imageInfo : info) {
            // 每获取一个图像创建一个任务
            completionService.submit( () -> imageInfo.downloadImage() );
        }

        renderText(source);

        try {
            for (int t = 0; t < info.size(); t++) {
                Future<ImageData> f = completionService.take();
                ImageData imageData = f.get();
                renderImage(imageData);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (ExecutionException e) {
            // throw launderThrowable(e.getCause());
        }
    }
}

多个 ExecutorCompletionService可以共享一个 Executor,因此可以创建一个对于特定计算私有,又能共享一个公共 Executor 的 ExecutorCompletionService。因此,CompletionService 的作用就相当于一组计算的句柄,这与 Future 作为单个计算的句柄是非常类似的。通过记录提交给 CompletionService 的任务数量,并计算出已经获得的已完成结果的数量,即使使用一个共享的 Executor,也能知道已经获得了所有任务结果的时间。

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页