Win32 執行緒集區 API ── 用 CreateThreadpoolWork 做「不自己建立執行緒」的並行

· · Windows, 多執行緒, C++, Windows 開發, Win32 API, 效能改善

「每個用戶端一條 CreateThread。」「計時器一條。」「等事件再一條。」── 原生 Windows 程式碼裡,執行緒往往就這樣增生。每條執行緒消耗堆疊與核心物件,建立與銷毀也有成本。工作很細,執行緒卻很重──作業系統為吸收這道落差而提供的,就是執行緒集區

.NET 的 ThreadPoolTask.Run 有多方便眾所周知,但其實Win32 原生也有設計良好、作業系統標準的執行緒集區 API。在 Windows Vista 全面重新設計後,這個 API 能用統一的回呼機制處理工作、計時器、等待與非同步 I/O,是原生並行的基礎。本文以用 C/C++ 撰寫 Windows 應用程式、服務與 DLL 的開發者為對象,依一次資訊解說這個 API 的結構、用法,以及容易踩到的陷阱。

1. 先講結論

  • 大量發出短命工作、以及取代只負責等待的執行緒,執行緒集區勝過自己 CreateThread把執行緒管理交給作業系統,就能減少執行緒數與內容切換。1
  • 該用的是新 API(CreateThreadpoolWork 系列)。Vista 重新設計後,它比舊 API(QueueUserWorkItem 系列)更簡單、更可靠、效能更高,也能在一個處理程序內建立數個獨立集區。12
  • 物件有四種。提交工作的 work、依時刻或週期觸發的 timer、核心物件收到訊號時觸發的 wait、非同步 I/O 完成時觸發的 io。全部走同一套回呼機制。3
  • 關閉是「先等,再關」。WaitForThreadpoolWorkCallbacks 系列等待完成,或透過清理群組一次處理──不把執行中回呼丟下的紀律是必要的。4
  • 回呼裡:不要長時間阻塞(若會,用 CallbackMayRunLong);不要同步等待同一集區上的完成;不要弄髒執行緒狀態。這三條是鐵則。56
  • 從 DLL 使用:小心卸載競態。在明確的關閉函式裡等待完成,並知道 FreeLibraryWhenCallbackReturns 這類專用 API。3

2. 為什麼用集區,何時用集區

執行緒集區的想法很單純。與其每個工作建一條執行緒,不如把工作(回呼)丟給作業系統管理的一組工作者執行緒。工作者依序執行工作,作業系統依負載調整數量。

官方文件列出集區划算的具體應用類型。1

  • 大量並行發出小型工作項目的應用程式(搜尋、網路 I/O 等)
  • 頻繁建立與拆除短命執行緒的應用程式
  • 在背景並行處理獨立工作的應用程式
  • 持有專門等待核心物件或事件的執行緒的應用程式

最後一項容易被漏掉。若有五條執行緒只為了「事件收到訊號時才跑」而睡著,那些可以換成集區上的五個 wait 物件,等待就被彙整到集區的等待執行緒上。

反過來說,也有不適合集區的工作。需要變更執行緒優先權、需要 COM STA、在處理程序整個生命週期持續跑──需要執行緒「個性」的工作放在專用執行緒上。工作者執行緒是共用資源;是借來的。

專用執行緒與集區的選擇先檢查是否需要優先權或 STA 等執行緒個性,以及是否會長時間執行;兩者都不符合的短命、大量或等待型工作才放到執行緒集區需要優先權或 STA 等個性?留在專用執行緒會長時間執行?放到執行緒集區短命工作、等待、計時器、I/O 完成

圖 1: 能放到集區的,只有「不需要個性、而且很快結束」的工作。其他仍留在專用執行緒。

還有一段歷史要釘死。執行緒集區 API 有兩代。從 Windows 2000 延續下來的舊 API(QueueUserWorkItemRegisterWaitForSingleObject 等),以及 Vista 全面重新設計的新 APICreateThreadpoolWork 系列)。新 API 統一工作者執行緒種類,提供專用常駐執行緒、一個處理程序內的多個集區、清理群組等,官方文件也明文說它「更簡單、更可靠、效能更好、更有彈性」。1 舊 API 還有「一旦排入佇列就無法取消」這類結構限制。2 以下本文只談新 API。

舊執行緒集區 API 與新 API 的對應舊 API 的 QueueUserWorkItem 對應新 API 的 work 物件,計時器佇列對應 timer,已註冊的等待對應 wait,BindIoCompletionCallback 對應 ioQueueUserWorkItemworkTimer queuestimerRegistered waitswaitBindIoCompletionCallbackio

圖 2: 從舊 API 遷移的目標是一對一。盤點既有程式碼可從這張對應開始。

3. 四種物件 ── work、timer、wait、io

新 API 的核心是回呼觸發條件不同的四種物件。3

物件 建立函式 回呼何時觸發
work CreateThreadpoolWork 以 SubmitThreadpoolWork 提交時
timer CreateThreadpoolTimer 指定的時刻或週期到達時
wait CreateThreadpoolWait 核心物件變成已收到訊號時
io CreateThreadpoolIo 關聯控制代碼上的非同步 I/O 完成時
執行緒集區的四種物件與回呼機制work 在明確提交時觸發,timer 依時間,wait 依核心物件訊號,io 依非同步 I/O 完成;全部都在同一組工作者執行緒上以回呼執行哪一種物件?work(提交時)計時器、等待或 io?timer(時刻/週期)等待或 io?wait(訊號時)io(I/O 完成)工作者執行回呼

圖 3: 觸發條件不同,但四種都統一成「同一集區上的工作者執行回呼」這套機制。

這份統一在實務上是優勢。不必把週期處理、事件回應、I/O 完成處理各自寫在專用執行緒上,就能對齊成同一種回呼風格。計時器彙整到整個集區的單一計時器佇列,等待彙整到少數等待執行緒──「只會睡著」的執行緒從處理程序裡消失。1

用 wait 物件取代只負責等待的執行緒以往每個事件各睡一條的等待專用執行緒變成 wait 物件,彙整到集區的等待執行緒上,因此只有收到訊號時才執行回呼5 條各自睡著的等待專用執行緒消耗 5 個堆疊與 5 條執行緒5 個 wait 物件彙整到集區的等待執行緒只有收到訊號時才執行回呼

圖 4: 「只睡著等待」的執行緒,改成 wait 物件就能拿掉。這是集區遷移清楚的第一步。

4. 基本模式 ── 用 work 物件走一趟來回

我們用最常用的 work 物件把規矩走一遍。4

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // The context is fixed at creation time. Per-item data is passed through a synchronised queue
    WORK_QUEUE* queue = (WORK_QUEUE*)context;
    ITEM* item = Dequeue(queue);        // Take one item under exclusive control
    ProcessItem(item);
}

// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }

// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);

// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);

// 4) Close
CloseThreadpoolWork(work);

有兩點要抓住。第一,同一個 work 物件可以多次 SubmitThreadpoolWork。每次提交都會執行回呼(可並行)。7 不過,傳給回呼的內容(context)在建立時就固定,因此用一個 work 物件寫「同一類工作的 N 個項目」時,要像上面程式碼那樣把同步佇列放進內容,每次提交取一個項目(每個項目建一個 work 物件也可以)。第二,關閉前一律等待完成。還有執行中或已排入的回呼就關閉物件,或釋放回呼所參照的記憶體,就是直接的 use-after-free。要讓這次等待成為安全關閉,先停掉提交端是前提──另一條執行緒還能與等待並行 Submit 的結構裡,等待之後的提交會和 Close 競態。把 WaitForThreadpoolWorkCallbacks 的第二個引數傳 TRUE,也會嘗試取消尚未開始的提交。

work 物件的生命週期以 CreateThreadpoolWork 建立;以 SubmitThreadpoolWork 提交後回呼並行執行。關閉時先停止新的提交,以 WaitForThreadpoolWorkCallbacks 等待所有回呼完成,再以 CloseThreadpoolWork 關閉以 CreateThreadpoolWork 建立以 SubmitThreadpoolWork 提交(可重複)回呼並行執行停止新的提交以 WaitForThreadpoolWorkCallbacks 等待完成以 CloseThreadpoolWork 關閉

圖 5: 關閉順序是「停提交 → 等完成 → 關閉」。跳過任何一步都會 use-after-free 或競態。

預設情況下,回呼跑在處理程序預設集區上。對許多用途那就夠了。下一章是想拆分集區時才看。

5. 自訂集區與清理群組

拆分集區。可用 CreateThreadpool 建立獨立集區,再用 SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum 設定執行緒數上下限。8 典型用途是隔離。為了讓「可能很慢的批次型工作」不要吃掉「需要立刻回應的工作」的工作者,把集區拆開,各自給執行緒預算。

用回呼環境綁定。工作跑在哪個集區,是初始化 TP_CALLBACK_ENVIRON(回呼環境)、用 SetThreadpoolCallbackPool 指向該集區,再把它當作 CreateThreadpoolWork 等的第三個引數來指定。9

用清理群組收攏。在建立許多物件的模組裡,關閉處理容易變成「全部等完、全部關閉」的朗讀。若用 CreateThreadpoolCleanupGroup 建立群組,並透過回呼環境把各物件掛上去,一次 CloseThreadpoolCleanupGroupMembers 就能對每個成員物件一併做完成等待與釋放34

透過回呼環境綁定組態回呼環境指向自訂集區與清理群組;用該環境建立的 work 與 timer 跑在該集區上,對清理群組的一次操作就收攏完成等待與釋放回呼環境(TP_CALLBACK_ENVIRON)自訂集區(控制執行緒數)清理群組建立 work/timer/wait/io 時傳入一次完成等待與釋放

圖 6: 回呼環境是在物件建立時注入「跑在哪個集區、誰來清理」的機制。

6. 陷阱 ── 回呼裡的紀律

幾乎所有執行緒集區錯誤,都來自「在借來的執行緒上隨自己高興」。

長時間阻塞。集區假設回呼會迅速返回來調整執行緒數。在預設情況下做長時間工作或長時間等待,會延遲其他回呼的執行。可能跑很久的回呼,應用 CallbackMayRunLong 宣告「這會跑很久」(集區把它當成加執行緒的提示),或一開始就送到專用執行緒。注意 CallbackMayRunLong 在無法為其他回呼準備工作者時會傳回 FALSE。不檢查傳回值就繼續阻塞,一樣會堵住集區,因此為 FALSE 時要落到不阻塞的那一側──拆開工作、送到專用執行緒等。5

同步等待同一集區上的完成。在回呼 A 裡,用 WaitForThreadpoolWorkCallbacks 等等待提交到同一集區的工作 B 完成,這種形態在每個工作者都變成「在等另一個工作者」的瞬間,就會變成集區飢餓死結。工作之間的相依不要寫成等待,而要寫成「從 B 的完成回呼提交下一件」的延續。

集區飢餓死結的結構若每條工作者執行緒都同步等待提交到同一集區的其他工作完成,就沒有空閒工作者去跑那些工作,大家永遠等下去工作者 1:在等作品 X 完成作品 X 與 Y 在等執行工作者 2:在等作品 Y 完成沒有空閒工作者去跑它們大家永遠等(飢餓死結)

圖 7: 從工作者裡面同步等待另一個工作者,就沒人去跑被等待的工作。

弄髒執行緒狀態。工作者執行緒會被下一個回呼重用。變更執行緒優先權、COM 初始化狀態、留在 TLS 的值、忘了離開的鎖定──任何一項都會變成下一個(無關)回呼的汙染。「丟到集區的函式不得依賴執行緒個性」從舊 API 時代就是官方警告。6 清理有專用機制;例如 LeaveCriticalSectionWhenCallbackReturns 可請集區「這個回呼返回時釋放這個鎖定」。3

與 DLL 卸載的競態。回呼還在跑就卸載含有該程式碼的 DLL,會發生存取違規。基本形態是在 DLL 的關閉函式裡徹底等待完成;FreeLibraryWhenCallbackReturns 則是為「這個回呼是最後一件工作,結束時希望連自己一起把 DLL 釋放」這種情況而提供。不過這個 API 只是「執行中的回呼返回時放開一個參照」;它不阻止回呼開始前的卸載。用法是成對的:提交前用 GetModuleHandleEx 自己拿一份模組參照,再讓回呼用這個 API 放開那份參照。3 而且不可在 DllMain 裡做這次完成等待──如「DllMain 與載入器鎖定」所說,在 DllMain 裡等待另一條執行緒是死結模式。

為 DLL 卸載與回呼的競態做準備回呼執行中卸載 DLL 會變成存取違規,因此基本形態是在明確的關閉函式裡等待完成再關閉;最後一個回呼自己釋放 DLL 時,用 FreeLibraryWhenCallbackReturns回呼期間卸載存取違規先等,再關安全卸載在關閉函式裡FreeLibraryWhenCallbackReturns最後回呼釋放 DLL不要在 DllMain 裡

圖 8: 基本形態是在明確的關閉函式裡做「先等,再關」。在 DllMain 裡等待會招來另一種死結。

已提交工作裡的例外與當機。工作者執行緒上未處理的例外會連處理程序一起帶走。在回呼入口全面捕捉例外並記錄,套用和專用執行緒的執行緒函式相同的政策。

7. 與標準程式庫、.NET 的關係 ── 寫在哪一層

最後整理它和其他工具的位置。

  • 若 C++ 的 std::async / std::thread 就夠,它們是第一候補。可攜、程式碼短,連 future 的語意也由標準定好。10
  • 直接用 Win32 執行緒集區的理由是:(1) 想要包含 timer、wait、io 的統一回呼機制,(2) 想拆分集區或控制執行緒數,或 (3) 不想在 DLL 或 COM 元件裡自己持有執行緒。
  • 在 .NET 端ThreadPoolTask 扮演相同角色,I/O 完成綁在 IOCP 上。這層地下室結構見「IOCP 與 .NET 執行緒集區」。
決定用哪一層的工具來寫若標準 C++ 的 async 或 thread 就夠,用那些;需要計時器、等待、I/O 完成的整合、集區拆分或執行緒數控制,或不想在 DLL/COM 元件裡自己持有執行緒時,才直接用 Win32 執行緒集區整合 timer、wait、io拆分集區/控制數量避免 DLL 裡自備執行緒標準 C++ 工具夠用嗎?std::async / std::thread你需要什麼?Win32 執行緒集區

圖 9: 拿不定主意就從標準程式庫開始;這個 API 上場的時機,是它表達不了的需求出現時。

也就是說,這個 API 是你已決定「寫原生」時,並行處理的基礎。務實的整理是分階段:作為從自己亂建 CreateThread 遷移的目標,先導入 work 物件,再把只負責等待的執行緒換成 wait、只負責計時的執行緒換成 timer。

8. 總結

  • 大量發出短命工作、以及整理只負責等待與只負責計時的執行緒,用作業系統標準執行緒集區,而不是自己的執行緒。用的是 Vista 以後的新 API。
  • 核心是 work、timer、wait、io 四種物件。觸發條件不同;統一在同一組工作者與同一種回呼風格上。
  • 規矩是「建立 → 提交 → 等待完成 → 關閉」。多次提交會並行執行。清理群組能把關閉處理一次做完。
  • 回呼的三條鐵則:不要長時間阻塞(若會,用 CallbackMayRunLong);不要同步等待同一集區;不要弄髒執行緒狀態。
  • 從 DLL 使用:小心卸載競態。在明確的關閉函式裡等待完成;不要在 DllMain 裡做。
  • 標準 C++ 或 .NET 夠用的地方就用那些。這個 API 上場的時機,是需要整合 timer/wait/io 或控制集區時。

在 Win32 API 裡,執行緒集區 API 屬於較新、設計也較好的一側。一旦把想法從「建立執行緒」轉成「丟回呼」,原生程式碼的並行就會好寫許多。

相關文章

相關諮詢領域

小村軟體有限公司承接從執行緒增生的原生程式碼遷移到執行緒集區的設計、C++ 應用程式與 DLL 並行處理的設計審查,以及集區飢餓或回呼造成的無回應與當機的根本原因調查。歡迎從盤點既有程式碼開始諮詢。

參考連結

  1. Microsoft Learn, Thread Pools. 關於執行緒集區是代表應用程式有效率地執行非同步回呼的工作者執行緒集合;適用的應用類型(大量並行發出小型工作項目、頻繁建立與拆除短命執行緒、並行處理獨立工作、對核心物件的專屬等待等);以及 Vista 的全面重新設計(統一工作者執行緒種類、單一計時器佇列、專用常駐執行緒、清理群組、一個處理程序內的多個集區,以及新 API)。  2 3 4 5

  2. Microsoft Learn, Thread Pooling. 關於較舊執行緒集區 API(QueueUserWorkItem、計時器佇列、已註冊的等待、BindIoCompletionCallback)的結構;一旦排入佇列就無法取消工作;以及 Vista 導入的新執行緒集區 API 被說明為更簡單,在可靠性、效能與彈性上都較優。  2

  3. Microsoft Learn, threadpoolapiset.h header. 關於包含 CreateThreadpoolWork、CreateThreadpoolTimer、CreateThreadpoolWait、CreateThreadpoolIo 四種物件建立函式、清理群組(CreateThreadpoolCleanupGroup),以及與回呼完成連動的清理(LeaveCriticalSectionWhenCallbackReturns、FreeLibraryWhenCallbackReturns 等)的函式清單。  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. 關於以 CreateThreadpoolWork 建立、以 SubmitThreadpoolWork 提交、以 WaitForThreadpoolWorkCallbacks 等待完成、以 CloseThreadpoolWork 關閉的基本程序;以及結合自訂集區、回呼環境與清理群組的組態範例。  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). 關於通知集區目前回呼可能跑很久,讓集區用來決定是否為其他回呼確保執行緒;以及長時間回呼應盡可能考慮專用執行緒。  2

  6. Microsoft Learn, Thread Pooling. 關於提交到執行緒集區的工作項目及其呼叫的函式必須對執行緒集區安全;不可假設執行中的執行緒是專用、常駐的執行緒;以及避免使用 TLS 與需要常駐執行緒的非同步呼叫。  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). 關於可不待前一個回呼完成就多次提交同一個 work 物件,因此回呼會並行執行;以及集區能為效率調整(節流)執行緒數。 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). 關於能為以 CreateThreadpool 建立的集區設定工作者執行緒數上限(下限是 SetThreadpoolThreadMinimum)。 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). 關於從回呼函式與內容指標建立 work 物件;以及第三個引數 TP_CALLBACK_ENVIRON 能指定回呼的執行環境(所屬集區等),NULL 表示在預設環境執行。 

  10. Microsoft Learn, <future>. 關於透過 std::async 與 future 以工作為單位的非同步執行被提供為標準程式庫,因此可不直接管理執行緒就寫並行。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

和用 CreateThread 自己建立執行緒相比,執行緒集區好在哪裡?
在有大量短命工作要做時的效率,以及減少執行緒管理程式碼。建立與銷毀執行緒有不可忽視的成本,因此反覆「每個工作都 CreateThread、做完就銷毀」的應用程式,或為了等事件而睡著、只為等待而存在的大量執行緒,改用集區就能減少執行緒數與內容切換。官方文件也把大量並行發出小型工作項目的應用程式、大量建立短命執行緒的應用程式、以及只為等待核心物件而專用執行緒的應用程式,列為集區的適用對象。反過來說,需要執行緒「個性」的工作──變更優先權、COM STA、長時間專用處理──仍應像以往一樣放在專用執行緒上。
這和 QueueUserWorkItem 等舊的執行緒集區函式有何不同?
執行緒集區在 Windows Vista 全面重新設計。目前的 threadpoolapiset 系列 API(CreateThreadpoolWork 等)是新 API;QueueUserWorkItem、RegisterWaitForSingleObject 等是舊(legacy)API。新 API 統一工作者執行緒的種類,可在一個處理程序內建立數個獨立集區,並提供透過清理群組一次釋放、以及與回呼完成連動的鎖定釋放或 DLL 卸載等機制。官方文件也說新 API 更簡單,在可靠性、效能與彈性上都較優。舊 API 還有「一旦排入佇列就無法取消」這類結構限制,因此新程式碼請用新 API。
回呼裡面有什麼不能做的事?
大致三件。第一,在預設情況下長時間阻塞或做長時間工作。集區假設回呼會迅速結束來調整執行緒數,因此會長時間的工作要用 CallbackMayRunLong 宣告,或改用專用執行緒。第二,同步等待同一個集區上其他已提交工作的完成。若每個工作者都變成「在等另一個工作者」,就會發生集區飢餓死結。第三,依賴執行緒的個性。工作者執行緒在回呼之間共用,因此帶著已變更的執行緒優先權或 COM 初始化狀態返回、或把狀態留在 TLS,都會汙染下一個回呼。結束時的清理(釋放鎖定或卸載 DLL)有 LeaveCriticalSectionWhenCallbackReturns、FreeLibraryWhenCallbackReturns 等專用機制。
從 DLL 使用執行緒集區有什麼要注意?
最大的危險是「回呼還在跑,DLL 卻被卸載」。卸載後回呼再執行就會存取違規。DLL 端必須在關閉處理中,確實等待自己發出的回呼完成──用 WaitForThreadpoolWorkCallbacks 這類等待函式,或清理群組的 CloseThreadpoolCleanupGroupMembers──然後才關閉物件。不過在 DllMain 裡做這次等待,可能和載入器鎖定交互而死結,因此原則是在明確的關閉函式裡做,不要在 DllMain。若回呼本身想在「這是最後一件工作」時釋放 DLL,有專用 API FreeLibraryWhenCallbackReturns。
既然已有 C++ 的 std::async 與 .NET 的 ThreadPool,還有直接用這個 API 的場合嗎?
有。判斷標準是「那一層的工具夠不夠」。若 C++ 所需的並行粒度能被 std::async 或 std::thread 涵蓋,從可攜性來看標準程式庫也是第一候補。另一方面,想把計時器、核心物件等待、非同步 I/O 完成統一成一套回呼機制;想依工作種類拆分集區並控制執行緒數;不想在 DLL 或 COM 元件裡自己持有執行緒──這些需求正是 Win32 執行緒集區的範圍。與 .NET ThreadPool、IOCP 的關係見相關文章;只要還在寫原生,知道這層底下的機制就不會白費。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽