线程池父子任务死锁:Future等待与任务拆分避坑指南 如果你接到过这样一个需求——把上万条数据拆成多个批次每个批次丢给线程池并发处理处理完再统一汇总结果——那你其实已经在写父子任务了。没错父任务负责拆分和聚合子任务真正干活这种结构在很多系统中天天出现。前段时间我为了优化一个批处理服务的耗时把这种写法用到了极致任务里面再拆任务结果白天一切正常晚上流量一上来服务大面积超时线程池队列从几百涨到几十万所有Worker线程全部WAITING。jstack一看清一色卡在Future.get()上。这个坑的阴险之处在于它不是必现的压测也很难压出来流量低的时候一切正常一旦并发一高就直接假死。而且父子任务搭配线程池时踩坑不止这一种还有ThreadLocal串号、异常被吞、部分失败伪装成成功等一长串问题。这篇文章把我实际排查和修复的完整过程写出来适合所有用过ExecutorService、或者正准备用线程池做任务拆分的后端开发同学。1. 场景复盘一个每天都会写的任务拆分代码1.1 场景长什么样先看一个最基础但每天都在出现的写法从库里查出n条记录每条记录独立处理为了提速丢给线程池并发跑最后get()等待全部完成。// 子任务单个订单处理 CallableResult task () - process(order); ExecutorService pool Executors.newFixedThreadPool(8); ListFutureResult futures new ArrayList(); for (Order order : orders) { futures.add(pool.submit(() - process(order))); } // 父线程等待所有子任务 for (FutureResult future : futures) { Result r future.get(); }注意这种父线程等待子任务的模式其实已经是父子任务了。只不过此时父任务是请求线程不是线程池里的Worker所以即使并发再高最多是这个请求线程被阻塞它不占线程池名额问题还不算严重。真正致命的是下一层子任务本身就在池里的Worker上跑而Worker内部又往下拆了一层任务并且也在等待结果。ExecutorService pool Executors.newFixedThreadPool(2); // 父任务 public void parentTask() { ListFutureResult futures new ArrayList(); for (int i 0; i 5; i) { futures.add(pool.submit(() - { // 子任务里还要继续拆分“孙任务” ListFutureResult innerFutures new ArrayList(); for (int j 0; j 3; j) { innerFutures.add(pool.submit(() - doChildWork())); } // 子任务同步等孙任务 for (FutureResult f : innerFutures) { f.get(); } return mergeResult(); })); } // 父任务同步等子任务 for (FutureResult f : futures) { f.get(); } }这段代码从逻辑上看非常顺畅外层拆子任务子任务再拆孙任务每层各自等待聚合。但凡是把它跑在高并发环境下的系统基本都会在某一个流量尖峰瞬间全线假死。原因不在这段代码的业务逻辑而在线程池的执行模型。1.2 线程池执行模型回顾很多人用Executors.newFixedThreadPool但未必清楚线程池内部那套四阶段执行逻辑。我快速过一遍因为后面解释死锁链路全靠它。一个ThreadPoolExecutor核心参数就四个corePoolSize核心线程数、maximumPoolSize最大线程数、workQueue工作队列、拒绝策略。提交新任务时如果当前线程数还没达到corePoolSize直接新建Worker线程执行。如果核心线程已经全部在忙新任务进入workQueue排队。如果队列也满了继续创建新线程直到maximumPoolSize。连最大线程数都用完了队列也满了才会走拒绝策略。换句话说任务优先用核心线程跑核心线程满了往队列塞队列塞满了才扩线程扩无可扩就拒绝。newFixedThreadPool是个特例corePoolSize和maximumPoolSize一样大队列用的是无界队列所以它永远走不到扩线程这一步任务只会进队列无限排队。这个模型有一个非常容易被忽略的隐含前提Worker线程是长期存活、不断从队列取任务的。正是这个不断取任务的机制加上父子任务的层级结构才引发了后面的假死。1.3 为什么测试环境复现不了这种坑最让人恼火的地方不是它多深奥而是测试环境怎么压都压不出来。原因其实很简单触发死锁需要一个临界条件——同一时刻被线程池处理的所有线程都被父任务占住。拿上面的代码来说newFixedThreadPool(2)意味着池里只有两个Worker。只要同一时刻进入池中的父任务数量小于2哪怕只有一个另一个线程仍然可以腾出来执行队列里的子任务程序最多慢一点不会卡死。只有父任务数量达到2两个Worker都被父任务占住父任务又都在get()等待子任务而子任务全部在队列里没人执行才会进入死锁。压测环境通常并发量低、线程池参数又开得比较大很多测试环境直接newFixedThreadPool(32)8个并发任务里有1个父任务其余7个Worker闲得没事干子任务秒级执行完自然看不出问题。线上生产环境一旦流量上来父任务并发瞬间打满核心线程假死就开始了。所以遇到低峰正常、高峰假死的线程池问题先别急着怀疑流量先怀疑是不是任务自己在互相等待。2. 坑点一同池互等线程池假死的完整链路2.1 死锁形成的四步过程为了把问题讲透我们假设线程池是Executors.newFixedThreadPool(2)同一时刻有两个父任务T1、T2被提交进来。第一步T1、T2各自占用一个Worker线程池内线程数达到核心数2新任务只能进队列。第二步T1内部提交子任务C1、C2T2内部提交子任务C3、C4。这四个子任务被放到同一个线程池的队列里。第三步T1执行到future.get()等待C1、C2完成。T2执行到future.get()等待C3、C4完成。两个Worker线程全部处于BLOCKED/WAITING状态等待子任务返回。第四步C1、C2、C3、C4在队列里等待空闲线程但池里唯二的两个线程都被父任务占着且父任务不结束线程就不会释放。于是队列里的任务永远等不到执行机会整个池进入死锁。用一张表格看更直观对象状态在等什么Worker线程1执行父任务T1WAITING等待子任务C1、C2的Future结果Worker线程2执行父任务T2WAITING等待子任务C3、C4的Future结果工作队列C1、C2、C3、C4排队等待Worker线程空闲线程池整体假死无任何线程能执行队列任务这和经典的哲学家就餐问题本质上是一回事每个线程都在等别人释放资源而资源恰恰被自己占着。只不过在这里资源不是锁而是线程池里的Worker线程名额。2.2 jstack现场长什么样我那次排障第一步就是jstack抓线程快照。看到的内容大致长这样pool-3-thread-1 #11 WAITING at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:400) at java.util.concurrent.FutureTask.get(FutureTask.java:201) at com.example.OrderService.lambda$parentTask$0(OrderService.java:88) ... pool-3-thread-2 #12 WAITING at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:400) at java.util.concurrent.FutureTask.get(FutureTask.java:201) at com.example.OrderService.lambda$parentTask$0(OrderService.java:88) ...两个线程全部WAITING状态码停在FutureTask.get最终指向同一个业务方法OrderService.lambda$parentTask$0。这时候线程池的健康指标也很典型getPoolSize等于corePoolSizegetActiveCount等于corePoolSizegetQueue().size()持续上涨getCompletedTaskCount几乎不动。如果你在线上看到这几个指标同时出现基本可以断定是任务互相等待导致的线程饥饿而不是简单的容量不够。当然后续还要通过堆栈确认等待链路是否指向同类线程池才能真正下结论。2.3 第一时间想到的扩容为什么无效当时我们的第一反应也是线程池不够用扩容。把maxPoolSize从2调到50corePoolSize也从2调到10然后满怀期待地看着监控——队列积压确实缓解了一会儿但随着父任务并发数压上来死锁重新出现只是把临界点往后推了而已。道理很简单这种死锁的本质是线程池Worker在执行父任务时等待同一个池的子任务只要父任务能把线程占满无论池里有2个还是50个线程最终都会出现所有线程都被父任务占住、子任务排队等待的僵局。扩容只是把触发并发数从2变成50并没有解决互相等待的根因。而且无脑扩容还会带来两个副作用一是线程数变大后上下文切换成本陡增系统整体吞吐反而下降二是无界队列模式下队列积压的任务会持续占用堆内存时间一长直接OOM。这也是为什么我后来在团队里立了一条规矩线程池参数必须是有界队列且必须监控队列积压量。2.4 正解让父任务和子任务不在同一个池里竞争破局的核心思路只有一句话不要让持有线程池Worker的线程去同步等待同一个线程池里的其他任务。顺着这个思路有三种常用解法。第一种父子任务使用不同的线程池。父任务放在父池或者直接由请求线程执行子任务放进一个独立的子池。这样父任务等待子任务时子池里永远有线程可以消费自己的队列。// 父池负责“父任务”这种组装/编排逻辑 ExecutorService parentPool Executors.newFixedThreadPool(4); // 子池负责真正的业务子任务 ExecutorService childPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), r - new Thread(r, child-pool), new ThreadPoolExecutor.AbortPolicy() );第二种如果任务天然适合分治合并模型比如一个大数据集要反复拆分、汇总可以考虑改用ForkJoinPool。它的Worker在调用join等待子任务时不是傻等而是会去任务队列里窃取别的子任务来执行这正是为递归拆分场景设计的机制。但要注意ForkJoinPool更适合CPU密集型任务如果子任务里全是IO等待工作窃取的优势会被阻塞抵消反而是普通线程池加异步化更合适。第三种任务结构尽量扁平化。很多父子任务其实没必要拆成两层完全可以先在内存里把任务粒度切好一次性提交一批子任务等这一批全部结束后再做下一批。两级变一级坑自然就没了。3. 坑点二Worker线程复用时ThreadLocal串号3.1 先看读不到子线程拿不到父线程的上下文死锁这个坑大家多少有点意识但第二个坑就没那么多人知道了ThreadLocal在线程池场景下的丢失和串号。很多系统会把当前登录用户、租户ID、traceId放在ThreadLocal里方便全局取用。在单线程模型下这完全没问题父线程set父线程get永远正确。可一旦涉及子任务很多同学会发现父线程设进去的userId子任务里取出来是null。这不算bug是ThreadLocal的基本机制——它是线程私有的A线程写的数据B线程默认读不到。子任务是Worker线程执行的Worker线程从来没有被父线程set过自然取不到。3.2 更危险的是读错脏数据串号比读不到更可怕的是读错。线程池里的Worker是长期存活、反复执行任务的。它执行完任务A之后ThreadLocal里的数据并不会被自动清掉。如果任务A随手set了一个userId 张三任务B恰好复用了这个Worker任务B又没有主动set那任务B读到的userId就是张三。ThreadLocalString currentUser new ThreadLocal(); // 任务1设置用户信息 pool.submit(() - { currentUser.set(U1); doSomething(); }); // 任务2复用同一个Worker线程不主动set pool.submit(() - { // 这里很可能打印出 U1 System.out.println(currentUser.get()); });凡是做多租户隔离、做数据权限控制的系统这种串号一旦发生就是严重的线上事故A用户可能看到B用户的数据A租户的请求可能被路由到B租户的配置。比死锁更隐蔽因为代码完全正常测试也大概率测不出来只有线上多个任务复用同一个线程时才会偶发。这里还要特别提一下InheritableThreadLocal。它是JDK自带的子线程继承父线程ThreadLocal的机制但线程池场景下依然有坑子线程必须是在new Thread()创建的那一刻才会继承父线程的值而线程池的Worker线程早就创建完了之后即使父线程改了值Worker也不会更新。更麻烦的是Worker复用时InheritableThreadLocal里的旧值依然存在串号照样发生。所以它并不适合作为线程池父子任务的上下文解决方案。3.3 一套通用的上下文装饰器写法正确的思路是在提交任务的那一刻把当前线程的上下文快照下来随任务一起交给WorkerWorker执行前恢复快照执行后必须清理。具体做法可以用一个装饰器统一包装Runnablepublic class ContextDecorator { public static Runnable wrap(Runnable task) { // 在提交任务时快照当前线程的上下文 MapString, String snapshot ContextHolder.snapshot(); return () - { // 进入子任务前把快照恢复到当前Worker线程 ContextHolder.restore(snapshot); try { task.run(); } finally { // 执行完毕必须清理防止脏数据串到下一个任务 ContextHolder.clear(); } }; } }所有提交任务的地方统一用ContextDecorator.wrap(...)包一层pool.submit(ContextDecorator.wrap(() - process(order)));如果你的项目用的是Spring的ThreadPoolTaskExecutor它内置了setTaskDecorator方法直接把装饰器配置进去所有任务都会自动被包装不需要在每个submit处手动处理。原生ThreadPoolExecutor没有这个API那就老老实实封装一个工具方法保证团队所有线程池提交入口都走同一个包装逻辑。有两个细节必须强调。第一finally里的clear绝对不能省否则当前任务的上下文会污染下一个任务串号问题依然存在。第二装饰器只能解决提交线程→执行线程的传递如果子任务又往其他线程池继续提交孙任务每一层都得包装中间断一层链路就断了。实际排查过此类问题的同学应该都明白多级线程池场景下上下文传递根本不能靠自觉必须靠全链路强制化的提交入口。开源社区倒是有现成的TransmittableThreadLocal工具配合它的TtlExecutors一行代码就能包装线程池底层原理就是上面这套快照和恢复。工具本身很好用但我还是建议你先理解透这套手写方案因为线上出了问题最终能帮你定位的还是底层原理而不是工具封装。4. 坑点三子任务异常被吞部分失败却显示成功4.1 execute与submit的异常行为差异第三个高频坑是父子任务批量模式下的异常被吞。很多同学对execute()和submit()的异常行为区别不清楚导致子任务挂了父任务却以为自己成功了。execute方式提交的任务如果抛出RuntimeException线程池会把这个异常交给UncaughtExceptionHandler默认行为是打印到System.err然后这个Worker线程被终结线程池再新建一个Worker顶替。整个过程对调用方完全透明代码根本感知不到异常。submit方式提交的任务异常会被封装进Future对象里get()的时候才抛ExecutionException。听起来submit比execute安全一些但问题出在批量场景如果父任务批量提交了100个子任务然后循环get()循环体里只catch了InterruptedException漏了ExecutionException或者干脆在catch块里只打了日志、没有做失败聚合那异常照样等于被吞掉了。提交方式任务抛异常时发生什么调用方能否感知execute异常交给UncaughtExceptionHandler默认仅打印不能感知Worker线程重建submit异常保存在Future内部get()时抛出ExecutionException只有get()且正确捕获才能感知submit但从不get异常一直挂在Future里没有人看它完全无感知类似吞掉4.2 加工过的部分成功案例我处理过一个典型的线上问题一个结算服务父任务生成100个子任务去更新100个账户余额其中3个子任务因为账户状态异常抛了错。父任务代码是标准的批量get循环catch了ExecutionException之后只打了一条error日志然后继续循环。循环结束没有任何地方把失败数量往上抛接口直接返回全部成功。结果就是97个账户余额改了3个账户没改上游系统收到成功信号开始后续步骤直到第二天对账才发现账不平。这个问题的根源不在线程池而在于批量父子任务把错误距离拉远了子任务的失败点离父任务的决策点太远中间只要处理不当失败就像沙子掉进大海无声无息。正确的做法是在父任务里显式聚合每一个子任务的执行结果包括成功和失败最后统一做决策ListFutureResult futures new ArrayList(); for (Order order : orders) { futures.add(subPool.submit(() - process(order))); } AtomicInteger failCount new AtomicInteger(0); ListResult results new ArrayList(); for (FutureResult future : futures) { try { Result r future.get(5, TimeUnit.SECONDS); results.add(r); } catch (ExecutionException e) { // 子任务执行失败记录原因继续获取其他任务结果 failCount.incrementAndGet(); log.error(子任务执行失败, e.getCause()); } catch (TimeoutException e) { // 单个任务超时取消它并计入失败 future.cancel(true); failCount.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); failCount.incrementAndGet(); break; } } if (failCount.get() 0) { // 明确失败不要返回成功。该抛出就抛出该触发补偿就触发补偿 throw new RuntimeException(子任务存在失败失败数量 failCount.get()); }这段代码每次get都带超时这是父子任务里很容易被忽略的一个点。如果不带超时某个子任务永久卡死父任务就会跟着永久卡死上游不断重试线程池被占满又是一个连锁故障。带超时后至少父任务能做出放弃本次批次的决策把线程让出来。4.3 CompletableFuture的异常处理别忘了两件小事现在很多人已经开始用CompletableFuture替代裸Future异常可以用exceptionally或者handle串接代码确实优雅不少。但CompletableFuture在父子任务场景有两个同样容易踩的细节。第一不指定Executor时它默认使用ForkJoinPool.commonPool()这是一个公共线程池整个JVM里所有不显式指定线程池的CompletableFuture都在这里跑。多个业务模块共用一套公共线程池高峰期互相挤兑一个模块卡住可能拖垮其他模块。所以凡是提交给CompletableFuture的异步任务一律显式传入自己的线程池。第二exceptionally只是给某个异步链加了兜底返回值它不会自动把异常传播给父任务。如果你在子链路里用了exceptionally返回了一个默认值父任务看到的就是一个正常结果业务上可能把失败但兜底当成了成功。该怎么区分、该不该兜底要在设计阶段想清楚不能靠运行时碰运气。5. 可落地的组合方案双池隔离、装饰器与监控缺一不可5.1 一条硬原则和一个推荐结构把前面三种坑串起来总结成一条我给团队立过的硬性原则持有线程池Worker的线程绝对不要去同步等待同一个线程池里的其他任务。违反这一条的代码不管当时看起来多合理都有假死风险。在这个基础上推荐的任务结构是父任务放在请求线程执行或者单独一个编排池子任务放在独立的业务池上下文用装饰器统一传递结果用带超时的聚合逻辑处理。这样死的死锁坑、脏数据坑、异常吞掉坑都能被结构性地规避。5.2 一个可以直接抄的完整实现下面是一个综合前面所有教训的完整示例父任务在请求线程执行直接当作普通方法调用即可public class TaskExecutor { // 子任务池有界队列拒绝策略选 AbortPolicy宁可抛异常也不要无限积压 private final ThreadPoolExecutor subPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), r - new Thread(r, sub-task-pool), new ThreadPoolExecutor.AbortPolicy() ); public Result executeParentTask(ListOrder orders) throws InterruptedException { int n orders.size(); CountDownLatch latch new CountDownLatch(n); ListResult results Collections.synchronizedList(new ArrayList()); AtomicInteger failCount new AtomicInteger(0); ListFuture? futures new ArrayList(); for (Order order : orders) { Future? future subPool.submit(ContextDecorator.wrap(() - { try { Result r process(order); results.add(r); } catch (Throwable t) { failCount.incrementAndGet(); log.error(子任务失败, orderId{}, order.getId(), t); } finally { // 必须放在 finally保证任何情况都能倒计数 latch.countDown(); } })); futures.add(future); } // 整体等待必须带超时。超时后取消所有未完成任务并抛出明确错误 if (!latch.await(30, TimeUnit.SECONDS)) { futures.forEach(f - f.cancel(true)); throw new RuntimeException(子任务整体执行超时已取消); } if (failCount.get() 0) { throw new RuntimeException(子任务存在失败失败数量 failCount.get()); } return merge(results); } private Result process(Order order) { // 真正业务逻辑 return new Result(order.getId(), Constants.SUCCESS); } private Result merge(ListResult results) { // 聚合逻辑 return Result.merge(results); } }这里用CountDownLatch而不是逐个Future.get是因为整体等待更容易统一设置超时也方便在超时后统一取消。失败信息通过failCount聚合最终决策只有一句有失败就整体抛错绝不假装成功。ContextDecorator包装解决ThreadLocal传递有界队列加AbortPolicy保证不会无限积压。这套组合是我们在多个核心服务里验证过的至少比赤手空拳写线程安全多了。5.3 监控和报警把看不见的坑变成指标最后说监控。父子任务相关的坑最麻烦的地方是初始阶段看不见。线程池假死从发生到被感知中间可能隔着几分钟甚至几十分钟的队列积压。所以我对线程池一直坚持做指标埋点至少每30秒采集一次以下数据getActiveCount()活跃线程数持续等于核心线程数时要注意。getQueue().size()队列积压量持续上涨说明任务消费能力不足。getCompletedTaskCount() / getTaskCount()已完成任务占比用来判断是不是有线程长期卡住。自定义拒绝策略里的rejectedCount只要出现拒绝就是系统容量到极限的信号必须触发告警。看这些指标要结合起来看不能单独看某一个。比如队列积压在涨同时activeCount长期等于corecompletedTaskCount半天不动那大概率不是流量问题而是任务互相等待。这时候果断jstack抓现场重点看有多少线程WAITING在FutureTask.get上基本就能定位。我在实际系统里见过各种变体有人把父任务和子任务分到了两个池但父池的Worker依然在等父池的其他任务有人用了装饰器却忘了finally清理脏数据照样串有人对子任务异常做了捕获吞掉而不是传播。每一条都是线上出过大问题的。如果你正在写多级拆分的代码建议先拿这几条逐项过一遍父子任务是否同池提交上下文是否统一包装异常是否带超时聚合线程池是否可监控。这四项做到位能省下的深夜排查远比你想象的要多。