「クライアントごとに CreateThread」「タイマーのために1本」「イベント待ちのために1本」── ネイティブのWindowsコードでは、こうしてスレッドが増殖しがちです。スレッドは1本ごとにスタックとカーネルオブジェクトを消費し、生成と破棄にもコストがかかります。仕事は小刻みなのにスレッドは重い ── このミスマッチを吸収するためにOSが用意しているのがスレッドプールです。
.NETの ThreadPool や Task.Run の便利さはよく知られていますが、実はWin32ネイティブにも、OS標準のよくできたスレッドプールAPIがあります。Windows Vistaで全面刷新されたこのAPIは、仕事(work)・タイマー(timer)・待機(wait)・非同期I/O(io)を統一的なコールバック機構で扱える、ネイティブ並行処理の土台です。この記事では、C/C++でWindowsアプリ・サービス・DLLを書く開発者を対象に、このAPIの構造と使い方、そして踏みやすい落とし穴を一次情報にもとづいて解説します。
1. まず結論
- 短命な仕事の大量発行・待機専用スレッドの置き換えには、自前
CreateThreadよりスレッドプールです。スレッド管理をOSに任せ、スレッド数とコンテキストスイッチを減らせます。1 - 使うべきは新API(
CreateThreadpoolWork系)です。Vistaでの再設計により、旧API(QueueUserWorkItem系)より簡単・高信頼・高性能で、プロセス内に独立した複数プールも作れます。12 - オブジェクトは4種類。仕事を積む work、時刻・周期で発火する timer、カーネルオブジェクトのシグナルで発火する wait、非同期I/O完了で発火する io。すべて同じコールバック機構に乗ります。3
- 終了処理は「待ってから閉じる」。
WaitForThreadpoolWorkCallbacks系での完了待機か、クリーンアップグループによる一括処理で、実行中コールバックを置き去りにしない規律が必須です。4 - コールバックの中では、長時間ブロックしない(するなら
CallbackMayRunLong)・同じプールの完了を同期待ちしない・スレッドの状態を汚さない。この3つが鉄則です。56 - DLLからの利用はアンロード競合に注意。完了待機を明示的な終了関数で行い、
FreeLibraryWhenCallbackReturnsなどの専用APIを知っておきます。3
2. なぜプールか、いつプールか
スレッドプールの発想は単純です。スレッドを仕事ごとに作るのではなく、OSが管理するワーカースレッドの一群に仕事(コールバック)を投げ込む。ワーカーは仕事を次々と実行し、負荷に応じてOSが本数を調整します。
公式ドキュメントは、プールが効くアプリの型を具体的に挙げています。1
- 小さな作業項目を大量に並列発行するアプリ(検索、ネットワークI/Oなど)
- 短命なスレッドを頻繁に作っては壊すアプリ
- 独立した作業をバックグラウンドで並列処理するアプリ
- カーネルオブジェクトの待機やイベント待ちの専用スレッドを抱えるアプリ
最後の項目は見逃されがちです。「イベントがシグナルされたら動く」ためだけに眠っているスレッドが5本あるなら、それはプールの wait オブジェクト5個に置き換えられ、待機はプールの待機スレッドに集約されます。
逆に、プールに向かない仕事もあります。スレッド優先度の変更が必要なもの、COMのSTAを要するもの、プロセスの生存期間ずっと動き続けるもの ── スレッドに「個性」が要る仕事は専用スレッドで持ちます。ワーカースレッドは共有資源であり、借り物だからです。
flowchart TB
accTitle: 専用スレッドとプールの使い分け
accDescr: 優先度やSTAなどスレッドの個性が必要か、長時間動き続けるかをまず確認し、どちらも該当しない短命・大量・待機系の仕事だけをスレッドプールに乗せる
q1{"優先度・STAなど個性が必要?"} -->|"はい"| ded["専用スレッドで持つ"]
q1 -->|"いいえ"| q2{"長時間動き続ける?"}
q2 -->|"はい"| ded
q2 -->|"いいえ"| pool["スレッドプールに乗せる"]
pool -.-> ex["短命な仕事・待機・タイマー・I/O完了"]
図1: プールに乗せてよいのは「個性が要らず、短く終わる」仕事だけ。それ以外は従来どおり専用スレッドで。
歴史的経緯もひとつ押さえておきます。スレッドプールAPIには2世代あります。Windows 2000から続く旧API(QueueUserWorkItem、RegisterWaitForSingleObject など)と、Vistaで全面再設計された新API(CreateThreadpoolWork 系)です。新APIはワーカースレッド種別の一本化、専用の永続スレッド、プロセス内複数プール、クリーンアップグループなどを備え、公式も「より簡単で、信頼性が高く、性能がよく、柔軟」と明言しています。1 旧APIには「一度キューに入れた作業をキャンセルできない」といった構造的な制約もあります。2 本記事は以降、新APIのみを扱います。
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"]
図2: 旧APIからの移行先は1対1で決まっている。既存コードの棚卸しはこの対応表から始められる。
3. 4つのオブジェクト ── work・timer・wait・io
新APIの中心は、コールバックの発火条件が異なる4種類のオブジェクトです。3
| オブジェクト | 作成関数 | コールバックの発火条件 |
|---|---|---|
| work | CreateThreadpoolWork | SubmitThreadpoolWorkで投入されたとき |
| timer | CreateThreadpoolTimer | 指定時刻・周期が来たとき |
| wait | CreateThreadpoolWait | カーネルオブジェクトがシグナルになったとき |
| io | CreateThreadpoolIo | 関連付けたハンドルの非同期I/Oが完了したとき |
flowchart TB
accTitle: スレッドプールの4オブジェクトとコールバック機構
accDescr: workは明示的な投入、timerは時刻、waitはカーネルオブジェクトのシグナル、ioは非同期I/O完了を発火条件とし、いずれも同じワーカースレッド群の上でコールバックとして実行される
w["work(投入で発火)"] --> pool["ワーカースレッド群がコールバックを実行"]
t["timer(時刻・周期で発火)"] --> pool
wt["wait(シグナルで発火)"] --> pool
io["io(I/O完了で発火)"] --> pool
図3: 発火条件が違うだけで、4種類とも「同じプールのワーカーがコールバックを実行する」仕組みに統合されている。
この統合が実務上の強みです。周期処理・イベント応答・I/O完了処理を、それぞれ専用スレッドで書く代わりに、ひとつのコールバック様式に揃えられます。タイマーはプール全体で単一のタイマーキューにまとめられ、待機は少数の待機スレッドに集約される ── 「眠っているだけのスレッド」がプロセスから消えていきます。1
flowchart TB
accTitle: 待機専用スレッドのwaitオブジェクトへの置き換え
accDescr: イベントごとに1本ずつ眠って待っていた待機専用スレッドは、waitオブジェクトに置き換えるとプールの待機スレッドに集約され、シグナルされたときだけコールバックが実行される形になる
old2["待機専用スレッド×5本が個別に眠る"] -.-> waste["スタックとスレッドを5本分消費"]
new2["waitオブジェクト×5個"] --> agg["プールの待機スレッドに集約"]
agg --> cb2["シグナル時だけコールバック実行"]
図4: 「眠って待つだけのスレッド」はwaitオブジェクト化で消せる。これがプール移行の分かりやすい初手になる。
4. 基本の使い方 ── workオブジェクトで一巡り
もっとも使用頻度の高いworkオブジェクトで、作法を一巡りします。4
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);
押さえどころは2つです。第一に、同じworkオブジェクトを複数回 SubmitThreadpoolWork してよいこと。投入のたびにコールバックが(並列に)実行されます。7 ただしコールバックに渡されるcontextは作成時に固定なので、「同種の仕事をN件」を1個のworkで書くときは、上のコードのように同期されたキューをcontextにして投入1回につき1件取り出す形にします(項目ごとにworkオブジェクトを作る設計でも構いません)。第二に、閉じる前に必ず完了を待つこと。実行中・実行待ちのコールバックを残したままオブジェクトを閉じたり、コールバックが参照するメモリを解放したりするのは、そのまま解放後アクセスです。この待機を安全な締めにするには、先に投入側を止めておくことが前提です ── 待機と並行して別スレッドがまだ Submit できる構造では、待機後の投入と Close が競合します。WaitForThreadpoolWorkCallbacks の第2引数にTRUEを渡すと、まだ開始していない投入分の取り消しも試みられます。
flowchart TB
accTitle: workオブジェクトのライフサイクル
accDescr: CreateThreadpoolWorkで作成し、SubmitThreadpoolWorkで投入するとコールバックが並列実行される。終了時はまず新規投入を止め、WaitForThreadpoolWorkCallbacksで全コールバックの完了を待ってからCloseThreadpoolWorkで閉じる
c["CreateThreadpoolWorkで作成"] --> s["SubmitThreadpoolWorkで投入(複数回可)"]
s --> run["コールバックが並列実行される"]
run --> stop3["新規投入を止める"]
stop3 --> w["WaitForThreadpoolWorkCallbacksで完了待機"]
w --> cl["CloseThreadpoolWorkで閉じる"]
図5: 終了の順序は「投入を止める→完了を待つ→閉じる」。どれかを飛ばすと解放後アクセスや競合になる。
既定では、コールバックはプロセス既定のプールで実行されます。多くの用途はこれで足ります。プールを分けたくなったときが次章です。
5. カスタムプールとクリーンアップグループ
プールを分ける。CreateThreadpool で独立したプールを作り、SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum でスレッド数の上限・下限を設定できます。8 用途の典型は隔離です。「遅くてもよいバッチ的な仕事」が「即応してほしい仕事」のワーカーを食い潰さないよう、プールを分けてスレッド数の予算を別々に与えます。
コールバック環境で結びつける。どのプールで実行するかは、TP_CALLBACK_ENVIRON(コールバック環境)を初期化して SetThreadpoolCallbackPool でプールを指し、それを CreateThreadpoolWork などの第3引数に渡すことで指定します。9
クリーンアップグループで畳む。オブジェクトを何個も作るモジュールでは、終了処理が「全部について待って、全部閉じる」の羅列になりがちです。CreateThreadpoolCleanupGroup でグループを作り、コールバック環境経由で各オブジェクトを所属させておくと、CloseThreadpoolCleanupGroupMembers の1回で所属オブジェクト全体の完了待機と解放をまとめて行えます。34
flowchart TB
accTitle: コールバック環境による構成の結びつけ
accDescr: コールバック環境がカスタムプールとクリーンアップグループを指し、その環境を渡して作成したworkやtimerはそのプールで実行され、クリーンアップグループの一括処理で完了待機と解放をまとめられる
env["コールバック環境(TP_CALLBACK_ENVIRON)"] --> cp["カスタムプール(スレッド数を制御)"]
env --> cg["クリーンアップグループ"]
env --> obj["work / timer / wait / io の作成に渡す"]
cg -.-> close["一括で完了待機と解放"]
図6: コールバック環境は「どのプールで動き、誰が後始末するか」をオブジェクト作成時に注入する仕組み。
6. 落とし穴 ── コールバックの中の規律
スレッドプールの不具合は、ほぼすべて「借り物のスレッドで好き勝手をした」ことに起因します。
長時間ブロックする。プールはコールバックが速やかに返る前提でスレッド数を調整しています。既定のまま長い処理・長い待機をすると、他のコールバックの実行が遅延します。長くなりうるコールバックは CallbackMayRunLong で「長く走る」と宣言するか(プールはスレッド追加のヒントにします)、そもそも専用スレッドに逃がします。なお CallbackMayRunLong は、他のコールバックのためのワーカーを用意できない場合にFALSEを返します。戻り値を確認せずにブロックし続ければ結局プールを詰まらせるので、FALSEのときは処理を分割する・専用スレッドへ逃がすなど、ブロックしない側に倒してください。5
同じプールの完了を同期待ちする。コールバックAの中で、同じプールに投入した仕事Bの完了を WaitForThreadpoolWorkCallbacks などで待つ形は、ワーカーが全員「他のワーカー待ち」になった瞬間にプール枯渇デッドロックになります。仕事の依存関係は、待つのではなく「Bの完了コールバックから次を投入する」継続の形に書き換えます。
flowchart TB
accTitle: プール枯渇デッドロックの構造
accDescr: 全ワーカースレッドが同じプールに投入した別の仕事の完了を同期待ちすると、その仕事を実行できる空きワーカーが存在せず、全員が永遠に待ち続けるデッドロックになる
w1["ワーカー1:仕事Xの完了を待機"] --> q["実行待ちの仕事X・Y"]
w2["ワーカー2:仕事Yの完了を待機"] --> q
q -.-> none["実行できる空きワーカーがいない"]
none -.-> dead["全員が永遠に待つ(枯渇デッドロック)"]
図7: ワーカーの中でワーカーを同期待ちすると、待たれている仕事を実行する者がいなくなる。
スレッドの状態を汚す。ワーカースレッドは次のコールバックに使い回されます。スレッド優先度の変更、COMの初期化状態、TLSに残した値、抜け忘れたロック ── どれも次の(無関係な)コールバックへの汚染になります。旧APIの時代から「プールに投げる関数はスレッドの個性に依存するな」が公式の注意事項です。6 後始末には専用の仕組みがあり、たとえば LeaveCriticalSectionWhenCallbackReturns は「このコールバックが返ったらこのロックを解放する」をプールに依頼できます。3
DLLアンロードとの競合。コールバックが動いている最中にそのコードを含むDLLがアンロードされると、アクセス違反になります。DLL側の終了関数で完了待機を徹底するのが基本で、「このコールバックが最後の仕事、終わったら自分ごとDLLを解放してほしい」という場面のために FreeLibraryWhenCallbackReturns が用意されています。ただしこのAPIは「実行中のコールバックが返るときに参照を1つ手放す」だけで、開始前のアンロードは防げません。投入前に GetModuleHandleEx で自分専用のモジュール参照を確保し、コールバックがその参照をこのAPIで手放す、という対で使います。3 なお、この完了待機を DllMain の中でやってはいけません ── DllMainとローダーロックの記事で述べたとおり、DllMain での他スレッド待機はデッドロックの型だからです。
flowchart TB
accTitle: DLLアンロードとコールバックの競合への備え
accDescr: コールバック実行中にDLLがアンロードされるとアクセス違反になるため、明示的な終了関数で完了を待機してから閉じるのが基本で、最後のコールバック自身がDLLを解放する場面にはFreeLibraryWhenCallbackReturnsを使う
risk["実行中にDLLアンロード"] -.-> av["アクセス違反"]
g1["終了関数で完了待機してから閉じる"] --> safe["安全なアンロード"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g1 -.-> ng["DllMainの中では行わない"]
図8: 「待ってから閉じる」を明示的な終了関数で行うのが基本形。DllMainでの待機は別のデッドロックを呼ぶ。
投入した仕事の中の例外・クラッシュ。ワーカースレッド上の未処理例外はプロセスを道連れにします。コールバックの入口で包括的に例外を捕捉してログする方針は、専用スレッドのスレッド関数と同じように適用してください。
7. 標準ライブラリ・.NETとの関係 ── どの層で書くか
最後に、他の道具との住み分けを整理します。
- C++の
std::async/std::threadで足りるなら、それが第一候補です。移植性があり、コードも短く、futureのセマンティクスまで含めて規格で決まっています。10 - Win32スレッドプールを直接使う理由は、(1) timer・wait・ioを含む統合コールバック機構が欲しい、(2) プール分割やスレッド数制御が欲しい、(3) DLL・COMコンポーネントの中で自前スレッドを持ちたくない、のいずれかがあるときです。
- .NET側では
ThreadPoolとTaskが同じ役割を担い、I/O完了はIOCPと結びついています。この地下構造はIOCPと.NETスレッドプールの記事で解説しています。
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
図9: 迷ったらまず標準ライブラリ、それで表現できない要求が出たときがこのAPIの出番。
つまりこのAPIは、「ネイティブで書く」と決めた時点での並行処理の土台です。自前 CreateThread の乱立からの移行先として、まずworkオブジェクトから導入し、待機専用スレッドをwaitに、タイマースレッドをtimerに置き換えていく ── という段階的な整理が現実的です。
8. まとめ
- 短命な仕事の大量発行、待機・タイマー専用スレッドの整理には、自前スレッドよりOS標準のスレッドプール。使うのはVista以降の新API。
- 中心はwork・timer・wait・ioの4オブジェクト。発火条件が違うだけで、同じワーカー群・同じコールバック様式に統合される。
- 作法は「作成→投入→完了を待って→閉じる」。複数投入は並列実行。クリーンアップグループで終了処理を一括化できる。
- コールバックの鉄則は3つ: 長時間ブロックしない(するなら
CallbackMayRunLong)、同じプールを同期待ちしない、スレッドの状態を汚さない。 - DLLからの利用はアンロード競合に注意。完了待機は明示的な終了関数で行い、
DllMainではやらない。 - 標準C++や.NETで足りる場面ではそちらを。timer/wait/ioの統合やプール制御が要るときが、このAPIの出番。
スレッドプールAPIは、Win32の中では新しく設計のよい部類の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, Thread Pooling. 旧来のスレッドプールAPI(QueueUserWorkItem、タイマーキュー、登録済み待機、BindIoCompletionCallback)の構造、キューに入れた作業をキャンセルする方法がないこと、Vistaで導入された新しいスレッドプールAPIのほうが簡単で信頼性・性能・柔軟性に優れると明記されていることについて。 ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. CreateThreadpoolWork・CreateThreadpoolTimer・CreateThreadpoolWait・CreateThreadpoolIoの4種のオブジェクト作成関数、クリーンアップグループ(CreateThreadpoolCleanupGroup)、コールバック完了と連動する後始末(LeaveCriticalSectionWhenCallbackReturns、FreeLibraryWhenCallbackReturnsなど)を含む関数一覧について。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Using the Thread Pool Functions. CreateThreadpoolWorkで作成しSubmitThreadpoolWorkで投入、WaitForThreadpoolWorkCallbacksで完了を待ちCloseThreadpoolWorkで閉じる基本手順、カスタムプールとコールバック環境・クリーンアップグループを組み合わせた構成例について。 ↩ ↩2 ↩3
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). 現在のコールバックが長時間実行される可能性をプールに通知し、プールが他のコールバックのためのスレッド確保を判断する材料にすること、長時間コールバックには可能なら専用スレッドの利用も検討すべきことについて。 ↩ ↩2
-
Microsoft Learn, Thread Pooling. スレッドプールに投入する作業項目とそこから呼ばれる関数はスレッドプールセーフでなければならず、実行スレッドが専用・永続スレッドであることを仮定してはならないこと、TLSの利用や永続スレッドを要する非同期呼び出しを避けるべきことについて。 ↩ ↩2
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). 同じworkオブジェクトを先行コールバックの完了を待たずに複数回投入でき、コールバックが並列に実行されること、効率のためプールがスレッド数を調整(スロットリング)しうることについて。 ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). CreateThreadpoolで作成したプールに対してワーカースレッド数の上限を設定できること(下限はSetThreadpoolThreadMinimum)について。 ↩
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). コールバック関数と文脈ポインターからworkオブジェクトを作成すること、第3引数のTP_CALLBACK_ENVIRONでコールバックの実行環境(所属プールなど)を指定でき、NULLなら既定環境で実行されることについて。 ↩
-
Microsoft Learn, <future>. std::asyncとfutureによるタスク単位の非同期実行が標準ライブラリとして提供されており、スレッドを直接管理せずに並行処理を書けることについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
DllMainとローダーロック ── 「DLLの初期化で何もするな」と言われる本当の理由
DllMainでLoadLibraryやスレッド同期をしてはいけないのはなぜか。全DLL通知を直列化するローダーロックの仕組みから、デッドロックが成立する典型シナリオ、遅延初期化などの正しい設計、ハング調査の手順までを一次情報で解説します。
名前付きパイプの実務 ── Windowsプロセス間通信の定番を設計からセキュリティまで
Windowsのプロセス間通信の定番・名前付きパイプを実務目線で解説します。バイト/メッセージモードの選択、複数クライアントを捌くサーバー設計、ACLと偽装のセキュリティ、.NETのNamedPipeStreamまで、一次情報にもとづき整理します。
「応答なし」の正体 ── Windowsがアプリを「固まった」と判定する仕組みと、固まらない設計
Windowsの「応答なし」は、ウィンドウが5秒間メッセージを取り出さないとOSが判定し、幽霊ウィンドウに差し替える仕組みです。判定の内部動作から、固まる定番原因、UIスレッドから重い処理を追い出す設計、ハングの調査手順まで解説します。
スプリアスウェイクアップ ── 条件変数が「通知なしに目覚める」理由とWindowsでの正しい待ち方
条件変数のwaitは通知が来ていなくても目覚めることがあります(スプリアスウェイクアップ)。仕様がそれを許す理由をWindowsの実装から解き明かし、whileと述語で書く正しい待ち方をWin32・C++・C#のコードで示します。
スリープ復帰で壊れるアプリ ── 電源イベントの仕組みと復帰に強い業務アプリの作り方
ノートPCを開いたら業務アプリの通信が切れていた――原因はスリープを想定しない設計です。WM_POWERBROADCASTによる通知の流れ、Modern Standbyの挙動、切断・再接続の設計、スリープ抑止と調査コマンドまでを一次情報で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
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との関係は関連記事で扱っているとおりで、ネイティブで書く以上、下の層にあるこの仕組みを知っておくことは無駄になりません。