更新履歴(1件・最終更新 2026年09月05日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- Win32スレッドプールAPIの解説を、道具の使い分け、workの実装、コールバックの規律、安全な終了処理の順に再構成した。技術的な主張と注意点、コード例、参考資料を維持し、固定contextと項目データの関係やDLLの寿命管理を小見出しと図で追いやすくした。 更新前のバージョンを見る (DOI: 10.5281/zenodo.22170917)
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.22170916)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「Win32スレッドプールAPI ── CreateThreadpoolWorkで「スレッドを作らない」並行処理」合同会社小村ソフト. https://doi.org/10.5281/zenodo.22170916 https://comcomponent.com/blog/win32-thread-pool-api/
- DOI(最新版)
- 10.5281/zenodo.22170916
- DOI(この版)
- 10.5281/zenodo.22346468
「クライアントごとにCreateThread」「タイマーのために1本」「イベント待ちのために1本」。ネイティブのWindowsコードでは、小さな仕事のためにスレッドを増やしてしまいがちです。スレッドは1本ごとにスタックとカーネルオブジェクトを消費し、生成・破棄にもコストがかかります。
このミスマッチを吸収するのが、OS標準のWin32スレッドプールAPIです。アプリは実行したい処理をコールバックとして渡し、ワーカースレッドの管理をOSに任せます。「スレッドを作らない」とは、スレッドが不要になるのではなく、アプリが仕事のたびに自分で生成・破棄しないという意味です。1
本記事は、C/C++でWindowsアプリ・サービス・DLLを書く開発者に向けて、使い分け → オブジェクトの選択 → workの実装 → コールバックと終了処理の注意点の順に解説します。対象はWindows Vistaで刷新されたCreateThreadpoolWork系のAPIです。コードは使い方の骨格を示す抜粋で、キューなどのアプリ固有部分は省略しています。
1. まず結論
短命な仕事を大量に処理するなら、スレッドではなく仕事を管理します。ただし、仕事をいつ止め、いつ解放できるかはアプリ側で設計します。
判断の軸は、次の3つです。
- 道具を選ぶ。 標準C++や.NETの道具で足りるなら、そちらを使います。Win32でタイマー・待機・I/O完了を統合したい、プールを分けたい場合が、このAPIの出番です。
- 仕事の単位で渡す。 新APIのwork・timer・wait・ioを使い、スレッド優先度やCOMのSTAなど、スレッド固有の状態が必要な仕事は専用スレッドに残します。
- 終了までを一組で作る。 基本は「投入を止める → 完了を待つ → 閉じる」。コールバックでは長時間ブロックせず、同じプールの仕事を同期待ちせず、スレッドの状態を元に戻します。
まず動かしたい場合は4章のworkの例から読み、5章のコールバックの規律を確認してください。複数オブジェクトの一括終了は6章、DLLのアンロードは7章で扱います。
2. 使い分け ── プール・専用スレッド・標準ライブラリ
2.1 プールが向くのは、短命・大量・待機中心の仕事
スレッドプールは、OSが管理するワーカースレッドの集合です。ワーカーがコールバックを順に実行し、OSが負荷に応じて本数を調整します。自前でスレッドを作っては壊す処理を任せることで、管理コードと生成・破棄の負担を減らせます。1
公式ドキュメントが適用対象に挙げるのは、小さな作業項目を大量に並列発行するアプリ、短命スレッドを頻繁に生成・破棄するアプリ、独立した仕事をバックグラウンドで並列処理するアプリ、カーネルオブジェクトやイベントを待つ専用スレッドを持つアプリです。検索やネットワークI/O、待機専用スレッドの整理が典型です。1
一方、スレッド優先度の変更が必要、COMのSTAが必要、プロセスの生存期間中ずっと動き続ける、といった仕事は専用スレッドで持ちます。判断基準は、処理を実行できればよいのか、スレッドそのものに「個性」が必要なのかです。
flowchart TB
accTitle: 専用スレッドとプールの使い分け
accDescr: 優先度やSTAなどスレッドの個性が必要か、長時間動き続けるかをまず確認し、どちらも該当しない短命・大量・待機系の仕事だけをスレッドプールに乗せる
q1{"優先度・STAなど個性が必要?"} -->|"はい"| ded["専用スレッドで持つ"]
q1 -->|"いいえ"| q2{"長時間動き続ける?"}
q2 -->|"はい"| ded
q2 -->|"いいえ"| pool["スレッドプールに乗せる"]
pool -.-> ex["短命な仕事・待機・タイマー・I/O完了"]
図1: プールに乗せてよいのは「個性が要らず、短く終わる」仕事だけ。それ以外は従来どおり専用スレッドで。
2.2 標準C++や.NETで足りるかを先に考える
C++のstd::asyncやstd::threadで足りる粒度なら、標準ライブラリが第一候補です。移植性があり、std::asyncとfutureの振る舞いも規格に基づいて扱えます。2
Win32スレッドプールを直接使う理由は、timer・wait・ioを含むコールバック機構を統合したい、仕事の種類ごとにプールやスレッド数を分けたい、DLL・COMコンポーネントで自前スレッドを持ちたくない、という要求です。単に並列化したいだけなのか、Windows固有の制御が必要なのかを分けて考えます。
flowchart TB
accTitle: どの層の道具で書くかの判断
accDescr: 標準C++のasyncやthreadで足りるならそれを使い、タイマー・待機・I/O完了の統合やプール分割・スレッド数制御が必要な場合、DLLやCOMコンポーネントで自前スレッドを持ちたくない場合にWin32スレッドプールを直接使う
q1{"標準C++の道具で足りる?"} -->|"はい"| std["std::async / std::thread"]
q1 -->|"いいえ"| q2{"何が必要?"}
q2 -->|"timer・wait・ioの統合"| tp["Win32スレッドプール"]
q2 -->|"プール分割・数の制御"| tp
q2 -->|"DLL内で自前スレッド回避"| tp
図2: 迷ったらまず標準ライブラリ、それで表現できない要求が出たときがこのAPIの出番。
.NETではThreadPoolやTaskが同じ役割を担い、I/O完了はIOCPと結びついています。関係の詳しい説明はIOCPと.NETスレッドプールの記事を参照してください。上の層の道具で足りる場面まで、Win32 APIへ降りる必要はありません。
2.3 新規コードでは、Vista以降の新APIを使う
Win32のスレッドプールAPIには2世代あります。Windows 2000から続くQueueUserWorkItemやRegisterWaitForSingleObjectなどの旧APIと、Windows Vistaで全面再設計されたCreateThreadpoolWork系の新APIです。
新APIでは、ワーカースレッドの種別が一本化され、単一のタイマーキュー、専用の永続スレッド、プロセス内の独立した複数プール、クリーンアップグループが用意されました。公式も、新APIの簡単さ、信頼性、性能、柔軟性を利点に挙げています。13
旧APIには、キューに入れた作業をキャンセルする方法がないという構造的な制約もあります。新規コードでは新APIを使い、既存コードを見直すときは次の対応を出発点にします。3
flowchart TB
accTitle: 旧スレッドプールAPIと新APIの対応
accDescr: 旧APIのQueueUserWorkItemは新APIのworkオブジェクトに、タイマーキューはtimerに、登録済み待機はwaitに、BindIoCompletionCallbackはioに、それぞれ置き換えられる
o1["QueueUserWorkItem"] --> n1["work"]
o2["タイマーキュー"] --> n2["timer"]
o5["登録済み待機"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
図3: 旧APIからの移行先は1対1で決まっている。既存コードの棚卸しはこの対応表から始められる。
3. 仕組み ── 発火条件の違う4つのオブジェクト
3.1 何をきっかけに動かすかで選ぶ
新APIの中心は、次の4種類です。「何を処理するか」だけでなく、何をきっかけにコールバックを実行したいかで選びます。4
| オブジェクト | 作成関数 | コールバックの発火条件 |
|---|---|---|
| work | CreateThreadpoolWork |
SubmitThreadpoolWorkで投入されたとき |
| timer | CreateThreadpoolTimer |
指定した時刻・周期が来たとき |
| wait | CreateThreadpoolWait |
カーネルオブジェクトがシグナルになったとき |
| io | CreateThreadpoolIo |
関連付けたハンドルの非同期I/Oが完了したとき |
発火条件は違っても、実行を担うのは同じプールのワーカー群です。周期処理・イベント応答・I/O完了処理を、それぞれ専用スレッドで書く代わりに、共通のコールバック機構へ揃えられます。
flowchart TB
accTitle: 発火条件とコールバックの実行を分ける構造
accDescr: 発火条件を持つオブジェクトから実行可能になったコールバックをプールのワーカー群が実行し、処理を終えたワーカーを次のコールバックにも使うため、アプリは仕事ごとにスレッドを生成しない
app["アプリは仕事と発火条件を指定"] --> obj["オブジェクトが条件を待つ"]
obj --> ready["コールバックが実行可能になる"]
ready --> workers["プールのワーカー群が実行"]
workers --> done["処理を終えてワーカーを返す"]
done --> next["次のコールバックにも再利用"]
図4: 発火を待つ仕組みと、処理を実行するワーカーを分けることで、仕事ごとのスレッド管理を減らす。
3.2 「眠って待つだけのスレッド」も置き換えられる
プールの効果は、CPUを使う仕事だけに限りません。タイマーは単一のタイマーキューへ、複数のハンドルの待機は少数の待機スレッドへ集約されます。1
たとえば「イベントがシグナルされたら動く」ためだけに眠っているスレッドが5本あるなら、waitオブジェクト5個に置き換える考え方ができます。個別に眠るスレッドを持つのではなく、シグナル時の処理をコールバックとして実行します。
flowchart TB
accTitle: 待機専用スレッドのwaitオブジェクトへの置き換え
accDescr: イベントごとに1本ずつ眠って待っていた待機専用スレッドは、waitオブジェクトに置き換えるとプールの待機スレッドに集約され、シグナルされたときだけコールバックが実行される形になる
old2["待機専用スレッド×5本が個別に眠る"] -.-> waste["スタックとスレッドを5本分消費"]
new2["waitオブジェクト×5個"] --> agg["プールの待機スレッドに集約"]
agg --> cb2["シグナル時だけコールバック実行"]
図5: 「眠って待つだけのスレッド」はwaitオブジェクト化で消せる。これがプール移行の分かりやすい初手になる。
4. 実装の基本 ── workを作り、投入し、安全に終了する
4.1 まず、作成から終了までを一巡りする
最も基本的なworkオブジェクトで、使い方を一巡りします。CreateThreadpoolWorkでコールバックとcontextを結びつけ、SubmitThreadpoolWorkで実行を依頼します。第3引数がNULLならプロセス既定のプールを使います。多くの用途は、この既定プールで足ります。56
以下は手順を示す抜粋で、そのままコンパイルする完全なプログラムではありません。WORK_QUEUE、ITEM、Enqueue、Dequeue、ProcessItemはアプリ側の処理を表します。キューの排他制御、投入側の停止、失敗処理は別途実装し、workの作成に失敗した場合は後続の投入・待機・解放へ進まないようにします。
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// contextは作成時に固定される。項目ごとのデータは同期されたキューで渡す
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // 排他制御付きで1件取り出す
ProcessItem(item);
}
// 1) 作成(コールバックと、共有する文脈=キューを結びつける)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* GetLastErrorで失敗処理 */ }
// 2) 1件積むごとに1回投入する(件数と投入回数を一致させる)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) 投入側を止めてから、完了を待つ(TRUEなら未実行分の取り消しも試みる)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) 閉じる
CloseThreadpoolWork(work);
flowchart TB
accTitle: workオブジェクトのライフサイクル
accDescr: CreateThreadpoolWorkで作成し、SubmitThreadpoolWorkで投入するとコールバックが並列実行される。終了時はまず新規投入を止め、WaitForThreadpoolWorkCallbacksで全コールバックの完了を待ってからCloseThreadpoolWorkで閉じる
c["CreateThreadpoolWorkで作成"] --> s["SubmitThreadpoolWorkで投入(複数回可)"]
s --> run["コールバックが並列実行される"]
run --> stop3["新規投入を止める"]
stop3 --> w["WaitForThreadpoolWorkCallbacksで完了待機"]
w --> cl["CloseThreadpoolWorkで閉じる"]
図6: 終了の順序は「投入を止める→完了を待つ→閉じる」。どれかを飛ばすと解放後アクセスや競合になる。
4.2 workは再利用できるが、contextは投入ごとに変わらない
同じworkオブジェクトは、前のコールバックが終わる前でも複数回投入できます。 投入のたびにコールバックが実行され、複数回分が並列に動きます。実際に使うスレッド数は、効率のためプールが調整することがあります。7
ここで区別したいのが、workと各作業項目のデータです。コールバックに渡されるcontextは作成時に固定されます。1個のworkを使って異なるデータをN件処理するなら、上の例のように、同期されたキューをcontextにします。データを1件積むごとに1回投入し、コールバックはキューから1件取り出します。項目ごとにworkオブジェクトを作る設計でも構いません。5
flowchart TB
accTitle: 固定のcontextから項目ごとのデータを受け取る
accDescr: workの作成時にcontextを共有キューへ固定し、投入側が項目を1件積むたびに1回Submitし、並列に動く各コールバックが排他制御付きのキューから1件ずつ取り出す
create["work作成時にcontextを固定"] -.-> queue["排他制御付きの共有キュー"]
item["項目を1件積む"] --> queue
item --> submit["1件につき1回Submit"]
submit --> callbacks["各コールバックが並列に動く"]
callbacks --> dequeue["キューから1件ずつ取り出す"]
queue --> dequeue
dequeue --> process["取り出した項目を処理"]
図7: 固定されるのはキューを指すcontextであり、項目ごとのデータは同期されたキューから渡す。
4.3 終了は、待機より先に投入を止める
安全な終了の順序は、「新規投入を止める → WaitForThreadpoolWorkCallbacksで完了を待つ → CloseThreadpoolWorkで閉じる」です。コールバックが参照するメモリも、完了を確認する前に解放してはいけません。実行中・実行待ちの処理を残したまま参照先を解放すれば、解放後アクセスにつながります。6
「完了待機を呼んだから安全」と考えるだけでは不十分です。別のスレッドがまだSubmitThreadpoolWorkを呼べると、待機が終わった後の投入とCloseが競合します。待機を終了処理の区切りにするには、先に投入側を止めることが前提です。
WaitForThreadpoolWorkCallbacksの第2引数がFALSEなら完了を待ち、TRUEなら、まだ開始していないコールバックの取り消しも指定します。実行中の処理を置き去りにしてよいという意味ではありません。複数のオブジェクトをまとめて終了する方法は6章で扱います。
5. コールバックの規律 ── 借りたスレッドを占有・汚染しない
5.1 長時間かかるなら宣言し、戻り値まで確認する
プールは、コールバックが速やかに返る前提でスレッド数を調整します。何も伝えずに長い処理や長い待機を続けると、ほかのコールバックの実行が遅れます。長くかかり得る場合は、CallbackMayRunLongでその可能性を伝えるか、専用スレッドへ分けます。8
ただし、CallbackMayRunLongを呼ぶだけでは十分ではありません。ほかのコールバックのためのワーカーを用意できない場合はFALSEが返ります。戻り値を無視してブロックし続けるのではなく、その場合は処理を分割する、専用スレッドへ移すなど、プールを塞がない側に倒します。8
flowchart TB
accTitle: 長時間コールバックの扱いを決める
accDescr: 長い処理や待機が必要な仕事は専用スレッドへ分けるかCallbackMayRunLongでプールへ通知し、ほかのワーカーを用意できずFALSEが返った場合は分割や専用スレッドへの移動でブロックを避ける
long["長い処理・待機が必要"] --> choice{"どこで処理するか"}
choice -->|"専用にする"| dedicated["専用スレッドへ分ける"]
choice -->|"プールで行う"| notify["CallbackMayRunLongで通知"]
notify --> result{"ほかのワーカーを確保できたか"}
result -->|"TRUE"| run["長時間の処理を行う"]
result -->|"FALSE"| split["分割するか専用へ移す"]
図8: 長時間処理の通知は、戻り値を確認して次の行動を決めるところまでが一組になる。
5.2 同じプールの仕事を、ワーカーの中で同期待ちしない
コールバックAの中から同じプールへ仕事Bを投入し、その完了をWaitForThreadpoolWorkCallbacksなどで待つ設計には注意が必要です。全ワーカーが「ほかのワーカーの仕事待ち」になると、Bを実行する空きワーカーがなくなり、プール枯渇型のデッドロックになります。
対処は、待つためのワーカーを確保することではなく、Bの完了コールバックから次の仕事を投入する継続の形へ変えることです。依存関係を、ワーカーを塞ぐ待機として書かないようにします。
flowchart TB
accTitle: プール枯渇デッドロックの構造
accDescr: 全ワーカースレッドが同じプールに投入した別の仕事の完了を同期待ちすると、その仕事を実行できる空きワーカーが存在せず、全員が永遠に待ち続けるデッドロックになる
w1["ワーカー1:仕事Xの完了を待機"] --> q["実行待ちの仕事X・Y"]
w2["ワーカー2:仕事Yの完了を待機"] --> q
q -.-> none["実行できる空きワーカーがいない"]
none -.-> dead["全員が永遠に待つ(枯渇デッドロック)"]
図9: ワーカーの中でワーカーを同期待ちすると、待たれている仕事を実行する者がいなくなる。
5.3 スレッドの状態を元に戻してから返る
ワーカースレッドは、次の無関係なコールバックにも使われます。優先度を変えたままにする、COMの初期化状態を残す、TLSに値を残す、ロックを解放し忘れる、といった処理は、次の仕事へ影響を持ち越します。プールに投入する関数と、その関数から呼ぶ処理まで含めて、専用スレッドで動くという仮定を置かないことが必要です。9
後始末をコールバックの終了に連動させるAPIもあります。たとえばLeaveCriticalSectionWhenCallbackReturnsは、コールバックが返った後のクリティカルセクションの解放をプールへ依頼します。4
flowchart TB
accTitle: スレッド状態を次のコールバックへ持ち越さない
accDescr: 同じワーカーを別のコールバックが再利用するため、優先度・COM・TLS・ロックなどの状態を残して返ると次の仕事を汚染し、後始末をして状態を元に戻すことでその持ち越しを防ぐ
first["コールバックAがワーカーを借りる"] --> cleanup{"返る前に後始末したか"}
cleanup -->|"いいえ"| dirty["状態を残したまま再利用"]
dirty --> impact["無関係なBへ影響する"]
cleanup -->|"はい"| clean["元の状態でワーカーを返す"]
clean --> next["次のコールバックBが利用"]
図10: スレッドは借り物なので、処理の結果だけでなく、返すときのスレッド状態にも責任を持つ。
5.4 未処理例外をワーカーの外へ漏らさない
ワーカースレッド上の未処理例外は、プロセス全体を巻き込むことがあります。専用スレッドのスレッド関数と同じように、コールバックの入口で例外を捕捉し、ログを残す方針を適用します。プールへ任せても、仕事の中で起きた失敗の扱いまで不要になるわけではありません。
6. 構成を分ける ── カスタムプールとクリーンアップグループ
6.1 カスタムプールは、仕事の種類を隔離するために使う
既定プールで足りなくなったときは、CreateThreadpoolで独立したプールを作れます。SetThreadpoolThreadMaximumとSetThreadpoolThreadMinimumで、ワーカースレッド数の上限・下限を設定します。10
典型的な目的は隔離です。「遅くてもよいバッチ処理」が「即応してほしい仕事」のワーカーを使い切らないように、プールを分け、それぞれにスレッド数の予算を与えます。単にスレッドを増やす話ではなく、どの仕事がどのワーカーを使うかを分ける話です。
6.2 コールバック環境で、実行先と後始末を結びつける
どのプールで実行するかを指定するのが、TP_CALLBACK_ENVIRONです。このコールバック環境を初期化し、SetThreadpoolCallbackPoolでプールを指定して、CreateThreadpoolWorkなどへ渡します。4章の例ではNULLだった第3引数が、ここでは環境を渡す場所になります。5
同じ環境を通じて、クリーンアップグループも結びつけられます。カスタムプールは実行先、クリーンアップグループは後始末の単位です。この2つの役割を分けて見ると、構成を追いやすくなります。
flowchart TB
accTitle: コールバック環境による構成の結びつけ
accDescr: コールバック環境がカスタムプールとクリーンアップグループを指し、その環境を渡して作成したworkやtimerはそのプールで実行され、クリーンアップグループの一括処理で完了待機と解放をまとめられる
env["コールバック環境(TP_CALLBACK_ENVIRON)"] --> cp["カスタムプール(スレッド数を制御)"]
env --> cg["クリーンアップグループ"]
env --> obj["work / timer / wait / io の作成に渡す"]
cg -.-> close["一括で完了待機と解放"]
図11: コールバック環境は「どのプールで動き、誰が後始末するか」をオブジェクト作成時に注入する仕組み。
6.3 複数オブジェクトの完了待機と解放をまとめる
モジュール内にworkやtimerなどが増えると、終了処理は「それぞれを待って、それぞれを閉じる」の羅列になります。CreateThreadpoolCleanupGroupでグループを作り、コールバック環境を介して作成するオブジェクトを所属させておくと、CloseThreadpoolCleanupGroupMembersの1回で、所属オブジェクト全体の完了待機と解放をまとめられます。46
ここでも狙いは、実行中のコールバックを置き去りにしないことです。4章のworkを個別に終了する形と、グループの単位で終了する形を、管理するオブジェクトの数に応じて使い分けます。
7. DLLから使うとき ── コールバックより先にアンロードしない
7.1 完了待機は、DllMainではなく明示的な終了関数で行う
DLLで最も危険なのは、コールバックのコードがまだ実行され得るのに、そのDLLがアンロードされることです。アンロードされたコードが実行されれば、アクセス違反になります。
基本は、DLLの明示的な終了関数で、投入を止め、コールバックの完了を待ち、オブジェクトを閉じてからアンロードすることです。個別の待機関数でも、6章のクリーンアップグループでも、この完了確認を省略してはいけません。6
この待機をDllMainの中で行ってはいけません。ローダーロックとの関係で別のデッドロックを起こすためです。詳しくはDllMainとローダーロックの記事で解説しています。
7.2 FreeLibraryWhenCallbackReturnsは、投入前の参照確保と対にする
「このコールバックが最後の仕事なので、返った後にDLLの参照を手放したい」という場面には、FreeLibraryWhenCallbackReturnsがあります。4
ただし、このAPIだけでは、コールバックが開始する前のアンロードを防げません。行うのは、実行中のコールバックが返るときにモジュール参照を1つ手放すことです。投入前にGetModuleHandleExでその処理用のモジュール参照を確保し、コールバックからこのAPIで手放す、という対で使います。
flowchart TB
accTitle: DLLを終了関数で閉じる場合とコールバックから参照を返す場合
accDescr: 基本はDllMainではない終了関数で投入を止めて完了待機と解放を行ってからDLLをアンロードし、最後のコールバックから参照を返す設計では投入前にGetModuleHandleExで参照を確保してFreeLibraryWhenCallbackReturnsによる終了後の解放と対にする
shutdown["明示的な終了関数"] --> stop["投入停止・完了待機・解放"]
stop --> unload["その後にDLLをアンロード"]
note["DllMainでは待機しない"] -.-> shutdown
acquire["投入前にモジュール参照を確保"] --> submit["コールバックを投入"]
submit --> callback["コールバック内で解放を予約"]
api["FreeLibraryWhenCallbackReturns"] -.-> callback
callback --> returned["返った後に参照を1つ解放"]
図12: 終了関数での完了待機と、コールバック用に確保した参照の解放を混同せず、どちらもDLLの寿命を先に設計する。
8. まとめ ── まずworkから、終了処理と一緒に置き換える
Win32スレッドプールは、ネイティブコードの並行処理を「スレッドを作る」から「仕事をコールバックとして渡す」へ変える土台です。短命な仕事や待機専用スレッドを整理し、スレッド管理をOSへ任せられます。
導入は段階的で構いません。まずworkで、作成・投入・投入停止・完了待機・解放までを一組にする。次に、待機専用スレッドをwaitへ、タイマー専用スレッドをtimerへ置き換える。 必要になったところで、ioの統合やプール分割を検討します。
flowchart TB
accTitle: 既存コードを段階的にスレッドプールへ移す
accDescr: 既存の自前スレッドを見直し、標準ライブラリや専用スレッドが適する仕事は残したうえで、プールに向く仕事をworkの投入停止と完了待機を含めて移し、待機をwaitへタイマーをtimerへ段階的に置き換える
inventory["既存の自前スレッドを見直す"] --> choose{"プールに向く仕事か"}
choose -->|"いいえ"| keep["標準ライブラリ・専用を選ぶ"]
choose -->|"はい"| work["workと終了処理を一組で移す"]
work --> more["待機はwait・タイマーはtimerへ"]
more --> optional["必要に応じてI/O統合・プール分割"]
図13: スレッドを減らすだけでなく、各段階で終了処理まで揃えることが、段階的な移行の基本になる。
最後まで守るのは、長時間ブロックしない、同じプールを同期待ちしない、スレッドの状態を汚さないという3つの規律です。DLLなら、さらにアンロードとの競合を防ぎます。標準C++や.NETの道具で足りるところはそちらに任せ、Windows固有の統合や制御が必要なところで、このAPIを使ってください。
関連記事
- Windows I/Oの深層(第3回) ── I/O完了ポート(IOCP)と.NETスレッドプール:async/awaitの地下室
- マルチスレッドの実務ベストプラクティス C++編
- マルチスレッドの実務ベストプラクティス C言語編
- スプリアスウェイクアップ ── 条件変数が「通知なしに目覚める」理由とWindowsでの正しい待ち方
- DllMainとローダーロック ── 「DLLの初期化で何もするな」と言われる本当の理由
- WindowsでSleep(1)よりイベント待機を優先すべき理由
関連する相談領域
合同会社小村ソフトでは、スレッドが増殖したネイティブコードのスレッドプールへの移行設計、C++アプリ・DLLの並行処理の設計レビュー、プール枯渇・コールバック起因のハングやクラッシュの原因調査を扱っています。既存コードの棚卸しからでもご相談いただけます。
参考リンク
-
Microsoft Learn, Thread Pools. スレッドプールがアプリに代わって非同期コールバックを効率的に実行するワーカースレッドの集合であること、適するアプリの型(小さな作業項目の大量並列発行、短命スレッドの頻繁な生成破棄、独立作業の並列処理、カーネルオブジェクトの排他的待機など)、Vistaでの全面再設計(ワーカースレッド種別の一本化、単一タイマーキュー、専用永続スレッド、クリーンアップグループ、プロセス内複数プール、新API)について。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, <future>. std::asyncとfutureによるタスク単位の非同期実行が標準ライブラリとして提供されており、スレッドを直接管理せずに並行処理を書けることについて。 ↩
-
Microsoft Learn, Thread Pooling. 旧来のスレッドプールAPI(QueueUserWorkItem、タイマーキュー、登録済み待機、BindIoCompletionCallback)の構造、キューに入れた作業をキャンセルする方法がないこと、Vistaで導入された新しいスレッドプールAPIのほうが簡単で信頼性・性能・柔軟性に優れると明記されていることについて。 ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. CreateThreadpoolWork・CreateThreadpoolTimer・CreateThreadpoolWait・CreateThreadpoolIoの4種のオブジェクト作成関数、クリーンアップグループ(CreateThreadpoolCleanupGroup)、コールバック完了と連動する後始末(LeaveCriticalSectionWhenCallbackReturns、FreeLibraryWhenCallbackReturnsなど)を含む関数一覧について。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). コールバック関数と文脈ポインターからworkオブジェクトを作成すること、第3引数のTP_CALLBACK_ENVIRONでコールバックの実行環境(所属プールなど)を指定でき、NULLなら既定環境で実行されることについて。 ↩ ↩2 ↩3
-
Microsoft Learn, Using the Thread Pool Functions. CreateThreadpoolWorkで作成しSubmitThreadpoolWorkで投入、WaitForThreadpoolWorkCallbacksで完了を待ちCloseThreadpoolWorkで閉じる基本手順、カスタムプールとコールバック環境・クリーンアップグループを組み合わせた構成例について。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). 同じworkオブジェクトを先行コールバックの完了を待たずに複数回投入でき、コールバックが並列に実行されること、効率のためプールがスレッド数を調整(スロットリング)しうることについて。 ↩
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). 現在のコールバックが長時間実行される可能性をプールに通知し、プールが他のコールバックのためのスレッド確保を判断する材料にすること、長時間コールバックには可能なら専用スレッドの利用も検討すべきことについて。 ↩ ↩2
-
Microsoft Learn, Thread Pooling. スレッドプールに投入する作業項目とそこから呼ばれる関数はスレッドプールセーフでなければならず、実行スレッドが専用・永続スレッドであることを仮定してはならないこと、TLSの利用や永続スレッドを要する非同期呼び出しを避けるべきことについて。 ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). CreateThreadpoolで作成したプールに対してワーカースレッド数の上限を設定できること(下限はSetThreadpoolThreadMinimum)について。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
DllMainとローダーロック ── 「DLLの初期化で何もするな」と言われる本当の理由
DllMainでLoadLibraryやスレッド同期をしてはいけないのはなぜか。全DLL通知を直列化するローダーロックの仕組みから、デッドロックが成立する典型シナリオ、遅延初期化などの正しい設計、ハング調査の手順までを一次情報で解説します。
引数はなぜ壊れるか ── Windowsのコマンドライン引数の規則
Windowsでは引数の配列は存在せず、CreateProcessに渡るのは1本の文字列で、分割は受け取り側が行います。CommandLineToArgvW・CRT・.NETの分割規則と、.NETのArgumentList・C++での正しい組み立て方を解説します。
親が落ちたあとに何が残るか ── Job Objectで子プロセスを飼う
UIを強制終了してもSDKのヘルパーが残り、カメラやCOMポートを握ったままになるのはなぜか。Job Objectでプロセスツリーを一つの単位にし、KillOnJobCloseと完了ポートで子プロセスの寿命を設計する方法を計測アプリ目線で解説します。
名前付きパイプの実務 ── Windowsプロセス間通信の定番を設計からセキュリティまで
Windowsのプロセス間通信の定番・名前付きパイプを実務目線で解説します。バイト/メッセージモードの選択、複数クライアントを捌くサーバー設計、ACLと偽装のセキュリティ、.NETのNamedPipeStreamまで、一次情報にもとづき整理します。
「応答なし」の正体 ── Windowsがアプリを「固まった」と判定する仕組みと、固まらない設計
Windowsの「応答なし」は、ウィンドウが5秒間メッセージを取り出さないとOSが判定し、幽霊ウィンドウに差し替える仕組みです。判定の内部動作から、固まる定番原因、UIスレッドから重い処理を追い出す設計、ハングの調査手順まで解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- CreateThreadで自前のスレッドを作るのと比べて、スレッドプールは何がよいのですか?
- 短命な仕事を大量にこなす場面での効率と、スレッド管理コードの削減です。スレッドの生成・破棄には無視できないコストがあり、「仕事のたびにCreateThreadして終わったら破棄」を繰り返すアプリや、イベント待ちのためだけに眠っているスレッドを多数抱えるアプリは、プールに置き換えることでスレッド数とコンテキストスイッチを減らせます。公式ドキュメントも、小さな作業項目を大量に並列発行するアプリ、短命スレッドを多数作るアプリ、カーネルオブジェクトの待機専用スレッドを持つアプリなどをプールの適用対象として挙げています。逆に、優先度の変更が必要、COMのSTAが必要、長時間動き続ける専用処理など「スレッドそのものに個性が要る」仕事は、従来どおり専用スレッドで持つべきです。
- QueueUserWorkItemなど昔からあるスレッドプール関数とは何が違うのですか?
- Windows Vistaでスレッドプールは全面的に再設計され、現在のthreadpoolapiset系API(CreateThreadpoolWorkなど)が新API、QueueUserWorkItemやRegisterWaitForSingleObjectなどは旧(レガシー)APIという関係です。新APIはワーカースレッドの種別を一本化し、プロセス内に独立した複数のプールを作れ、クリーンアップグループによる一括解放や、コールバック完了と連動したロック解放・DLLアンロードなどの仕組みを備えます。公式も新APIのほうが簡単で信頼性・性能・柔軟性に優れるとしています。旧APIには「キューに入れた作業をキャンセルする方法がない」などの構造的制約もあるため、新規コードでは新APIを使ってください。
- コールバックの中でやってはいけないことはありますか?
- 大きく3つあります。第一に、長時間のブロックや長い処理を既定のまま行うこと。プールはコールバックが速やかに終わる前提でスレッド数を調整するため、長くかかる処理にはCallbackMayRunLongで宣言するか、専用スレッドを使います。第二に、同じプールに投入した別の作業の完了を同期的に待つこと。ワーカーが全員「他のワーカー待ち」になるとプール枯渇型のデッドロックを起こします。第三に、スレッドの個性に依存すること。ワーカースレッドはコールバック間で共有されるため、スレッド優先度やCOM初期化状態を変更したまま返す、TLSに状態を残す、といった行為は次のコールバックへの汚染になります。終了時の後始末(ロック解放やDLLアンロード)にはLeaveCriticalSectionWhenCallbackReturnsやFreeLibraryWhenCallbackReturnsという専用の仕組みが用意されています。
- DLLの中でスレッドプールを使うときの注意点はありますか?
- 最大の危険は「コールバックがまだ動いているのにDLLがアンロードされる」ことです。アンロード後にコールバックが実行されるとアクセス違反になります。DLL側は、終了処理でWaitForThreadpoolWorkCallbacksなどの待機関数(またはクリーンアップグループのCloseThreadpoolCleanupGroupMembers)で自分の発行したコールバックの完了を確実に待ってからオブジェクトを閉じる必要があります。ただしDllMainの中でこの待機を行うとローダーロックとの絡みでデッドロックしうるため、DllMainではなく明示的な終了関数で行うのが原則です。コールバック自身が「この処理が最後」という場面でDLLを解放したい場合のために、FreeLibraryWhenCallbackReturnsという専用APIが用意されています。
- C++のstd::asyncや.NETのThreadPoolがある今、このAPIを直接使う場面はありますか?
- あります。判断基準は「その層の道具で足りるか」です。C++でstd::asyncやstd::threadで足りる粒度の並行処理なら、移植性の観点でも標準ライブラリが第一候補です。一方、タイマー・カーネルオブジェクト待機・非同期I/O完了をひとつのコールバック機構に統合したい、プールを分けて仕事の種類ごとにスレッド数を制御したい、DLLやCOMコンポーネントの中で自前スレッドを持ちたくない、といった要求はWin32スレッドプールの守備範囲です。.NETのThreadPoolやIOCPとの関係は関連記事で扱っているとおりで、ネイティブで書く以上、下の層にあるこの仕組みを知っておくことは無駄になりません。