动态

详情 返回 返回

Java故障案例分析第一期:父子任務使用不當線程池死鎖 - 动态 详情

引言

在Java多線程編程中,線程池是提高性能和資源利用率的常用工具。然而,當父子任務使用同一線程池時,可能導致潛在的死鎖問題。本文將深入分析一個實際案例,闡述為何這種設計可能引發死鎖,以及如何排查這類問題。

案例背景

考慮以下的偽代碼,展示了一個可能導致死鎖的場景:


import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

class Scratch {
    private static final ExecutorService pool1 = Executors.newFixedThreadPool(2);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 50; i++) {
            pool1.submit(() -> {
                // 一些任務邏輯
                outerTask();
            });
        }
        try {
            boolean allDone = pool1.awaitTermination(10000, TimeUnit.MILLISECONDS);
            if (allDone) {
                System.out.println("任務完成!");
            } else {
                System.err.println("任務超時未完成!");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    private static void outerTask() {
        Future<?> future = pool1.submit(() -> {
            innerTask();
        });
        try {
            // 獲取結果
            future.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void innerTask() {
        // 一些任務邏輯
    }
}

簡單解釋下這個代碼, 我們有一個固定線程數大小為2的線程池, 然後向線程池提交任務, 這個任務直接調用outerTask, 這個outerTask不做任何事情, 只通過線程池異步調用innerTask, 但是注意這裏使用了同一個線程池提交innerTask.

最後通過awaitTermination等待線程池執行完畢線程終止就結束, 設置了超時10s, 如果任務都完成了打印"任務完成"否則打印"任務超時未完成", 而由於outerTask和innerTask內部都沒有其他邏輯, 理論上應該是很快執行完畢, 打印"任務完成", 但實際如何呢, 執行一下, 結果是:

任務超時未完成!

好, 這是肯定的😳. 那我們分析下為什麼? 這是一個線程故障因此首先想到通過jstack打印堆棧分析:

看到的線程調用棧為:

"pool-1-thread-1@852" tid=0x19 nid=NA waiting
  java.lang.Thread.State: WAITING
      at jdk.internal.misc.Unsafe.park(Unsafe.java:-1)
      at java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
      at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:500)
      at java.util.concurrent.FutureTask.get(FutureTask.java:190)
      at Scratch.outerTask(scratch_18.java:32) // 注意這裏
      at Scratch.lambda$main$0(scratch_18.java:11)
      at Scratch$$Lambda$14/0x00000008010029f0.run(Unknown Source:-1)
      at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:577)
      at java.util.concurrent.FutureTask.run$$$capture(FutureTask.java:317)
      at java.util.concurrent.FutureTask.run(FutureTask.java:-1)
      at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
      at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
      at java.lang.Thread.run(Thread.java:1589)

可以看到大量pool-1-thread-1開頭線程阻塞在了outerTask提交任務的地方, 同時通過查看線程池的workQueue對象可以看到有很多任務堆積:

image.png

原因分析

子任務需要等待父任務完成,而父任務內部的子任務通過同一個線程池提交,又需要等待線程池有空閒線程才能得到執行,但父任務需要等待子任務執行完才能執行完畢釋放出空閒線程, 陷入了“死鎖”。

但在測試環境中可能無法發現,只要線程池線程數量夠多,測試環境的併發請求數不夠是發現不了這個問題的,只有併發請求數量足夠才可能觸發而這往往是上到生產環境才可能發生了,通常會造成嚴重事故,重啓或者擴容後在一定時間內看上去恢復正常了但過不久可能又會出現阻塞情況(在我的公司實際發生過這種故障,開發不停重啓和擴容但過一段時間仍然會發生這個問題,排查了很長時間才發現問題原因)

解決方案

為避免父子任務使用同一線程池造成死鎖,可以考慮使用獨立線程池:將父任務和子任務分別提交到不同的線程池,避免共享線程池資源,減少死鎖的可能性。

private static final ExecutorService parentPool = Executors.newFixedThreadPool(1);
private static final ExecutorService childPool = Executors.newFixedThreadPool(1);

總結

作為第一篇文章,這個故障實際非常基礎,但卻十分值得注意,因為這個故障很常見而且容易被誤導為機器數量不夠導致重啓或擴容後依然無法恢復。

Add a new 评论

Some HTML is okay.