はじめに
「デッドロックってなんとなく聞いたことあるけど、実際どんな状態なの?」という方に向けた記事です。
この記事では以下の2ステップで理解を深めます。
- デッドロックとは何か を把握
- Javaコードで実際に再現する
- 正しい回避策を知る
デッドロックとは
デッドロックを一言で言うと、
複数の処理がお互いの資源や実行リソースの解放を待ち続けることで、誰も処理を続行できなくなった状態
です。
例)DBのデッドロック
タスクA → 資源Aをロック → 資源Bを待つ
タスクB → 資源Bをロック → 資源Aを待つ
→ お互いが相手のロック解除を待ち続けて、どちらも処理を進められない
一つのタスクが、タスク実行を邪魔させないように資源Aをロックするが、その際に資源Bを使う必要があるがその資源がロックされているとどちらも進めない。
例)スレッドプールのデッドロック
スレッドプール(上限: 2スレッド)
スレッド1 → タスクAを実行中 → 子タスクCをプールに投入 → 完了を待つ(ブロック)
スレッド2 → タスクBを実行中 → 子タスクDをプールに投入 → 完了を待つ(ブロック)
キュー: [タスクC, タスクD] ← 実行できるスレッドがいない!
ロックをしたわけではないですが、
スレッドプールで上限が決まっていてすでに上限分使っているので、子要素のタスクを実行する隙がなくて進めない。
DBのデッドロックとスレッドプールのデッドロックの大きな違いは、スレッドプールだと検知されないところです。
DBのデッドロックは、多くのDBMSが循環待ちを検知し、どちらか一方のトランザクションをロールバックしてエラーを返します。
一方で、スレッドプールのデッドロックは、自動で検知・解消される仕組みは基本的にないので起こるとコードベースでの設計の見直しが必要となり厄介です。
実際に再現
スレッドプールのデッドロックについて、実際に再現してみます。
以下のファイルを実行すると、スレッドプールのデッドロックが起こります。
import java.util.concurrent.*;
public class DeadlockSample {
private static final ExecutorService executor =
Executors.newFixedThreadPool(2);
public static void main(String[] args) throws Exception {
Callable<String> parentTask = () -> {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " 親タスク開始");
Future<String> childTask = executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 子タスク開始");
Thread.sleep(1000);
return "子タスク完了";
});
System.out.println(threadName + " 子タスク完了待ち...");
return childTask.get();
};
executor.submit(parentTask);
executor.submit(parentTask);
}
}
実際に試してみる
ここからは、実際にスレッドプールのデッドロックを再現してみます。
以下のサンプルコーのファイルDeadlockSample.javaを作ってjavac DeadlockSample.javaで実行します。
サンプルコード
import java.util.concurrent.*;
public class DeadlockSample {
private static final ExecutorService executor =
Executors.newFixedThreadPool(4);
public static void main(String[] args) throws Exception {
Callable<String> parentTask = () -> {
String threadName = Thread.currentThread().getName();
System.out.println(threadName + " 親タスク開始");
Future<String> childTask = executor.submit(() -> {
System.out.println(Thread.currentThread().getName() + " 子タスク開始");
Thread.sleep(1000);
return "子タスク完了";
});
System.out.println(threadName + " 子タスク完了待ち...");
return childTask.get();
};
executor.submit(parentTask);
executor.submit(parentTask);
}
}
実行結果
実行すると、次のようなログが出力されたまま止まります。
pool-1-thread-2 親タスク開始
pool-1-thread-1 親タスク開始
pool-1-thread-1 子タスク完了待ち...
pool-1-thread-2 子タスク完了待ち...
--- 3秒後に強制終了 ---
小タスクが完了されず、そもそも開始されません。
これがスレッドプールのデッドロックです。
スレッド数を増やすとどうなる?
試しにスレッドプールのサイズを4に変更してみます。
ExecutorService executor = Executors.newFixedThreadPool(4);
今度は親タスクが2本動いていても、残り2本のスレッドで子タスクが実行されました。
pool-1-thread-2 親タスク開始
pool-1-thread-1 親タスク開始
pool-1-thread-2 子タスク完了待ち...
pool-1-thread-4 子タスク開始 ← 今回は出た!
pool-1-thread-3 子タスク開始 ← 今回は出た!
pool-1-thread-1 子タスク完了待ち...
ただ、これは根本的な解決ではありません。。この問題を避けるには、
- 親タスク内で
Future#get()のような同期待ちをしない -
CompletableFutureを使って非同期処理を組み合わせる
などの設計を見直すことが大事そうです。推奨される設計仕様を正しく把握して実装するのが大事なのだと思いました。
まとめ
デッドロックとは何か、デッドロックと一言に言っても技術によって対処方法は異なるのだと言うことがわかりました。
