更新履歴(4件・最終更新 2026年09月07日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- C言語とWin32 APIによるマルチスレッド設計の主張を維持し、目的別の案内、共有データとロックの規律、協調停止の適用範囲、失敗時の確認と検証手順を追いやすく整理した。
- 知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
- 知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
- 知識マップの関係を総点検し、説明文と食い違っていた関係(向きの逆転・過剰な一般化・述語の取り違え)を修正しました。本文の説明は変えていません。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.22054233)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「マルチスレッドの実務ベストプラクティス C言語編 ── Win32 APIの流儀で安全に書く」合同会社小村ソフト. https://doi.org/10.5281/zenodo.22054233 https://comcomponent.com/blog/multithreading-best-practices-c/
- DOI(最新版)
- 10.5281/zenodo.22054233
- DOI(この版)
- 10.5281/zenodo.22603973
「C製の常駐プロセスが、終了するときだけ固まる」「古い装置制御アプリにスレッドを追加したい」「TerminateThread で止めているが、ときどきプロセスごと動かなくなる」。Cでマルチスレッドを書く現場では、処理を並べることよりも、共有データを誰が守り、どう待ち、どう終わるかが問題になります。
Cには、C++のRAIIのようにスコープを抜けると自動でロックやハンドルを解放する仕組みがありません。例外やテンプレートによる支援もないため、APIを選ぶだけでは足りず、取得・解放・終了待ちの規律をコードの構造に落とし込む必要があります。
本記事はマルチスレッド実務シリーズのC言語編です。CとWin32 APIを使う開発者に向けて、スレッドの乱造を避ける、共有可変状態を減らす、ロックの規律を決める、止め方を最初に設計する、という原則を具体的なAPIへ落とし込みます。
扱うのは、_beginthreadex による作成、同期オブジェクトの選択、TerminateThread に頼らない協調停止、DllMainの制約です。説明の情報時点は2026年8月です。本編だけで読めますが、同じ原則を別の言語で扱う「.NET編」「C++編」「Java編」もあります。
困っていることから読む
| 困っていること | 最初に確認すること | 読む箇所 |
|---|---|---|
| スレッドを追加したい/短い仕事を大量に動かしたい | 自前スレッドとスレッドプールの使い分け | スレッドの作り方 |
| カウンターが合わない/共有データが壊れる | 共有を減らす設計と、ロック・原子操作の対応 | 共有状態を減らす・同期の選び方 |
| ロックを入れたら固まった | 保護するデータ、保持中の処理、取得順序 | ロックの規律 |
| 終了時に固まる/強制終了を使っている | 停止要求と終了確認を分け、待機中も停止できるか | 協調停止 |
| イベントで起こしているのに仕事が偏る | 単一ワーカーと複数ワーカーの通知方式の違い | 停止サンプルの適用範囲 |
| DLLのアンロードで止まる | DllMainの外で起動・停止・合流しているか | DllMainの制約 |
| Linuxとコードを共有したい/不具合を再現できない | 移植性の要件と、設計レビュー・ログ・負荷試験 | C11の選択肢・検証とデバッグ |
初めて設計する場合は、2章でCの前提を押さえ、3〜5章で実行単位と共有データを決め、6章で停止経路を確認してください。既存コードの点検には、最後のチェックリストも使えます。
1. まず結論
仕事の性質に合わせて、実行する場所を選ぶ
CRTを呼ぶ自前スレッドは _beginthreadex で作ります。CreateThread で作ったスレッドがCRTを呼ぶと、メモリ不足時にCRTがプロセスを終了させることがあります。短い仕事を大量に並列化するなら、自前スレッドを繰り返し作るのではなく、Windowsスレッドプールの CreateThreadpoolWork を使います。123
プールのスレッドを ExitThread や TerminateThread で終わらせてはいけません。コールバックとして借りた実行場所を、後片付けして返すという使い方です。4
共有データの保護と、待ち合わせを分ける
プロセス内のロックはSRWロックを既定とし、再帰取得が必要な場合にCRITICAL_SECTIONを選びます。高頻度のプロセス内排他にMutexを使うと、カーネル遷移のコストを負います。5
単一変数の更新にはInterlocked系関数を使います。volatile を付けるだけでは、原子性と必要な同期を確保できません。ほとんどのInterlocked関数はフルメモリバリアを伴います。待ち合わせには条件変数、またはイベントと待機関数を使い、Sleep のポーリングでCPUや応答性を無駄にしないようにします。67
止め方と解放の順序を、起動前に決める
TerminateThread は使わず、停止イベントと WaitForMultipleObjects による協調停止を設計します。強制終了はロック・ヒープ・DLLの状態を壊すおそれがあり、コード分析警告C6258の対象です。停止を要求しただけで解放へ進まず、スレッドの終了を確認してからハンドルを閉じます。89
DllMainではスレッドの作成・同期・終了待ちを行いません。ローダーロックの外に明示的な初期化・終了関数を用意します。10
移植性が必要ならC11も選択肢です。2026年8月時点の整理では、<threads.h> はVS 2022 17.8以降で使え、<stdatomic.h> はexperimentalです。Windows専用のコードでは、本記事のWin32 APIを使う方針を基本にします。11
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全23件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. なぜマルチスレッドは難しいのか ── 競合状態とデッドロック
最初に押さえたいのは、実行順序によって結果が変わる競合状態と、待ち合わせの循環で進めなくなるデッドロックです。APIを覚える前に、この2つを区別しておきます。
競合状態(race condition)は、複数のスレッドが特定のコードへ到達する順序によって、結果が変わってしまうバグです。
共有カウンターの count++ を考えると、1つの式でも、同期がなければ「読み込み → 加算 → 書き戻し」を不可分に実行できるとは限りません。2つのスレッドが同じ値を読み、それぞれ加算して書き戻すと、一方の加算が失われます。6 図1は、10から2回増やしたつもりなのに11になる実行順序を示しています。
sequenceDiagram
participant A as スレッドA
participant M as 共有変数 count
participant B as スレッドB
Note over M: count = 10
A->>M: 読み込み(10)
B->>M: 読み込み(10)
A->>A: 手元で加算(11)
B->>B: 手元で加算(11)
A->>M: 書き戻し(11)
B->>M: 書き戻し(11)
Note over M: 2回加算したのに count = 11<br/>スレッドAの加算が失われた
図1: 共有カウンターで加算が失われる典型的な競合状態。count++ の3ステップの間に別スレッドが割り込むと、後から書き戻したほうが上書きする
デッドロックは、2つのスレッドが互いに相手の持っているロックを待ち合って、どちらも先へ進めなくなる状態です。スレッドAがロック1を持ってロック2を待ち、スレッドBがロック2を持ってロック1を待つ ── これだけで両者は永遠に止まります。
flowchart LR
A["スレッドA<br/>ロック1を保持中"] -->|"ロック2の解放待ち"| B["スレッドB<br/>ロック2を保持中"]
B -->|"ロック1の解放待ち"| A
図2: デッドロックの循環待ち。待ちの矢印が輪を作った瞬間、輪の中の全スレッドが永遠に停止する
こうした不具合はタイミングに依存します。開発機ではめったに現れない実行順序が、コア数や負荷の違う客先では繰り返し現れることがあります。デバッガーを付けると再現しなくなるのも、観測によってタイミングが変わるためです。
そこで、以降の原則はすべて 「正しく同期する」より前に「同期が必要な場所を減らす」ことから始めます。
2.1. Cならではの前提 ── 言語は何も守ってくれない
この原則群は、Cでは「守らせる仕組み」が言語にないぶん、規律として明文化する必要があります。
解放は、関数の出口まで含めて設計する
C++のRAIIに相当する仕組みがないため、ロックの解放やハンドルの CloseHandle は自分で保証します。関数の出口を一本化する goto cleanup パターンや、取得と解放を対で書く規約を使います。途中に早期 return を追加しても、解放が抜けない構造にすることが要点です。
単純な読み書きがアトミックでも、同期は別に必要
データ競合を避けなければならない点はC++と同じです。Windowsでは、適切に整列された32ビット変数の単純な読み書きはアトミックですが、それだけでアクセスの同期や前後のメモリ操作の順序は保証されません。32ビットWindowsでの64ビット変数、読み出して加算する複合操作、複数変数の整合性も、単純な読み書きと同じには扱えません。12
「たまたま動いている」ことを根拠にせず、ロックやInterlockedで必要な同期を明示します。コンパイラや最適化レベルが変わっても、この規律は必要です。
所有権は、受け渡す時点まで書く
「このバッファはどのスレッドが書くのか」「いつから誰のものになるのか」を関数コメントに明記します。所有権の規律は、同期プリミティブの選択と同じくらい重要です。後述する分割処理やキューも、この境界が決まって初めて安全に使えます。
3. スレッドの作り方 ── _beginthreadex 一択
3.1. CreateThread がだめな理由
Win32のネイティブAPIは CreateThread ですが、CRT(Cランタイム)関数を呼ぶスレッドは _beginthreadex で作るのが公式の指針です。_beginthreadex は、CRTがスレッドごとに使う内部データを初期化してから実行を開始します。2
CreateThread で作ったスレッドがCRT関数を呼ぶと、メモリ不足時にCRTがプロセスを終了させることがあります。1 printf、malloc、strtok もCRTなので、実用上は「Cで書く自前スレッドは _beginthreadex」を基本にできます。
ハンドルを待って閉じるまでが、作成側の責任
_beginthread(exなし)も避けます。スレッドが早く終了すると、返されたハンドルが無効になり、別スレッドを指すおそれがあるためです。同期APIへハンドルを渡せる _beginthreadex を選び、呼び出し側で終了を待って CloseHandle します。13
次は作成と合流の流れを示す抜粋です。WorkerContext の定義、ctx の初期化、失敗処理はアプリ側で用意する前提で、単独でコンパイルする完成プログラムではありません。作成に失敗したら待機へ進まず、成功した場合もワーカーが使う ctx を終了確認まで有効に保ちます。
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... 5章・6章の待機ループ ... */
return 0;
}
HANDLE hThread = (HANDLE)_beginthreadex(
NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* 失敗処理 */ }
/* ...停止要求後... */
WaitForSingleObject(hThread, INFINITE); /* 合流 */
CloseHandle(hThread);
3.2. 短い仕事はWindowsスレッドプールへ
「小さな仕事を大量に投げたい」「短命なスレッドを何度も作っては壊している」場合は、Windowsスレッドプールを使います。Windows Vista以降のAPIでは、CreateThreadpoolWork で仕事オブジェクトを作り、SubmitThreadpoolWork で投入します。プールのワーカーがコールバックを実行するため、スレッド数の管理をOSに任せ、仕事ごとに自前スレッドを作成・破棄する必要をなくせます。3
借りたスレッドの状態を、戻る前に復元する
プールのスレッドを TerminateThread / ExitThread で終わらせないこと、コールバックで変更したTLSやスレッド優先度などを戻る前に復元すること、待機ハンドルをプールが使い終わるまで有効に保つことが、公式の規律です。4
スレッド数の管理だけでは、投入量は抑えられない
プールにスレッド数の管理を任せても、未実行のコールバックが無制限に積み上がる設計は防げません。投入が処理を上回り続ける常駐プロセスでは、アプリ側にセマフォなどの入場制限や容量付きキューを設けます。
満杯のときに投入側が待つか、投入を拒否するかまで決めてください。過負荷をメモリ消費へ移し替えないための背圧であり、5章の有限バッファと同じ考え方です。
4. 共有可変状態を最小化する ── 分割・読み取り専用・受け渡し
同期プリミティブを選ぶ前に、複数スレッドが同じ可変データを触る場所を減らせないかを考えます。手段は、分割する、読み取り専用にする、キューで受け渡す、の3つです。
スレッドごとに分割し、最後に合流する
並列集計では、各スレッドが共有カウンターへ毎回書き込むのではなく、ローカル変数や専用バッファへ小計を作ります。最後に1回だけ InterlockedAdd などで合流させれば、共有への書き込みを「反復のたび」から「スレッドごとに1回」へ減らせます。
同期コストも競合する機会も減らせる方法です。2.1節で決めた「どのスレッドがこのバッファを所有するか」が、そのまま分割の設計になります。
初期化の完了後は、読み取り専用にする
起動時に構築し、以後書き換えない設定やテーブルは、初期化完了後に複数スレッドから読み取れます。境界を曖昧にせず、初期化を全スレッドの起動前に済ませるか、遅延初期化なら InitOnceExecuteOnce を使います。5
大切なのは「読み取り専用のつもり」ではなく、いつ初期化が終わり、いつから書き換えないかをコードで明確にすることです。
両側から触る共有変数を、キューでの受け渡しへ変える
スレッド間のデータの流れは、共有変数を両側から操作するより、プロデューサー/コンシューマーのキューに寄せます。CとWin32では、有限循環バッファーと SleepConditionVariableCS を組み合わせる公式実装例があります。7
容量に上限を設ければ、生産が消費を追い越したときに生産側が待つ、自然な背圧にもなります。
5. 同期オブジェクトの選び方とロックの規律
Win32の同期プリミティブは種類が多く、選択を誤ると性能も正しさも損ねます。公式の指針を一枚にまとめます。5
flowchart TB
S{"プロセスをまたいで<br/>同期する?"} -->|"はい"| Q2{"用途は?"}
Q2 -->|"排他"| MTX["名前付きMutex"]
Q2 -->|"同時アクセス数の制限"| SEM["名前付きセマフォ"]
Q2 -->|"できごとの通知"| EVT["名前付きイベント"]
S -->|"いいえ(プロセス内)"| Q3{"同一スレッドの<br/>再帰取得が必要?"}
Q3 -->|"はい"| CS["CRITICAL_SECTION"]
Q3 -->|"いいえ"| Q4{"移植性重視の<br/>C++コード?"}
Q4 -->|"はい"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"いいえ"| SRW["SRWロック(既定の選択肢)"]
図3: Win32同期プリミティブの選び方。最初の分岐は「プロセスをまたぐか」で、またがないのにカーネルオブジェクト(Mutex)を選ばないことが要点
| プリミティブ | スコープ | 特徴 | 使いどころ |
|---|---|---|---|
| SRWロック | プロセス内 | 高速(通常ユーザーモードで完結)、ポインターサイズ、再帰不可 | 新規コードの既定。AcquireSRWLockShared で読み取り共有も可能 |
| CRITICAL_SECTION | プロセス内 | 高速(スピン後カーネル待機)、再帰可 | 同一スレッドの再帰取得が必要な場合 |
| Mutex | プロセス内/プロセス間 | 常にカーネルオブジェクトで低速 | プロセスをまたぐ排他(名前付き)、WaitForMultipleObjects との併用 |
| セマフォ | プロセス内/プロセス間 | カーネルオブジェクト | 資源プールへの同時アクセス数の制限 |
| イベント | プロセス内/プロセス間 | カーネルオブジェクト | 「何かが起きた」の通知(データ保護には使わない) |
| Interlocked関数 | プロセス内(共有メモリならプロセス間も) | ロック不要の原子操作 | カウンター・フラグ・ポインタ差し替え6 |
カーネルオブジェクトは、プロセス間専用ではない
図3が示すのは、プロセス内のロックに、理由なくMutexを選ばないという要点です。プロセス内の通知にはイベント、同時数の制限にはセマフォを使えます。6章の停止イベントも、名前なしのカーネルオブジェクトです。
また、名前がないからといって、必ずプロセス内に限定されるわけではありません。ハンドルの継承や DuplicateHandle による複製で、同じオブジェクトを複数プロセスから使うこともできます。名前付けは、別プロセスが同じオブジェクトを開き直せるようにする代表的な手段のひとつです。
Interlockedは、操作・整列・寿命を分けて考える
単一変数への操作を、不可分にする
InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange は、単一変数への操作を不可分に行う関数です。.NET編の Interlocked クラス、C++編の std::atomic に対応する道具で、ほとんどの関数はフルメモリバリアも伴います。6
volatile を付けただけで原子性や必要な同期が得られる、と考えてはいけません。複数の変数をまとめて整合させる場合は、SRWロックやCRITICAL_SECTIONで保護します。
対象変数の整列を守る
Interlockedの対象は、自然な境界に整列している必要があります。32ビット値なら4バイト境界、64ビット値なら8バイト境界です。整列していない場合の動作は予測できません。12
#pragma pack した構造体や、通信フォーマットをそのままマップしたバッファのフィールドを対象にせず、カウンターやフラグはコンパイラが適切に整列させる通常の変数として用意します。
ポインタの差し替えは、古いデータの寿命を守らない
InterlockedExchangePointer などで不可分になるのは、ポインタの差し替えそのものです。読み手が古いポインタを取得した直後に、書き手が差し替えて古いブロックを free すれば、読み手は解放済みメモリへアクセスしてしまいます。
差し替えと回収は別の問題です。ロックや安全な参照カウント管理など、読み手が使い終わるまで古いブロックを回収しない手順が必要です。迷う場合はSRWロックで保護してください。
条件変数は、目覚めてから条件を確認する
有限バッファのプロデューサー/コンシューマーキューでは、条件変数を使います。InitializeConditionVariable で初期化し、消費側はCRITICAL_SECTIONと組み合わせた SleepConditionVariableCS で待ち、生産側は WakeConditionVariable で起こすのが公式実装例の形です。7 SRWロックと組み合わせる場合は SleepConditionVariableSRW を使います。
重要なのは、目覚めたらロック内で条件を再確認し、条件が偽なら再び待つループにすることです。通知なしで目覚めるスプリアスウェイクアップもあれば、起きた時点ですでに別の消費者が要素を取っていることもあります。「起こされた」と「キューが空ではない」は同じではありません。
.NET編4.3のチャネル、C++編4章の BlockingQueue と同じ構図を、Cで組むための道具です。
5.1. ロックの規律 ── どれを選んでも変わらない3原則
プリミティブを正しく選んでも、使い方の規律がなければ競合は防げません。
1. データとロックの対応を固定する
守る可変データの集合ごとに、SRWロックやCRITICAL_SECTIONを1つ対応させます。そのデータに触るすべての場所で、同じロックを取ります。
Cでは、ヘッダーに「この構造体は g_lockFoo が守る」と書く運用が特に役立ちます。レビューでも、この対応を表にして確認します。
2. ロック保持中に、時間のかかる処理や外部呼び出しをしない
ロックの中は、守っているデータの読み書きに絞ります。保持したままのファイルI/O、ネットワーク通信、コールバック呼び出しは、保持時間を延ばします。呼び出し先が別のロックを取得すると、図2の循環待ちも作り得ます。
3. 複数ロックの取得順序を固定する
2つ以上のロックを取る場合は、全スレッドが同じ順序に従うよう、ロック階層を定義します。DLLのベストプラクティス文書も、取得順序の逆転がデッドロックを招くことと、階層へ一貫して従う必要を説明しています。10
6. 止め方の設計 ── TerminateThread を使わない
6.1. TerminateThread が壊すもの
TerminateThread は、対象スレッドにユーザーモードの後片付けを実行させずに終了させます。保持していた状態は、安全な形で片付くとは限りません。8
| 終了させられたタイミング | 起こり得ること |
|---|---|
| クリティカルセクションを保持中 | ロックが解放されず、ほかのスレッドが待ち続ける |
| ヒープからメモリを確保中 | ヒープロックが残り、その後のメモリ確保が止まる |
| DLLのグローバル状態を操作中 | DLL内部の状態が壊れる |
公式には「最も極端な場合にのみ使うべき危険な関数」と位置づけられ、コード分析でも警告C6258として検出されます。89
「たまにプロセスごと固まる」アプリの原因調査で TerminateThread が見つかる、というのは実務で本当によくある光景です。見つけたら、それは修理対象です。
6.2. 正しい形: 停止イベント + WaitForMultipleObjects
協調停止では、止める側がスレッドを消すのではなく、停止を要求し、ワーカー自身に後片付けして終了してもらいます。手動リセットの停止イベントを1つ作り、ワーカーが仕事の合図と停止の合図を同時に待つ形が定石です。C6258の公式資料も、イベントを監視して自分で終了する方法を案内しています。9
停止要求 → ワーカーの後片付け → スレッド終了の確認 → ハンドルや共有資源の解放を別々の段階として扱ってください。停止イベントをシグナル状態にしただけでは、終了したことにはなりません。
次のコードも説明用の抜粋です。イベントの作成と失敗確認、キューの排他制御、ProcessNextItem・LogLastError・Cleanup の実装は省略しています。イベントとキューは待機・処理中に破棄せず、下で説明する単一ワーカー向けの仕事通知という前提で読んでください。
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): 手動リセット */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): 自動リセット。
受け取りで自動的に非シグナルへ戻る(手動リセットにすると
一度シグナルされた後は待機が素通りになり、空のキューを
回り続けるビジーループになる) */
static unsigned __stdcall WorkerMain(void* arg)
{
HANDLE waits[2] = { hStopEvent, hWorkEvent };
for (;;) {
DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (r == WAIT_FAILED) { /* ハンドル無効など。放置すると全力の空回り */
LogLastError(); /* GetLastError()を記録して脱出する */
break;
}
if (r == WAIT_OBJECT_0) /* 停止要求 */
break;
if (r == WAIT_OBJECT_0 + 1) { /* 仕事あり */
/* ProcessNextItemにも停止イベントを渡す: 1件の内部で長く待つ場合、
そこでも停止を観測できないとシャットダウンが1件に人質に取られる */
while (ProcessNextItem(hStopEvent)) { /* キューから1件処理。空ならFALSE */
/* 排出中も停止要求を確認する。これを怠ると、仕事が
積まれ続ける限り停止できない(停止の飢餓) */
if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
break;
}
}
}
Cleanup(); /* 自分の後片付けは自分でして */
return 0; /* 自分で終わる */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* 停止要求が届いていないなら、 */
LogLastError(); /* 無期限の合流に入ってはいけない */
return FALSE;
}
/* 全員に一斉に停止要求が立った */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* 合流を確認できた本だけ閉じる */
} else {
LogLastError(); /* WAIT_FAILED: ハンドル無効など */
ok = FALSE; /* 「全員止まった」とは報告しない */
}
}
return ok; /* FALSEなら共有資源の解放に進んではいけない */
}
合流は、待機の成功を確認してから先へ進む
StopWorkers は、まず SetEvent の成功を確認します。停止要求を届けられなかったのに、無期限の合流へ進んではいけません。各スレッドについても、WaitForSingleObject が WAIT_OBJECT_0 を返したハンドルだけを閉じ、待機に失敗した場合は「全員が止まった」と報告しません。戻り値が FALSE なら共有資源の解放へ進まない、という使い方です。
終了待ちを1本ずつにしているのは、WaitForMultipleObjects が一度に待てるハンドル数に MAXIMUM_WAIT_OBJECTS(64個) の上限があるためです。上限を超える配列では待機が WAIT_FAILED になり得ます。全員の終了を待つだけなら、1本ずつ待つループにはこの配列長の制約がありません。
flowchart TB
OWNER["止める側"] -->|"SetEvent(hStopEvent)"| SE["停止イベント<br/>(手動リセット: 全員に見える)"]
SE --> W1["ワーカー1:<br/>WaitForMultipleObjectsで<br/>停止と仕事を同時に待機"]
SE --> W2["ワーカー2:<br/>WaitForMultipleObjectsで<br/>停止と仕事を同時に待機"]
W1 --> C1["後片付けして自分でreturn"]
W2 --> C2["後片付けして自分でreturn"]
C1 --> J["止める側がスレッドハンドルを待って合流<br/>ここで初めて「止まった」と言える"]
C2 --> J
図4: 停止イベントパターン。停止用に手動リセットイベントを使うと、1回のSetEventで待機中の全ワーカーが一斉に目覚める。終わり方は各スレッドが自分で決め、合流の完了をもって停止とみなす
停止イベントの設定と順序を守る
停止イベントは手動リセットにして、1回の SetEvent を全ワーカーから見える状態にします。仕事の通知とは役割が違います。
また、WaitForMultipleObjects の待機配列では停止イベントを先頭に置きます。このサンプルのように bWaitAll が FALSE の待機では、複数のオブジェクトがシグナル状態なら、小さい添字のハンドルが優先されます。最後に、止める側はスレッドハンドルの合流を確認してから、そのハンドルを閉じます。
単一ワーカー: イベントで起こし、キューを排出する
上の「自動リセットイベントで起こして、キューを全件排出する」形は、ワーカー1本の構成向けです。自動リセットイベントは、複数回 SetEvent しても仕事の件数を数えません。シグナル状態は1つなので、連続した合図は合体します。
複数ワーカーにこの形をそのまま使うと、1本だけが起きて、まとまった仕事を直列に処理することがあります。
複数ワーカー: セマフォの許可1つと、仕事1件を対応させる
キューを複数ワーカーで分担するなら、仕事の合図をセマフォへ替えます。生産側は1件積むごとに ReleaseSemaphore(hSem, 1, NULL) で許可を増やし、消費側は待機成功1回につき1件だけを取り出します。待機成功で許可が1つ消費されるため、件数との対応を保てます。
このとき、サンプルの全件排出ループを残してはいけません。許可を1つしか消費していないのにキューを空にすると、残った許可で別ワーカーが空のキューに対して起きたり、生産側の ReleaseSemaphore が上限超過で失敗したりします。「許可1つ=仕事1件」が前提です。
1件の処理が長くても、停止を観測できるようにする
項目間で停止を確認するだけでは不十分です。ProcessNextItem の内部で長いブロッキング待ちをするなら、そこへも停止イベントを渡して同時に待つか、有限のタイムアウトを設けます。
そうしないと、1件が終わらないために、シャットダウンがいつまでも待つことになります。.NET編の StopAsync、C++編の jthread とjoinも同じ考え方です。
ブロッキングI/Oには、I/O側の停止経路も用意する
パイプ・ソケット・シリアルポートのI/Oで待っているスレッドは、そのままでは停止イベントを確認しに戻れません。OVERLAPPED とイベントの重ね合わせ待機や、CancelIoEx によるI/Oのキャンセルを含め、I/O側にも待機を終える経路が必要です。
具体例は「シリアル通信アプリの落とし穴」で扱っています。
7. DllMain とローダーロック ── DLLを書くときの地雷原
Cで書く共有部品はDLLになることが多く、そこにはローダーロックという固有の制約があります。DllMain はOSのローダーがローダーロックを保持したまま呼び出すため、その中で次のことをすると、デッドロックまたはクラッシュの原因になります。10
- 他スレッドとの同期(ロック取得・スレッドの終了待ち)
LoadLibrary/FreeLibraryの呼び出し(直接・間接を問わず)- スレッドの作成(同期を伴えば危険)や
ExitThread
DllMainの外に、明示的な終了関数を用意する
「DLLのアンロード時に、DllMainでワーカーの終了を待つ」は、一見正しそうですが典型的なデッドロックです。終了するスレッドも DLL_THREAD_DETACH の配送にローダーロックを必要とし、DllMainと互いに待ち合うためです。10
スレッドを持つDLLは、MyLib_Init / MyLib_Shutdown のような初期化・終了関数を公開し、スレッドの起動と合流をDllMainの外で行います。呼び出し側は終了関数で停止を確認した後にアンロードします。DllMain自体は、できるだけ空に近いスタブにする設計です。10
8. C11 スレッドという選択肢 ── 現状整理
Win32へ依存せず、ほかのOSとコードを共有したい場合は、C11の <threads.h> と <stdatomic.h> が選択肢になります。2026年8月時点のMSVC対応を分けて見ると、次のとおりです。11
| 機能 | MSVCでの位置づけ | 確認する条件 |
|---|---|---|
<threads.h> (thrd_create / mtx_lock / cnd_wait) |
Visual Studio 2022 17.8でサポート | /std:c11 と対応するWindows SDK |
<stdatomic.h> |
experimental | /experimental:c11atomics オプション |
スレッドとアトミック操作を、まとめて同じ対応段階だと考えないことが重要です。
Linuxとのコード共有が要件ならC11スレッド(またはpthreadラッパー)に価値がありますが、Windows専用のコードベースであれば、本記事のWin32流儀のほうが情報量・実績・デバッグのしやすさで有利です。いずれを選んでも、ここまでの設計原則(共有を減らす、ロックとデータの対応、協調停止)は変わりません。
9. 検証とデバッグ ── 「再現しない」前提で備える
通常のテストが成功しても、競合がないとは言い切れません。「たまたま問題のある実行順序にならなかった」可能性が残るためです。設計を確認する、異常を観測できるようにする、負荷で実行順序を揺さぶる、という3層で備えます。
設計レビュー: データ・ロック・停止経路を対応させる
共有する可変データ、それを守るロック、複数ロックの取得順序、停止イベントが届くワーカーを表にして確認します。5.1節の規律が実際のコードへ対応しているかを見る作業です。
この対応が書けない状態では、「動いている」ことを設計完了の根拠にできません。
観測: タイムアウト・ログ・ダンプで、待ちの場所を残す
待機をすべて無条件の INFINITE にするのではなく、要所にタイムアウトを設け、時間切れをログへ残します。待ち続けるだけだった状態を、調査できる失敗として扱うためです。ただし、タイムアウトしたことはスレッドの終了確認ではなく、それだけで共有資源を解放してよいわけではありません。
ハング時にはダンプを採取して全スレッドのスタックを見て、ロック待ちが循環していないか確認します。DLL周辺の誤りには、公式に推奨されるApplication Verifierも使います。10 ログとダンプの整備は「Windowsアプリのクラッシュ時にログとダンプを残す設計」を参照してください。
負荷試験: 開発機で現れにくい実行順序を試す
コア数より多いスレッドで長時間回す、処理順をランダム化する、人工遅延を挿入する、といったストレステストで、不具合が現れる機会を増やします。最適化されたリリースビルドと高負荷の組み合わせも確認してください。
負荷試験は設計レビューの代わりではありません。3層を組み合わせ、再現したときに原因を追える状態へ近づけます。
10. まとめ ── C言語版チェックリスト
- スレッド作成はすべて
_beginthreadexか(CreateThread/_beginthreadが混ざっていないか) - スレッドハンドルは合流(
WaitForSingleObject)してからCloseHandleしているか - 短命な仕事に自前スレッドを乱造していないか(スレッドプールAPIに投げられないか)
- プロセス内の排他がSRWロック/CRITICAL_SECTIONになっているか(Mutexを誤用していないか)
- 共有カウンター・フラグは
volatile頼みでなくInterlocked系になっているか Sleepポーリングが残っていないか(条件変数・イベント待機に置き換えたか)TerminateThread(他スレッドの強制終了)が1か所もないか。ワーカーはExitThreadの呼び出しではなくスレッド関数からのreturnで終えているか(CRTの後始末が_endthreadex経由で正しく走る)- 停止イベント+
WaitForMultipleObjectsの停止経路が全ワーカーにあるか、ブロッキングI/O中のスレッドも起こせるか - ロック・ハンドルの解放が全リターン経路で保証されているか(
goto cleanupの規律) DllMainでスレッドの作成・同期・終了待ちをしていないか
言語の支援がない代わりに、CのマルチスレッドはAPIの選択と規律がそのまま品質になります。_beginthreadex・SRWロック・Interlocked・停止イベント ── この4点セットを既定にすれば、Cでも「たまに固まる」から距離を取った設計ができます。
関連記事
- マルチスレッドの実務ベストプラクティス .NET編
- マルチスレッドの実務ベストプラクティス C++編
- マルチスレッドの実務ベストプラクティス Java編
- 共有メモリの落とし穴と実務ベストプラクティス
- WindowsでSleep(1)よりイベント待機を優先すべき理由
- シリアル通信アプリの落とし穴
- Windowsアプリのクラッシュ時にログとダンプを残す設計
関連する相談領域
合同会社小村ソフトでは、C製の常駐プロセス・装置制御アプリ・DLLのマルチスレッド設計レビュー、TerminateThreadやロック漏れに起因するハング・クラッシュの原因調査(ダンプ解析)、レガシーCコードへのスレッド追加の技術相談を扱っています。
参考リンク
-
Microsoft Learn, CreateThread function. CRTを呼ぶ実行ファイル内のスレッドはCreateThread / ExitThreadではなく_beginthreadex / _endthreadexで管理すべきこと、CreateThreadで作られたスレッドがCRTを呼ぶと低メモリ状態でCRTがプロセスを終了させることがあることについて。 ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32. CRTライブラリを呼ぶプログラムではスレッドを_beginthread / _beginthreadexで開始すべきでWin32のCreateThread / ExitThreadを使わないこと、_beginthread系がCRTのスレッドごと変数を初期化すること、SuspendThreadがCRT内部データ構造へのアクセス中のスレッドを止めてデッドロックを招きうることについて。 ↩ ↩2
-
Microsoft Learn, CreateThreadpoolWork function. CreateThreadpoolWorkで仕事オブジェクトを作成し、SubmitThreadpoolWorkを呼ぶたびにプールのワーカースレッドがコールバックを実行すること、コールバック環境(TP_CALLBACK_ENVIRON)で実行環境を指定できること、Windows Vista以降で利用できることについて。 ↩ ↩2
-
Microsoft Learn, Thread Pools. スレッドプールが短い仕事を大量に非同期実行するアプリや短命スレッドを頻繁に作るアプリに適すること、Vistaで再設計された新スレッドプールAPIの構成要素、ベストプラクティスとしてプールのスレッドをTerminateThreadで終了させたりコールバックからExitThreadを呼んだりしないこと、コールバックで作った状態を戻る前に後片付けすること、待機ハンドルをプールが使い終わるまで生かしておくことについて。 ↩ ↩2
-
Microsoft Learn, About Synchronization. Win32同期プリミティブの選択指針として、SRWロックが新規コードの既定でポインターサイズかつ通常ユーザーモードで完結すること、CRITICAL_SECTIONは再帰取得が必要な場合に使うこと、Mutexは常にカーネルオブジェクトでプロセス間の名前付き同期とWaitForMultipleObjectsとの併用に使うこと、プロセス内同期にMutexを使うのは高頻度操作で大幅に低速になる「よくある間違い」であること、セマフォが資源プールの同時アクセス数制限に・イベントが通知に使われることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. Interlocked関数が複数スレッドで共有される変数へのアクセスを同期し操作を不可分に行うこと、InterlockedIncrement / Decrementが読み込み・加算・書き戻しを1つの原子操作に束ね、同期なしでは2スレッドの同時インクリメントが1回分失われうること、InterlockedExchange / InterlockedCompareExchange等の関数群、共有メモリ上の変数なら異なるプロセスのスレッド間でも使えること、ほとんどのInterlocked関数がフルメモリバリアを提供しAcquire / Release版で順序セマンティクスを選べることについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables. CRITICAL_SECTIONで保護した有限循環バッファーによるプロデューサー/コンシューマーキューの実装例、InitializeConditionVariableで条件変数を作り、コンシューマーがSleepConditionVariableCSで待機しWakeConditionVariableで相手を起こす構造、条件変数がWindows Vista以降でサポートされることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. TerminateThreadが対象スレッドにユーザーモードコードを一切実行させずに終了させること、対象がクリティカルセクションを保持していれば解放されないこと、ヒープからのメモリ確保中ならヒープロックが解放されないこと、kernel32の状態やDLLのグローバル状態が破壊されうること、「最も極端な場合にのみ使うべき危険な関数」であり対象スレッドが実行しうるコードを完全に把握・制御している場合以外は呼ぶべきでないことについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Warning C6258. コード分析警告C6258がTerminateThreadの使用を検出すること、TerminateThreadでは適切なスレッドクリーンアップができないこと、正しい終了方法としてCreateEventでイベントを作り、各スレッドがWaitForSingleObjectでイベント状態を監視し、シグナル状態になったら自分で実行を終える手順が示されていることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Dynamic-Link Library Best Practices. DllMainがローダーロック保持中に呼ばれるため呼べるAPIに重大な制約があること、DllMain内での他スレッドとの同期がデッドロックを招くこと、LoadLibrary呼び出しが禁止事項であること、DLLアンロード時にDllMain内でスレッド終了を待つと終了スレッドのDLL_THREAD_DETACH配送と互いに待ち合ってデッドロックする構図、理想のDllMainが空に近いスタブであり初期化はできる限り遅延させるべきこと、ロック階層を定義しローダーロックを最上位に置くべきことについて。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. C標準ライブラリ機能の対応表で、C11スレッド(threads.h)がVisual Studio 2022 17.8でサポートされたこと、stdatomic.hがexperimental扱いであること(/experimental:c11atomicsオプション)、C11 / C17のコンパイラ対応にはVisual Studio 2019 16.8以降と対応するWindows SDKが必要なことについて。 ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access. 適切に整列された32ビット変数の単純な読み書きはアトミックだがアクセスの同期(順序)は保証されないこと、64ビット変数の単純な読み書きは64ビットWindowsではアトミックだが32ビットWindowsでは保証されないこと、その他のサイズの変数はどのプラットフォームでもアトミック性が保証されないことについて。 ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex. _beginthreadexが_beginthreadより安全である理由として、_beginthreadで作ったスレッドが早く終了すると返却済みハンドルが無効になり別スレッドを指しうること、_beginthreadexのハンドルは呼び出し側がCloseHandleで閉じる必要があり有効性が保証されること、_beginthreadexなら同期APIにハンドルを渡せること、スレッド関数が__stdcall規約でスレッド終了コードを返すこと、マルチスレッド版CRTへのリンクが必要なことについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
マルチスレッドの実務ベストプラクティス C++編 ── RAIIとjthreadで事故を構造から消す
C++のマルチスレッドはデータ競合が未定義動作になる世界。std::threadのデストラクタの罠、jthreadとstop_tokenによる停止設計、scoped_lockのデッドロック回避、atomicの正しい位置づけ、Win32同期APIとの使い分けまで整理します。
マルチスレッドの実務ベストプラクティス .NET編 ── スレッドを増やす前に決めておくこと
「スレッドを立てたら、たまに落ちる・固まる」を防ぐ設計の定石を.NET/C#向けに整理。スレッドを自分で作らずTaskに乗る、共有可変状態を減らす、ロックの規律、CancellationTokenでの停止設計、UIスレッドの扱いまで解説します。
スリープ復帰で壊れるアプリ ── 電源イベントの仕組みと復帰に強い業務アプリの作り方
ノートPCを開いたら業務アプリの通信が切れていた――原因はスリープを想定しない設計です。WM_POWERBROADCASTによる通知の流れ、Modern Standbyの挙動、切断・再接続の設計、スリープ抑止と調査コマンドまでを一次情報で解説します。
DllMainとローダーロック ── 「DLLの初期化で何もするな」と言われる本当の理由
DllMainでLoadLibraryやスレッド同期をしてはいけないのはなぜか。全DLL通知を直列化するローダーロックの仕組みから、デッドロックが成立する典型シナリオ、遅延初期化などの正しい設計、ハング調査の手順までを一次情報で解説します。
「応答なし」の正体 ── Windowsがアプリを「固まった」と判定する仕組みと、固まらない設計
Windowsの「応答なし」は、ウィンドウが5秒間メッセージを取り出さないとOSが判定し、幽霊ウィンドウに差し替える仕組みです。判定の内部動作から、固まる定番原因、UIスレッドから重い処理を追い出す設計、ハングの調査手順まで解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
不具合調査 / 長期稼働テーマ
再現しにくい不具合、通信停止、長期稼働障害、失敗パス検証を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
不具合調査・原因解析
再現しにくい障害、長期稼働後の不具合、通信停止などの調査を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- CreateThread と _beginthreadex はどちらを使うべきですか?
- C ランタイムライブラリ(CRT)の関数を呼ぶスレッドなら _beginthreadex です。_beginthreadex は CRT がスレッドごとに必要とする内部データを初期化してからスレッドを開始します。CreateThread で作ったスレッドが CRT 関数を呼ぶと、CRT がメモリ不足時にプロセスを終了させることがあると公式ドキュメントに明記されています。実質的に、C で書くアプリのスレッドはほぼ確実にどこかで CRT 関数(printf、malloc、strtok など)を呼ぶので、「常に _beginthreadex」と覚えて差し支えありません。なお _beginthread(exなし)は、スレッドが早く終了するとハンドルが無効になる罠があるため、こちらも _beginthreadex を選びます。
- TerminateThread でスレッドを止めてはいけないのですか?
- いけません。TerminateThread は対象スレッドにユーザーモードコードを一切実行させずに消し去るため、そのスレッドがクリティカルセクションを持っていれば解放されず、ヒープからメモリ確保中ならヒープロックが握られたままになり、DLL のグローバル状態を操作中なら DLL の状態が破壊されます。公式ドキュメントは「最も極端な場合にのみ使うべき危険な関数」と明記しており、コード分析でも警告 C6258 として検出されます。正しい停止は、停止用イベントを作り、各スレッドが WaitForSingleObject / WaitForMultipleObjects でそのイベントを監視して、自分で後片付けをして終わる協調停止です。
- プロセス内の排他に Mutex を使っていました。何がまずいのですか?
- 動作はしますが、性能を大きく損ねています。Win32 の Mutex は常にカーネルオブジェクトなので、取得・解放のたびにカーネルモード遷移が発生します。プロセス内の排他であれば、ユーザーモードで完結する(競合時のみカーネル待機に落ちる)SRW ロックか CRITICAL_SECTION が大幅に高速で、公式ドキュメントもプロセス内同期に Mutex を使うことを「よくある間違い」と明記しています。Mutex の出番は、名前付きオブジェクトとしてプロセスをまたぐ排他が必要な場合と、WaitForMultipleObjects で他のカーネルオブジェクトと同時に待ちたい場合です。
- C11 の threads.h や stdatomic.h は Windows で使えますか?
- MSVC では C11 スレッド(threads.h)が Visual Studio 2022 17.8 でサポートされました(/std:c11 と対応する Windows SDK が必要)。一方 stdatomic.h は experimental 扱いで、/experimental:c11atomics オプションが必要な段階です(2026年8月時点の公式対応表による)。移植性を最優先する場合の選択肢にはなりますが、Windows 専用のコードベースであれば、実績と情報量の面で Win32 API(_beginthreadex、SRW ロック、条件変数、Interlocked)で書くのが現実的です。
- volatile を付ければ共有フラグは安全になりますか?
- なりません。C の volatile はコンパイラの最適化(レジスタへのキャッシュ等)を抑止するだけで、操作の原子性も、プロセッサ間のメモリ順序も保証しません。適切に整列された32ビット変数の単純な読み書きは Windows ではそれ自体アトミックですが、「読んで足して書き戻す」は分割されますし、前後のメモリ操作との順序も規定されません。共有カウンターやフラグの更新には Interlocked 系関数を使ってください。ほとんどの Interlocked 関数はフルメモリバリアを伴うため、順序の保証も同時に得られます。複数の変数をまとめて守る場合は SRW ロックまたは CRITICAL_SECTION です。