TopC++ 入門 › STEP 12 › 第68回 std::mutex

第68回 std::mutex と lock_guard — 共有データを壊さずに守る

複数スレッドが同じ変数を読み書きすると、値が壊れる「データ競合」が起きます。std::mutex で「同時に 1 人だけ」のルールを作り、std::lock_guard で自動的にロック解除される RAII を使うのが定番です。

最低限ここだけ

  • std::mutex m; を共有オブジェクトと一緒に置く
  • std::lock_guard<std::mutex> lk(m); で守る
  • ロック範囲はできるだけ狭く

余裕があれば

  • unique_lock / scoped_lock の違い
  • デッドロックを避けるための順序付け
  • shared_mutex(読み書きロック)

1. まず触ってみる — データ競合を目撃する

2 スレッドが同じ int カウンタを 100000 回ずつインクリメントしたら、結果は 200000 になるはず — と思いきや、ロック無しだと値が壊れます。まずはデモで目撃しましょう。

▶ データ競合シミュレータ

期待値

200000

実際

損失

競合の様子がここに表示されます。

1-1. 最小コード(壊れる版)

race.cppUB
int n = 0; // 共有変数 void inc() { for (int i=0; i<100000; ++i) ++n; // ← 裸のインクリメント } int main() { std::thread a(inc), b(inc); a.join(); b.join(); std::cout << n; // 200000 になるとは限らない } // (規格的には UB = 未定義動作)

テキスト上は ++n が 1 行でも、CPU では「読む→足す→書く」の 3 手順に分解されます。2 スレッドの手順が混ざると、読んだ瞬間はまだ古い値のまま片方の更新が吹き飛びます。

2. なぜ壊れるのか — ++n が 3 命令である話

たった 1 行の ++n は以下の 3 段階です:

  • ① メモリから n を読む(例: レジスタに 5)
  • ② レジスタで +1(6 になる)
  • ③ レジスタの値を n に書き戻す(n = 6)

スレッド A と B が完全に重なると、こんな事が起きます:

ステップスレッド Aスレッド Bn
1n を読む(=5)5
2n を読む(=5)5
3+1 = 65
4+1 = 65
5n に書く(6)6
6n に書く(6)6(7 にならない)

2 回 ++ したのに 1 しか増えない。これがデータ競合による更新喪失です。mutex でこの 3 命令を「原子的(まとめて行う)」に見せる必要があります。

3. mutex + lock_guard で守る

std::mutex は「同時に 1 人しか持てない鍵」です。lock() で鍵を取り、unlock() で返します。lock_guard はこの 2 行を RAII にしてくれる小さなヘルパで、スコープを抜けると自動で unlock されます。

mutex.cppOK
std::mutex m; int n = 0; void inc() { for (int i=0; i<100000; ++i) { std::lock_guard<std::mutex> lk(m); // ← ロック ++n; } // ← lk の dtor が unlock } // C++17 以降は type deduction で書き直せる: std::lock_guard lk(m);

3-1. ロックのスコープはできるだけ狭く

wide.cpp遅い
void process() { std::lock_guard lk(m); heavyCompute(); // 共有データに触らない ++counter; // 共有 }
narrow.cpp速い
void process() { heavyCompute(); // ロック外でやる { std::lock_guard lk(m); ++counter; // 共有だけ守る } }

ロックを持っている間、他のスレッドは全員待たされます。無関係な処理までロック内に入れると並行性が失われます。

3-2. クラスのメンバに mutex を持つ場合

counter.cppC++
class Counter { std::mutex m_; int n_ = 0; public: void inc() { std::lock_guard lk(m_); ++n_; } int value() { std::lock_guard lk(m_); return n_; } }; // Counter 自体をコピーするなら mutex は mutable にする or 削除

4. ロックの種類早見表

ラッパ特徴使いどころ
lock_guardシンプル。作ったら lock、壊れたら unlock。途中で手放せない基本はこれ
unique_lock途中で unlock/再 lock 可。condition_variable と組む条件変数と一緒に
scoped_lock(C++17)複数 mutex を同時にロック(デッドロック回避)2 つ以上のロックを同時に取る時
shared_lockshared_mutex と組む。複数スレッドで同時読み読み中心のキャッシュ等
scoped.cppC++17
// 2 つの mutex を安全に同時ロック std::mutex a, b; void transfer(Acc& x, Acc& y, int v) { std::scoped_lock lk(x.m, y.m); // ← 両方を矛盾なく確保 x.balance -= v; y.balance += v; } // デッドロックフリーなアルゴリズムを std が内部で使ってくれる

5. デッドロックと予防策

デッドロックは、2 つ以上のスレッドがお互いの持つ鍵を待ち合って、永久に動けなくなる現象です。

deadlock.cppNG
std::mutex A, B; // スレッド 1 { std::lock_guard la(A); std::lock_guard lb(B); // ← B が取れない(2 が握ってる) ... } // スレッド 2 { std::lock_guard lb(B); std::lock_guard la(A); // ← A が取れない(1 が握ってる) ... } // → お互い永久に待つ = ハング

5-1. 予防策 3 つ

  • ① ロック取得順序を全員で揃える。アドレス順・ID 順など決まりを作る。
  • ② std::scoped_lock を使う。複数 mutex を渡せば安全な順序で取ってくれる(C++17 以降)。
  • ③ ロック中に別のロックを取らない。呼び出し先で別の mutex を触らないように設計。
avoid.cppOK
// scoped_lock で両方同時に取る → デッドロックしない std::scoped_lock lk(A, B); // スレッドが A→B か B→A かを自動で調整する
デバッグ Tips: デッドロックは再現性が低いので再現しづらいバグです。Thread Sanitizer(-fsanitize=thread)はデータ競合とデッドロックの両方を検出してくれます(第 74 回で詳述)。

6. 実務の注意

  • 共有するなら必ず守る。「読み取りだけだから安全」は嘘(最適化で値が変わる)。const 参照でも書き手がいるなら mutex 必須。
  • std::cout も共有資源。複数スレッドから直に吐くと文字が混ざる。ログは mutex 付きヘルパか、専用キューに投げる。
  • mutex メンバはムーブ禁止。mutex を持つクラスは copy/move できないのが既定。move したいなら mutable + pointer/unique_ptr で持つ。
  • 読みが圧倒的に多いなら shared_mutex。それ以外は std::mutex で十分(shared は重い)。
  • 単純なカウンタは atomic。mutex は「複数行の作業をまとめて守る」とき。1 変数の ++ だけなら std::atomic<int>(第 61 回)。

7. 理解度チェック

4 問。

Q1. 2 スレッドがロック無しで共有 int を ++ したとき、最終値は?

必ず期待値通り
必ず期待値より少なくなる
期待値以下の値になりうる(しかも実行ごとに異なる)
規格上は UB。実際はタイミング次第で期待値より少なくなることが多い。mutex または atomic で保護必須。

Q2. lock_guard と scoped_lock の違いは?

scoped_lock は複数 mutex を安全に同時ロックできる
lock_guard は自動 unlock しない
scoped_lock は C++11 からある
lock_guard は単一 mutex 用。scoped_lock(C++17)は可変引数で複数 mutex を受け、デッドロック回避アルゴリズムで順序を自動調整する。

Q3. デッドロック予防として正しいのは?

各スレッドでロック取得順を好きに決める
全スレッドでロック取得順を統一する
全部のロックを detach で逃げる
デッドロックは「循環した待ち関係」で発生する。全員が同じ順で取れば循環は生まれない。

Q4. ロックを極力狭くすべき理由は?

ロック中は他スレッドが待たされ、並行性が落ちるから
ロックは取得するたびにメモリを消費するから
コンパイラが最適化できなくなるから
クリティカルセクションが長いと実質シングルスレッドに近づく。共有データへの書き込みだけをガードするのが基本。