デッドロックとは?FreeRTOSでタスクが動かなくなる原因と対策を初心者向けに解説

組み込み基礎

FreeRTOSでMutexを使うと、複数のタスクからI2CやSPI、共有変数などの共有リソースを安全に利用できるようになります。

しかし、Mutexを複数使うようになると、新しい問題が発生することがあります。

それがDeadlock(デッドロック)です。

Task A
 ↓
Mutex 1を取得
 ↓
Mutex 2を待つ


Task B
 ↓
Mutex 2を取得
 ↓
Mutex 1を待つ


結果

Task A → Task Bを待つ
Task B → Task Aを待つ

どちらも進めない

プログラムそのものは動作しているのに、特定のタスクだけが突然進まなくなったように見えることもあります。

しかも、タスクが切り替わるタイミングによって「たまにしか発生しない」という厄介な不具合になることがあります。

この記事では、デッドロックとは何かという基本から、FreeRTOSで発生する具体例、デッドロックが成立する4つの条件、そして実際の組み込み開発でどのように防ぐのかまで順番に解説します。

デッドロックの仕組み。2つのタスクがMutexを互いに待つ状態、4つの発生条件、FreeRTOSでの対策を示した図
デッドロックの発生パターンと4つの条件、FreeRTOSでの代表的な対策

デッドロックとは?

デッドロックとは、複数のタスクなどが互いに相手の持っているリソースの解放を待ち続け、処理を進められなくなった状態です。

「Dead Lock」という名前のとおり、処理がロックされたまま動けなくなります。

典型的なのが、2つのMutexを使った次のような状態です。

Task A

Mutex 1を持っている
       ↓
Mutex 2が欲しい
       ↓
Task Bが持っているので待つ


Task B

Mutex 2を持っている
       ↓
Mutex 1が欲しい
       ↓
Task Aが持っているので待つ

Task AはTask Bを待ち、Task BはTask Aを待っています。

どちらかがMutexを解放すれば進めますが、両方とも別のMutexを待っているため、Mutexを解放する処理まで到達できません。

その結果、永久に待ち続ける可能性があります。

Mutexを1つだけ使う場合は?

単純に1つのMutexを複数タスクから利用するだけなら、通常は次のように順番に処理できます。

Task A
 ↓
Mutex取得
 ↓
共有リソース使用
 ↓
Mutex解放


        ↓


Task B
 ↓
Mutex取得
 ↓
共有リソース使用
 ↓
Mutex解放

Task Bは一時的に待ちますが、Task AがMutexを解放すれば処理を続けられます。

問題になりやすいのは、複数の共有リソースを扱うために複数のMutexを組み合わせる場合です。

デッドロックが発生する具体例

Mutex 1とMutex 2があるとします。

Task Aは次の順番で取得します。

Task A

① Mutex 1
② Mutex 2

一方、Task Bは逆の順番で取得します。

Task B

① Mutex 2
② Mutex 1

タイミングによって、次のような状態になる可能性があります。

① Task AがMutex 1を取得

② タスク切り替え

③ Task BがMutex 2を取得

④ Task BがMutex 1を取得しようとする
   → Task Aが持っているので待つ

⑤ Task Aが再び実行される

⑥ Task AがMutex 2を取得しようとする
   → Task Bが持っているので待つ


Task A → Mutex 2待ち

Task B → Mutex 1待ち


デッドロック

FreeRTOSのコードで見るデッドロック

実際のコードで考えてみましょう。

まずTask Aです。

void taskA(void *pvParameters)
{
    while (1) {

        xSemaphoreTake(
            mutex1,
            portMAX_DELAY
        );

        vTaskDelay(
            pdMS_TO_TICKS(100)
        );

        xSemaphoreTake(
            mutex2,
            portMAX_DELAY
        );

        // 処理

        xSemaphoreGive(mutex2);
        xSemaphoreGive(mutex1);
    }
}

Task Bでは取得順序を逆にします。

void taskB(void *pvParameters)
{
    while (1) {

        xSemaphoreTake(
            mutex2,
            portMAX_DELAY
        );

        vTaskDelay(
            pdMS_TO_TICKS(100)
        );

        xSemaphoreTake(
            mutex1,
            portMAX_DELAY
        );

        // 処理

        xSemaphoreGive(mutex1);
        xSemaphoreGive(mutex2);
    }
}

このコードではTask AとTask BでMutexの取得順序が逆になっています。

さらに、1つ目のMutexを取得した状態で意図的に待ち時間を入れているため、デッドロックが発生しやすい構成になっています。

これはデッドロックの仕組みを理解するための例です。実際のプログラムでMutexを保持したまま不要な待ち処理を入れるのは避けましょう。

デッドロックが厄介な理由

デッドロックの厄介なところは、必ず発生するとは限らないことです。

起動1回目
→ 正常


起動2回目
→ 正常


起動3回目
→ デッドロック


起動4回目
→ 正常

タスクの実行タイミング、割り込み、通信処理、タスク優先度などによって、Mutexを取得するタイミングが変化するためです。

開発中には発生しなかったのに、長時間運転すると数日に1回だけ停止する、といった問題になる可能性もあります。

デッドロックが発生する4つの条件

デッドロックについて学ぶときによく登場するのが、デッドロック成立に関係する4つの条件です。

  1. 相互排他(Mutual Exclusion)
  2. 保持と待ち(Hold and Wait)
  3. 非横取り(No Preemption)
  4. 循環待ち(Circular Wait)

一般に、この4条件が同時に成立するとデッドロックが起こり得ます。

逆に考えると、少なくとも1つの条件が成立しないように設計することがデッドロック対策につながります。

① 相互排他(Mutual Exclusion)

1つのリソースを同時に複数のタスクが利用できない状態です。

Task A
 ↓
Mutex取得
 ↓
リソース使用中


Task B
 ↓
利用できない

Mutexによって保護する共有リソースは、まさにこの状態です。

② 保持と待ち(Hold and Wait)

すでに何らかのリソースを保持した状態で、別のリソースが利用可能になるのを待っている状態です。

Task A

Mutex 1
  ↓
保持している

同時に

Mutex 2
  ↓
待っている

③ 非横取り(No Preemption)

あるタスクが保持しているリソースを、別のタスクが強制的に取り上げられない状態です。

Mutexは基本的に、所有しているタスク自身が解放します。

Task A
 ↓
Mutex保持


Task B
 ↓
「それ貸して」
 ↓
強制的には奪えない

④ 循環待ち(Circular Wait)

複数のタスクが輪になるように互いのリソースを待っている状態です。

Task A
 ↓
Task Bのリソースを待つ
 ↓
Task B
 ↓
Task Aのリソースを待つ
 ↓
Task A...

2タスクだけでなく、3つ以上のタスクでも循環待ちは発生します。

Task A
 ↓
Task B待ち
 ↓
Task B
 ↓
Task C待ち
 ↓
Task C
 ↓
Task A待ち

デッドロックを防ぐ基本は「Mutexの取得順序を統一する」

複数のMutexを使う場合、非常に重要なのがMutexを取得する順序を統一することです。

悪い例では、Task AとTask Bで取得順序が異なっていました。

Task A

Mutex 1
 ↓
Mutex 2


Task B

Mutex 2
 ↓
Mutex 1

これを両方とも同じ順序にします。

Task A

Mutex 1
 ↓
Mutex 2


Task B

Mutex 1
 ↓
Mutex 2

Task BがMutex 1を取得できなければ、Mutex 2を取得する前に待つことになります。

これによって、Task AがMutex 1を保持しながらMutex 2を待ち、Task BがMutex 2を保持しながらMutex 1を待つ、という循環を防げます。

ロック順序をルール化する

規模が大きくなると、「気を付けて同じ順番にする」だけでは管理が難しくなります。

そこで、共有リソースごとに順序を決めておく方法があります。

ロック順位

1 : System Mutex
2 : I2C Mutex
3 : Display Mutex
4 : Log Mutex

複数取得する場合は、必ず番号の小さい方から取得する、といったルールを決めます。

これをLock Orderingなどと呼びます。

Mutexを必要以上に複数保持しない

そもそも複数のMutexを同時に保持する必要がなければ、デッドロックの可能性を大きく減らせます。

たとえば、

Mutex A取得
 ↓
処理A
 ↓
Mutex B取得
 ↓
処理B
 ↓
Mutex B解放
 ↓
Mutex A解放

となっている処理が、本当に同時に両方を保持する必要があるのか確認します。

場合によっては、

Mutex A取得
 ↓
処理A
 ↓
Mutex A解放


Mutex B取得
 ↓
処理B
 ↓
Mutex B解放

と分離できることがあります。

Mutexを保持する時間を短くする

Mutexを取得したまま長時間処理すると、他のタスクが待たされる時間が長くなります。

Mutex取得
 ↓
共有データ更新
 ↓
Mutex解放

○


Mutex取得
 ↓
共有データ更新
 ↓
delay()
 ↓
通信待ち
 ↓
大量の計算
 ↓
Mutex解放

△

特にMutexを保持したままvTaskDelay()などで長時間待つ構成は、必要性をよく確認しましょう。

タイムアウトを設定する

Mutex取得時に必ずportMAX_DELAYを使う必要はありません。

一定時間で取得できなければエラーとして処理する設計もできます。

if (xSemaphoreTake(
        mutex,
        pdMS_TO_TICKS(1000)
    ) == pdTRUE) {

    // 共有リソースを使用

    xSemaphoreGive(mutex);

} else {

    Serial.println(
        "Mutex timeout"
    );
}

1秒待ってもMutexを取得できなければ、別の処理へ進みます。

タイムアウトを設けることで「永久に待ち続ける」という状態を回避できる場合があります。

ただし、タイムアウトを付けただけで設計上のデッドロック原因がなくなるわけではありません。タイムアウト後に何をするのかまで設計する必要があります。

Mutexの解放忘れにも注意する

デッドロックとは少し異なりますが、Mutexの解放忘れでも他のタスクが永久に待つ状態になることがあります。

xSemaphoreTake(
    mutex,
    portMAX_DELAY
);

// 処理

if (error) {

    return; // Mutexを解放していない
}

xSemaphoreGive(mutex);

エラー時のreturnによって、Mutexを解放しないまま関数を抜けています。

このような経路がないか確認することも重要です。

Queueを使う設計に変えられないか考える

共有リソースを複数タスクから直接操作するのではなく、1つのタスクだけがそのリソースを操作する設計にすると、Mutexそのものを減らせる場合があります。

たとえばUARTを複数タスクから直接操作するのではなく、Log TaskだけがUARTを操作する構成です。

Task A ──┐
         ↓
Task B → Queue → Log Task → UART
         ↑
Task C ──┘

Task A・B・CはUARTを直接操作せず、Queueへログデータを送ります。

UARTを操作するのはLog Taskだけなので、UARTに対するMutexが不要になる可能性があります。

Queueについては「Queue(キュー)とは?FreeRTOSのタスク間通信を初心者向けに解説」も参照してください。

KUMITATE-C3で考えるデッドロック

KUMITATE-C3で複数のタスクを使ったシステムを作る場合を考えてみましょう。

たとえば、I2Cバスとログ出力をそれぞれMutexで保護しているとします。

Sensor Task

I2C Mutex
    ↓
センサー読み取り
    ↓
Log Mutex
    ↓
ログ出力


Display Task

Log Mutex
    ↓
ログ出力
    ↓
I2C Mutex
    ↓
OLED更新

このようにMutexの取得順序が異なると、タイミングによっては循環待ちが発生する可能性があります。

そこで、システム全体で、

① I2C Mutex
      ↓
② Log Mutex

のように取得順序を統一します。

デッドロックとライブロックの違い

デッドロックと似た言葉にLivelock(ライブロック)があります。

デッドロックでは、タスクがお互いを待って動けなくなります。

Deadlock

Task A → 待つ
Task B → 待つ

何も進まない

ライブロックでは、タスク自体は動作しているものの、お互いの状態に反応し続けて本来の処理が進まない状態になります。

Livelock

Task A
「Task Bに譲ろう」

Task B
「Task Aに譲ろう」

Task A
「やっぱり譲ろう」

Task B
「こちらも譲ろう」

動いているが
処理は進まない

デッドロックとスタベーションの違い

もう1つ似た問題がStarvation(スタベーション)です。

スタベーションは、あるタスクが必要なCPU時間やリソースをなかなか取得できず、長時間処理できない状態です。

高優先度Task
 ↓
実行


高優先度Task
 ↓
また実行


高優先度Task
 ↓
また実行


低優先度Task
 ↓
なかなか実行されない

デッドロックでは複数の処理が互いを待ち続けますが、スタベーションではシステム全体は動いていても、特定のタスクが処理機会を得られない状態になります。

デッドロック・ライブロック・スタベーションを比較

問題状態特徴
デッドロック待ったまま進めない互いのリソースを待つ
ライブロック動いているが進まない状態変更を繰り返す
スタベーション特定タスクが進めないCPUやリソースを得られない

優先度継承でデッドロックは防げる?

FreeRTOSのMutexにはPriority Inheritance(優先度継承)があります。

しかし、優先度継承は主に優先度逆転の影響を抑えるための仕組みです。

Mutex AとMutex Bを互いに待つ循環待ちそのものを自動的に解消してくれる機能ではありません。

Priority Inheritance
        ↓
優先度逆転への対策


Lock Orderingなど
        ↓
デッドロックへの対策

この2つを混同しないことが重要です。

ISRからMutexを使わない

Mutexはタスク間で共有リソースを排他的に利用するための仕組みであり、ISRから取得して待つような用途には適していません。

割り込みからタスクへ処理を渡したい場合は、Queue、Binary Semaphore、Task Notificationなど、ISRから利用できる仕組みを検討します。

Semaphoreについては「Semaphore(セマフォ)とは?FreeRTOSのタスク同期を初心者向けに解説」も参照してください。

デッドロックを調べるときのポイント

「システムが止まったように見える」ときは、単純な無限ループだけでなく、タスクがMutex待ちになっていないかも確認します。

  • どのタスクが止まっているか
  • そのタスクはどのMutexを待っているか
  • そのMutexを現在どのタスクが保持しているか
  • 保持しているタスクは別のMutexを待っていないか
  • Mutexの取得順序がタスクごとに異なっていないか
  • Mutexの解放漏れがないか
  • Mutexを保持したまま長時間待っていないか

デバッガを利用できる環境なら、各タスクの状態やコールスタックを確認することも有効です。

ウォッチドッグでデッドロックは直せる?

組み込みシステムでは、異常状態から復帰するためにWatchdog Timer(ウォッチドッグタイマー)を利用することがあります。

しかし、ウォッチドッグによるリセットはデッドロックそのものの設計原因を解決するものではありません。

デッドロック発生
       ↓
Watchdog
       ↓
再起動
       ↓
一時的に復帰


しかし


設計上の原因
       ↓
残ったまま

ウォッチドッグは異常時の復旧手段として重要ですが、Mutexの取得順序などの原因は別途修正する必要があります。

ウォッチドッグについては「ウォッチドッグタイマーとは?マイコンの暴走を検出して復旧する仕組みを解説」も参照してください。

デッドロックを防ぐチェックリスト

  • 共有リソースごとにMutexの目的を明確にする
  • Mutexの数を必要以上に増やさない
  • 複数のMutexを取得する場合は順序を統一する
  • ロック順序を設計ルールとして明文化する
  • Mutexを保持する時間を短くする
  • Mutexを保持したまま不要な待ち処理をしない
  • すべての処理経路でMutexが解放されることを確認する
  • 必要に応じて取得タイムアウトを設定する
  • 共有リソースを1つの専用タスクへ集約できないか検討する
  • Queueなど別のタスク間通信方式を利用できないか検討する
  • ISRからMutexを利用しない

Queue・Semaphore・Mutex・Deadlockの関係

ここまで学んできたFreeRTOS関連の仕組みを整理すると、次のようになります。

項目主な役割
Queueタスク間でデータを渡す
Semaphoreイベント通知や同期を行う
Mutex共有リソースを排他的に利用する
Deadlock複数の処理が互いのリソースを待ち続ける問題

Mutexについては「Mutex(ミューテックス)とは?FreeRTOSの排他制御を初心者向けに解説」も合わせて確認してください。

まとめ

デッドロックは、複数のタスクが互いに相手の保持しているリソースを待ち続け、処理を進められなくなる状態です。

  • 複数のMutexを使うシステムではデッドロックに注意する
  • 典型例はTask AとTask Bが異なるMutexを保持して互いに待つ状態
  • デッドロックはタスクの実行タイミングによって発生することがある
  • 相互排他・保持と待ち・非横取り・循環待ちの4条件がある
  • Mutexの取得順序を統一することが重要
  • 複数のMutexを必要以上に同時保持しない
  • Mutexの保持時間をできるだけ短くする
  • タイムアウトによって永久待ちを避けられる場合がある
  • Mutexの解放漏れにも注意する
  • Queueや専用タスクによって共有リソースそのものを減らす方法もある
  • ライブロックやスタベーションとは異なる問題
  • 優先度継承だけではデッドロックそのものは防げない
  • ウォッチドッグによる再起動は根本的な解決ではない

デッドロック対策で特に覚えておきたいのは、「複数のMutexを取得するときは、システム全体で取得順序を統一する」という考え方です。

RTOSを使ったプログラムが大きくなるほど、タスクやMutexの数も増えます。Mutexを追加するたびに、そのMutex単体だけを見るのではなく、他のMutexとの取得関係まで含めて設計することが重要です。

KUMITATE

読むだけでなく、
実際に動かして学びませんか?

クミタテは、組み込み開発や電子工作を 実践しながら学べる学習プラットフォームです。 ESP32を使ったプログラミングから、電子回路、センサー、 通信、基板設計まで、自分のペースで学習できます。

  • 無料で学べる実践的な教材を掲載
  • すぐに試せるサンプルプログラム付き
  • Googleアカウントですぐに登録可能
クミタテに無料登録する
組み込み基礎
スポンサーリンク