线程池参数、队列与快慢接口隔离

1 分钟阅读
·

系列目录

  1. 从单线程到线程池:云盘转 Java 后的第一堂并发课
  2. 线程池参数、队列与快慢接口隔离(本篇)
  3. 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上

上一篇讨论了 Node.js 单事件循环与 Java 请求线程池的差异。此前写过 Node.js、C# 等后端服务,我知道语言和框架改变后,阻塞调用、任务排队与共享资源都会有不同的表现。Java 版云盘上线后,我先把线程池作为需要补齐的基础知识,阅读 JDK 7 的 ThreadPoolExecutor 源码和 Javadoc,并将参数、队列和拒绝策略用于后续项目改造。

一个慢接口影响整个服务

3 月底的一天下午,监控上大盘接口的响应时间集体抬头,先是几个接口超时告警,随后几乎所有接口都超时,包括平时只要几毫秒的元数据查询。终端侧的列表刷不出来,下载卡住。

先检查下游。数据库的慢查询日志里有一个历史遗留的目录递归查询,数据量上来后执行时间从几十毫秒涨到秒级。它能解释文件列表接口变慢,但获取用户信息等不访问该表的接口也在超时。

jstack 的线程栈显示,业务池的 worker 线程都停在同一个 DAO 调用,栈顶在 socket read 上等待数据库返回。业务线程池已经打满。

按照上一篇的线程模型,当时所有接口的请求由 Jetty 线程接收后,统一提交到同一个业务线程池执行,Jetty 线程同步等待业务池返回结果。慢查询持续占用业务线程,池中的线程逐渐被该接口占满,后续任务在队列中等待。每个 Jetty 线程等待自己提交的任务返回,可用于处理新请求的 Jetty 线程减少;请求积压到连接器或操作系统的排队上限时,可能表现为连接超时。共享业务线程池使慢接口影响其他接口。

当时先重启服务清掉积压请求,让 RT 回落,并在入口对慢接口限流。慢 SQL 需要单独优化。线程池仍需要设置资源边界,否则其他慢接口也可能占满业务线程池。

ThreadPoolExecutor 的任务提交顺序

出问题的业务线程池使用 Executors.newFixedThreadPool(...) 创建。我先前只知道线程池用于复用线程,没有把参数与任务提交路径对应起来。回头阅读 JDK 7 的 ThreadPoolExecutor 源码和 Javadoc 后,execute(任务) 的主要处理顺序如下:

ThreadPoolExecutor 任务提交流程

  1. 当前线程数 < 核心线程数(corePoolSize):创建新线程执行任务,即使池中已有空闲线程。
  2. 核心线程满了:尝试把任务放入队列(workQueue),等待线程取出执行。
  3. 队列也满了:只要总线程数还没到最大线程数(maximumPoolSize),就创建非核心线程立即执行。这类线程空闲超过 keepAliveTime 会被回收。
  4. 线程数到顶、队列也满:触发拒绝策略(RejectedExecutionHandler)。默认的 AbortPolicy 抛出 RejectedExecutionExceptionCallerRunsPolicy 由调用方线程执行任务;DiscardPolicyDiscardOldestPolicy 会丢弃任务,后者会先丢弃队列中等待最久的任务,再尝试提交当前任务。

第 2、3 步的顺序会直接影响扩容条件。配置“核心 10、最大 50”时,线程池会先创建最多 10 个核心线程,再将任务放入队列;只有队列满后才会创建第 11 个线程。队列决定了线程池何时扩容。

池中的核心线程默认按需创建。核心线程数设为 50 后,没有任务进来时不会创建线程;可以调用 prestartAllCoreThreads 预启动核心线程,当时没有用到。建池本身的成本较低,参数需要结合负载选择。

无界队列的限制

带着这个提交流程查看 Executors.newFixedThreadPool(n) 的源码,可以看到它内部是:

new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<Runnable>());

LinkedBlockingQueue 不传容量时,容量为 Integer.MAX_VALUE。队列在可用内存耗尽前不会满,因此不会进入第 3 步,也不会因队列满进入第 4 步的拒绝策略。maximumPoolSize 大于 corePoolSize 时也不会因此扩容。写个最小 demo 验证一下(JDK 7 可跑):

public class UnboundedQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        // 核心 2、最大 4、无界队列、默认 AbortPolicy
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2, 4,
                60L, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(),
                new ThreadPoolExecutor.AbortPolicy());

        for (int i = 0; i < 100; i++) {
            final int id = i;
            pool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(5000); // 模拟慢任务
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                    System.out.println("task " + id + " done");
                }
            });
        }

        Thread.sleep(1000);
        System.out.println("maximumPoolSize=4,当前线程数 = " + pool.getPoolSize());
        System.out.println("队列里堆积的任务 = " + pool.getQueue().size());
        pool.shutdown();
    }
}

在这段程序中,当前线程数为 2,队列中有 98 个任务,全程不会出现 RejectedExecutionException。“最大 4 个线程”没有参与调度。

把队列换成 new ArrayBlockingQueue<Runnable>(10) 再跑,线程数会涨到 4,第 15 个任务提交时抛出 RejectedExecutionException。池子的队列和线程数都有上限。

无界队列会持续接收任务,任务在队列中的等待时间随积压增加,调用方可能在任务完成前超时。队列中的任务对象也会占用内存;积压持续增长时,进程可能因内存不足而失败。那次不报错但接口超时的表现符合这一模式。

如何确定线程池参数

理解提交顺序后,四个参数的职责比较明确,但具体数值需要按负载确定。我当时先根据已有后端经验和任务性质设初值,再用压测数据调整。

  • 核心线程数:先判断任务性质。CPU 密集型任务常以 CPU 核数附近的线程数作为起点;云盘接口大多是 IO 密集型任务,需要等待数据库、存储或缓存,线程在等待期间不占用 CPU,因此初始线程数可以高于核数。具体数值仍要结合 CPU 使用率、RT 和实际阻塞时间在压测中调整。
  • 最大线程数:决定队列满后允许额外创建多少线程。它受可用 CPU、内存、下游服务的并发能力和可接受的突发流量影响。
  • 队列长度:表示允许积压多少突发请求。队列过大,会把过载转化为更长的等待和调用方超时;队列过小,则可能在短暂波动时拒绝原本可处理的请求。
  • 拒绝策略:Web 入口可使用 AbortPolicy 让提交方快速得到失败,以便上层把“系统忙”尽快返回给调用方。CallerRunsPolicy 会让提交任务的 Jetty 处理线程执行任务,降低可处理新请求的 Jetty 线程数,将压力传递到入口。当时只在内部任务池里用它。

压测调参的细节留到系列第 10 篇。当时还为线程工厂命名:使用自定义 ThreadFactory 给不同池子的线程加业务前缀,之后通过 jstack 可以识别线程所属的池子。

按快慢接口拆分线程池

分析清楚后,改造目标是让快慢接口使用独立线程池。

  1. 业务线程池一分为二。 元数据类快接口(目录信息、属性查询,毫秒级)使用一个池,文件操作类慢接口(大列表、转存、批量操作,秒级也是它)使用另一个池。两个池各自设置核心数、有界队列和拒绝策略。慢接口把自己的池打满后,快接口仍可使用自己的线程池。这是当时采用的线程池隔离。两个池长这样(数字是脱敏后的示意,量级感受即可):
// 快接口池:核心线程多、队列短,追求快进快出
ThreadPoolExecutor fastPool = new ThreadPoolExecutor(
        32, 64, 60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<Runnable>(256),
        new NamingThreadFactory("meta-fast-"), // 自定义 ThreadFactory:给线程名加前缀(实现略)
        new ThreadPoolExecutor.AbortPolicy());

// 慢接口池:核心线程少、队列同样有限,打满就拒绝
ThreadPoolExecutor slowPool = new ThreadPoolExecutor(
        8, 16, 60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<Runnable>(64),
        new NamingThreadFactory("file-slow-"),
        new ThreadPoolExecutor.AbortPolicy());
  1. 队列全部换成有界的 ArrayBlockingQueue 长度按压测中可容忍的排队时间反推,避免请求在队列中无限等待。
  2. 拒绝后快速失败。 业务池拒绝后,上层统一捕获 RejectedExecutionException,给终端返回“系统繁忙”类的明确错误,而不是让请求挂着直到超时。客户端可以据此决定是否重试和提示。
  3. 接入监控告警。 给每个池子的活跃线程数、队列长度做了埋点上报(公司监控平台,只写用法:暴露指标、配阈值告警)。队列持续积压或频繁拒绝时触发告警,使问题能在用户反馈前从监控曲线中发现。

改造上线后,慢查询在优化前又被触发过一次。监控显示慢接口池打满并出现拒绝告警,慢接口开始快速失败;快接口的 RT 曲线没有明显变化。线程池隔离限制了慢接口对快接口线程资源的影响。上一次是全站超时加手动重启,这一次是局部报错加一条告警。

线程隔离后的数据库连接问题

线程池隔离处理的是线程这一共享资源。如果慢接口持有的数据库连接来自快慢接口共用的连接池,慢接口占满连接池后,快接口即使在线程池中有可用线程,获取连接时仍会排队或超时。线程池隔离不能处理共享数据库连接池的竞争。

下一篇讨论数据库连接池的参数、等待超时,以及它和 Spring 声明式事务的关系。

参考资料

  • JDK 7 ThreadPoolExecutor 源码与 Javadoc
  • 《Java并发编程实战》(Brian Goetz)第 8 章

343 字 · 46 段落
ximing

Written by ximingFollow onGitHub

相关文章