マルチスレッドの実務ベストプラクティス C++編 ── RAIIとjthreadで事故を構造から消す

· · Windows, マルチスレッド, C++, Visual Studio, 業務アプリ, 不具合調査, 設計

「C#では動いていた設計をC++に持ってきたら、たまに落ちるようになった」「std::thread を使ったら、例外発生時にアプリごと terminate で即死した」「volatile bool のフラグで止めていたが、リリースビルドでだけ止まらない」── C++のマルチスレッドには、マネージド言語にはない固有の怖さがあります。データ競合がそのまま未定義動作(undefined behavior)になるからです。壊れた値が読めるだけでは済まず、コンパイラの最適化前提が崩れて「何が起きてもおかしくない」状態になります。

本記事はマルチスレッド実務シリーズのC++編です。業務アプリ・装置制御・DLLをモダンC++(C++17/20)で書く開発者を対象に、マルチスレッド設計の原則 ── スレッドを直接増やさない、共有可変状態を減らす、ロックの規律、止め方を最初に設計する ── をC++とWindowsの道具に落とし込み、C++固有の落とし穴と合わせて、2026年8月時点の一次情報にもとづいて整理します。本編単体で読めるように書いています。同じ原則を別の言語で展開した「.NET編」「C言語編」「Java編」もあります。

1. まず結論

  • C++ではデータ競合は「壊れた値が読める」ではなく「未定義動作」です。同期されていない共有可変アクセスを1か所も残さないことが、他言語以上に絶対条件になります。1
  • std::thread を裸で使わないでください。joinable なまま std::thread のデストラクタが走ると std::terminate でプロセスが即死します。C++20の std::jthread はデストラクタで自動joinし、停止要求(stop_token)も内蔵しています。23
  • ロックは必ずRAIIで持ちます。mtx.lock() の手書きはやめて lock_guard / scoped_lock を使います。例外が飛んでもデストラクタが確実に解放します。複数ロックの同時取得は scoped_lock がデッドロック回避アルゴリズムで面倒を見ます。4
  • volatile は同期の道具ではありません。共有フラグ・カウンターは std::atomic、複数変数の保護は std::mutex です。std::atomic は不可分性と memory_order による順序付けを提供します。5
  • 待ち合わせは condition_variable の述語付き wait で。条件変数にはスプリアスウェイクアップ(通知なしに目覚める現象)があるため、述語なしの wait は誤りの温床です。6
  • 止め方は jthread + stop_token(C++20)が基本形です。それ以前の環境では std::atomic<bool> + 条件変数で協調停止を組みます。スレッドの強制終了はC++の世界には存在しないものと考えてください。3
  • std::async は future のデストラクタがブロックすることを知ってから使ってください。戻り値を捨てると直列実行と同じになります。7
  • Win32同期オブジェクトの出番は「Win32待機APIとの連携」と「プロセス間」だけです。それ以外は標準ライブラリで書くのが移植性・保守性の点で有利です。8

2. なぜマルチスレッドは難しいのか ── 競合状態・デッドロック・未定義動作

マルチスレッドが持ち込む問題は、言語を問わず突き詰めると2種類です。

競合状態(race condition)は、複数のスレッドがどの順序で特定のコードに到達するかによって結果が変わってしまうバグです。定番の例が共有カウンターで、++count という1つの式は機械語レベルでは「読み込み → 加算 → 書き戻し」の3ステップに分かれます。2つのスレッドがこの3ステップに同時に入ると、一方の加算がもう一方の書き戻しで上書きされて消えます。実行のたびに結果が変わり、どの結果になるかは予測できません。

スレッドB共有変数 countスレッドAスレッドB共有変数 countスレッドAcount = 102回加算したのに count = 11スレッドAの加算が失われた読み込み(10)読み込み(10)手元で加算(11)手元で加算(11)書き戻し(11)書き戻し(11)

図1: 共有カウンターで加算が失われる典型的な競合状態。++count の3ステップの間に別スレッドが割り込むと、後から書き戻したほうが上書きする

デッドロックは、2つのスレッドが互いに相手の持っているロックを待ち合って、どちらも先へ進めなくなる状態です。スレッドAがロック1を持ってロック2を待ち、スレッドBがロック2を持ってロック1を待つ ── これだけで両者は永遠に止まります。

ロック2の解放待ちロック1の解放待ちスレッドAロック1を保持中スレッドBロック2を保持中

図2: デッドロックの循環待ち。待ちの矢印が輪を作った瞬間、輪の中の全スレッドが永遠に停止する

厄介なのは、どちらもタイミング依存だという点です。開発機では数万回に1回しか当たりを引かない実行順の組み合わせが、コア数もタイミングも違う客先マシンでは毎日起きます。「デバッガーを付けると再現しない」「ログを足したら消えた」というのも、観測がタイミングを変えてしまうためで、競合バグの典型的な振る舞いです。だからこそ本記事の原則はすべて、「正しく同期する」より前に「同期が必要な場所を減らす」という一つの方向を向いています。

2.1. C++では、データ競合はそのまま未定義動作になる

そのうえでC++には、他言語より一段深い事情があります。C++の規格では、あるメモリ位置に対して複数スレッドが同期なしにアクセスし、少なくとも一方が書き込みであれば、それはデータ競合であり未定義動作です。C++ Core Guidelinesの並行性の章(CP.2「データ競合を避けよ」)は、これを絶対規則として最初に掲げています。1 未定義動作とは「古い値か新しい値のどちらかが読める」という生易しい話ではありません。コンパイラは「データ競合は存在しない」という前提で最適化するため、ループから条件判定が消える、書き込みが並べ替えられる・統合されるなど、ソースコードから想像できない挙動が正当に起こります。「volatile bool の停止フラグがリリースビルドでだけ効かない」という古典的な事故はこの典型です。

2.2. RAIIが土台になる

もう一つのC++固有の前提が例外とリソース管理です。C++には finally がない代わりにRAII(デストラクタによる自動解放)があり、マルチスレッドの道具立てもRAIIを前提に設計されています。「ロックはオブジェクトの寿命で管理する」「スレッドの合流もオブジェクトの寿命で保証する」── この流儀に乗ることが、C++でマルチスレッドを安全に書く土台になります。

3. スレッドの起こし方 ── thread の罠と jthread

3.1. std::thread のデストラクタは「事故る仕様」

std::thread には有名な罠があります。joinableなまま(joinもdetachもされないまま)デストラクタが走ると、std::terminate が呼ばれてプロセスが即死するのです。9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← ここで例外が飛ぶと…
    worker.join();      // ← joinに到達せず、workerのデストラクタでterminate
}

例外安全にするには try/catch で join を保証する必要があり、RAIIの言語なのにスレッドだけ手動管理という歪んだ状態でした。C++20の std::jthread がこれを解決します。デストラクタで自動的に停止要求を出してjoinするため、上のコードは std::jthread に替えるだけで例外安全になります。2 MSVCではVisual Studio 2019 16.9以降で <stop_token>jthread が使えます。3

std::threadjoin も detach もしていないstd::threadjoin 済みstd::jthread (C++20)スレッドを起動したスコープを抜けるときどうなる?std::terminateプロセス即死安全に合流自動で request_stop + join例外が飛んでも安全

図3: スレッドオブジェクトの寿命と終わり方。std::thread は「joinし忘れたら即死」という仕様なので、C++20以降は jthread を既定にする

detach() は原則使いません。合流手段を失ったスレッドは、プロセス終了時に静的変数やヒープの破棄と競合して終了時クラッシュの定番原因になります。

3.2. 「スレッドより上」の道具 ── async・future と並列アルゴリズム

.NET編の「スレッドを自分で作らない」という原則は、C++では次の道具に対応します。

  • std::async + std::future: 一発の非同期タスクと結果の受け取り。ただし重要な仕様として、std::async 起動のタスクに紐づく future(または最後の shared_future)は、タスク未完了のままデストラクタが走ると完了までブロックします。7 std::launch::async で実際に起動された仕事なら、戻り値のfutureを捨てた瞬間に同期実行と等価になります。さらに悪いことに、起動ポリシーを指定しない既定では処理系が deferred(遅延実行)を選ぶことがあり、その場合 get() / wait() を誰も呼ばなければ仕事はそもそも実行されず黙って消えます。確実に並行実行したいなら std::launch::async を明示し、futureの寿命を持ち主が管理してください。
  • PPL(Parallel Patterns Library)の concurrency::parallel_for / parallel_for_each: コレクション全要素への並列適用。ただし1反復の仕事が小さすぎるとフォーク/ジョインのオーバーヘッドが利得を食い潰すため、並列化は外側のループで行うのが原則です。10
  • C++17の並列アルゴリズム(std::execution::par): MSVCでは主要なアルゴリズムが並列化されています(すべてではありません)。11 注意点として、実行ポリシー下の要素処理から例外が漏れると std::terminate が呼ばれます。コールバックの中に自前の例外境界(try/catch)を置くのは、6章のスレッド境界と同じ考え方です。

「I/O待ちはスレッドを増やす対象ではない」という線引きもそのまま有効です。WindowsのネイティブコードならOVERLAPPED I/OやIOCPがその受け皿になります(仕組みは「Windows I/Oの深層 第2回」参照)。

4. 共有可変状態を減らす ── 分割・値渡し・const・キュー

競合は「複数のスレッド」と「共有された可変データ」が揃ったときにだけ起きます。スレッドの数は要件で決まるので、設計で削れるのは共有のほうです。手段は「分割」「不変化」「受け渡し」の3系統で、C++ではこう書きます。

分割する。並列集計のような処理では、共有の合計変数に各スレッドが書き込むのではなく、スレッドごとにローカルな小計を持たせて、最後に1回だけ合流させます。共有への書き込みが「反復のたび」から「スレッドごとに1回」まで減り、同期のコストも競合の窓も桁違いに小さくなります。合流の1回は std::mutex でも std::atomic への fetch_add でも構いません。

値で渡す。スレッドの起動時に必要なデータをコピー(またはムーブ)で渡してしまえば、そのデータはスレッド専有になり、同期は不要です。ラムダのキャプチャを参照([&])にして寿命切れの変数を触る事故が多いので、スレッドに渡すラムダは明示キャプチャで、原則コピーかムーブにします。ただし「コピーしたから専有」が成り立つのは、値がポインタや shared_ptr などの別名(エイリアス)を含まない、深い値のグラフである場合だけです。生ポインタ入りの構造体をコピーしても、指している先は共有されたままです。

constで共有する。読み取りしかされないデータは、何スレッドから同時に読んでも安全です。設定値・マスターデータ・計算の入力などは、構築後に書き換えない const な共有(std::shared_ptr<const Config> など)にすれば同期なしで共有できます。注意点として、shared_ptr<const T> が禁じるのはそのハンドル経由の変更だけです。どこかに非constの別名が残っていたり、mutable メンバーが書き換えられたりすれば競合は残るので、「構築が終わったら非constの参照を手放し、以後誰も書かない」ことまで含めて設計してください。「変更が必要になったら、書き換えるのではなく新しいオブジェクトを作って差し替える」と決めるだけで、守るべき可変状態が一つ減ります(差し替え自体の寿命管理は5.2の注意を参照)。

キューで受け渡す。スレッド間のデータの流れは、共有変数ではなくプロデューサー/コンシューマーのキューに寄せます。C++標準にはチャネルがないため、std::mutex + std::condition_variable で小さなキューを書くのが定石です。

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // 容量0は全Pushが永遠に待つ罠になる
            throw std::invalid_argument("capacity must be positive");
    }

    // 満杯なら空きが出る(または停止要求)まで待つ。falseは停止要求。
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // 停止要求で起こされた
            if (st.stop_requested())                // 空きと停止が同時なら停止を優先し、
                return false;                       // 停止開始後の投入を受け付けない
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // 通知はロックの外で
        return true;
    }

    // 停止要求(stop_token)か、要素の到着まで待つ。停止時は nullopt。
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // 停止要求で起こされた
            if (st.stop_requested())                // 要素と停止が同時なら停止を優先し、
                return std::nullopt;                // 停止開始後は新しい仕事に着手しない
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // stop_token対応のwaitを使うためanyを選ぶ
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

2つ、設計上の要点があります。第一に、容量に上限を付けて、満杯なら生産側を待たせること。上限のないキューは、生産が消費より速い構成で「動いてはいるがメモリが増え続ける」時限爆弾になります。満杯時に Push がブロックすることが自然な背圧(バックプレッシャー)として働き、過負荷を機械的に上流へ伝えます。第二に、条件変数にはスプリアスウェイクアップ(通知がないのに目覚める現象)があるため、wait は必ず述語付きで呼びます。述語付きの wait が内部で「条件が真になるまでループ」を実行してくれます。6

5. ロックの規律 ── RAIIとscoped_lock

共有可変状態を減らしても、ゼロにはできないことが多い。残った共有には排他制御を使いますが、規律なしのロックは競合を隠すだけです。

まず、ロックの単位は「コードの区間」ではなく「データ」で考えます。守りたい可変データの集合ごとにミューテックスを1つ対応させ(外部に公開しない private メンバーにします)、そのデータに触るすべての場所で同じミューテックスを取る ── この対応表が崩れているのが競合バグの実態です。そして、ロックを持っている間にしてよいのは守っているデータの読み書きだけです。ロック保持中のファイルI/O・ネットワーク呼び出し・コールバック(外部コードの呼び出し)は、保持時間を引き延ばすだけでなく、呼んだ先が別のロックを取ろうとしてデッドロックする経路を作ります。ロックの外で準備し、ロックの中では差し替えだけが基本形です。

5.1. lock()/unlock() の手書きは禁止

std::mutexlock() / unlock() を直接呼ぶコードは、例外や早期リターンで解放漏れを起こします。ロックの取得と解放は必ずRAIIラッパーに任せます。

ラッパー 用途
std::lock_guard 1本のミューテックスをスコープの間だけ握る、最も基本の形
std::scoped_lock(C++17) 複数のミューテックスを同時に取得する。デッドロック回避アルゴリズムで順序問題を解決4
std::unique_lock 途中で解放・再取得したい場合や、condition_variable::wait に渡す場合

ロックが2つ以上あるとき、取得順序がスレッドによって入れ替わるのがデッドロックの古典的パターンです(図2の循環待ちはこうして生まれます)。対策は「全スレッドが同じ順序で取る」をルール化することですが、同時に取る場面ならC++にはより良い答えがあり、std::scoped_lock に複数のミューテックスをまとめて渡せば、取得順序のデッドロック回避をライブラリが保証してくれます。4 2つのオブジェクト間の送金処理のような「両方ロックしたい」場面では、個別に取らず必ずまとめて取ります。

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // 同一口座なら何もしない(下記注)
    std::scoped_lock lock(from.mtx, to.mtx);   // 2本まとめて、順序はライブラリが解決
    from.balance -= amount;
    to.balance   += amount;
}

先頭の同一性チェックは飾りではありません。同じ Accountfromto に渡されると、再帰不可の同じミューテックスを scoped_lock に2回渡すことになり、ハングや未定義動作の原因になります。「両方ロックする」関数には同一オブジェクトの除外を必ず添えてください。

「読み取りは多いが書き込みはまれ」なデータには std::shared_mutex(C++17)で読み書きロックを使えます。12 また recursive_mutex は「同じスレッドが再取得しても壊れない」ための型ですが、再帰取得が必要になる設計はロックの責務境界が曖昧になっているサインであることが多く、構造の見直しを先に検討してください。

5.2. atomic の正しい位置づけ

std::atomic は、単一変数への不可分な操作と、memory_order にもとづく順序付けを提供します。5 出番は.NET編の Interlocked と同じで、カウンター・フラグのような単一変数の更新です。複数の変数をまとめて整合させることはできないので、その場合は std::mutex に戻ります。

生ポインタの差し替え(std::atomic<T*>)には固有の罠があります。差し替え自体は不可分でも、入れ替えた後の古いオブジェクトの寿命は誰も守ってくれません。読み手が古いポインタをロードした直後に書き手が差し替えて delete すれば、解放済みメモリへのアクセスです。「不変オブジェクトを差し替えて共有する」設計をC++でやるなら、ロックで守った std::shared_ptr<const T> の付け替え(またはC++20の std::atomic<std::shared_ptr<T>>)のように、寿命管理とセットになった手段を選んでください。

そして繰り返しになりますが、volatile はスレッド同期の道具ではありません。memory_order を自分で指定するロックフリープログラミングは、既定(seq_cst)から緩める正当な理由と検証手段を持つ専門家の領域です。業務アプリでは既定のまま使うか、そもそも mutex で書いてください。

6. 止め方の設計 ── stop_token と協調停止

マルチスレッド設計のレビューで最初に聞くべき質問は「これはどうやって止まるのですか」です。そしてC++にはスレッドを外から安全に止める手段は存在しません(Win32の TerminateThread がいかに危険かはC言語編で詳述します)。したがって止め方は協調停止 ── 止める側は要求を出すだけ、いつ・どう終わるかはスレッド自身が後片付きのよい所で決め、joinの完了をもって「止まった」とみなす ── を、C++の道具で組みます。

C++20では std::jthread が停止機構を内蔵しています。request_stop() を呼ぶと、スレッド関数が受け取った std::stop_token に停止要求が立ち、ループはそれをポーリングします。condition_variable_anywaitstop_token を直接受け取れるため、「仕事が来るまで待っているスレッド」も停止要求で即座に起こせます(4章の BlockingQueue::Pop がその形です)。

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // 動いている間の二重Startを拒否する。
            throw std::logic_error("already running"); // 拒否せず代入すると、新スレッドが走り
                                                       // 出してから旧スレッドの停止を待つ間、
                                                       // 2本のワーカーが並走してしまう
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // 停止要求でも目覚める
                        try {
                            Process(*item, st);          // 内部でブロックしうる処理にもstを渡す
                        } catch (...) {
                            ReportError(std::current_exception());  // 1件の失敗は記録して続行
                        }
                    }
                }
            } catch (...) {
                // スレッド境界の最終防衛線(Popやムーブの失敗もここで受ける)。
                // ここから例外が漏れるとstd::terminateでプロセスごと落ちるため、
                // ReportErrorは例外を投げない実装にしておく
                ReportError(std::current_exception());
            }
        });
    }
    // 明示的なStopは不要:
    // Workerのデストラクタ → jthreadのデストラクタ → request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // 容量上限つき(4章)
    std::jthread thread_;
};
停止要求止める側(jthreadのデストラクタ or request_stop)stop_token計算ループ:stop_requested() をポーリング待機中のスレッド:condition_variable_any::wait(lock, st, pred)が即座に目覚める後片付けして自分でreturnjoin で合流完了ここで初めて「止まった」と言える

図4: C++20の協調停止。止める側は要求を出すだけ、終わり方はスレッド自身が決め、joinの完了をもって停止とみなす

もう一点、ワーカー内の try/catch は省略できません。jthread が例外安全にしてくれるのはjoinだけで、スレッド関数から例外が漏れれば std::thread と同様に std::terminate でプロセスが落ちます。仕事1件の失敗をどう扱うか(記録して続行するか、エラーチャネルで持ち主に伝えるか)は、スレッド境界で明示的に決めておきます。

同じ理由で、Process にも stop_token を渡していることに注目してください。仕事1件の処理が内部でブロックする(ネットワーク待ち・長い計算など)場合、そこが停止要求を観測できないと、デストラクタの暗黙のjoinがその1件の完了を延々と待つことになります。協調停止は「待つ場所すべて」にトークンが届いて初めて成立します。中断できない外部呼び出しを含むなら、タイムアウトを付けて1件の実行時間に上限を設けてください。

C++17以前の環境では、std::atomic<bool> の停止フラグ + condition_variablenotify_all で同じ構図を手組みします。このとき停止フラグの確認は条件変数の述語に含めるのが要点です(フラグだけ立てて通知を忘れると、待機中のスレッドが永遠に目覚めません)。

7. Windows固有の注意 ── Win32 APIとの境界線

7.1. 標準ライブラリとWin32同期オブジェクトの使い分け

Microsoftのドキュメントは、移植性を重視するC++コードには std::mutex / std::shared_mutex を推奨し、Win32同期オブジェクトの出番を「Win32の待機APIが必要な場合」と「プロセス間同期」に整理しています。8

状況 選択
通常のプロセス内排他 std::mutex + RAII(既定)
読み取り多数・書き込みまれ std::shared_mutex
WaitForMultipleObjects で複数オブジェクトを同時に待ちたい Win32のイベント・Mutex等のカーネルオブジェクト
プロセスをまたぐ排他・通知 名前付きMutex・イベント・セマフォ
Win32 APIを直接使うプロセス内ロック SRWロック(再帰が必要なときだけCRITICAL_SECTION)8

プロセス間で共有メモリを排他する具体的な設計は「共有メモリの落とし穴と実務ベストプラクティス」を参照してください。

7.2. DllMainでスレッドに触らない

DLLを書く場合の重大な制約がローダーロックです。DllMain はローダーロックを保持した状態で呼ばれるため、その中で他スレッドと同期する・スレッドの終了を待つ・LoadLibrary を呼ぶ、といった操作はデッドロックや不定動作の原因になります。スレッドを起動・合流するような初期化は DllMain の外(明示的な初期化関数)に出してください。13

7.3. UIスレッドとCOMアパートメント

Windowsのデスクトップアプリには、言語に関係なく効く強い制約があります。ウィンドウとコントロールは、それを作成したスレッド(UIスレッド)だけが触れるという掟です。Windowsはウィンドウメッセージを、そのウィンドウを作ったスレッドのメッセージキューに配送するため、UIの作成と操作はそのスレッドに集約されている必要があります。ワーカースレッドから画面を更新したいときは、直接触るのではなく PostMessage(非同期)でUIスレッドに依頼し、UIスレッド側のウィンドウプロシージャで処理します。同期形の SendMessage は、UIスレッドがそのワーカーの完了を待っている状況で呼ぶと互いを待ち合うデッドロックになるため、ワーカーからの通知は非同期形を既定にしてください。COMが絡む場合のSTA/MTAは「COM STA/MTA の基礎知識」で解説しています。また、/clr でコンパイルするC++/CLIコードでは <thread> <mutex> などの標準スレッドヘッダーがブロックされる点にも注意が必要です。14

8. 検証とデバッグ ── 「再現しない」前提で備える

競合バグはテストで見つかることを期待できません。通常のテストは「たまたま競合しなかった」実行を成功と数えてしまうからです。備えは3層で考えます。

第一の防衛線は、ここまでの設計原則そのものです。レビューでは「共有している可変データはどれか」「それぞれどのミューテックスが守るか」「複数ロックの取得順序は一意か(または scoped_lock でまとめて取っているか)」「停止経路はどこか」を表で確認します。この表が書けない設計は、動いていてもまだ完成していません。

第二に、異常を隠さず観測可能にします。取れないはずのロックには timed_mutextry_lock_forcondition_variablewait_for でタイムアウトを付け、時間切れを異常としてログに残せば、永遠のハングを検出可能な失敗に変えられます。スレッド境界の try/catch(6章)で受けた例外は必ず記録します。ハングやクラッシュの現場ではダンプを採取して全スレッドのスタックを確認し、互いのロック待ちが循環していないかを見ます。ダンプとログの整備は「Windowsアプリのクラッシュ時にログとダンプを残す設計」で扱っています。

第三に、負荷をかけて揺さぶります。コア数より多い並列度で長時間回す、処理順をランダム化する、人工的な遅延を挿入するといったストレステストは、開発機で競合の「当たり」を引きやすくする現実的な手段です。デバッグビルドで消えるバグも、最適化されたリリースビルド+高負荷なら再現することがよくあります。

9. まとめ ── C++版チェックリスト

言語共通の原則(スレッドを直接作らない・共有可変状態の最小化・ロックとデータの1対1対応・協調停止)に、C++固有の確認を重ねます。

  1. std::thread を裸で使っていないか(jthread にできないか、joinは例外経路でも保証されているか)
  2. detach() を使っていないか
  3. ラムダキャプチャは明示か、参照キャプチャした変数の寿命はスレッドより長いか
  4. 同期なしの共有可変アクセス(=未定義動作)が1か所もないと言い切れるか
  5. lock() / unlock() の手書きがないか、複数ロックは scoped_lock でまとめて取っているか
  6. condition_variable::wait はすべて述語付きか
  7. 共有フラグに volatile を使っていないか(std::atomic になっているか)
  8. 停止経路は stop_token(または atomicフラグ+通知)で設計され、joinで合流完了を確認しているか
  9. std::async の future を捨てていないか
  10. DllMain でスレッドの起動・同期・joinをしていないか

C++のマルチスレッドは「未定義動作」という崖のすぐ横を歩く作業ですが、裏を返せば、RAIIと標準ライブラリの流儀に素直に乗るだけで崖から大きく距離を取れます。jthreadscoped_lock・述語付きwaitatomic ── 道具の既定を正しく選ぶことが、C++では設計原則の実践そのものです。

関連記事

関連する相談領域

合同会社小村ソフトでは、C++製アプリ・DLLのマルチスレッド設計レビュー、「たまに落ちる・リリースビルドでだけおかしい」といった競合起因の不具合調査(ダンプ解析)、レガシーなスレッドコードのモダンC++への移行相談を扱っています。

参考リンク

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. 並行・並列の章の冒頭規則としてCP.1(自分のコードがマルチスレッドで動くと想定せよ)とCP.2(データ競合を避けよ)が掲げられ、データ競合があるとどんな保証も成り立たないこと、ロックの保持範囲やRAIIの利用など並行コードの設計規則が体系化されていることについて。  2

  2. cppreference.com, std::jthread. C++20のjthreadがstd::threadと異なりデストラクタで自動的にrequest_stop()を呼んでからjoinすること、スレッド関数の先頭引数としてstd::stop_tokenを受け取れること、これにより例外発生時でもスレッドの合流と停止要求が保証されることについて。  2

  3. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. P0660R10(<stop_token>とjthread)およびP1135R6(C++20同期ライブラリ)がVisual Studio 2019 16.9でサポートされたこと、C++標準ライブラリ機能のバージョン別対応状況について。  2 3

  4. Microsoft Learn, scoped_lock Class. C++17のscoped_lockが構築時に1つ以上のミューテックスを取得しデストラクタで解放すること、複数のミューテックスを渡した場合はstd::lock相当のデッドロック回避アルゴリズムで取得されること、例外が投げられても確実に解放されること、単一ミューテックスならlock_guard/unique_lockも選択肢になることについて。  2 3

  5. Microsoft Learn, <atomic>. アトミック操作が不可分であるため他スレッドからは操作の前か後の状態しか観測できないこと、memory_order引数にもとづいて他のアトミック操作の可視性への順序付け要件を確立し、それに反するコンパイラ最適化を抑止すること、atomic_flagが常にロックフリーであること、/clr:pureではこのヘッダーがブロックされることについて。  2

  6. Microsoft Learn, <condition_variable>. 条件変数の待機にはミューテックスが必要で、待機中はロックが外れること、通知なしに目覚めるスプリアスウェイクアップが存在するため、待機側は復帰時に条件を明示的に確認すべきであり、述語付きのwait(lock, pred)がそのループを代行すること、condition_variable_anyが任意のミューテックス型と組み合わせられることについて。  2

  7. Microsoft Learn, <future>. futureとshared_futureのデストラクタは原則ブロックしないが、唯一の例外としてstd::asyncで起動されたタスクに紐づくfuture(または最後のshared_future)は、タスクが未完了のままデストラクタが走ると共有状態がreadyになるまでブロックすること、この挙動が規格のノートとして明記されていることについて。  2

  8. Microsoft Learn, About Synchronization. Win32同期プリミティブの選択指針として、移植性重視のC++コードにはstd::mutex / std::shared_mutexとRAIIが推奨されること、Win32の待機APIやプロセス間同期が必要な場合にWin32同期オブジェクトを使うこと、プロセス内の新規コードの既定はSRWロックで再帰取得が必要なときだけCRITICAL_SECTIONであること、プロセス内同期にMutexを使うのは常にカーネル遷移を伴う「よくある間違い」であることについて。  2 3

  9. cppreference.com, std::thread::~thread. std::threadのデストラクタが、スレッドがjoinableなまま(joinもdetachもされないまま)呼ばれた場合にstd::terminateを呼ぶこと、つまりスレッドオブジェクトの破棄前に必ずjoinまたはdetachの判断を済ませていなければならないことについて。 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. 並列化はできるだけ高いレベル(外側のループ)で表現すべきこと、各反復の仕事が小さい・不均衡な並列ループではフォーク/ジョインのスケジューリングオーバーヘッドが並列実行の利得を上回りうること、その傾向はプロセッサ数が増えるほど強まることについて。 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. C++17の並列アルゴリズムライブラリが完成している一方、「完成」はすべてのアルゴリズムがあらゆる場合に並列化されることを意味せず、最重要のアルゴリズムが並列化され、並列化されないものにも実行ポリシーのシグネチャが提供されるという実装方針について。 

  12. Microsoft Learn, C++ standard library header files. マルチスレッド関連の標準ヘッダーとして<atomic>(C++11)、<mutex>(C++11)、<shared_mutex>(C++14)、<condition_variable>(C++11)、<future>(C++11)、<stop_token>・<semaphore>・<latch>・<barrier>(C++20)、<thread>(C++11)が整理されていることについて。 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. DllMainがローダーロック保持中に呼ばれるため呼べるAPIに重大な制約があること、DllMain内で他スレッドとの同期を行うとデッドロックしうること、LoadLibrary呼び出しやスレッド終了待ちが典型的な禁止事項であること、初期化はできる限り遅延させDllMainの外に出すべきこと、ロック階層を定義しローダーロックを最上位に置くべきことについて。 

  14. Microsoft Learn, <thread>. <thread>ヘッダーがthreadクラスとsleep_for等の補助関数を定義すること、/clrでコンパイルされるコードではこのヘッダーがブロックされること、__STDCPP_THREADS__マクロでスレッドサポートの有無を判定できることについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

ボリュームシャドウコピー(VSS)の仕組みと実務 ── 使用中ファイルのバックアップがなぜ取れるのか

使用中のファイルは共有違反でコピーできないのに、バックアップソフトはなぜ取れるのか。ボリュームシャドウコピー(VSS)のリクエスター・ライター・プロバイダーの役割分担、コピーオンライトの仕組み、vssadminの実務と差分領域の落とし穴を解説します。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

std::mutex と Win32 の CRITICAL_SECTION・SRWロックは、どう使い分ければよいですか?
移植性を重視する通常のC++コードでは std::mutex / std::shared_mutex と RAII ラッパー(lock_guard / scoped_lock)が第一候補です。Win32 の同期オブジェクトを選ぶのは、WaitForMultipleObjects のような Win32 の待機APIと組み合わせたい場合や、名前付きオブジェクトでプロセスをまたぐ同期が必要な場合です。プロセス内で Win32 API を直接使うなら、新規コードの既定は SRW ロックで、同一スレッドの再帰取得が必要なときだけ CRITICAL_SECTION を使います。プロセス内の排他に Win32 Mutex を使うのは、常にカーネル遷移を伴い低速になる典型的な間違いです。
std::thread の detach() を使ってもよいですか?
原則として避けてください。detach したスレッドは合流(join)する手段を失い、プロセス終了時にそのスレッドがまだ動いているかどうかを制御できなくなります。静的変数やヒープが破棄された後にデタッチ済みスレッドが動き続けて終了時クラッシュを起こす、というのは典型的な事故です。「終わりを待てる」ことはスレッド設計の基本要件なので、jthread(自動join)を使うか、threadならスコープ終了前に必ずjoinする構造にしてください。detachが許されるのは、プロセスと運命を共にしてよく、共有状態に一切触らないことを保証できる限定的な場面だけです。
volatile はC++でもスレッド間の同期に使えますか?
使えません。C++ の volatile はメモリマップドI/Oのような「コンパイラに最適化させたくない読み書き」のための修飾子で、スレッド間の可視性や順序付けを保証するものではありません。複数スレッドが同じ変数に同期なしでアクセスすればデータ競合であり、未定義動作です。スレッド間で共有するフラグやカウンターには std::atomic を使い、複数の変数をまとめて守るなら std::mutex を使ってください。std::atomic は操作の不可分性と、memory_order にもとづく順序付けの両方を提供します。
std::async は手軽そうですが、落とし穴はありますか?
最大の落とし穴は future のデストラクタです。std::async で起動したタスクに紐づく future(または最後の shared_future)は、タスクが未完了のままデストラクタが走ると完了までブロックします。戻り値の future を受け取らずに捨てると、その場で同期実行と同じことになり「非同期にしたつもりが直列」という事故が起きます。また、起動ポリシーを指定しない場合に本当に別スレッドで走るかは処理系の裁量です。使うなら future の寿命を明示的に管理し、確実に並行実行したい箇所では std::launch::async を指定してください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る