Skip to content
Published on

[オペレーティングシステム] 08. デッドロック:予防、回避、検出、復旧

Authors

デッドロックとは

デッドロック(Deadlock)は、2つ以上のプロセスが互いに相手が保有するリソースを待ち、永遠に進行できない状態である。

[デッドロックの日常的な比喩]

狭い橋で両側から来た車が向かい合う:
  <---AB --->
        []
両方とも相手が引くのを待つ -> デッドロック

システムでの例:
スレッド 1: lock(A) -> lock(B) 試行(待機)
スレッド 2: lock(B) -> lock(A) 試行(待機)
// デッドロックを引き起こすコード例
#include <pthread.h>

pthread_mutex_t mutex_a = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex_b = PTHREAD_MUTEX_INITIALIZER;

void *thread1(void *arg) {
    pthread_mutex_lock(&mutex_a);    // A獲得
    sleep(1);                         // タイミング問題を誘発
    pthread_mutex_lock(&mutex_b);    // B待機(デッドロック!)
    // ...
    pthread_mutex_unlock(&mutex_b);
    pthread_mutex_unlock(&mutex_a);
    return NULL;
}

void *thread2(void *arg) {
    pthread_mutex_lock(&mutex_b);    // B獲得
    sleep(1);                         // タイミング問題を誘発
    pthread_mutex_lock(&mutex_a);    // A待機(デッドロック!)
    // ...
    pthread_mutex_unlock(&mutex_a);
    pthread_mutex_unlock(&mutex_b);
    return NULL;
}

デッドロックの4つの必要条件

デッドロックは以下の4つの条件が同時に成立する場合にのみ発生する。

  1. 相互排除(Mutual Exclusion): リソースを一度に1つのプロセスのみ使用可能
  2. 保持と待機(Hold and Wait): リソースを保持したまま他のリソースを追加要求して待機
  3. 非プリエンプション(No Preemption): 既に割り当てられたリソースを強制的に奪えない
  4. 循環待ち(Circular Wait): P0 -> P1 -> P2 -> ... -> Pn -> P0 の形で循環的に待機

4つの条件のうち1つでも除去すればデッドロックを防止できる。


リソース割り当てグラフ(Resource-Allocation Graph)

デッドロックを視覚的に表現する有向グラフである。

[リソース割り当てグラフの要素]

プロセス:円 (O)
リソースタイプ:四角形内の点(インスタンス)
要求辺:Pi -> Rj(プロセスがリソースを要求)
割り当て辺:Rj -> Pi(リソースがプロセスに割り当て済み)
[デッドロックのあるリソース割り当てグラフ]

    P1 --要求--> R1 (1) --割当--> P2
    ^                               |
    |                               |
  割当                            要求
    |                               |
    R2 (1) <--要求-- P3 <--割当-- R3 (1)

P1R1を要求(P2が保有)
P2R3を要求(P3が保有)
P3R2を要求(P1が保有)
-> 循環が存在 -> デッドロック!
[デッドロックのないリソース割り当てグラフ - リソースインスタンスが複数]

    P1 --要求--> R1 (2)  インスタンス1 --割当--> P2
                            インスタンス2 --割当--> P3

P2P3のどちらかがR1を返せばP1が進行可能
-> 循環があってもデッドロックでない場合がある
   (リソースインスタンスが2個以上の場合)

ルールのまとめ。

  • リソースタイプあたりインスタンスが1個:循環があれば必ずデッドロック
  • リソースタイプあたりインスタンスが複数:循環があってもデッドロックでない場合がある

デッドロック処理方法

デッドロックを扱う4つのアプローチがある。

  1. 予防(Prevention): 4つの条件のうち1つを根本的に除去
  2. 回避(Avoidance): リソース要求時、安全な場合にのみ許可
  3. 検出と復旧(Detection and Recovery): デッドロック発生を許可し、検知後に復旧
  4. 無視(Ignore): デッドロックを無視(ほとんどのOSが採用、いわゆるダチョウアルゴリズム)

デッドロック予防(Prevention)

1. 相互排除の否定

共有可能なリソース(例:読み取り専用ファイル)は相互排除が不要である。しかしプリンタやミューテックスなど本質的に共有不可能なリソースには適用できない。

2. 保持と待機の否定

プロセスがリソースを要求する際、他のリソースを保持していないことを保証する。

方法A:実行前に必要なすべてのリソースを一度に要求
  利点:シンプル
  欠点:リソース利用率低下、飢餓の可能性

方法B:新しいリソース要求時に保持リソースをすべて返却後に再要求
  利点:柔軟
  欠点:実装が複雑

3. 非プリエンプションの否定

リソースを保持するプロセスが他のリソースを得られない場合、保持リソースを強制的に回収する。

プロセスPがリソースAを保持しBを要求:
  Bが使用不可なら:
    Aを強制的に解放(プリエンプト)
    PAB両方を待つ状態に切り替え
    AB両方が使用可能になったら再開

CPUレジスタやメモリなど状態を保存/復元できるリソースにのみ適用可能である。

4. 循環待ちの否定

すべてのリソースタイプに番号を付与し、番号順にのみ要求するよう強制する。

// 循環待ち予防:リソース順序規約
// リソース番号:mutex_a = 1, mutex_b = 2, mutex_c = 3

// 正しい使い方:常に小さい番号から獲得
void safe_function() {
    pthread_mutex_lock(&mutex_a);    // 番号 1
    pthread_mutex_lock(&mutex_b);    // 番号 2
    pthread_mutex_lock(&mutex_c);    // 番号 3

    // クリティカルセクション

    pthread_mutex_unlock(&mutex_c);
    pthread_mutex_unlock(&mutex_b);
    pthread_mutex_unlock(&mutex_a);
}

// 間違った使い方:逆順で要求するとデッドロックの危険
void unsafe_function() {
    pthread_mutex_lock(&mutex_c);    // 番号 3
    pthread_mutex_lock(&mutex_a);    // 番号 1(順序違反!)
}

デッドロック回避(Avoidance)

デッドロック回避はシステムに追加情報を提供し、リソース割り当てがデッドロックを引き起こす可能性がある場合は要求を拒否する。

安全状態(Safe State)

システムが安全状態にあれば、すべてのプロセスをある順序で完了できる**安全順序(safe sequence)**が存在する。

[安全状態とデッドロックの関係]

+-------------------------------------------+
|              全体の状態                      |
|  +----------------------------------+     |
|  |          安全状態                  |     |
|  |                                  |     |
|  |                                  |     |
|  +----------------------------------+     |
|            非安全状態                      |
|       (デッドロック可能領域を含む)          |
|                 +------+                  |
|                 |デッド |                  |
|                 |ロック |                  |
|                 +------+                  |
+-------------------------------------------+

安全状態 -> デッドロックなし(保証)
非安全状態 -> デッドロック可能(必ずではない)

銀行家アルゴリズム(Banker's Algorithm)

Dijkstraが提案したデッドロック回避アルゴリズムである。銀行が融資する際に破産しないよう管理することに例えられる。

必要なデータ構造(n = プロセス数、m = リソースタイプ数)。

Available[m]:     各リソースタイプの利用可能インスタンス数
Max[n][m]:        各プロセスの最大要求量
Allocation[n][m]: 現在各プロセスに割り当てられた量
Need[n][m]:       各プロセスの残りの必要量(Max - Allocation)

安全性アルゴリズム

[安全性チェックアルゴリズム]

1. Work = Availableをコピー
   Finish[i] = false(すべてのiに対して)

2. 以下の条件を満たすiを見つける:
   Finish[i] == false AND Need[i] <= Work

3. 見つかれば:
   Work = Work + Allocation[i]
   Finish[i] = true
   ステップ2に戻る

4. 見つからなければ:
   すべてのFinish[i]trueなら -> 安全状態
   そうでなければ -> 非安全状態

例題

[銀行家アルゴリズムの例]

リソース:A(10), B(5), C(7)

        Allocation    Max        Need       Available
        A  B  C      A  B  C    A  B  C    A  B  C
P0      0  1  0      7  5  3    7  4  3    3  3  2
P1      2  0  0      3  2  2    1  2  2
P2      3  0  2      9  0  2    6  0  0
P3      2  1  1      2  2  2    0  1  1
P4      0  0  2      4  3  3    4  3  1

安全順序の探索:
1. Work = [3,3,2]
   P1のNeed[1,2,2] <= [3,3,2]? はい!
   Work = [3,3,2] + [2,0,0] = [5,3,2], Finish[P1] = true

2. Work = [5,3,2]
   P3のNeed[0,1,1] <= [5,3,2]? はい!
   Work = [5,3,2] + [2,1,1] = [7,4,3], Finish[P3] = true

3. Work = [7,4,3]
   P4のNeed[4,3,1] <= [7,4,3]? はい!
   Work = [7,4,3] + [0,0,2] = [7,4,5], Finish[P4] = true

4. Work = [7,4,5]
   P2のNeed[6,0,0] <= [7,4,5]? はい!
   Work = [7,4,5] + [3,0,2] = [10,4,7], Finish[P2] = true

5. Work = [10,4,7]
   P0のNeed[7,4,3] <= [10,4,7]? はい!
   Finish[P0] = true

安全順序:P1 -> P3 -> P4 -> P2 -> P0
現在のシステムは安全状態!

リソース要求アルゴリズム

[P1がリソース(1,0,2)を追加要求した場合]

1. Request[1] = [1,0,2] <= Need[1] = [1,2,2]? はい(有効な要求)
2. Request[1] = [1,0,2] <= Available = [3,3,2]? はい(リソースあり)
3. 仮定的に割り当て:
   Available = [3,3,2] - [1,0,2] = [2,3,0]
   Allocation[1] = [2,0,0] + [1,0,2] = [3,0,2]
   Need[1] = [1,2,2] - [1,0,2] = [0,2,0]

4. 安全性チェック実行:
   新しいAvailable = [2,3,0]で安全順序の存在を確認
   -> 安全なら要求許可、そうでなければ拒否
// 銀行家アルゴリズムのJava実装
public class BankersAlgorithm {
    private int[][] max;
    private int[][] allocation;
    private int[][] need;
    private int[] available;
    private int numProcesses;
    private int numResources;

    public BankersAlgorithm(int[][] max, int[][] alloc,
                             int[] avail) {
        this.max = max;
        this.allocation = alloc;
        this.available = avail;
        this.numProcesses = max.length;
        this.numResources = avail.length;
        this.need = new int[numProcesses][numResources];

        for (int i = 0; i < numProcesses; i++)
            for (int j = 0; j < numResources; j++)
                need[i][j] = max[i][j] - allocation[i][j];
    }

    public boolean isSafe() {
        int[] work = available.clone();
        boolean[] finish = new boolean[numProcesses];
        int count = 0;

        while (count < numProcesses) {
            boolean found = false;
            for (int i = 0; i < numProcesses; i++) {
                if (!finish[i] && canAllocate(need[i], work)) {
                    // リソース回収
                    for (int j = 0; j < numResources; j++)
                        work[j] += allocation[i][j];
                    finish[i] = true;
                    count++;
                    found = true;
                    System.out.println("P" + i + " 実行可能");
                }
            }
            if (!found) return false;
        }
        return true;
    }

    private boolean canAllocate(int[] need, int[] work) {
        for (int j = 0; j < numResources; j++)
            if (need[j] > work[j]) return false;
        return true;
    }
}

デッドロック検出(Detection)

デッドロックの発生を許可し、定期的に検出アルゴリズムを実行する。

リソースタイプあたりインスタンスが1つの場合

待ちグラフ(Wait-for Graph)で循環を検出する。

[待ちグラフ]

リソース割り当てグラフからリソースノードを除去し
プロセス間の直接依存関係のみを表示

P1 --> P2 --> P3
 ^            |
 |            v
 +---- P4 <--+

循環が存在:P1->P2->P3->P4->P1 -> デッドロック!

リソースタイプあたりインスタンスが複数の場合

銀行家アルゴリズムと類似した検出アルゴリズムを使用する。

[デッドロック検出アルゴリズム]

1. Work = Available
   Allocation[i] != 0 なら Finish[i] = false
   Allocation[i] == 0 なら Finish[i] = true

2. Finish[i] == false AND Request[i] <= Work である i を見つける

3. 見つかれば:Work = Work + Allocation[i]
               Finish[i] = true
               ステップ2に戻る

4. Finish[i] == false のプロセスがあれば
   -> そのプロセスがデッドロック状態

検出アルゴリズムはどのくらいの頻度で実行すべきか?

  • デッドロックが頻繁に発生する場合:頻繁に実行(オーバーヘッド増加)
  • まれに発生する場合:間隔を空けて実行(検出遅延)
  • CPU利用率が40%以下に低下したら実行する方法もある

デッドロック復旧(Recovery)

プロセスの終了

  • すべてのデッドロックプロセスを終了: 確実だがコストが大きい
  • 1つずつ終了: デッドロックが解消されるまで1つずつ終了。毎回検出アルゴリズムを再実行

終了順序の決定基準は以下の通りである。

  • プロセスの優先度
  • これまで使用したリソースと時間
  • 完了までに必要なリソース
  • 終了すべきプロセスの数
  • プロセスのタイプ(対話型 vs バッチ)

リソースのプリエンプション

デッドロックプロセスからリソースを強制的に回収する。

[リソースプリエンプション時の考慮事項]

1. 犠牲者選択:どのプロセスからリソースを奪うか?
   コスト最小化基準で選択

2. ロールバック:リソースを奪われたプロセスを安全な状態に戻す
   - 完全ロールバック:最初からやり直し
   - 部分ロールバック:デッドロックを脱するのに十分な時点まで戻す

3. 飢餓防止:同じプロセスが常に犠牲者に選ばれないように
   ロールバック回数をコスト要素に含める

実際のシステムでのデッドロック処理

ほとんどのオペレーティングシステム(Linux、Windows)はデッドロックを無視する戦略を使用する。デッドロック予防や回避のオーバーヘッドが大きく、デッドロックがまれにしか発生しないためである。

アプリケーションレベルでの対策は以下の通りである。

// trylockを使用したデッドロック防止
int try_acquire_both(pthread_mutex_t *a, pthread_mutex_t *b) {
    while (1) {
        pthread_mutex_lock(a);

        if (pthread_mutex_trylock(b) == 0) {
            return 0;  // 成功:両方のロックを獲得
        }

        // bを得られなければaも解放してリトライ
        pthread_mutex_unlock(a);
        usleep(rand() % 1000);  // しばらく待ってリトライ
    }
}
// JavaのtryLockを使用したデッドロック防止
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

public class DeadlockFree {
    private final ReentrantLock lockA = new ReentrantLock();
    private final ReentrantLock lockB = new ReentrantLock();

    public void safeMethod() throws InterruptedException {
        while (true) {
            boolean gotA = lockA.tryLock(100, TimeUnit.MILLISECONDS);
            boolean gotB = lockB.tryLock(100, TimeUnit.MILLISECONDS);

            if (gotA && gotB) {
                try {
                    // 両方のロック獲得 - 安全に作業
                    System.out.println("両方のリソースを獲得!");
                    break;
                } finally {
                    lockA.unlock();
                    lockB.unlock();
                }
            }

            // どちらか失敗したら保持しているロックを解放
            if (gotA) lockA.unlock();
            if (gotB) lockB.unlock();

            // ランダム待機後にリトライ(ライブロック防止)
            Thread.sleep((long)(Math.random() * 100));
        }
    }
}

まとめ

デッドロックは相互排除、保持と待機、非プリエンプション、循環待ちの4つの条件が同時に成立する場合に発生する。予防は条件の1つを除去し、回避は銀行家アルゴリズムで安全状態を維持し、検出はデッドロック発生後に検知して復旧する。実際のシステムではデッドロックを無視するか、アプリケーションレベルでtrylock等の手法で対処するのが一般的である。