第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 になるはず — と思いきや、ロック無しだと値が壊れます。まずはデモで目撃しましょう。
▶ データ競合シミュレータ
競合の様子がここに表示されます。
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 | スレッド B | n |
| 1 | n を読む(=5) | | 5 |
| 2 | | n を読む(=5) | 5 |
| 3 | +1 = 6 | | 5 |
| 4 | | +1 = 6 | 5 |
| 5 | n に書く(6) | | 6 |
| 6 | | n に書く(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_lock | shared_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. ロックを極力狭くすべき理由は?
ロック中は他スレッドが待たされ、並行性が落ちるから
ロックは取得するたびにメモリを消費するから
コンパイラが最適化できなくなるから
クリティカルセクションが長いと実質シングルスレッドに近づく。共有データへの書き込みだけをガードするのが基本。