更新紀錄(僅初版,2026年08月22日 發布)
- 初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22176827)
以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。
Go Komura(2026)。〈Win32 執行緒集區 API ── 用 CreateThreadpoolWork 做「不自己建立執行緒」的並行〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/win32-thread-pool-api/
- DOI(已登錄存檔)
- 10.5281/zenodo.22176827
- DOI(上次登錄版本)
- 10.5281/zenodo.22176828
「每個用戶端一條 CreateThread」「計時器再來一條」「等事件再來一條」。在原生的 Windows 程式碼裡,人們很容易為了一點小工作就增加執行緒。執行緒每多一條就要消耗一份堆疊與核心物件,建立與銷毀本身也要付出成本。
吸收這個落差的,正是作業系統內建的 Win32 執行緒集區 API。應用程式把想執行的處理當成回呼交出去,工作者執行緒的管理則交給作業系統。這裡說的「不自己建立執行緒」,並不是說執行緒變得不需要,而是指應用程式不再每來一件工作就自己建立、自己銷毀。1
本文面向用 C/C++ 撰寫 Windows 應用程式、服務與 DLL 的開發者,依照如何取捨 → 選擇物件 → 實作 work → 回呼與結束處理的注意事項的順序解說。談的是 Windows Vista 上全面翻新的 CreateThreadpoolWork 系列 API。程式碼只是呈現用法骨架的節錄,佇列之類的應用程式專屬部分已經省略。
1. 先講結論
要大量處理短命工作,就不要管理執行緒,而要管理工作。但工作何時停止、何時可以釋放,得由應用程式自己設計。
判斷時看三條軸線。
- 先挑工具。標準 C++ 或 .NET 的工具夠用,就用它們。想在 Win32 上整合計時器、等待與 I/O 完成,或是想拆分集區,才輪到這套 API 出場。
- 以工作為單位交出去。使用新 API 的 work、timer、wait、io,而需要執行緒優先權、COM 的 STA 這類執行緒專屬狀態的工作,仍然留在專用執行緒上。
- 把結束一併設計進去。基本流程是「停止提交 → 等待完成 → 關閉」。回呼裡不要長時間封鎖,不要同步等待同一個集區上的工作,返回前把執行緒狀態復原。
想先跑起來的話,可以從第 4 章的 work 範例讀起,再確認第 5 章的回呼紀律。多個物件的整批結束在第 6 章,DLL 的卸載在第 7 章。
2. 如何取捨 ── 集區、專用執行緒、標準函式庫
2.1 適合集區的是短命、大量、以等待為主的工作
執行緒集區是一群由作業系統管理的工作者執行緒。工作者執行緒依序執行回呼,作業系統再依負載調整數量。把自己建立又銷毀執行緒的活兒交出去,就能省掉管理程式碼,也省掉建立與銷毀的負擔。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 有兩代。一代是從 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 遷移的去向是一對一決定的。既有程式碼的盤點可以從這張對照表開始。
3. 機制 ── 觸發條件各不相同的四種物件
3.1 依「由什麼來觸發」來選擇
新 API 的核心是下面這四種。選擇時看的不只是「要處理什麼」,更是想讓回呼由什麼來觸發。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 條執行緒只是為了「事件發出信號時動一下」而睡著,那就可以考慮換成 5 個 wait 物件。不再各自持有睡著的執行緒,而是把發出信號時的處理當成回呼來執行。
flowchart TB
accTitle: 把等待專用執行緒換成 wait 物件
accDescr: 原本每個事件各睡一條的等待專用執行緒,換成 wait 物件之後就匯集到集區的等待執行緒上,只在發出信號時才執行回呼
old2["等待專用執行緒 5 條各自睡著"] -.-> waste["消耗 5 份堆疊與執行緒"]
new2["wait 物件 5 個"] --> agg["匯集到集區的等待執行緒"]
agg --> cb2["僅在發出信號時執行回呼"]
圖 5: 「只是睡著等待的執行緒」可以透過 wait 物件化消除掉。這是遷移到集區時最容易上手的第一步。
4. 實作基礎 ── 建立 work、提交、安全結束
4.1 先把從建立到結束走一遍
用最基礎的 work 物件把用法走一遍。CreateThreadpoolWork 把回呼與 context 綁在一起,SubmitThreadpoolWork 請求執行。第三個引數為 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); // 帶互斥控制地取出一件
ProcessItem(item);
}
// 1) 建立(把回呼與共用的內容=佇列綁在一起)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* 用 GetLastError 做失敗處理 */ }
// 2) 每放入一件就提交一次(讓件數與提交次數一致)
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 在建立時就固定了。如果要用一個 work 處理 N 件不同的資料,就像上面的例子那樣,把同步過的佇列當成 context。資料每放入一件就提交一次,回呼則從佇列裡取出一件。採用每一項各建一個 work 物件的設計也沒問題。5
flowchart TB
accTitle: 從固定的 context 取得逐項的資料
accDescr: 在建立 work 時把 context 固定為共用佇列,提交端每放入一項就 Submit 一次,並行運作的各個回呼再從帶互斥控制的佇列裡逐件取出
create["建立 work 時固定 context"] -.-> queue["帶互斥控制的共用佇列"]
item["放入一項"] --> queue
item --> submit["每一項 Submit 一次"]
submit --> callbacks["各回呼並行運作"]
callbacks --> dequeue["從佇列逐件取出"]
queue --> dequeue
dequeue --> process["處理取出的項目"]
圖 7: 被固定的是指向佇列的 context,逐項的資料則由同步過的佇列來傳遞。
4.3 結束時,要先停止提交再等待
安全結束的順序是:「停止新的提交 → 用 WaitForThreadpoolWorkCallbacks 等待完成 → 用 CloseThreadpoolWork 關閉」。回呼所參照的記憶體,同樣不能在確認完成之前釋放。讓正在執行或正在排隊的處理留著不管卻釋放了參照目標,就會導致釋放後存取。6
只想著「我呼叫了完成等待所以安全」是不夠的。只要別的執行緒還能呼叫 SubmitThreadpoolWork,等待結束之後的提交就會跟 Close 撞在一起。要讓等待成為結束處理的分界線,前提是先把提交端停下來。
WaitForThreadpoolWorkCallbacks 的第二個引數為 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 的第三個引數,這裡正是傳入環境的位置。5
透過同一個環境,還能把清理群組也綁上去。自訂集區是執行去向,清理群組是善後的單位。把這兩個角色分開來看,結構就容易理清了。
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 把所屬物件的完成等待與釋放統一做完。46
這裡的目標同樣是不把正在執行的回呼丟下不管。第 4 章那種逐個結束 work 的形式,與依群組結束的形式,可以依要管理的物件數量分別選用。
7. 從 DLL 使用時 ── 不要在回呼之前卸載
7.1 完成等待要放在明確的結束函式裡,而不是 DllMain
DLL 裡最危險的,是回呼的程式碼還有可能被執行,那個 DLL 卻已經被卸載。執行已經被卸載的程式碼,就是存取違規。
基本做法是:在 DLL 明確的結束函式裡停止提交,等待回呼完成,關閉物件,然後再卸載。無論用逐個的等待函式,還是用第 6 章的清理群組,這一步完成確認都不能省。6
這個等待不能放在 DllMain 裡。因為它與載入器鎖定的關係會引發另一種死結。詳情在談 DllMain 與載入器鎖定的文章裡有說明。
7.2 FreeLibraryWhenCallbackReturns 要與提交前取得參照配成一對
「這個回呼是最後一件工作,希望它返回之後就放開 DLL 的參照」——這種場合有 FreeLibraryWhenCallbackReturns。4
不過,光靠這個 API 無法防止回呼開始之前的卸載。它做的事,是在正在執行的回呼返回時放開一個模組參照。用法是成對的:提交前用 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["返回之後釋放一個參照"]
圖 12: 不要把結束函式裡的完成等待,與為回呼取得的參照的釋放混為一談;兩者都要先把 DLL 的生命週期設計好。
8. 總結 ── 先從 work 開始,連同結束處理一起換掉
Win32 執行緒集區是把原生程式碼的並行處理從「建立執行緒」改為「把工作當成回呼交出去」的基礎。它能整理掉短命工作與等待專用執行緒,把執行緒管理交給作業系統。
導入可以分階段進行。先用 work,把建立、提交、停止提交、完成等待、釋放做成一套。接著把等待專用執行緒換成 wait,把計時器專用執行緒換成 timer。等到有需要時,再考慮 io 的整合與集區的拆分。
flowchart TB
accTitle: 把既有程式碼分階段移到執行緒集區
accDescr: 先盤點既有的自建執行緒,把適合標準函式庫或專用執行緒的工作留下,再把適合集區的工作連同停止提交與完成等待一起移過去,然後分階段把等待換成 wait、把計時器換成 timer
inventory["盤點既有的自建執行緒"] --> choose{"是否適合集區"}
choose -->|"否"| keep["選擇標準函式庫或專用執行緒"]
choose -->|"是"| work["把 work 與結束處理成套移過去"]
work --> more["等待換成 wait、計時器換成 timer"]
more --> optional["視需要做 I/O 整合與集區拆分"]
圖 13: 不只是減少執行緒,在每個階段都把結束處理備齊,才是分階段遷移的基本功。
要堅持到最後的,是不長時間封鎖、不同步等待同一個集區、不弄髒執行緒狀態這三項紀律。若是 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 四種物件建立函式、清理群組(CreateThreadpoolCleanupGroup),以及與回呼完成連動的善後(LeaveCriticalSectionWhenCallbackReturns、FreeLibraryWhenCallbackReturns 等)在內的函式一覽。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). 關於用回呼函式與內容指標建立 work 物件,關於第三個引數 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 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序
為什麼強制結束 UI 之後,SDK 的輔助處理程序仍然殘留,一直佔著攝影機或 COM 連接埠?本文從量測應用的角度,說明如何用 Job Object 把處理程序樹變成一個單位,並借助 KillOnJobClose 與完成埠來設計子處理程序的壽命。
具名管道實務 ── 從設計到安全,看懂 Windows 行程間通訊的標準做法
以實務角度說明 Windows 行程間通訊的標準做法——具名管道。依據一手資料整理位元組模式與訊息模式的取捨、同時接受多個用戶端的伺服器結構、ACL 與模擬的安全設計,以及 .NET 的具名管道串流。
「沒有回應」的真面目 ── Windows 如何判定應用程式卡住,以及不卡住的設計
Windows 的「沒有回應」,是視窗 5 秒沒有取出訊息時由作業系統判定、並換成幽靈視窗的機制。本文從判定的內部運作,講到卡住的經典原因、把重工作移出 UI 執行緒的設計,以及無回應的調查程序。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 跟用 CreateThread 自己建立執行緒相比,執行緒集區好在哪裡?
- 好在大量處理短命工作時的效率,以及執行緒管理程式碼的減少。建立與銷毀執行緒的成本不可忽視,因此反覆「每來一件工作就 CreateThread、做完就銷毀」的應用程式,或者養著大量只為了等待事件而睡著的執行緒的應用程式,換成集區就能減少執行緒數與內容切換。官方文件也把大量並行發出小型工作項目的應用程式、頻繁建立短命執行緒的應用程式、擁有專門等待核心物件的執行緒的應用程式列為集區的適用對象。反過來說,需要變更優先權、需要 COM 的 STA、需要長時間持續運轉的專用處理——這些「執行緒本身要有個性」的工作,仍應該像以前一樣放在專用執行緒上。
- 跟 QueueUserWorkItem 這類早期的執行緒集區函式有什麼不同?
- 執行緒集區在 Windows Vista 上被全面重新設計,現在的 threadpoolapiset 系列 API(CreateThreadpoolWork 等)是新 API,QueueUserWorkItem 與 RegisterWaitForSingleObject 等則是舊(傳統)API。新 API 統一了工作者執行緒的種類,可以在一個處理程序內建立多個彼此獨立的集區,還提供了透過清理群組批次釋放、以及與回呼完成連動的釋放鎖定與卸載 DLL 等機制。官方也表示新 API 更簡單,在可靠性、效能與彈性上都更好。舊 API 還有「無法取消已經排入佇列的工作」這類結構性限制,因此新的程式碼請使用新 API。
- 回呼裡有沒有不能做的事?
- 主要有三項。第一,什麼都不宣告就長時間封鎖或執行冗長的處理。集區是以「回呼會迅速結束」為前提來調整執行緒數,因此耗時較長的處理要嘛用 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 之間的關係,相關文章裡已經說明;只要還在寫原生程式碼,了解底下這一層的機制就不會白費。