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

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

更新履歴(2件・最終更新 2026年09月07日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

C++マルチスレッドの解説を、実行方法の選択、共有状態の削減、同期と寿命、停止要求から合流、Windows固有の制約、検証の順に整理した。元の主張・条件、コード例、比較表、チェックリスト、出典と知識マップを保持し、値のコピーと別名、atomicと寿命、自動joinと例外処理を分けて説明した。
知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.22054231)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「マルチスレッドの実務ベストプラクティス C++編 ── RAIIとjthreadで事故を構造から消す」合同会社小村ソフト. https://doi.org/10.5281/zenodo.22054231 https://comcomponent.com/blog/multithreading-best-practices-cpp/

DOI(最新版)
10.5281/zenodo.22054231
DOI(この版)
10.5281/zenodo.22606488

「例外が起きたら、std::threadの後片付けでアプリごと終了した」「停止フラグを立ててもワーカーが戻ってこない」「C#で動いていた設計をC++に移したら、たまに落ちる」。C++のマルチスレッドでは、処理を並べる前に、共有データの守り方と、スレッドの終わり方を決める必要があります。

特にC++では、データ競合は単なる計算違いではなく未定義動作(undefined behavior)です。たまたま正しい結果が出たことを、安全性の根拠にはできません。1

本記事は、業務アプリ・装置制御・DLLをモダンC++(C++17/20)で書く開発者に向けた、マルチスレッド実務シリーズのC++編です。実行方法を選ぶ → 共有を減らす → 同期と停止を実装する → Windows固有の制約と検証を確認する、という順で整理します。C++20専用の例は、その箇所で区別します。

同じ原則を別の言語で展開した「.NET編」「C言語編」「Java編」もありますが、本記事だけでも読めます。

1. まず結論 ── 「同期を足す」前に、共有と寿命を決める

第一に共有可変状態を減らし、残った共有は標準ライブラリとRAIIで守ります。そして、停止要求からjoin完了までを一つの設計にします。1

決める順序 判断すること 本記事の参照先
1. 実行方法 一回のタスクか、集合への並列処理か、長く動くワーカーか。I/O待ちをスレッド増設で解決しようとしていないか 3章
2. データの持ち主 分割、値渡し、不変化、キューで共有への書き込みを減らせないか 4章
3. 残った共有の保護 どのデータをどのmutexで守るか。atomicで扱う単一の更新と、複数変数の整合を区別したか 5章
4. 終了の手順 停止要求が待機中・処理中の両方へ届くか。例外を記録し、最後にjoinできるか 6章
5. 配置先と検証 DLL・UI・COMの制約を守り、ログ・ダンプ・負荷試験で調べられるか 7〜8章

道具の既定は、スレッドの寿命にstd::jthread、ロックにRAII、待機に述語付きwait、単一の共有フラグやカウンターにstd::atomicです。volatileは同期に使いません。C++20より前の停止設計は、atomicフラグと条件変数で組みます。2345

ただし、道具を選んだだけでは完成しません。jthreadが自動でjoinしても、処理が終了できなければ待ち続けます。atomicでポインタを差し替えても、古いオブジェクトの寿命までは守れません。この二つを、後のコード例と合わせて確認してください。

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全27件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. 先に理解する三つの前提 ── 競合・待ち合い・RAII

2.1. 競合状態は実行順に依存し、C++のデータ競合は未定義動作になる

競合状態(race condition)は、複数スレッドがどの順に処理へ到達するかで結果が変わるバグです。共有カウンターの++countを「読み込み → 加算 → 書き戻し」と分けて考えると、二つのスレッドが同じ値を読み、一方の更新が消える様子が分かります。

共有カウンターの更新が失われる例二つのスレッドが同じ値を読み、それぞれ加算して書き戻すと更新が失われる様子を模式的に示す。countは10AもBも10を読むそれぞれ手元で11へ加算Aが11を書き戻すBも11を書き戻すC++では結果はこれに限らない

図1: 加算が失われる模式図であり、C++の未定義動作の結果を限定するものではない。

これは競合を理解するための模式図です。C++で非atomicな同じメモリ位置を複数スレッドが扱い、少なくとも一方が書き込みで、必要な同期がない場合は、規格上のデータ競合による未定義動作になります。実際の結果が「加算が一回消える」だけに限定されるわけではありません。1

コンパイラはデータ競合がない前提で最適化します。そのため、条件判定や読み書きの並べ替え・統合などを含め、ソースコードから期待した動作を保証できなくなります。「古い値か新しい値のどちらかが読める」と考えてはいけません。volatile boolを停止フラグにしても、C++のスレッド間同期にはならないので、この問題は解決しません。15

2.2. デッドロックは「待つ相手」が輪になることで起きる

デッドロックは、互いが持つロックの解放を待ち、どちらも先へ進めなくなる状態です。スレッドAがロック1を保持してロック2を待ち、スレッドBがロック2を保持してロック1を待つだけで成立します。

二つのロックの循環待ち相手が保持しているロックを互いに待つと、どちらも先へ進めなくなる。ロック2を待つロック1を待つAはロック1を保持Bはロック2を保持

図2: 待ちの矢印が輪になると、相手が進むのを待ったまま停止する。

競合もデッドロックも、問題となる実行順が開発機では現れず、コア数や負荷の違う客先で現れることがあります。デバッガーやログ追加がタイミングを変え、再現しなくなることもあります。だから、正しく同期する以前に、同期が必要な場所を減らすことが重要です。

2.3. RAIIで、例外経路も含めた後片付けを構造にする

C++にはfinallyがない代わりに、オブジェクトの寿命とリソース管理を結び付けるRAII(Resource Acquisition Is Initialization)があります。ロックを取得したオブジェクトがスコープを抜けるときに解放する、スレッドを所有するオブジェクトの破棄時に合流する、という形です。1

通常終了だけでなく、早期returnや例外でスコープを離れる経路も、同じ後片付けに乗せます。以降のjthreadやロックラッパーは、この考え方を実装した道具として読むと、使い分けが分かりやすくなります。

3. 実行方法を選ぶ ── まずタスク、必要なときにスレッド

3.1. 一回の仕事・集合への処理・I/O待ちを分ける

「スレッドを自分で増やさない」という原則は、C++でも同じです。まず仕事の形を見て、それを表す道具を選びます。

仕事の形 主な選択肢 最初に確認すること
一回の非同期処理と結果の受け取り std::asyncstd::future 起動ポリシーとfutureの寿命
コレクションへの並列処理 PPL、C++17の並列アルゴリズム 一反復の仕事量と例外の扱い
寿命を持つワーカー C++20のstd::jthread 停止要求を観測する場所とjoin
I/O待ち WindowsならOVERLAPPED I/OやIOCP スレッド増設ではなく非同期I/Oの利用
実行方法を選んでから寿命を決める仕事の形に合った実行方法を選び、その方法に応じた結果と停止の管理を決める。単発・集合処理長く動く処理I/Oを待つ仕事の形を確認何を実行するかタスクや並列アルゴリズムワーカーの寿命を管理非同期I/Oを検討結果・例外・終了を設計

図3: スレッドを増やす前に、仕事の形と終了を管理する持ち主を決める。

I/Oを待つためだけにスレッドを増やすのは避けます。Windowsネイティブコードでの受け皿となるOVERLAPPED I/OやIOCPは、「Windows I/Oの深層 第2回」で扱っています。

3.2. asyncは「開始の条件」と「結果を持つ期間」をセットで決める

std::asyncは手軽ですが、戻り値のfutureを捨ててはいけません。注意点は二つあります。6

一つ目は、破棄時の待機です。 std::launch::asyncで起動した仕事が未完了のまま、その共有状態を最後に持つfutureやshared_futureを破棄すると、完了待ちが発生します。戻り値をその場で捨てれば、非同期にしたつもりでも、その式の終わりで待つため直列実行と同じ形になります。

二つ目は、遅延実行です。 起動ポリシーを指定しなければ、処理系がdeferredを選ぶことがあります。その場合、誰もget()wait()を呼ばなければ、仕事は実行されません。確実に別スレッドで実行したい箇所はstd::launch::asyncを明示し、futureを誰がいつまで持つか決めます。

asyncの起動方針とfutureの寿命asyncで起動した場合の破棄時待機と、deferredで待機しなければ実行されない場合を区別する。asyncdeferredstd::asyncを呼ぶ選ばれた起動方針別スレッドで実行最後のfutureを破棄未完了なら完了待ちgetやwaitまで実行を遅延呼ばなければ未実行

図4: 起動ポリシーだけでなく、futureをいつまで保持するかで動作が変わる。

3.3. 並列ループは粒度と例外境界を確認する

PPL(Parallel Patterns Library)のconcurrency::parallel_forparallel_for_eachは、集合の要素へ処理を並列に適用する道具です。ただし、一反復の仕事が小さすぎると、フォーク・ジョインやスケジューリングのオーバーヘッドが利得を上回ります。並列化はできるだけ外側のループで表すのが原則です。7

C++17の並列アルゴリズムではstd::execution::parを使えます。MSVCは主要なアルゴリズムを並列化していますが、すべてのアルゴリズムが常に並列に走るという意味ではありません。8

また、標準の実行ポリシーを使うアルゴリズムの要素処理から例外が漏れると、std::terminateにつながります。必要なtry/catchは呼び出し元だけでなく、要素処理のコールバック内に置きます。6章で扱うスレッド関数の例外境界と同じ考え方です。

3.4. スレッドを所有するなら、joinを例外経路でも保証する

std::threadは、joinもdetachもされずjoinableなまま破棄されるとstd::terminateを呼びます。スレッド関数の仕事が終わったかどうかとは別に、オブジェクト側で合流を済ませる必要があります。9

次は、その落とし穴を示す例です。

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

末尾にjoin()を書くだけでは、途中の例外経路を守れません。std::threadを使うならtry/catchやRAIIで合流を保証します。C++20を使えるなら、std::jthreadを既定にすると、joinableな場合にデストラクタが停止要求を出してからjoinしてくれます。MSVCではVisual Studio 2019 16.9以降で<stop_token>jthreadが利用できます。210

スレッドオブジェクトの破棄と合流joinableなthreadの破棄はterminateにつながり、jthreadの破棄は停止要求とjoinを行う。いいえはいthreadjthreadスレッドオブジェクトを破棄joinableか合流する対象なし型は何かstd::terminate停止要求を出してjoin処理自身が終了する必要あり

図5: jthreadは合流を自動化するが、仕事の強制終了は行わない。

ここで自動化されるのは、所有側の停止要求と合流です。スレッド関数から漏れる例外を受け止めたり、止まらない処理を強制終了したりする機能ではありません。6章で、その二つを別に設計します。

detach()は原則として使いません。合流手段を失うため、静的変数やヒープの破棄とスレッドの実行が競合し、終了時クラッシュにつながります。「終わりを待てる」ことを設計の基本要件にしてください。

4. 共有を減らす ── 分割・値渡し・不変化・キュー

4.1. 共有の合計ではなく、スレッドごとの小計を持つ

競合の原因になる共有可変状態は、ロックを足す前に減らせます。並列集計なら、全スレッドが共有の合計値を更新する代わりに、それぞれがローカルな小計を作り、最後に一回だけ合流します。

共有への書き込みが「反復ごと」から「スレッドごとに一回」へ減るので、同期コストも競合しうる箇所も小さくできます。合流時の更新には、std::mutexでもstd::atomicfetch_addでも使えます。

スレッド別の小計から最後に合流する共有合計を毎回更新せず、スレッドごとの小計を最後に一度だけ合計へ反映する。入力を分割スレッドAの小計スレッドBの小計最後に同期して合流共有する合計値

図6: 反復中は専有データを更新し、共有への書き込みを合流時へ集める。

4.2. 値で渡す。ただし、コピー先の参照先まで確認する

起動時に必要なデータをコピーまたはムーブで渡し、その仕事だけのデータにすれば、以後の同期を減らせます。ラムダは[&]に頼らず、明示キャプチャで、原則コピーかムーブにします。参照キャプチャを使う場合は、参照先がスレッドより長く生存することが必要です。

ただし、ポインタをコピーしても、指しているオブジェクトは専有になりません。生ポインタやshared_ptrを含む構造体には、別名(エイリアス)を通じた共有が残ります。「値をコピーしたから安全」と判断できるのは、参照先も含めて共有可変状態を持たない、深い値のグラフになっている場合です。

ポインタをコピーしても参照先は共有されるポインタを含む値をコピーすると、二つの変数は別でも参照先のオブジェクトは同じままになる。ポインタ値をコピー元の値にあるポインタ同じ参照先オブジェクトコピー先のポインタ書き換えるなら同期が必要

図7: コピーした値だけでなく、そこから参照するオブジェクトまで確認する。

4.3. constで共有するなら「誰も変更しない」状態を作る

設定・マスターデータ・計算の入力のように、構築後に変更しないものは、読み取り専用で共有できます。例えばstd::shared_ptr<const Config>は、そのハンドルからの変更を禁じます。

しかし、別の場所に非constの参照が残っていたり、mutableメンバーが変更されたりすれば、競合は残ります。構築が終わったら非constの参照を手放し、以後誰も書き換えないところまでを設計に含めます。

変更が必要なときは、既存オブジェクトを書き換えるのではなく、新しいものを作って差し替える方針にします。ただし、差し替えるポインタ自体の同期と、古いオブジェクトの寿命管理は別に必要です。5.4節で確認します。

4.4. キューには容量上限と、停止できる待機を用意する

スレッド間の受け渡しは、共有変数を直接触り合うよりも、プロデューサーとコンシューマーのキューに寄せます。ここで扱うC++17/20の標準ライブラリにはチャネルがないため、mutexと条件変数を組み合わせた小さなキューが基本形です。

次の例はC++20を前提にします。std::stop_tokenを受け取るwaitを使うので、条件変数はstd::condition_variable_anyです。PushPopの戻り値で、停止要求を観測して処理をやめたことを伝えます。

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_;
};

このコードで見るべき点は、次の三つです。

確認点 コード上の対応 防ぐ問題
キューが満杯になったらどうするか 容量上限を設け、Pushで空きを待つ。容量0は拒否する 生産が消費を上回り、メモリが増え続けること
待機から戻ったら何を確認するか waitにキューの状態を調べる述語を渡す 通知なしの復帰や、起床時に条件が変わっていること
止めるときに待機から戻れるか stop_token対応のwaitを使い、操作前にも停止を確認する 空のキューや満杯のキューを待ったまま終了できないこと

満杯時に生産側を待たせることが背圧(バックプレッシャー)となり、過負荷を上流へ伝えます。また、条件変数には通知なしに目覚めるスプリアスウェイクアップがあるため、待機は述語付きにします。述語付きwaitが、条件を再確認するループを代行します。4

容量上限のあるキューと待機解除生産側は満杯なら空きを待ち、消費側は空なら要素を待ち、停止要求も両方の待機へ届く。生産側満杯なら空きを待つ容量上限のあるキュー空なら要素を待つ消費側停止要求

図8: 容量上限が背圧となり、停止要求が生産側・消費側の待機にも届く。

この例は、停止を観測したら残りをすべて処理するのではなく、次の投入・取得をやめる方針です。ただし、停止要求と、すでに進行中の操作を一つの不可分な処理にしているわけではありません。停止確認を通過した直後に要求が来た操作まで巻き戻す保証はなく、処理中の仕事は6章の協調停止で扱います。

例は同期と停止の骨格です。必要なヘッダーや業務固有の型は組み込み先で用意し、要素のムーブやキュー操作が失敗した場合も、6章の例外境界で扱います。

5. 残った共有を守る ── ロックの規律とatomicの範囲

5.1. ロックはコードではなく、守るデータに対応させる

共有可変状態をゼロにできない場合は、守るデータの集合ごとにmutexを対応させ、すべてのアクセスで同じmutexを取るようにします。mutexは外部へ公開せず、データと一緒にprivateメンバーとして持つのが基本です。1

ロック中は、守るデータの読み書きだけを短く行います。ファイルI/O、ネットワーク呼び出し、コールバックのような外部コードをロック中に呼ぶと、保持時間が伸びるだけでなく、呼び出し先のロックと待ち合う経路を作ります。ロックの外で準備し、中では差し替えだけを行う形を目指します。

ロックの外で準備し中で差し替える時間のかかる準備をロックの外へ出し、共有データの変更だけを短いロック区間で行う。ロックの外で準備RAIIでロックを取得共有データを変更スコープを抜けて解放外部への通知などを実行

図9: 守るデータとmutexを対応させ、外部処理を保持区間へ持ち込まない。

5.2. 取得と解放はRAIIへ任せ、複数ロックはまとめて取る

lock()unlock()を手書きで対応させると、早期returnや例外で解放を忘れます。取得と解放は、次のラッパーへ任せます。

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

複数のロックを別々に取るなら、全スレッドで取得順序を統一します。同時に必要なロックなら、std::scoped_lockにまとめて渡すと、取得時のデッドロック回避をライブラリへ任せられます。これは渡したmutex群の取得を扱う仕組みであり、ロック中の外部呼び出しなど、別の循環待ちまで防ぐものではありません。3

二つの口座を同時に守る例です。業務としての残高検査などではなく、ロックの取得方法に注目してください。

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;
}

先頭の同一性チェックは必須です。同じ口座を二つの引数へ渡すと、同じ再帰不可mutexを二回渡すことになり、ハングや未定義動作の原因になります。「両方ロックする」関数では、同一オブジェクトを除外します。

5.3. shared_mutexとrecursive_mutexは、用途を確認して選ぶ

読み取りが多く、書き込みがまれなデータには、C++17のstd::shared_mutexで読み書きロックを使えます。11

recursive_mutexは、同じスレッドによる再取得を許す型です。ただし、再帰取得が必要なのはロックの責務が曖昧になっているサインであることも多いため、型を替えて済ませる前に構造を見直します。

5.4. atomicな更新と、オブジェクトの寿命は別問題

std::atomicは、単一変数への不可分な操作と、memory_orderに基づく順序付けを提供します。カウンターやフラグの更新が主な出番です。複数の変数を一組として整合させたいときは、各変数をatomicにするだけでは足りないので、mutexでまとめて守ります。5

特にstd::atomic<T*>は、ポインタの差し替えを不可分にするだけで、参照先の寿命を延ばしません。読み手が古いポインタを取得した直後に、書き手が差し替えて古いオブジェクトをdeleteすると、読み手は解放済みメモリに触ります。

atomicなポインタ交換だけでは寿命を守れない読み手が取得した古いポインタは、書き手が交換後に削除すると参照先を失う。読み手が古いポインタを取得書き手がポインタを交換書き手が古い対象をdelete読み手が古い対象へアクセス解放済みメモリへのアクセス交換の不可分性だけでは不足

図10: ポインタの交換と参照先の寿命を、別々に保護する必要がある。

不変オブジェクトを差し替えて共有するなら、ロックで守ったstd::shared_ptr<const T>の付け替えや、C++20のstd::atomic<std::shared_ptr<T>>など、寿命管理を伴う手段を選びます。shared_ptrを選んでも、参照先のデータを自由に書き換えてよいわけではない点は、4.3節のとおりです。

また、volatileはここでも代用品になりません。業務アプリではatomicの既定のseq_cstを使うか、mutexで書きます。memory_orderを緩めるロックフリー設計は、必要性と検証手段を説明できる場合に限る、専門的な選択肢です。

6. 停止を完成させる ── 要求・待機解除・例外処理・join

6.1. request_stopは要求であり、join完了が停止の確認になる

レビューでは、起動方法より先に「どうやって止めるか」を説明できる必要があります。C++に、外からスレッドを安全に強制終了する手段はありません。Win32のTerminateThreadの危険性はC言語編で扱っています。

基本は協調停止です。止める側は要求を出し、スレッド自身が後片付けできる場所で終了し、持ち主がjoinの完了を確認します。C++20では、jthreadrequest_stop()が、スレッド関数へ渡したstop_tokenに要求を伝えます。2

計算ループではstop_requested()を確認し、待機中ならcondition_variable_any::wait(lock, st, pred)で停止要求を受け取ります。4章のキューは、空・満杯の待機でもこの経路で戻れる形です。停止要求によって待機を解除できることと、スケジューラーやロックの再取得を含めて一定時間内に停止が完了することは、同じではありません。

協調停止はjoinまでを一つの経路にする持ち主が停止を要求し、計算と待機がそれを観測して終了し、join完了で停止を確認する。持ち主が停止要求stop_tokenへ伝達計算中は要求を確認対応する待機を解除後片付けしてreturn持ち主のjoinが完了

図11: 要求を出した時点ではなく、join完了をもって停止を確認する。

6.2. Workerの例を、寿命と二重起動の観点で読む

次は4章のBlockingQueueを使うC++20のワーカーです。WorkItemProcessReportErrorは業務側で用意する型・関数です。特にReportErrorは例外を投げない実装にします。

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_;
};

Start()の先頭では、joinableなスレッドへの二重起動を拒否しています。確認せずに新しいjthreadを代入すると、新スレッドが走り出してから旧スレッドの停止を待つ間、二本が並走し得るためです。この確認は、複数の呼び出し元からの同時Start()を同期するロックではありません。起動と破棄は持ち主側で直列に管理する前提です。

メンバーの宣言順も寿命の一部です。例ではqueue_より後にthread_を宣言しているので、破棄はthread_が先です。その停止要求とjoinが完了してから、ワーカーが使うキューを破棄します。

Workerのメンバー破棄順とキューの寿命後に宣言されたjthreadを先に破棄し、合流完了後にワーカーが使うキューを破棄する。Workerを破棄後に宣言したthread_を破棄停止要求とjoinワーカーの終了を確認queue_を破棄

図12: ワーカーが参照するキューを、joinより先に破棄しない。

6.3. jthreadは、ワーカーから漏れる例外を受け止めない

自動joinと、スレッド関数の例外処理は別です。関数から例外が漏れれば、jthreadでもstd::threadと同様にstd::terminateにつながります。

例のtry/catchには二つの役割があります。

例外境界 対象 例の方針
内側 Processによる仕事一件の失敗 記録して次の仕事へ進む
外側 Popや要素のムーブを含む、ループ全体の失敗 最終防衛線として記録し、関数の外へ漏らさない
仕事一件とスレッド全体の例外境界仕事一件の失敗とキュー操作などの失敗を別の境界で受け止め、スレッド関数の外へ例外を漏らさない。一件の例外取得などの例外ワーカーループキューから取得仕事一件を処理内側で記録して続行外側で記録して終了記録処理も例外を投げない

図13: 自動joinに頼らず、スレッド関数の例外境界を明示する。

実際の業務では、一件失敗したら続行するのか、エラーチャネルで持ち主へ伝えて止めるのかを決めます。catchしたまま黙って捨てず、観測できるようにします。

6.4. Processの内部まで、停止できる経路を通す

Process(*item, st)にもトークンを渡すのは、キューを待っている時間だけでなく、仕事一件の処理中にも停止へ反応する必要があるからです。長い計算やネットワーク待ちが要求を観測しなければ、デストラクタの暗黙のjoinは、その処理が終わるまで待ち続けます。

協調停止は、待つ場所すべてへ停止の経路が届いて初めて成立します。中断できない外部呼び出しにはタイムアウトを付け、一件の実行時間に上限を設けます。停止トークンを引数へ追加しただけで、その外部APIが中断可能になるわけではありません。

6.5. C++17以前では、停止フラグと通知を一組にする

C++20の停止機構を使えない環境では、std::atomic<bool>の停止フラグと条件変数のnotify_allで同じ構図を組みます。待機側の述語にも停止フラグを含めます。フラグだけ立てて通知しなければ、待機中のスレッドが起きないためです。4

さらに、フラグがatomicでも、条件確認と待機開始の間で通知を取り逃さないための規律は必要です。停止状態の変更も待機側と同じmutexで調停し、状態を変えてから通知します。値のデータ競合を防ぐことと、起床通知を取り逃さないことを分けて確認してください。

7. Windowsへ組み込む ── 同期API・DLL・UIの境界線

7.1. 通常のC++は標準ライブラリ、Win32連携は要件から選ぶ

移植性を重視するC++コードでは、std::mutexstd::shared_mutexとRAIIを既定にします。Win32同期オブジェクトを選ぶ理由は、Win32の待機APIとの連携や、プロセスをまたぐ同期です。12

状況 選択
通常のプロセス内排他 std::mutex + RAII(既定)
読み取り多数・書き込みまれ std::shared_mutex
WaitForMultipleObjects で複数オブジェクトを同時に待ちたい Win32のイベント・Mutex等のカーネルオブジェクト
プロセスをまたぐ排他・通知 名前付きMutex・イベント・セマフォ
Win32 APIを直接使うプロセス内ロック SRWロック(再帰が必要なときだけCRITICAL_SECTION)12
標準同期とWin32連携の選択通常のC++コードは標準同期を既定にし、Win32待機やプロセス間同期が必要ならWin32オブジェクトを選ぶ。いいえはい同期の要件を確認Win32待機やプロセス間か標準mutexとRAIIを既定にWin32オブジェクトを選ぶ直接Win32ならSRWなど

図14: OSとの連携要件から選び、通常のプロセス内排他と混同しない。

プロセス内の通常の排他をWin32 Mutexへ置き換えるのは避けます。カーネル遷移を伴うため、その用途では余計なコストになります。直接Win32を使う新規コードならSRWロック、同一スレッドの再帰取得が必要な場合だけCRITICAL_SECTION、という線引きです。12

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

7.2. DllMainでは、起動・同期・終了待ちをしない

DllMainローダーロックを保持した状態で呼ばれます。そこで他スレッドと同期したり、終了を待ったり、LoadLibraryを呼んだりすると、デッドロックなどの原因になります。13

スレッドを起動・合流するような初期化や終了処理は、DllMainの外の明示的な関数へ出します。jthreadだから例外ではなく、自動joinがどこで起きるかまで確認します。

DLLのスレッド処理を明示的な関数へ分けるローダーロック中のDllMainでは同期せず、スレッドの起動と終了待ちを外側の関数へ分ける。DllMainローダーロック中起動・同期・joinを置かないDllMainの外の明示的な関数起動と停止・合流を管理

図15: スレッドオブジェクトの破棄による暗黙のjoinも、実行場所を確認する。

7.3. UI更新は作成スレッドへ依頼し、同期通知で待ち合わない

ウィンドウとコントロールの操作は、それを作成したUIスレッドへ集約します。ワーカーから直接画面を更新せず、非同期のPostMessageで依頼し、UI側のウィンドウプロシージャで処理するのを基本にします。

同期形のSendMessageは、UIスレッドがワーカーの完了を待っているときに呼ぶと循環待ちを作ります。ワーカーからの通知を非同期形にするのは、この経路を避けるためです。

UIとワーカーの同期通知による循環待ちUIがワーカー完了を待つ一方でワーカーがSendMessageの処理を待つと循環待ちになる。ワーカー完了を待つSendMessageの完了を待つUIスレッドワーカースレッドワーカーからの通知PostMessageで依頼UI側で処理

図16: UIへの依頼は非同期形を既定にし、相互の完了待ちを作らない。

COMが絡む場合のSTA/MTAの制約は、「COM STA/MTA の基礎知識」で確認してください。C++/CLIにも別の制約があり、/clrでコンパイルするコードでは<thread><mutex>などの標準スレッドヘッダーがブロックされます。14

8. 検証する ── 設計表・観測・ストレス試験の三層で備える

8.1. 動作結果より先に、共有と停止を説明できるか確かめる

通常のテストが通っても、その実行で競合しなかっただけかもしれません。最初の防衛線は、ここまでの設計です。レビューでは次を表にして確認します。

対象 説明できる必要があること
共有している可変データ 誰が読み書きするか、どのmutexが守るか
複数のロック 取得順序を統一したか、scoped_lockでまとめたか
渡したデータ コピー後にも別名が残らないか、寿命が足りるか
停止 どこで要求を観測し、待機を解除し、誰がjoinするか

この対応を説明できない設計は、動いていても完成ではありません。

8.2. タイムアウト・ログ・ダンプで、異常を見えるようにする

取れないはずのロックや終わらない待機には、timed_mutex::try_lock_forcondition_variable::wait_forなどのタイムアウトを検討します。時間切れをログへ残せば、無言のハングを検出できる失敗として扱えます。スレッド境界で捕捉した例外も、必ず記録します。

現場でハングやクラッシュが起きたら、ダンプから全スレッドのスタックを確認し、ロック待ちが循環していないかを追います。備え方は「Windowsアプリのクラッシュ時にログとダンプを残す設計」で扱っています。

8.3. リリースビルドでも、実行順と負荷を揺さぶる

コア数より多い並列度で長時間回す、処理順をランダム化する、人工的な遅延を入れる、といったストレス試験で、問題のある実行順を引きやすくします。デバッグビルドだけでなく、最適化したリリースビルドでも負荷をかけます。

設計と観測を用意してストレス試験する共有と停止を設計で確認し、ログとダンプで観測できるようにしてから負荷と実行順を変えて試す。共有・ロック・寿命・停止を確認ログとダンプを用意負荷と実行順を変えて試験見つかった問題を設計へ戻す

図17: 試験の成功だけを安全性の証明とせず、設計・観測・試験を組み合わせる。

試験は設計の代わりではありません。共有と寿命を構造で守ったうえで、異常を観測し、試験で弱い箇所を探すという順序です。

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

最後に、スレッドを直接増やさないこと、共有可変状態を減らすこと、ロックとデータを対応させること、協調停止することを、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と標準ライブラリの流儀に乗れば、後片付けや同期の失敗を構造から減らせます。

実行方法を選び、共有を減らし、残る共有を守り、停止要求からjoinまでを完成させる。 jthreadscoped_lock・述語付きwaitatomicを、この順序の中で使うことが実務の基本です。

関連記事

関連する相談領域

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

参考リンク

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

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

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

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

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

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

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

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

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

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

  11. 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)が整理されていることについて。 

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

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

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

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

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

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

よくある質問

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

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

ブログ一覧に戻る