12 March 2016

线程池简化了线程的创建和复用,但任务如何排队、失败如何收集、程序如何等待任务结束,仍需要我们明确处理。下面以批量读取文本文件为例,把这些问题串起来。

先明确这个例子要做什么

读取一个目录下的普通 .txt 文件,每个文件交给一个任务,按 UTF-8 逐行读取并统计行数。主线程汇总每个文件的结果,打印失败原因;有文件失败时,以失败状态结束程序。

这里的行数统计只是文件处理的演示,你可以将任务内部替换成自己的解析逻辑。它不递归处理子目录,文件数量也应在可控范围内。示例会保留每个文件的路径和 Future,因此不是海量文件调度器。

多线程是否更快取决于瓶颈。磁盘已经饱和时,增加并发未必提升速度;应在实际数据上测量,而不是默认并发越大越好。

几个容易混淆的概念

Executor 定义任务执行入口,ExecutorService 增加提交任务、获取结果和管理生命周期的方法,ThreadPoolExecutor 是具体实现之一。Executors 则提供常见执行器的工厂方法。

工厂方法 主要行为 使用时要考虑什么
newFixedThreadPool(n) 最多 n 个线程同时执行任务,其他任务进入共享无界队列;工作线程会复用 线程数有界不代表积压任务有界
newCachedThreadPool() 有可用线程就复用,没有则按需创建;默认回收空闲 60 秒的线程 提交速度过快可能创建过多线程
newSingleThreadExecutor() 任务按顺序执行,同一时刻最多一个任务运行,使用无界队列 执行较慢时仍可能积压任务
newScheduledThreadPool(n) 支持延迟或周期任务 需要明确周期任务的异常处理和取消方式

固定线程池不是等到线程结束后才执行下一个任务。通常是一个任务完成,工作线程继续从队列取任务。默认固定线程池中的核心线程不会仅因空闲而自动回收;也不应该把 keepAliveTime=0 简单解释成所有线程立即超时。

一个可以独立运行的例子

下面只使用 JDK API,采用 Java 8 语法。这里显式创建 5 个工作线程、容量为 64 的任务队列,并采用 CallerRunsPolicy:队列满且执行器尚未关闭时,由提交任务的线程执行任务,从而放慢继续提交的速度。

这意味着主线程也可能参与文件处理,不能把它理解为整个进程始终只有 5 个任务并行。此例的关闭由主线程统一控制;若设计成其他线程可以随时关闭执行器,需要重新处理提交和拒绝的竞态。

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class FileBatch {
    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new IllegalArgumentException("Usage: java FileBatch <directory>");
        }
        Path directory = Paths.get(args[0]);
        if (!Files.isDirectory(directory)) {
            throw new IOException("Not a directory: " + directory);
        }
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                5, 5, 0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(64),
                new ThreadPoolExecutor.CallerRunsPolicy());
        Map<Path, Future<Long>> tasks = new LinkedHashMap<>();
        try {
            try (DirectoryStream<Path> stream =
                         Files.newDirectoryStream(directory, "*.txt")) {
                for (Path file : stream) {
                    if (Files.isRegularFile(file)) {
                        tasks.put(file, pool.submit(() -> countLines(file)));
                    }
                }
            }
            pool.shutdown();
            int failed = 0;
            for (Map.Entry<Path, Future<Long>> task : tasks.entrySet()) {
                try {
                    long lines = task.getValue().get();
                    System.out.println(task.getKey() + ": " + lines + " lines");
                } catch (ExecutionException e) {
                    failed++;
                    System.err.println("Failed: " + task.getKey());
                    e.getCause().printStackTrace(System.err);
                }
            }
            if (failed > 0) {
                throw new IllegalStateException(failed + " file(s) failed");
            }
        } catch (InterruptedException e) {
            pool.shutdownNow();
            Thread.currentThread().interrupt();
            throw e;
        } finally {
            shutdownAndAwait(pool);
        }
    }

    private static long countLines(Path file) throws IOException {
        long count = 0;
        try (BufferedReader reader =
                     Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
            while (reader.readLine() != null) {
                if (Thread.currentThread().isInterrupted()) {
                    throw new IOException("Interrupted: " + file);
                }
                count++;
            }
        }
        return count;
    }

    private static void shutdownAndAwait(ThreadPoolExecutor pool) {
        pool.shutdown();
        try {
            if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
                pool.shutdownNow();
                if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
                    System.err.println("Workers have not terminated");
                }
            }
        } catch (InterruptedException e) {
            pool.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

保存为 FileBatch.java 后执行:

javac -encoding UTF-8 FileBatch.java
java FileBatch ./sample-files

为什么这样收集结果

任务只负责读取自己的文件,返回一个行数,不去修改共享的失败列表。submit 返回 Future,任务中的异常会在主线程调用 get 时通过 ExecutionException 暴露。主线程可以继续检查其他文件,最后统一决定这一批任务是否成功。

如果让多个工作线程调用同一个普通 ArrayList 的 add 来记录失败文件,就会引入并发安全问题。用线程安全容器可以保护这些修改,而让任务直接返回结果,能进一步避免工作线程共享汇总状态。

主线程按登记顺序获取结果,所以较慢的前一个任务会延迟后续结果的展示,但不会阻止后续任务并发执行。如果需要谁先完成就先处理谁的结果,可以进一步使用 ExecutorCompletionService。

关闭和等待要分开理解

shutdown() 不再接受新任务,但会让已提交的任务继续执行;它本身不等待任务全部结束。awaitTermination 等待的是执行器终止,超时时返回 false,不能把调用过这个方法当成任务已经完成的证明。

正常情况下,上面的程序先通过每个 Future 获取结果,再等待线程池终止。因此清理方法中的 30 秒是关闭阶段的等待时间,不是每个文件的执行超时。若业务要求限制任务时长,应另外设计超时和取消策略。

发生中断时,程序请求停止剩余任务,并恢复当前线程的中断标志。shutdownNow() 只是尝试中断,不会强制终止任意代码;任务必须配合检查中断,阻塞 IO 也不一定立即响应。示例在行间检查中断,但不能据此承诺所有 IO 都能及时退出。

最后,逐行读取避免一次把整个文件加载到内存,但路径和 Future 仍会随文件数增长。用于更大规模任务时,需要进一步控制在途任务和结果的保留数量。

参考资料





blog comments powered by Disqus
Fork me on GitHub