リアルタイム制御

ミューテックス

1つの資源を排他制御する仕組み。

概要

ミューテックス(Mutex: Mutual Exclusion、相互排除)は、複数のタスクやスレッドが同一の共有資源(メモリ、ペリフェラル、データ構造など)に同時アクセスすることを防ぐための同期プリミティブである。ある時点でミューテックスを「ロック」できるのは1つのタスクだけで、他のタスクはロックを試みると解放されるまで待機状態(ブロック)になる。

組み込みリアルタイムシステムでは、共有データへの非アトミックな読み書き(read-modify-write)が競合状態(レースコンディション)を引き起こし、データ破壊・誤動作の原因となる。例えばADCの読み取り値をグローバル変数に格納し、割り込みハンドラとメインループが同時にアクセスするような場面でミューテックスが活躍する。

RTOSではミューテックスは通常、優先度継承(Priority Inheritance)機能を持ち、優先度逆転問題を緩和できる。この点で単純なセマフォと異なり、ミューテックスはRTOSタスク間の排他制御に推奨される。

歴史・背景

相互排除の概念はダイクストラ(E.W. Dijkstra)が1965年に提唱し、セマフォとともに並行プログラミングの基礎理論を確立した。「Mutex」という名称はMutual Exclusionの略で、1970年代のUNIX開発とともに実用化された。

POSIXスレッド(pthreads)の標準化(1995年、IEEE Std 1003.1c)により、pthread_mutex_t としてUNIX系OS全体で統一されたミューテックスAPIが使えるようになった。組み込みRTOSではVxWorks、μITRON、FreeRTOS(2003年〜)がミューテックスを採用し、リアルタイムシステムでの排他制御の標準手法となった。

1997年に発生したNASAの火星探査機Mars Pathfinderの不具合は、VxWorksの優先度逆転問題が原因で、ミューテックスの優先度継承の重要性を世に知らしめた有名な事例である。

技術仕様

ミューテックスの基本操作

操作FreeRTOS APIZephyr APIPOSIX(pthreads)
作成xSemaphoreCreateMutex()k_mutex_init()pthread_mutex_init()
ロック(待機あり)xSemaphoreTake()k_mutex_lock()pthread_mutex_lock()
ロック(タイムアウト付き)xSemaphoreTake(timeout)k_mutex_lock(timeout)pthread_mutex_timedlock()
ロック(ノンブロッキング)xSemaphoreTake(0)k_mutex_lock(K_NO_WAIT)pthread_mutex_trylock()
アンロックxSemaphoreGive()k_mutex_unlock()pthread_mutex_unlock()
削除vSemaphoreDelete()-pthread_mutex_destroy()

優先度継承の仕組み

通常のミューテックス(優先度継承なし):
  高優先度タスクH → Mutex待ち
  低優先度タスクL → Mutexを保持中
  中優先度タスクM → 実行中(LをプリエンプトしてHをブロック)
  ★ Hはいつまでも実行できない(優先度逆転)

優先度継承あり(ミューテックス):
  高優先度タスクH → Mutex待ち
  低優先度タスクL → 一時的にHの優先度に昇格
  → LがMをプリエンプトしてMutexを早期解放
  → HがMutexを取得して実行再開
  ★ 問題が緩和される(完全な解決は優先度上限プロトコル)

再帰ミューテックス(Recursive Mutex)

通常のミューテックスを同じタスクが再度ロックしようとするとデッドロックになる。再帰ミューテックスは所有タスクが何度でもロックできる(ロック回数を内部でカウント)。

// FreeRTOSの再帰ミューテックス
SemaphoreHandle_t recursive_mutex = xSemaphoreCreateRecursiveMutex();

void function_a(void) {
    xSemaphoreTakeRecursive(recursive_mutex, portMAX_DELAY);
    function_b();  // 内部でも同じミューテックスをロック
    xSemaphoreGiveRecursive(recursive_mutex);
}

void function_b(void) {
    xSemaphoreTakeRecursive(recursive_mutex, portMAX_DELAY);
    // 処理
    xSemaphoreGiveRecursive(recursive_mutex);  // ロック回数が1減るだけ
}

動作原理

ミューテックスの内部状態

ミューテックスは本質的に以下の状態を持つデータ構造だ。

// ミューテックスの概念的な内部構造
typedef struct {
    bool locked;           // ロック状態
    TaskHandle_t owner;    // 現在の所有タスク(NULLなら未ロック)
    uint32_t lock_count;   // 再帰ロックカウント(再帰ミューテックスの場合)
    uint16_t owner_priority; // 元の優先度(優先度継承用)
    ListType_t wait_list;  // 待機タスクのキュー
} Mutex_t;

FreeRTOSでの実装例

#include "FreeRTOS.h"
#include "semphr.h"

// グローバル共有バッファとミューテックス
static uint8_t shared_buffer[256];
static uint32_t shared_data_len = 0;
static SemaphoreHandle_t buffer_mutex;

void app_init(void) {
    // 起動時にミューテックスを作成(ロック数分のメモリが確保される)
    buffer_mutex = xSemaphoreCreateMutex();
    configASSERT(buffer_mutex != NULL);
}

// 送信タスク:バッファに書き込む
void uart_send_task(void *pvParam) {
    uint8_t data[] = {0x01, 0x02, 0x03};

    for (;;) {
        // ミューテックス取得(最大100ms待機)
        if (xSemaphoreTake(buffer_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
            // クリティカルセクション開始
            memcpy(shared_buffer, data, sizeof(data));
            shared_data_len = sizeof(data);
            // クリティカルセクション終了
            xSemaphoreGive(buffer_mutex);  // 必ず解放する
        } else {
            // タイムアウト処理
            log_error("Mutex timeout in send task");
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// 受信確認タスク:バッファを読み出す
void uart_recv_task(void *pvParam) {
    uint8_t local_buf[256];
    uint32_t len;

    for (;;) {
        if (xSemaphoreTake(buffer_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
            len = shared_data_len;
            memcpy(local_buf, shared_buffer, len);
            xSemaphoreGive(buffer_mutex);
            // ローカルコピーを使って処理(ミューテックス外)
            process_data(local_buf, len);
        }
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

ISR(割り込みハンドラ)からの使用禁止

ミューテックスはブロッキング操作であり、割り込みハンドラ(ISR)からは使用できない。ISRでブロックするとシステム全体がハングアップする。ISRと通常タスク間の同期には代わりにセマフォ(バイナリセマフォ)やキューを使用する。

// 間違った使い方(ISRでミューテックスを使う)
void UART_IRQHandler(void) {
    // NG: ISRでxSemaphoreTakeを呼んではいけない
    // xSemaphoreTake(buffer_mutex, portMAX_DELAY);  // デッドロック/クラッシュ
}

// 正しい使い方(ISRからはFROMISRサフィックス版を使う)
static SemaphoreHandle_t data_ready_sem;

void UART_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // バイナリセマフォ(ミューテックスではない)をISRからGive
    xSemaphoreGiveFromISR(data_ready_sem, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

用途・ユースケース

SPI/I2Cバスの排他制御

SPII2Cバスに複数のデバイスがつながる場合、バスへのアクセスを1タスクずつ許可する排他制御が必要だ。ミューテックスはこの典型的なユースケースに最適だ。

// SPI バスアクセス管理の例
SemaphoreHandle_t spi_mutex;

bool spi_write(uint8_t cs_pin, const uint8_t *data, size_t len) {
    if (xSemaphoreTake(spi_mutex, pdMS_TO_TICKS(50)) != pdTRUE) {
        return false;  // タイムアウト
    }
    gpio_write(cs_pin, LOW);
    spi_transfer(data, NULL, len);
    gpio_write(cs_pin, HIGH);
    xSemaphoreGive(spi_mutex);
    return true;
}

ログバッファの保護

複数タスクからログを書き込む場合、バッファへのアクセスをミューテックスで保護する。ロックを保持する時間は最小限に留め、重いI/O処理はミューテックス外で行う。

FLASH書き込みの保護

フラッシュメモリの書き込み・消去は長時間かかるため、複数タスクからの同時アクセスを防ぐミューテックスが不可欠だ。EEPROMや設定データの読み書きにも同様の保護が必要になる。

ネットワークスタックの共有

TCP/IPスタック(lwIPなど)はグローバルな接続テーブルや送受信バッファを持つ。複数タスクからsendやrecvを呼ぶ場合、内部的にミューテックスで保護されている。

実装・開発のポイント

ロック粒度の最適化

ミューテックスの保持時間(ロック粒度)は必要最小限にする。長時間ロックを保持すると、待機タスクのレイテンシが増大しリアルタイム性が損なわれる。

// 悪い例:ロック中にI2C通信などの重い処理を行う
void bad_practice(void) {
    xSemaphoreTake(mutex, portMAX_DELAY);
    // I2C通信(数ミリ秒かかる可能性がある)
    i2c_read(addr, data, len);
    shared_var = process(data);
    xSemaphoreGive(mutex);
}

// 良い例:ロック範囲を最小化する
void good_practice(void) {
    uint8_t data[32];
    // I2C通信はミューテックス外で実行
    i2c_read(addr, data, len);
    uint32_t result = process(data);

    // 共有変数への書き込みだけを保護
    xSemaphoreTake(mutex, portMAX_DELAY);
    shared_var = result;
    xSemaphoreGive(mutex);
}

例外・エラー時のアンロック保証

エラー発生時にミューテックスを解放し忘れるとデッドロックが発生する。C++ではRAII(Resource Acquisition Is Initialization)パターンで自動アンロックを実装できる。

// C++でのRAIIミューテックスラッパー(組み込みC++向け)
class MutexGuard {
    SemaphoreHandle_t &mutex_;
public:
    explicit MutexGuard(SemaphoreHandle_t &m, TickType_t timeout = portMAX_DELAY)
        : mutex_(m) {
        if (xSemaphoreTake(mutex_, timeout) != pdTRUE) {
            // タイムアウトエラー処理(例外の代わりにassert等)
            configASSERT(false);
        }
    }
    ~MutexGuard() {
        xSemaphoreGive(mutex_);  // スコープ終了時に必ず解放
    }
};

void safe_function(void) {
    MutexGuard lock(spi_mutex);
    // ここでエラーが発生してもデストラクタが解放する
    spi_write(data, len);
}  // ← デストラクタが自動的にxSemaphoreGiveを呼ぶ

デッドロック検出

タイムアウト付きのロック取得(xSemaphoreTake(mutex, timeout))を使い、タイムアウト時にアサートや警告ログを出力することでデッドロックを早期検出できる。本番コードではportMAX_DELAY(無限待機)より、設計上の最大待機時間を設定することを推奨する。

他技術との比較

項目ミューテックスバイナリセマフォカウンティングセマフォクリティカルセクション
主な用途資源の排他制御タスク間シグナル資源プール管理割り込み無効化
所有権あり(ロックしたタスクのみ解放可)なしなしなし
優先度継承あり(RTOS依存)なしなし不要
ISRからの使用不可可(FromISR版)可(FromISR版)可(制限あり)
デッドロックリスクあり低い低いなし
レイテンシへの影響最小(ただし割り込み遅延)

セマフォとの使い分け

セマフォはカウンターを持ち、「空きスロット数」を管理するのに使う。一方ミューテックスは所有権概念を持ち、「ロックしたタスクだけが解放できる」という制約がある。共有資源の排他制御にはミューテックス、タスク間の同期(ISR→タスクへの通知等)にはバイナリセマフォが適している。

クリティカルセクションとの使い分け

クリティカルセクション(割り込み無効化)はOSなしでも使えシンプルだが、割り込み禁止中はリアルタイム性が損なわれる。ミューテックスはタスクをブロックするだけで割り込みは生きたままにできる。クリティカルセクションは数命令の超短い保護区間、ミューテックスはより長い排他制御に使い分ける。

関連用語

参考リンク