使用Thread Pool不當引發的死鎖
簡介
死鎖是兩個或多個執行緒互相等待對方所擁有的資源的情形。舉個例子,執行緒 A 等待 lock1,lock1 當前由執行緒 B 鎖住,然而執行緒 B 也在等待由執行緒 A 鎖住的 lock2。最壞情況下,應用程式將無限期凍結。讓我給你看個具體例子。假設這裡有個Lumberjack
(伐木工) 類,包含了兩個裝備的鎖:
import com.google.common.collect.ImmutableList; import lombok.RequiredArgsConstructor; import java.util.concurrent.locks.Lock; @RequiredArgsConstructor class Lumberjack { private final String name; private final Lock accessoryOne; private final Lock accessoryTwo; void cut(Runnable work) { try { accessoryOne.lock(); try { accessoryTwo.lock(); work.run(); } finally { accessoryTwo.unlock(); } } finally { accessoryOne.unlock(); } } }
每個Lumberjack
(伐木工)需要兩件裝備:helmet
(安全帽) 和 chainsaw
(電鋸)。在他開始工作前,他必須擁有全部兩件裝備。我們通過如下方式建立伐木工們:
import lombok.RequiredArgsConstructor; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @RequiredArgsConstructor class Logging { private final Names names; private final Lock helmet = new ReentrantLock(); private final Lock chainsaw = new ReentrantLock(); Lumberjack careful() { return new Lumberjack(names.getRandomName(), helmet, chainsaw); } Lumberjack yolo() { return new Lumberjack(names.getRandomName(), chainsaw, helmet); } }
可以看到,有兩種伐木工:先戴好安全帽然後再拿電鋸的,另一種則相反。謹慎派(careful()
)伐木工先戴好安全帽,然後去拿電鋸。狂野派伐木工(yolo()
)先拿電鋸,然後找安全帽。讓我們併發生成一些伐木工:
private List<Lumberjack> generate(int count, Supplier<Lumberjack> factory) { return IntStream .range(0, count) .mapToObj(x -> factory.get()) .collect(toList()); }
generate()
方法可以建立指定型別伐木工的集合。我們來生成一些謹慎派伐木工和狂野派伐木工。
private final Logging logging; //... List<Lumberjack> lumberjacks = new CopyOnWriteArrayList<>(); lumberjacks.addAll(generate(carefulLumberjacks, logging::careful)); lumberjacks.addAll(generate(yoloLumberjacks, logging::yolo));
最後,我們讓這些伐木工開始工作:
IntStream .range(0, howManyTrees) .forEach(x -> { Lumberjack roundRobinJack = lumberjacks.get(x % lumberjacks.size()); pool.submit(() -> { log.debug("{} cuts down tree, {} left", roundRobinJack, latch.getCount()); roundRobinJack.cut(/* ... */); }); });
這個迴圈讓所有伐木工一個接一個(輪詢方式)去砍樹。實質上,我們向執行緒池(ExecutorService
)提交了和樹木數量(howManyTrees
)相同個數的任務,並使用 CountDownLatch
來記錄工作是否完成。
CountDownLatch latch = new CountDownLatch(howManyTrees); IntStream .range(0, howManyTrees) .forEach(x -> { pool.submit(() -> { //... roundRobinJack.cut(latch::countDown); }); }); if (!latch.await(10, TimeUnit.SECONDS)) { throw new TimeoutException("Cutting forest for too long"); }
其實想法很簡單。我們讓多個伐木工(Lumberjacks
)通過多執行緒方式去競爭一個安全帽和一把電鋸。完整程式碼如下:
import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.IntStream; @RequiredArgsConstructor class Forest implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(Forest.class); private final ExecutorService pool; private final Logging logging; void cutTrees(int howManyTrees, int carefulLumberjacks, int yoloLumberjacks) throws InterruptedException, TimeoutException { CountDownLatch latch = new CountDownLatch(howManyTrees); List<Lumberjack> lumberjacks = new ArrayList<>(); lumberjacks.addAll(generate(carefulLumberjacks, logging::careful)); lumberjacks.addAll(generate(yoloLumberjacks, logging::yolo)); IntStream .range(0, howManyTrees) .forEach(x -> { Lumberjack roundRobinJack = lumberjacks.get(x % lumberjacks.size()); pool.submit(() -> { log.debug("{} cuts down tree, {} left", roundRobinJack, latch.getCount()); roundRobinJack.cut(latch::countDown); }); }); if (!latch.await(10, TimeUnit.SECONDS)) { throw new TimeoutException("Cutting forest for too long"); } log.debug("Cut all trees"); } private List<Lumberjack> generate(int count, Supplier<Lumberjack> factory) { return IntStream .range(0, count) .mapToObj(x -> factory.get()) .collect(Collectors.toList()); } @Override public void close() { pool.shutdownNow(); } }
現在,讓我們來看有趣的部分。如果我們只建立謹慎派伐木工(careful Lumberjacks
),應用程式幾乎瞬間執行完成,舉個例子:
ExecutorService pool = Executors.newFixedThreadPool(10); Logging logging = new Logging(new Names()); try (Forest forest = new Forest(pool, logging)) { forest.cutTrees(10000, 10, 0); } catch (TimeoutException e) { log.warn("Working for too long", e); }
但是,如果你對伐木工(Lumberjacks
)的數量做些修改,比如,10 個謹慎派(careful
)伐木工和 1 個狂野派(yolo
)伐木工,系統就會經常執行失敗。怎麼回事?謹慎派(careful
)團隊裡每個人都首先嚐試獲取安全帽。如果其中一個伐木工取到了安全帽,其他人會等待。然後那個幸運兒肯定能拿到電鋸。原因就是其他人在等待安全帽,還沒到獲取電鋸的階段。目前為止很完美。但是如果團隊裡有一個狂野派(yolo
)伐木工呢?當所有人競爭安全帽時,他偷偷把電鋸拿走了。這就出現問題了。某個謹慎派(careful
)伐木工牢牢握著安全帽,但他拿不到電鋸,因為被其他某人拿走了。更糟糕的是電鋸所有者(那個狂野派伐木工)在拿到安全帽之前不會放棄電鋸。這裡並沒有一個超時設定。那個謹慎派(careful
)伐木工拿著安全帽無限等待電鋸,那個狂野派(yolo
)伐木工因為拿不到安全帽也將永遠發呆,這就是死鎖。
如果所有伐木工都是狂野派(yolo
)會怎樣,也就是說,所有人都首先去嘗試拿電鋸會怎樣?事實證明避免死鎖最簡單的方式就是以相同的順序獲取和釋放各個鎖,也就是說,你可以對你的資源按照某個標準來排序。如果一個執行緒先獲取 A 鎖,然後是 B 鎖,但第二個執行緒先獲取 B 鎖,會引發死鎖。
執行緒池自己引發的死鎖
這裡有個與上面不同的死鎖例子,它證明了單個執行緒池使用不當時也會引發死鎖。假設你有一個ExecutorService
,和之前一樣,按照下面的方式執行。
ExecutorService pool = Executors.newFixedThreadPool(10); pool.submit(() -> { try { log.info("First"); pool.submit(() -> log.info("Second")).get(); log.info("Third"); } catch (InterruptedException | ExecutionException e) { log.error("Error", e); } });
看起來沒什麼問題 —— 所有信息按照預期的樣子呈現在螢幕上:
INFO [pool-1-thread-1]: First INFO [pool-1-thread-2]: Second INFO [pool-1-thread-1]: Third
注意我們用get()
阻塞執行緒,在顯示“Third
”之前必須等待內部執行緒(Runnable
)執行完成。這是個大坑!等待內部任務完成意味著需要從執行緒池額外獲取一個執行緒來執行任務。然而,我們已經使用到了一個執行緒,所以內部任務在獲取到第二個執行緒前將一直阻塞。當前我們的執行緒池足夠大,執行沒問題。讓我們稍微改變一下程式碼,將執行緒池縮減到只有一個執行緒,另外關鍵的一點是我們移除 get()
方法:
ExecutorService pool = Executors.newSingleThreadExecutor(); pool.submit(() -> { log.info("First"); pool.submit(() -> log.info("Second")); log.info("Third"); });
程式碼正常執行,只是有些亂:
INFO [pool-1-thread-1]: First INFO [pool-1-thread-1]: Third INFO [pool-1-thread-1]: Second
兩點需要注意:
- 所有程式碼執行在單個執行緒上(毫無疑問)
- “Third”資訊顯示在“Second”之前
順序的改變完全在預料之內,沒有涉及執行緒間的競態條件(事實上我們只有一個執行緒)。仔細分析一下發生了什麼:我們向執行緒池提交了一個新任務(列印“Second
”的任務),但這次我們不需要等待這個任務完成。因為執行緒池中唯一的執行緒被列印“First
”和“Third
”的任務佔用,所以這個外層任務繼續執行,並列印“Third
”。當這個任務完成時,將單個執行緒釋放回執行緒池,內部任務最終開始執行,並列印“Second
”。那麼死鎖在哪裡?來試試在內部任務里加上 get()
方法:
ExecutorService pool = Executors.newSingleThreadExecutor(); pool.submit(() -> { try { log.info("First"); pool.submit(() -> log.info("Second")).get(); log.info("Third"); } catch (InterruptedException | ExecutionException e) { log.error("Error", e); } });
死鎖出現了!我們來一步一步分析:
- 列印“First”的任務被提交到只有一個執行緒的執行緒池
- 任務開始執行並列印“First”
- 我們向執行緒池提交了一個內部任務,來列印“Second”
- 內部任務進入等待任務佇列。沒有可用執行緒因為唯一的執行緒正在被佔用
- 我們阻塞住並等待內部任務執行結果。不幸的是,我們等待內部任務的同時也在佔用著唯一的可用執行緒
- get() 方法無限等待,無法獲取執行緒
- 死鎖
這是否意味單執行緒的執行緒池是不好的?並不是,相同的問題會在任意大小的執行緒池中出現,只不過是在高負載情況下才會出現,這維護起來更加困難。你在技術層面上可以使用一個無界執行緒池,但這樣太糟糕了。
Reactor/RxJava
請注意,這類問題也會出現在上層庫,比如Reactor
:
Scheduler pool = Schedulers.fromExecutor(Executors.newFixedThreadPool(10)); Mono .fromRunnable(() -> { log.info("First"); Mono .fromRunnable(() -> log.info("Second")) .subscribeOn(pool) .block();//VERY, VERY BAD! log.info("Third"); }) .subscribeOn(pool);
當你部署程式碼,它似乎可以正常工作,但很不符合程式設計習慣。根源的問題是相通的,最後一行的subscribeOn()
表示外層任務(Runnable
)請求了執行緒池(pool
)中一個執行緒,同時,內部任務(Runnable
)也試圖獲取一個執行緒。如果把基礎的執行緒池換成只包含單個執行緒的執行緒池,會發生死鎖。對於 RxJava/Reactor 來說,解決方案很簡單——用非同步操作替代阻塞操作。
Mono .fromRunnable(() -> { log.info("First"); log.info("Third"); }) .then(Mono .fromRunnable(() -> log.info("Second")) .subscribeOn(pool)) .subscribeOn(pool)
防患於未然
並沒有徹底避免死鎖的方法。試圖解決問題的技術手段往往會帶來死鎖風險,比如共享資源和排它鎖。如果無法根治死鎖(或死鎖並不明顯,比如使用執行緒池),還是試著保證程式碼質量、監控執行緒池和避免無限阻塞。我很難想象你情願無限等待程式執行完成,如同get()
方法和 block()
方法在沒有設定超時時間的情況下執行。
感謝閱讀!
原文連結:ofollow,noindex">dzone 翻譯:ImportNew.com -一杯雜湊不加鹽
譯文連結:[]