ミューテックス

複数のスレッドが共有リソースに同時アクセスすることを防ぐ排他制御の同期プリミティブ

並行処理非同期

ミューテックスとは

ミューテックス (Mutex: Mutual Exclusion) は、複数のスレッドが共有リソースへ同時にアクセスすることを防ぐ同期プリミティブである。1 つのスレッドがロックを取得している間、他のスレッドは解放されるまで待機する。

ミューテックス vs セマフォ

どちらも「待たせる」ための仕組みだが、守っている対象が違う。ミューテックスは 1 つの資源を今どのスレッドが触っているかを守り、セマフォは同時に何本まで走らせるかを数える。カウンタの初期値を 1 にしたセマフォ (バイナリセマフォ) はミューテックスとよく似た動きになるが、所有者の概念を持たないため、取得していないスレッドがカウンタを増やしても誰も止めない点が決定的に違う。

観点ミューテックスセマフォ
同時アクセス数1 (排他)N (カウンタ)
所有権所有者の概念がある (強制の度合いは実装差あり)所有者の概念がない
用途共有リソースの保護リソースプールの制限

「所有者だけが解放できる」がどこまで強制されるかは実装によって分かれる。POSIX スレッドでは、ミューテックスの型が ERRORCHECK か RECURSIVE の場合に所有していないスレッドからの解放が EPERM で失敗する。一方 Go の sync.Mutex はロック中の状態が特定の goroutine に紐づかず、ある goroutine がロックして別の goroutine に解放させる書き方も許される。言語ごとに何が守られるのかを確認しておかないと、解放漏れや二重解放が実行時まで表に出てこない。

Rust の Mutex

RustMutex は守る対象のデータを型として包み込み、ロックを取らないと中身に触れられない。ロック忘れが実行時のデータ競合ではなくコンパイルエラーとして出る点が、後から規律で守る方式との違いになる。

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    })); // ロックはスコープを抜けると自動解放
}
for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 10);

lock()Result を返すのは、ロックを保持したままスレッドが panic した場合に、その Mutex が毒された (poisoned) 状態になるためである。以降の lock()Err を返し、例のように unwrap() していればそこで panic する。途中で壊れた可能性のある状態を黙って読み進めない作りになっているので、握り潰す前に、本当に一貫性が保てているのかを確かめる必要がある。復帰させる手段としては clear_poison がある。

Go の sync.Mutex

Go では、守る対象のフィールドのすぐ上に Mutex を置き、どの変数を守っているのかを配置で示すのが定石である。

var mu sync.Mutex
var count int

mu.Lock()
count++
mu.Unlock()

Lock の直後に defer mu.Unlock() を書く形が実務では基本になる。途中の return や panic で解放が飛ぶ経路を潰せるからである。加えて Go の Mutex は再入できないため、ロックを持ったまま同じ Mutex を取り直す関数を呼ぶと、自分自身の解放を待って止まる。ロックされていない MutexUnlock は実行時エラーになる。

デッドロック

デッドロックは、次の 4 つが同時に成り立ったときにだけ起きる。①資源が排他的に使われる ②ロックを持ったまま別のロックを待つ ③他者が持つロックを横取りできない ④待ちの関係が輪を描く。逆に言えば、対策はこのうちどれか 1 つを崩すことに帰着する。以下は ④ が成立した典型例である。

スレッド A: Lock(X) → Lock(Y) を待つ
スレッド B: Lock(Y) → Lock(X) を待つ
→ 互いに相手の解放を待ち、永久に停止
対策説明
ロック順序の統一常に X → Y の順で取得し、待ちの輪 (条件 ④) ができないようにする。最も確実で、複数ロックを扱うコードでは順序を文書化しておく
タイムアウト・取得の試行一定時間で諦めて持っているロックも手放す (条件 ② を崩す)。諦めた後の再試行を無条件に繰り返すと、今度は取得の譲り合いで進まなくなるため、待ち時間をばらす
ロックの粒度を細かく必要最小限の範囲でロックし、ロックを持ったまま外部呼び出し (I/O・コールバック) をしない。呼び出し先が別のロックを取ると、順序の統一が破れる

Lambda とミューテックス

1 つの実行環境は、あるリクエストを処理している間は他のリクエストを受け付けず、処理が終わってから同じ関数の次のリクエストに再利用される。したがって同時に走る 2 つのリクエストが同じプロセス内のメモリを踏み合うことはなく、呼び出しをまたいだ排他をプロセス内のミューテックスで書いても効果がない (並行して走っている環境からは別のミューテックスに見える)。一方で環境が再利用される事実は残るので、ハンドラーの外に置いた状態は次の呼び出しに引き継がれる。環境をまたぐ排他には、共有ストレージ側で決着させる分散ロック (DynamoDB の条件付き書き込みなど) を使う。

// Lambda: 分散ロック (ミューテックスの代替)
await db.put({
  TableName: 'locks',
  Item: { lockId: 'resource-1', owner: requestId, expiresAt: Date.now() + 30000 },
  ConditionExpression: 'attribute_not_exists(lockId) OR expiresAt < :now',
  ExpressionAttributeValues: { ':now': Date.now() },
});

この形は「期限切れなら奪ってよい」という前提で成り立っているため、時計のずれや処理の一時停止によって、まだ持っているつもりの側と期限切れと見た側が同時に動く瞬間が生まれる。守る対象が課金や在庫のように取り返せないものなら、ロックだけに頼らず、書き込み側でも一意なトークンによる重複検知を用意する。この論点は 分散ロック 側で詳しく扱っている。

ロックフリーアルゴリズム

ロックを取らずに競合を扱う方向もある。考え方は「待たせる」代わりに「衝突したらやり直す」で、更新前の値を確認しながら 1 命令で書き換える CAS が土台になる。

方式説明
CAS (Compare-And-Swap)CPU 命令でアトミックに更新
Atomic 型ロックなしでカウンタを更新
不変データデータを変更しない設計

CAS の落とし穴は、値が A から B に変わり再び A に戻った場合に、変化がなかったのと区別できない点である (ABA 問題)。値と一緒に世代番号を持たせ、番号ごと比較することで回避する。またロックフリーは常に速いわけではなく、競合が激しいとやり直しが積み上がる。ロックの方が素直で速い場面も多い。

どこまでをロックで守り、どこから先は競合そのものが起きない設計 (資源を分ける・不変にする) に寄せるかの判断が、並行処理の設計の中心になる。この判断基準はミューテックスを扱う関連書籍でも体系的に整理されている。

この記事は役に立ちましたか?

関連用語

関連する記事