Mutex(ミューテックス)とは?FreeRTOSの排他制御を初心者向けに解説」

組み込み基礎

RTOSで複数のタスクを動かしていると、複数のタスクから同じデータやハードウェアへアクセスしたい場面が出てきます。

たとえば、2つのタスクから同じI2Cバスを使う場合を考えてみましょう。

Sensor Task
     ↓
    I2C
     ↑
Display Task

Sensor TaskがI2C通信を行っている途中でDisplay Taskも同じI2Cバスを操作すると、通信が意図しない形で競合する可能性があります。

同じ問題はI2Cだけではありません。

  • SPIバス
  • UARTへの出力
  • SDカード
  • 共有変数
  • 共有バッファ
  • ファイル
  • 同じ周辺回路

などを複数のタスクから利用するときにも考える必要があります。

このような共有リソースへ複数のタスクが同時にアクセスしないようにする仕組みがMutex(ミューテックス)です。

この記事では、Mutexとは何かという基本から、排他制御、Race Condition、FreeRTOSでの使い方、Semaphoreとの違い、さらに優先度逆転とPriority Inheritanceまで順番に解説します。

FreeRTOSのMutexの仕組み。排他制御、Race Condition、Mutexの取得と解放、I2C共有、優先度継承を示した図
FreeRTOSのMutexによる排他制御と、共有リソース・優先度継承の基本

Mutex(ミューテックス)とは?

MutexはMutual Exclusion(相互排他)から来た言葉で、複数のタスクが同じ共有リソースへ同時にアクセスしないようにする仕組みです。

考え方は非常にシンプルです。

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


Task B
  ↓
Mutexを取得したい
  ↓
Task Aが使用中
  ↓
待機

Task AがMutexを保持している間は、Task Bは同じMutexを取得できません。

Task Aが共有リソースを使い終わってMutexを解放すると、Task Bが取得できるようになります。

排他制御とは?

複数の処理が同じリソースを同時に操作しないように制御することを排他制御と呼びます。

Task A ──┐
         │
         ↓
      [ Mutex ]
         ↓
   共有リソース
         ↑
         │
Task B ──┘

Mutexは「今このリソースを使ってよいタスクは1つだけ」という状態を作ります。

これによって、複数のタスクから共有リソースを利用するときの競合を防ぎます。

なぜMutexが必要なの?

単純な変数の加算を例に考えてみましょう。

counter++;

見た目は1行ですが、概念的には次のような処理が必要になります。

counterを読む
      ↓
1を加える
      ↓
counterへ書き戻す

たとえば初期値が100だったとします。

Task AとTask Bがほぼ同時にcounter++を実行すると、次のようなことが起こり得ます。

counter = 100


Task A
100を読み出す


        ↓ タスク切り替え


Task B
100を読み出す
101を書き込む


        ↓ タスク切り替え


Task A
101を書き込む

2回加算したので102になってほしいところですが、結果は101になってしまいました。

このように、複数の処理の実行タイミングによって結果が変わってしまう問題をRace Condition(競合状態)と呼びます。

Race Conditionとは?

Race Conditionは、複数のタスクや割り込みなどが共有データや共有リソースへアクセスし、実行される順番やタイミングによって結果が変わってしまう状態です。

厄介なのは、毎回同じように発生するとは限らないことです。

実行1 → 正常

実行2 → 正常

実行3 → 正常

実行4 → 異常

実行5 → 正常

タスクの切り替わるタイミングなどに依存するため、「たまにしか発生しない不具合」になることがあります。

組み込み開発でRTOSを使うときに、特に注意したい問題の1つです。

MutexでRace Conditionを防ぐ

先ほどのcounter++をMutexで保護すると、1つのタスクが処理している間、別のタスクを待たせることができます。

Task A
 ↓
Mutex取得
 ↓
counterを読む
 ↓
+1
 ↓
counterへ書く
 ↓
Mutex解放


その後


Task B
 ↓
Mutex取得
 ↓
counterを読む
 ↓
+1
 ↓
counterへ書く
 ↓
Mutex解放

これなら、一連の処理の途中に別のタスクが同じ共有データへアクセスすることを防げます。

クリティカルセクションとは?

複数のタスクから同時に実行されると問題になるコード領域をCritical Section(クリティカルセクション)と呼びます。

Mutex取得

    ↓

┌────────────────┐
│                │
│ Critical       │
│ Section        │
│                │
│ 共有データ操作 │
│                │
└────────────────┘

    ↓

Mutex解放

Mutexを使う場合は、保護したい共有リソースへのアクセス部分をMutexの取得と解放で囲みます。

ただし、Mutexによる排他と、割り込み禁止などを使ったRTOSやCPUの「クリティカルセクションAPI」は同じものではありません。用途に応じて使い分ける必要があります。

FreeRTOSでMutexを作成する

FreeRTOSではxSemaphoreCreateMutex()を使ってMutexを作成できます。

SemaphoreHandle_t mutex;

mutex = xSemaphoreCreateMutex();

MutexもSemaphore関連のAPIを利用するため、ハンドル型としてSemaphoreHandle_tを使います。

作成に失敗した場合はNULLが返されます。

if (mutex == NULL) {

    Serial.println("Mutex create failed");

    return;
}

xSemaphoreTake()でMutexを取得する

Mutexを取得するには、Semaphoreと同じようにxSemaphoreTake()を使用します。

if (xSemaphoreTake(
        mutex,
        portMAX_DELAY
    ) == pdTRUE) {

    // 共有リソースを使用
}

Mutexを取得できれば、そのタスクが共有リソースを使用します。

Mutexを取得できないとどうなる?

別のタスクがすでにMutexを保持している場合、すぐには取得できません。

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


Task B
 ↓
Mutex取得を試す
 ↓
取得できない
 ↓
Blocked

待ち時間を指定している場合、Task BはMutexが利用できるようになるまでBlocked状態になります。

その間CPUを占有して待ち続ける必要はありません。

待ち時間を指定できる

xSemaphoreTake()の第2引数には待ち時間を指定できます。

xSemaphoreTake(
    mutex,
    0
);

0なら、Mutexを取得できなくても待たずに戻ります。

xSemaphoreTake(
    mutex,
    pdMS_TO_TICKS(100)
);

この場合は最大100ms待ちます。

xSemaphoreTake(
    mutex,
    portMAX_DELAY
);

portMAX_DELAYは、FreeRTOSの設定などの条件のもとで、Mutexを取得できるまで実質的な無期限待ちとして利用できます。

xSemaphoreGive()でMutexを解放する

共有リソースを使い終わったら、xSemaphoreGive()でMutexを解放します。

xSemaphoreGive(mutex);

基本的な形は次のようになります。

if (xSemaphoreTake(
        mutex,
        portMAX_DELAY
    ) == pdTRUE) {

    // ----------------
    // 共有リソース使用
    // ----------------

    xSemaphoreGive(mutex);
}

Mutexは取得したタスクが解放する

Mutexには所有者(Owner)という考え方があります。

Mutexを取得したタスクが、そのMutexの所有者になります。

Task A
 ↓
Mutex取得

Owner = Task A

 ↓

Task A
 ↓
Mutex解放

基本的には、Mutexを取得したタスク自身が解放します。

これはイベント通知として使われるBinary Semaphoreとの重要な違いです。

Mutexを使って共有変数を保護する

先ほどのcounterをFreeRTOSのMutexで保護すると、次のように書けます。

int counter = 0;

SemaphoreHandle_t counterMutex;


void incrementCounter()
{
    if (xSemaphoreTake(
            counterMutex,
            portMAX_DELAY
        ) == pdTRUE) {

        counter++;

        xSemaphoreGive(counterMutex);
    }
}

複数のタスクからincrementCounter()を呼んでも、Mutexを取得したタスクだけがcounterを更新します。

volatileだけでは排他制御にならない

共有変数を見ると、以前学習したvolatileを使えばよいと思うかもしれません。

volatile int counter;

しかし、volatileを付けてもMutexの代わりにはなりません。

volatileは主にコンパイラ最適化に関係する指定であり、複数タスクからの一連の読み書きを不可分な処理にする機能ではありません。

たとえばcounter++のRace Conditionを、単にvolatileを付けるだけで防ぐことはできません。

詳しくは「constとvolatileとは?組み込み開発での意味と使い方を初心者向けに解説」も参照してください。

I2Cを複数タスクから使う場合

組み込み開発でMutexが役立つ代表的な例が、I2CやSPIなどの共有バスです。

たとえばKUMITATE-C3に温湿度センサーとOLEDを接続し、それぞれ別のタスクから操作するとします。

Sensor Task
    ↓
温湿度センサー
    ↓
   I2C
    ↑
   OLED
    ↑
Display Task

どちらも同じI2Cバスを使用するなら、アクセス方法や使用するドライバのスレッドセーフ性によっては、複数タスクからの同時利用を防ぐ必要があります。

I2CをMutexで保護する

I2Cバスを共有リソースとしてMutexで保護するなら、概念的には次のようにします。

SemaphoreHandle_t i2cMutex;


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

        if (xSemaphoreTake(
                i2cMutex,
                portMAX_DELAY
            ) == pdTRUE) {

            float temperature =
                readTemperature();

            xSemaphoreGive(i2cMutex);
        }

        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

Display Taskでも同じMutexを使用します。

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

        if (xSemaphoreTake(
                i2cMutex,
                portMAX_DELAY
            ) == pdTRUE) {

            updateDisplay();

            xSemaphoreGive(i2cMutex);
        }

        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

これにより、Sensor TaskがI2Cバスを使用している間はDisplay Taskが待機し、Sensor TaskがMutexを解放してからDisplay Taskが利用できます。

KUMITATE-C3でMutexを試してみよう

KUMITATE-C3でもFreeRTOSのMutexを試すことができます。

まずはハードウェアを追加せずに、2つのタスクからシリアルモニターへ複数行のメッセージを出力してみます。

1つのメッセージを複数回のSerial.print()で構成する場合、タスク切り替えによって別タスクの出力が途中へ入り込む可能性があります。

そこで、一連の出力をMutexで保護します。

SemaphoreHandle_t serialMutex;


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

        if (xSemaphoreTake(
                serialMutex,
                portMAX_DELAY
            ) == pdTRUE) {

            Serial.print("[Task A] ");
            Serial.print("KUMITATE-C3 ");
            Serial.println("Hello!");

            xSemaphoreGive(serialMutex);
        }

        vTaskDelay(pdMS_TO_TICKS(500));
    }
}


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

        if (xSemaphoreTake(
                serialMutex,
                portMAX_DELAY
            ) == pdTRUE) {

            Serial.print("[Task B] ");
            Serial.print("FreeRTOS ");
            Serial.println("Mutex!");

            xSemaphoreGive(serialMutex);
        }

        vTaskDelay(pdMS_TO_TICKS(700));
    }
}


void setup()
{
    Serial.begin(115200);

    serialMutex =
        xSemaphoreCreateMutex();

    if (serialMutex == NULL) {

        Serial.println(
            "Mutex create failed"
        );

        return;
    }

    xTaskCreate(
        taskA,
        "Task A",
        2048,
        NULL,
        1,
        NULL
    );

    xTaskCreate(
        taskB,
        "Task B",
        2048,
        NULL,
        1,
        NULL
    );
}


void loop()
{
    vTaskDelay(pdMS_TO_TICKS(1000));
}

この例では、Task AまたはTask Bのどちらか一方だけがMutexを取得し、その間に一連のシリアル出力を行います。

なお、実際のSerial実装自体がどのような同期処理を持つかは開発環境によって異なります。この例では、Mutexによる排他制御の考え方を理解するために、複数の出力処理を1つのまとまりとして保護しています。

SemaphoreとMutexは何が違う?

前回学習したSemaphoreとMutexはAPIが似ているため、混同しやすいポイントです。

Binary SemaphoreMutex
主な用途イベント通知・同期共有リソースの排他制御
Take可能可能
Give可能可能
所有者基本的に考えないあり
別タスクからGive用途によって行う基本的に所有タスクが解放
ISRからGive可能通常使用しない
優先度継承なしあり

簡単に整理すると、

イベントを知らせたい
        ↓
Binary Semaphore


データを渡したい
        ↓
Queue


共有リソースを守りたい
        ↓
Mutex

MutexをISRから使わない

Binary SemaphoreではxSemaphoreGiveFromISR()を使って、割り込みからタスクへイベントを通知できました。

一方、Mutexは所有者や優先度継承などタスクを前提とした仕組みを持つため、ISRから取得・解放する用途には適していません。

割り込みからタスクへ処理を通知したい場合は、Binary SemaphoreやQueue、Task Notificationなど、その目的に合ったISR対応の仕組みを利用します。

Mutexを長時間保持しない

Mutexを取得したら、必要な処理を行ってできるだけ早く解放することが重要です。

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

○ 短い


Mutex取得
 ↓
通信
 ↓
長い計算
 ↓
delay()
 ↓
別の処理
 ↓
Mutex解放

△ 他タスクを長く待たせる

Mutexを保持している時間が長いほど、同じ共有リソースを必要とする他のタスクの待ち時間も長くなります。

特に、Mutexを取得したまま不要なdelay()や長時間の処理を行わないよう注意しましょう。

Mutexの解放忘れに注意

Mutexを取得したまま解放を忘れると、他のタスクがそのMutexを取得できなくなります。

xSemaphoreTake(
    mutex,
    portMAX_DELAY
);

// 共有リソース使用

// xSemaphoreGive()を書き忘れた!

このMutexを必要とする別のタスクは待ち続けることになります。

途中でreturnするコードなどがある場合も、解放されない経路がないか注意が必要です。

優先度逆転とは?

Mutexを使ったRTOSでは、もう1つ重要な問題があります。

それがPriority Inversion(優先度逆転)です。

たとえば次の3つのタスクがあるとします。

Task H
高優先度

Task M
中優先度

Task L
低優先度

最初にTask LがMutexを取得します。

Task L
 ↓
Mutex取得

その後、高優先度のTask Hが実行可能になり、同じMutexを取得しようとします。

Task H
 ↓
Mutexが欲しい
 ↓
Task Lが保持中
 ↓
待機

Task Hは、低優先度のTask LがMutexを解放するまで待たなければなりません。

さらにMutexを必要としない中優先度のTask Mが実行を続けると、Task Lがなかなか実行できず、結果として高優先度のTask Hも長く待たされる可能性があります。

Task H(高)
Mutex待ち
    ↑
    │
Task L(低)
Mutex保持
    ↑
    │
Task M(中)が実行
    ↓
Task Lが進めない
    ↓
Task Hも待たされる

高優先度タスクが低優先度タスクの影響で待たされるため、「優先度が逆転したような状態」になります。

Priority Inheritance(優先度継承)とは?

FreeRTOSのMutexには、優先度逆転の影響を抑えるためのPriority Inheritance(優先度継承)があります。

低優先度のTask LがMutexを保持していて、高優先度のTask HがそのMutexを待つとします。

Task H
高優先度
 ↓
Mutex待ち


Task L
低優先度
 ↓
Mutex保持中

このときMutexを保持しているTask Lの優先度を一時的に引き上げ、Task Lが共有リソースの処理を終えてMutexを解放しやすくします。

Task L
低優先度
 ↓
Mutex保持
 ↓
高優先度Taskが待つ
 ↓
一時的に優先度を引き上げ
 ↓
処理を完了
 ↓
Mutex解放
 ↓
元の優先度へ

これが優先度継承です。

ただし、優先度継承があればRTOSの優先度設計を考えなくてよいわけではありません。複雑なロック構造や長時間のMutex保持などは依然として問題になる可能性があります。

Mutexを複数使うときはロック順序に注意

複数のMutexを使う場合は、取得する順番にも注意が必要です。

たとえばMutex AとMutex Bがあるとします。

Task 1

Mutex A取得
 ↓
Mutex Bを待つ


Task 2

Mutex B取得
 ↓
Mutex Aを待つ

Task 1はMutex Bが解放されるのを待ち、Task 2はMutex Aが解放されるのを待っています。

しかし、お互いが相手のMutexを待っているため、どちらも先へ進めません。

この状態をDeadlock(デッドロック)と呼びます。

デッドロックを防ぐには?

代表的な対策の1つは、複数のMutexを取得するときの順番を統一することです。

すべてのTaskで

Mutex A
 ↓
Mutex B

の順番で取得する

タスクによって取得順序を逆にしないことで、循環して待ち続ける状態を作りにくくできます。

また、不要な複数ロックを避けることや、タイムアウトを設けることなどもシステム設計によって検討します。

Mutexを使えばすべて安全になるわけではない

Mutexは強力ですが、共有リソースへMutexを付ければ自動的にすべて安全になるわけではありません。

  • すべてのアクセス経路で同じMutexを使っているか
  • Mutexを取得せずアクセスしているコードがないか
  • Mutexを解放し忘れていないか
  • 保持時間が長すぎないか
  • 複数Mutexの取得順序が統一されているか
  • ISRから不適切にMutexを操作していないか

などを考える必要があります。

Mutexが使われる代表的な場面

  • I2Cバスの共有
  • SPIバスの共有
  • UART出力の排他
  • SDカードへのアクセス
  • 共有バッファの操作
  • 共有データ構造の更新
  • ログ出力
  • ファイルシステムへのアクセス
  • 複数タスクから利用するデバイスドライバの保護

Queue・Semaphore・Mutexを整理しよう

ここまでRTOSでよく使う3つの仕組みを学習してきました。

仕組み主な目的例
Queueデータの受け渡し温度データを別タスクへ送る
Semaphore同期・イベント通知割り込み発生をタスクへ知らせる
Mutex共有リソースの排他制御I2Cバスを1タスクずつ使う
データを渡す
      ↓
    Queue


イベントを知らせる
      ↓
  Semaphore


共有リソースを守る
      ↓
    Mutex

実際のシステムでは、この3つを組み合わせて使うこともあります。

KUMITATE-C3で考えるとどうなる?

KUMITATE-C3へ複数のセンサーや周辺回路を接続し、RTOSで処理を分割した場合を考えてみましょう。

GPIO割り込み
     ↓
Semaphore
     ↓
Button Task


SHT30
  ↓
Sensor Task
  ↓
Queue
  ↓
Display Task


Sensor Task ──┐
              ↓
          I2C Mutex
              ↓
           I2Cバス
              ↑
Display Task ─┘

それぞれの仕組みに役割があります。

  • Semaphore:イベント通知
  • Queue:データの受け渡し
  • Mutex:I2Cバスの排他制御

このように考えると、RTOSの各機能を単独で覚えるよりも、実際の組み込み機器でどのように組み合わせるのか理解しやすくなります。

Mutexを使うときのポイント

  • 共有リソースを明確にする
  • 共有リソースへのアクセスを同じMutexで保護する
  • Mutexを取得したタスクが解放する
  • Mutexを保持する時間をできるだけ短くする
  • 取得したまま不要なdelayをしない
  • Mutexの解放忘れに注意する
  • ISRからMutexを操作しない
  • 複数Mutexを使う場合は取得順序を統一する
  • 優先度逆転とPriority Inheritanceを理解する

まとめ

Mutexは、複数のタスクから共有リソースを安全に利用するための排他制御の仕組みです。

  • MutexはMutual Exclusionの略
  • 複数タスクによる共有リソースへの同時アクセスを防ぐ
  • 実行タイミングによって結果が変わる問題をRace Conditionと呼ぶ
  • FreeRTOSではxSemaphoreCreateMutex()で作成できる
  • xSemaphoreTake()でMutexを取得する
  • xSemaphoreGive()でMutexを解放する
  • 取得できないタスクはBlocked状態にできる
  • Mutexには所有者があり、基本的に取得したタスクが解放する
  • volatileはMutexの代わりにはならない
  • I2C・SPI・UART・SDカードなどの共有リソース保護に利用できる
  • Mutexを長時間保持しないことが重要
  • FreeRTOSのMutexには優先度継承の仕組みがある
  • 複数のMutexを扱う場合はデッドロックに注意する

RTOSでは、複数のタスクを動かせるようになるほど、「同じものを複数のタスクから触る」という問題が増えてきます。

Mutexは、その共有リソースを「今は誰が使っているのか」を管理し、一度に1つのタスクだけが利用できるようにするための重要な仕組みです。

Queue・Semaphore・Mutexまで理解すると、FreeRTOSで複数のタスクを作るだけでなく、タスク同士を安全に協調させる設計が見えてくるようになります。

KUMITATE

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

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

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