「装置制御の常駐プロセスをCで書いている」「20年もののC製アプリにスレッドを足すことになった」「TerminateThread で止めているが、たまにプロセスごと固まる」── C言語でのマルチスレッドは、言語からの支援がもっとも少ない世界です。例外もRAIIもテンプレートもなく、同期の正しさはすべてAPIの選択と呼び出し規律にかかっています。
本記事はマルチスレッド実務シリーズのC言語編です。C×Win32 APIで書く開発者を対象に、マルチスレッド設計の原則 ── スレッドの乱造をやめる、共有可変状態を減らす、ロックの規律、止め方を最初に設計する ── をWin32の道具に落とし込み、スレッドの作り方(_beginthreadex)、同期オブジェクトの選び方、TerminateThread を使わない停止設計、DllMainの制約まで、2026年8月時点の一次情報にもとづいて整理します。本編単体で読めるように書いています。同じ原則を別の言語で展開した「.NET編」「C++編」「Java編」もあります。
1. まず結論
- スレッドの作成は
CreateThreadではなく_beginthreadexです。CRTを呼ぶスレッドをCreateThreadで作ると、CRTがメモリ不足時にプロセスを終了させることがあります。12 - プロセス内のロックはSRWロックが既定、再帰取得が必要なときだけCRITICAL_SECTIONです。プロセス内の排他にMutexを使うのは、常にカーネル遷移を伴う「よくある間違い」です。3
- 単一変数の更新はInterlocked系関数で。
volatileは原子性も順序も保証しません。ほとんどのInterlocked関数はフルメモリバリアを伴います。4 - 待ち合わせは条件変数(
SleepConditionVariableCS系)か、イベント+待機関数で。SleepのポーリングループはCPUと応答性の両方を無駄にします。5 TerminateThreadは使いません。ロック・ヒープ・DLL状態を壊す危険な関数で、コード分析警告C6258の対象です。停止は「停止イベント+WaitForMultipleObjects」の協調停止で設計します。67- 短い仕事の並列化は自前スレッドではなくWindowsスレッドプール(
CreateThreadpoolWork)に投げます。プールのスレッドをExitThread/TerminateThreadで終わらせてはいけません。89 - DllMainではスレッドの作成・同期・終了待ちをしません。ローダーロック保持中に呼ばれるため、デッドロックの温床です。10
- C11の
<threads.h>はVS 2022 17.8以降で使えますが、<stdatomic.h>はまだexperimentalです。Windows専用コードならWin32 APIの流儀が現実的です。11
2. なぜマルチスレッドは難しいのか ── 競合状態とデッドロック
マルチスレッドが持ち込む問題は、言語を問わず突き詰めると2種類です。
競合状態(race condition)は、複数のスレッドがどの順序で特定のコードに到達するかによって結果が変わってしまうバグです。定番の例が共有カウンターで、count++ という1つの式は機械語レベルでは「読み込み → 加算 → 書き戻し」の3ステップに分かれます。2つのスレッドがこの3ステップに同時に入ると、一方の加算がもう一方の書き戻しで上書きされて消えます。4 実行のたびに結果が変わり、どの結果になるかは予測できません。
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: デッドロックの循環待ち。待ちの矢印が輪を作った瞬間、輪の中の全スレッドが永遠に停止する
どちらもタイミング依存で、開発機では数万回に1回しか当たりを引かない実行順の組み合わせが、コア数もタイミングも違う客先マシンでは毎日起きます。「デバッガーを付けると再現しない」のも観測がタイミングを変えるためで、競合バグの典型的な振る舞いです。したがって本記事の原則はすべて、「正しく同期する」より前に「同期が必要な場所を減らす」という方向を向いています。
2.1. Cならではの前提 ── 言語は何も守ってくれない
この原則群は、Cでは「守らせる仕組み」が言語にないぶん、規律として明文化する必要があります。
第一に、解放の保証を構造で作ること。C++のRAIIに相当するものがないので、ロックの解放やハンドルの CloseHandle は、関数の出口を一本化する goto cleanup パターンや、取得と解放を対で書くコーディング規約で守ります。早期 return の追加でロックが漏れる、というのがCの典型的な事故です。
第二に、データ競合の扱いはC++と同じであること。適切に整列された32ビット変数の単純な読み書きこそWindowsではアトミックですが、それ以上 ── 64ビット変数(32ビットWindows)、複合操作、複数変数の整合 ── は何も保証されません。12 「たまたま動いている」コードがコンパイラや最適化レベルの変更で壊れます。
第三に、所有権を決めること。「このバッファはどのスレッドが書き、いつから誰のものになるのか」を関数コメントに明記する文化が、Cのマルチスレッドでは同期プリミティブの選択と同じくらい効きます。
3. スレッドの作り方 ── _beginthreadex 一択
3.1. CreateThread がだめな理由
Win32のネイティブAPIは CreateThread ですが、CRT(Cランタイム)関数を呼ぶスレッドは _beginthreadex で作るのが公式の指針です。_beginthreadex はCRTがスレッドごとに使う内部データを初期化してからスレッドを開始します。CreateThread で作ったスレッドがCRT関数を呼ぶと、メモリ不足の状況でCRTがプロセスを終了させることがあります。12 printf も malloc も strtok もCRTですから、実用上「Cで書くスレッドは常に _beginthreadex」です。
_beginthread(exなし)も避けます。作ったスレッドが早く終了すると、返されたハンドルが無効になっている(別のスレッドを指しうる)という罠があり、同期APIにハンドルを渡せる _beginthreadex のほうが安全です。_beginthreadex が返したハンドルは呼び出し側が CloseHandle で閉じます。13
#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スレッドプール(Vista以降のスレッドプールAPI)を使います。CreateThreadpoolWork で作った仕事オブジェクトを SubmitThreadpoolWork で投函すると、プールのワーカースレッドがコールバックを並列に実行します。8 スレッド数の管理はOSに任せられ、スレッドの作成・破棄コストも消えます。「スレッドを自分で作らない」という原則の、Cにおける答えがこれです。
プール利用時の規律も公式に明記されています。プールのスレッドを TerminateThread / ExitThread で終わらせない、コールバック内で作った状態(TLS・スレッド優先度など)は戻る前に復元する、待機ハンドルはプールが使い終わるまで生かしておく、などです。9 もうひとつ実務の注意として、プールが制限するのはワーカースレッドの数だけで、SubmitThreadpoolWork で投函された未実行のコールバックはいくらでも積み上がります。投入が処理を上回り続ける常駐構成では、アプリ側にセマフォなどの入場制限か容量付きキューを置いて、満杯時に投入側が待つ・拒否する形にしてください(過負荷をメモリに移し替えないための背圧で、5章のキュー設計と同じ原則です)。
4. 共有可変状態を最小化する ── 分割・読み取り専用・受け渡し
競合は「複数のスレッド」と「共有された可変データ」が揃ったときにだけ起きます。同期プリミティブの選択(次章)より先に、そもそも共有を減らせないかを考えます。手段は3系統です。
分割する。並列集計では、共有のカウンターに各スレッドが書き込むのではなく、スレッドごとのローカル変数(またはスレッドごとに割り当てたバッファ)に小計を作り、終了時に1回だけ InterlockedAdd などで合流させます。共有への書き込みが「反復のたび」から「スレッドごとに1回」に減り、同期コストも競合の窓も桁違いに小さくなります。「このバッファはどのスレッドの持ち物か」という2.1の所有権の明文化が、そのまま分割の設計図になります。
読み取り専用にする。起動時に構築して以後書き換えない設定・テーブル類は、初期化完了後は何スレッドから読んでも安全です。「初期化は全スレッド起動前に済ませる」か、遅延初期化が必要なら Win32 のワンタイム初期化(InitOnceExecuteOnce)を使い、「いつから読み取り専用になるのか」の境界をコードで明確にします。3
受け渡す。スレッド間のデータの流れは、共有変数を両側から触るのではなく、プロデューサー/コンシューマーのキューに寄せます。Cでの実装は5章の条件変数(有限循環バッファー+SleepConditionVariableCS)がそのまま公式の実装例で、容量に上限のあるバッファーは「生産が消費を追い越したら生産側が待つ」という自然な背圧にもなります。5
5. 同期オブジェクトの選び方とロックの規律
Win32の同期プリミティブは種類が多く、選択を誤ると性能も正しさも損ねます。公式の指針を一枚にまとめます。3
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関数 | プロセス内(共有メモリならプロセス間も) | ロック不要の原子操作 | カウンター・フラグ・ポインタ差し替え4 |
表とフローチャートの補足をひとつ。イベント・セマフォ・Mutexといったカーネルオブジェクトは、名前なしで作ればプロセス内の同期に普通に使えます(6章の停止イベントがまさに名前なしイベントです)。カーネルオブジェクト=プロセス間専用ではありません。なお「名前なし=絶対にプロセス内限定」でもなく、ハンドルを子プロセスに継承させたり DuplicateHandle で別プロセスへ複製したりすれば、名前がなくても同じカーネルオブジェクトを複数プロセスから使えます。名前付けは、プロセス間で同じオブジェクトを開き直せるようにするための代表的な手段のひとつ、というのが正確な理解です。図3の分岐は「プロセス内のロックにカーネルオブジェクトを選ばない」という選択の要点を示したもので、プロセス内の通知(イベント)や同時数制限(セマフォ)は名前なしのカーネルオブジェクトが引き続き正解です。
Interlocked系は.NET編の Interlocked クラス、C++編の std::atomic に対応します。InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange は単一変数への操作を不可分に行い、ほとんどの関数はフルメモリバリアを伴うため順序の保証も得られます。4 「volatile を付けたから大丈夫」は、原子性も順序も保証しない誤解です(FAQ参照)。もうひとつの前提条件が整列(アラインメント)です。Interlocked関数の対象変数は自然な境界に整列していることが必須で(32ビット値なら4バイト境界、64ビット値なら8バイト境界)、整列していないと動作は予測不能になります。12 #pragma pack した構造体や、通信フォーマットをそのままマップしたバッファ上のフィールドをInterlockedの対象にしてはいけません。カウンターやフラグは普通に宣言した(=コンパイラが整列させた)変数に限定してください。また、InterlockedExchangePointer などによるポインタの差し替えには固有の注意があります。不可分なのは差し替えそのものだけで、入れ替えた後の古いブロックの寿命は誰も守ってくれません。読み手が古いポインタをロードした直後に書き手が差し替えて free すれば、解放済みメモリへのアクセスです。ポインタ差し替えで共有データを更新する設計は、ロック・参照カウントなどの回収プロトコルとセットでのみ成立します(迷ったらSRWロックで守るのが無難です)。
待ち合わせには条件変数があります。InitializeConditionVariable で作り、消費側は SleepConditionVariableCS(CRITICAL_SECTIONと組み)で眠り、生産側は WakeConditionVariable で起こす ── 有限バッファのプロデューサー/コンシューマーキューの公式実装例がこの形です。5 重要な作法として、目覚めたら必ずロック内で条件(キューが空でないか)を再確認し、偽ならwaitに戻るループにしてください。条件変数には通知なしに目覚めるスプリアスウェイクアップがあり、目覚めた時には別の消費者が要素を先取りしていることもあるため、「起こされた=条件成立」とは限りません。 .NET編4.3のチャネル、C++編4章の BlockingQueue と同じ構図をCで組む道具です。SRWロックと組む場合は SleepConditionVariableSRW を使います。
5.1. ロックの規律 ── どれを選んでも変わらない3原則
プリミティブを正しく選んでも、使い方の規律がなければ競合は防げません。
- 「どのデータをどのロックが守るか」を1対1で決める。守りたい可変データの集合ごとにロック(SRWロックやCRITICAL_SECTION)を1つ対応させ、そのデータに触るすべての場所で同じロックを取ります。ヘッダーのコメントに「この構造体は
g_lockFooが守る」と明記する運用が、Cでは特に効きます。 - ロック保持中に時間のかかること・外部のことをしない。ロック中にしてよいのは守っているデータの読み書きだけです。保持したままのファイルI/O・ネットワーク・コールバック呼び出しは、保持時間を延ばすうえに、呼んだ先が別のロックを取ろうとして図2の循環待ちを作ります。
- 複数ロックの取得順序を固定する。2つ以上のロックを取る場所では、全スレッドが同じ順序(ロック階層)で取ることをルール化します。順序の逆転(lock order inversion)がデバッグ困難なデッドロックを生むことと、階層を定義して一貫して従うべきことは、DLLのベストプラクティス文書が明文化しています。10
6. 止め方の設計 ── TerminateThread を使わない
6.1. TerminateThread が壊すもの
TerminateThread は対象スレッドに一切のユーザーモードコードを実行させずに消し去ります。公式ドキュメントが列挙する結果は深刻です。対象スレッドがクリティカルセクションを保持していれば永遠に解放されず、ヒープ操作中ならヒープロックが握られたままになり(以後 malloc する全スレッドがハング)、DLLのグローバル状態を操作中ならDLLの状態が破壊されます。「最も極端な場合にのみ使うべき危険な関数」というのが公式の位置づけで、コード分析も警告C6258で検出します。67
「たまにプロセスごと固まる」アプリの原因調査で TerminateThread が見つかる、というのは実務で本当によくある光景です。見つけたら、それは修理対象です。
6.2. 正しい形: 停止イベント + WaitForMultipleObjects
Cでの協調停止の定石は、手動リセットの停止イベントを1つ作り、各ワーカースレッドが「仕事の合図」と「停止の合図」を同時に待つ形です。警告C6258のドキュメント自身が、この形(イベントを作り、各スレッドが WaitForSingleObject でイベントを監視して自分で終わる)を正しい終了方法として案内しています。7
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なら共有資源の解放に進んではいけない */
}
止める側の合流を1本ずつの WaitForSingleObject にしているのには理由があります。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で待機中の全ワーカーが一斉に目覚める。終わり方は各スレッドが自分で決め、合流の完了をもって停止とみなす
ポイントは3つあります。停止イベントは手動リセットにする(1回の SetEvent で全ワーカーに見える)、待機配列の先頭に停止イベントを置く(同時にシグナルされたとき停止が優先される)、そして止める側は必ずスレッドハンドルの合流を待ってからハンドルを閉じる。
2つ、適用範囲の注意を添えます。第一に、この「イベント+全件排出」の形はワーカー1本の構成向けです。自動リセットイベントは何回 SetEvent しても「シグナル状態が1つある」ことしか表現できず(連続する合図は合体します)、ワーカーが複数いると1本だけが起きてバーストを直列に処理することになります。複数ワーカーでキューを分担するなら、仕事の合図はセマフォに替えて、1件積むたびに ReleaseSemaphore(hSem, 1, NULL) でカウントを増やします。セマフォの待機成功はカウントを1消費するので、「積まれた件数ぶんだけ、待っているワーカーが1本ずつ起きる」という正しい対応になります(この用途は図3の表の「資源プールの同時アクセス数制限」と同じくセマフォの守備範囲です)。ただしセマフォに替えるときは、消費側も「待機成功1回=キューから1件だけ処理」に改めてください。上のサンプルの全件排出ループをそのまま残すと、1回の待機で許可を1つしか消費していないのにキューを空にしてしまい、残った許可で他のワーカーが空のキューに対して目覚める・生産側の ReleaseSemaphore が上限超過で失敗する、といった形で勘定が崩れます。「許可1つ=仕事1件」の対応を守ることが、セマフォ方式の前提です。第二に、1件の処理そのものにも停止経路を通すこと。ProcessNextItem の内部で長いブロッキング待ちをするなら、そこにも停止イベントを渡して重ね合わせで待つか、有限のタイムアウトを付けます。項目間のチェックだけでは、「1件が終わらないせいでシャットダウンが永遠に待つ」穴が残ります。.NET編の StopAsync、C++編の jthread + join と、言っていることは同じです。
ブロッキングI/O(パイプ・ソケット・シリアルポート)で待っているスレッドはイベントを見に来られないため、I/O側も OVERLAPPED +イベントの重ね合わせ待機にするか、CancelIoEx でI/Oを起こしてやる設計が必要になります(シリアル通信の具体例は「シリアル通信アプリの落とし穴」参照)。
7. DllMain とローダーロック ── DLLを書くときの地雷原
Cで書く共有部品はDLLになることが多く、そこにはローダーロックという固有の制約があります。DllMain はOSのローダーがローダーロックを保持したまま呼び出すため、その中で次のことをすると、デッドロックまたはクラッシュの原因になります。10
- 他スレッドとの同期(ロック取得・スレッドの終了待ち)
LoadLibrary/FreeLibraryの呼び出し(直接・間接を問わず)- スレッドの作成(同期を伴えば危険)や
ExitThread
「DLLのアンロード時にワーカースレッドの終了を DllMain で待つ」は、いかにも正しそうに見えて典型的なデッドロックです(終了するスレッドが DLL_THREAD_DETACH 配送のためにローダーロックを取ろうとし、互いに待ち合います)。スレッドを持つDLLは、MyLib_Init / MyLib_Shutdown のような明示的な初期化・終了関数を公開し、スレッドの起動と合流はそこで行う設計にしてください。DllMain の理想は空に近いスタブです。10
8. C11 スレッドという選択肢 ── 現状整理
「Win32に依存しない移植可能なCで書きたい」場合の選択肢がC11の <threads.h>(thrd_create / mtx_lock / cnd_wait)と <stdatomic.h> です。MSVCでの対応状況は公式の適合表によると、<threads.h> はVisual Studio 2022 17.8でサポート(要 /std:c11 と対応SDK)、一方 <stdatomic.h> はexperimentalで /experimental:c11atomics オプションが必要な段階です。11
Linuxとのコード共有が要件ならC11スレッド(またはpthreadラッパー)に価値がありますが、Windows専用のコードベースであれば、本記事のWin32流儀のほうが情報量・実績・デバッグのしやすさで有利です。いずれを選んでも、ここまでの設計原則(共有を減らす、ロックとデータの対応、協調停止)は変わりません。
9. 検証とデバッグ ── 「再現しない」前提で備える
競合バグはテストで見つかることを期待できません。通常のテストは「たまたま競合しなかった」実行を成功と数えるからです。備えは3層で考えます。
第一の防衛線は設計です。レビューでは「共有している可変データはどれか」「それぞれどのロックが守るか(5.1の対応表)」「ロックの取得順序は一意か」「停止イベントは全ワーカーに届くか」を表で確認します。この表が書けない設計は、動いていてもまだ完成していません。
第二に、異常を観測可能にします。待機には無条件の INFINITE ではなく、要所でタイムアウトを付けて時間切れをログに残せば、永遠のハングを検出可能な失敗に変えられます。ハングの現場ではダンプを採取して全スレッドのスタックを確認し、互いのロック待ちが循環していないかを見ます。DLLまわりの誤りには Application Verifier での検査が公式に推奨されています。10 ダンプとログの整備は「Windowsアプリのクラッシュ時にログとダンプを残す設計」で扱っています。
第三に、負荷で揺さぶります。コア数より多いスレッドで長時間回す・処理順をランダム化する・人工遅延を挿入するといったストレステストは、開発機で競合の「当たり」を引きやすくする現実的な手段です。最適化されたリリースビルド+高負荷での再現試験も忘れずに行います。
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, 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
-
Microsoft Learn, Warning C6258. コード分析警告C6258がTerminateThreadの使用を検出すること、TerminateThreadでは適切なスレッドクリーンアップができないこと、正しい終了方法としてCreateEventでイベントを作り、各スレッドがWaitForSingleObjectでイベント状態を監視し、シグナル状態になったら自分で実行を終える手順が示されていることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, CreateThreadpoolWork function. CreateThreadpoolWorkで仕事オブジェクトを作成し、SubmitThreadpoolWorkを呼ぶたびにプールのワーカースレッドがコールバックを実行すること、コールバック環境(TP_CALLBACK_ENVIRON)で実行環境を指定できること、Windows Vista以降で利用できることについて。 ↩ ↩2
-
Microsoft Learn, Thread Pools. スレッドプールが短い仕事を大量に非同期実行するアプリや短命スレッドを頻繁に作るアプリに適すること、Vistaで再設計された新スレッドプールAPIの構成要素、ベストプラクティスとしてプールのスレッドをTerminateThreadで終了させたりコールバックからExitThreadを呼んだりしないこと、コールバックで作った状態を戻る前に後片付けすること、待機ハンドルをプールが使い終わるまで生かしておくことについて。 ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices. DllMainがローダーロック保持中に呼ばれるため呼べるAPIに重大な制約があること、DllMain内での他スレッドとの同期がデッドロックを招くこと、LoadLibrary呼び出しが禁止事項であること、DLLアンロード時にDllMain内でスレッド終了を待つと終了スレッドのDLL_THREAD_DETACH配送と互いに待ち合ってデッドロックする構図、理想のDllMainが空に近いスタブであり初期化はできる限り遅延させるべきこと、ロック階層を定義しローダーロックを最上位に置くべきことについて。 ↩ ↩2 ↩3 ↩4 ↩5
-
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スレッドの扱いまで解説します。
マルチスレッドの実務ベストプラクティス Java編 ── 仮想スレッド時代の定石
Javaのマルチスレッドはスレッドを直接作らずExecutorServiceと仮想スレッドに乗るのが定石。synchronizedとReentrantLockの使い分け、割り込みによる協調停止、ConcurrentHashMapの原子操作、SwingのEDTまで実務の原則を...
ボリュームシャドウコピー(VSS)の仕組みと実務 ── 使用中ファイルのバックアップがなぜ取れるのか
使用中のファイルは共有違反でコピーできないのに、バックアップソフトはなぜ取れるのか。ボリュームシャドウコピー(VSS)のリクエスター・ライター・プロバイダーの役割分担、コピーオンライトの仕組み、vssadminの実務と差分領域の落とし穴を解説します。
Windowsアプリ 外注・受託開発を依頼する前に整理したいこと
Windowsアプリの外注・受託開発を依頼する前に、既存ソフト改修、装置連携、COM/ActiveX、配布・更新、保守の整理ポイントを解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
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 です。