モダン第5章:【C++11/14】標準マルチスレッドと並行処理
std::thread・排他制御(mutex/lock_guard)・データ競合の撲滅とstd::atomic
現代のすべてのCPUはマルチコア(8コア、16コア)が当たり前であり、1本のメインスレッドだけでゲームや高負荷システムを動かすのはCPUリソースの大半をドブに捨てるようなものです。しかし、かつてのC++03時代は言語仕様にスレッドの概念が存在せず、OS依存の泥臭いAPI(POSIX pthreadsやWin32 API)を直接叩かなければなりませんでした。C++11で初めて言語仕様として組み込まれた【標準スレッドライブラリ(std::thread)】と【メモリモデル】は、クロスプラットフォームで安全・高速な並行処理への扉を開きました。本章では、非同期タスクの起動・待機(join / detach)から、未定義動作を引き起こす「データ競合(Data Race)」のメカニズム、RAIIによる排他制御(std::mutex, std::lock_guard)、そして極限の低オーバーヘッドを実現する「ロックフリー原子的操作(std::atomic)」までを徹底解説します。
M5.1 OS依存スレッドAPIの終焉:C++11標準メモリモデルの革命
pthreadやWin32から脱却し、なぜ言語仕様レベルでスレッドが定義される必要があったのかを解説します。
🐧 ペンギン先輩
シロクマ先生!ステージ切り替え時に裏でBGMや敵テクスチャを非同期ロードしようとしたら、Windowsでは CreateThread、MacやLinuxでは pthread_create を書かねばならず、#ifdef 地獄でコードが爆発しました…!
🐻❄️ シロクマ先生
ふぉっふぉっふぉ!C++03までは「世界はシングルスレッドで動くもの」として規格が作られておったからのう。マルチスレッドはすべてOS独自の拡張機能に頼るしかなかったのじゃ。
🐧 ペンギン先輩
C++11で標準ライブラリに std::thread が入って、何が変わったんですか?
🐻❄️ シロクマ先生
OSの差分が完全に消滅しただけでなく、言語規格として【メモリモデル(変数への読み書きの順序保証)】が厳格に定義されたのじゃ!どのOS・どのCPU(x86/ARM)でも、決定論的で安全な並行処理が書けるようになったのじゃぞ!
M5.2 データ競合(Data Race)の恐怖と std::lock_guard によるRAII排他制御
複数のスレッドが同時に同じ変数を更新した瞬間に起きる破滅と、デッドロックを防ぐRAIIロックの仕組みを学びます。
🐧 ペンギン先輩
先生!2つのスレッドからスコア変数 g_score にそれぞれ 1000回ずつ +1 加算したのに、計算結果が 2000 ではなく 1432 とか変な数字になります!
🐻❄️ シロクマ先生
それこそが並行処理最大の魔物【データ競合(Data Race / レースコンディション)】じゃ!g_score++ は1命令ではなく、「CPUレジスタに読み込む」「加算する」「メモリに書き戻す」という3ステップじゃから、途中で別スレッドに割り込まれて値が上書き消滅したのじゃ!
M5.3 実装編:std::atomic によるロックフリー高速カウンタとスレッド処理
重いミューテックスを回避し、CPUのハードウェアアトミック命令で超高速に動作するカウンタを実装します。
📄 ThreadSafeCounter.cpp
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>
#include <atomic>
// 1. ミューテックスによる排他制御
int g_mutexCounter = 0;
std::mutex g_mtx;
void incrementWithMutex(int count) {
for (int i = 0; i < count; ++i) {
std::lock_guard<std::mutex> lock(g_mtx); // RAIIロック
g_mutexCounter++;
}
}
// 2. std::atomic によるロックフリー原子的加算(CPU命令レベルの排他)
std::atomic<int> g_atomicCounter(0);
void incrementWithAtomic(int count) {
for (int i = 0; i < count; ++i) {
g_atomicCounter.fetch_add(1); // または単に g_atomicCounter++
}
}
int main() {
const int THREAD_COUNT = 4;
const int ITERATIONS = 10000;
std::cout << "=== 1. std::mutex による並行処理 ===" << std::endl;
std::vector<std::thread> threads1;
for (int i = 0; i < THREAD_COUNT; ++i) {
threads1.push_back(std::thread(incrementWithMutex, ITERATIONS));
}
for (size_t i = 0; i < threads1.size(); ++i) {
threads1[i].join(); // 全スレッド終了待機
}
std::cout << "Mutex結果: " << g_mutexCounter << " (期待値: " << (THREAD_COUNT * ITERATIONS) << ")" << std::endl;
std::cout << "\n=== 2. std::atomic によるロックフリー処理 ===" << std::endl;
std::vector<std::thread> threads2;
for (int i = 0; i < THREAD_COUNT; ++i) {
threads2.push_back(std::thread(incrementWithAtomic, ITERATIONS));
}
for (size_t i = 0; i < threads2.size(); ++i) {
threads2[i].join();
}
std::cout << "Atomic結果: " << g_atomicCounter.load() << " (期待値: " << (THREAD_COUNT * ITERATIONS) << ")" << std::endl;
return 0;
}
💡 このセクションの重要ポイント
- C++11標準スレッドの恩恵: OS依存コードを完全に排除し、std::thread とラムダ式で型安全・ポータブルな並行処理を実現する。
- データ競合は未定義動作: 複数スレッドが同期なしで共有変数を書き換えるとメモリが破損する。必ず mutex か atomic を介さねばならない。
- RAIIロックの徹底: 生 mutex.lock() は使わず、常に std::lock_guard または std::unique_lock を使用して例外発生時のアンロック漏れを防ぐ。
- std::atomic の威力: 単一整数のインクリメントやフラグ更新には、OSのコンテキストスイッチを伴わないロックフリー原子的操作(std::atomic)が最速。
📝 理解度チェッククイズ
Q1. C++11の `std::thread` オブジェクトを破棄する際、join() も detach() も呼ばれていない場合に発生する重大な動作はどれですか?
- 1. 裏で勝手にスレッドが終了するまで静かに待機する
- 2. std::terminate() が強制的に呼び出され、プログラム全体が即座に異常終了(クラッシュ)する
- 3. コンパイルエラーになる
- 4. スレッドが自動的に detach されてOSに引き渡される
正解と解説を見る
正解:std::terminate() が強制的に呼び出され、プログラム全体が即座に異常終了(クラッシュ)する
C++規格では、joinable な状態(join も detach もされていない)の std::thread のデストラクタが走ると、安全のため問答無用で std::terminate() が呼ばれ、プロセスが即時アボートします。
Q2. ミューテックスのロックに `std::lock_guard<std::mutex>` を使用する最大のメリットは何ですか?
- 1. ロックの処理速度が10倍速くなるため
- 2. スコープを抜けた際や途中で例外がスローされた場合でも、デストラクタにより確実に unlock() が呼び出されデッドロックを防ぐため
- 3. 複数のミューテックスを1つの変数で同時に操作できるため
- 4. メモリ使用量がゼロになるため
正解と解説を見る
正解:スコープを抜けた際や途中で例外がスローされた場合でも、デストラクタにより確実に unlock() が呼び出されデッドロックを防ぐため
std::lock_guard は RAII(Resource Acquisition Is Initialization)の典型例であり、構築時に lock() し破棄時に必ず unlock() を呼び出すため、関数の途中で return したり例外が飛んでもアンロック漏れが発生しません。
Q3. 整数カウンタの並行加算において、`std::mutex` ではなく `std::atomic<int>` を選ぶ主な利点は何ですか?
- 1. atomic を使うとカウンタが無限大まで扱えるようになるため
- 2. OSのカーネル呼び出しやスレッド休止(コンテキストスイッチ)を伴わず、CPUの原子的ハードウェア命令で超高速に加算できるため
- 3. atomic はC++03でも利用可能なため
- 4. atomic 変数はスタックメモリを一切消費しないため
正解と解説を見る
正解:OSのカーネル呼び出しやスレッド休止(コンテキストスイッチ)を伴わず、CPUの原子的ハードウェア命令で超高速に加算できるため
std::atomic はCPUのロックプレフィックス命令(LOCK XADDなど)に直接コンパイルされるため、ミューテックスのような重いOSカーネル同期やスレッドのブロック・ウェイクアップのオーバーヘッドなしに最高速で動作します。
🐻❄️ シロクマC++ラボ インタラクティブ学習システム
ブラウザでJavaScriptを有効にすると、ブラウザ内インベーダーゲームエミュレータ、メモリマップ可視化、UMLクラス図、対話型解説、理解度クイズが起動します。
インタラクティブ学習を開始する →