多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫

· · Windows, 多執行緒, C 語言, Win32 API, 業務應用程式, 缺陷調查, 設計

更新紀錄(僅初版,2026年08月02日 發布)
初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22175916)

以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。

Go Komura(2026)。〈多執行緒實務最佳實踐 C 語言篇 ── 以 Win32 API 的方式安全撰寫〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/multithreading-best-practices-c/

DOI(已登錄存檔)
10.5281/zenodo.22175916
DOI(上次登錄版本)
10.5281/zenodo.22175917

「用 C 撰寫的常駐處理程序,只有在結束時會卡住」「想替老舊的裝置控制應用程式加上執行緒」「一直用 TerminateThread 停止執行緒,但整個處理程序有時會不動」。在用 C 撰寫多執行緒的現場,比起把工作並排跑起來,共用資料由誰守護、要怎麼等待、要怎麼結束才是真正的問題。

C 語言沒有像 C++ 的 RAII 那樣、離開範圍就自動釋放鎖與控制代碼的機制。也沒有例外或樣板(template)可以借力,因此光是選對 API 還不夠,必須把取得、釋放、等待結束的紀律落實到程式碼的結構裡。

本文是多執行緒實務系列的C 語言篇。針對使用 C 與 Win32 API 的開發者,把「避免濫造執行緒、減少共用可變狀態、訂好鎖的紀律、最先設計停止方式」這幾項原則,落實到具體的 API 上。

本文涵蓋的是:以 _beginthreadex 建立執行緒、同步物件的選擇、不依賴 TerminateThread 的協調式停止,以及 DllMain 的限制。說明所依據的資訊時點為 2026 年 8 月。只讀本篇也能完整理解;此外還有以其他語言講述同一套原則的「.NET 篇」「C++ 篇」「Java 篇」。

依遇到的問題閱讀

遇到的問題 首先要確認的事 閱讀位置
想增加執行緒/想大量執行短任務 自建執行緒與執行緒集區的分工 執行緒的建立方式
計數器對不上/共用資料被破壞 減少共用的設計,以及鎖與原子操作的對應 減少共用狀態同步的選法
加了鎖反而卡死 要保護的資料、持鎖期間的處理、取得順序 鎖的紀律
結束時卡住/正在使用強制終止 是否把停止要求與結束確認分開,等待期間能否停止 協調式停止
明明用事件喚醒了,工作卻分配不均 單一工作執行緒與多個工作執行緒在通知方式上的差異 停止範例的適用範圍
DLL 卸載時卡住 啟動、停止、合流是否都放在 DllMain 之外 DllMain 的限制
想與 Linux 共用程式碼/無法重現缺陷 可攜性的要求,以及設計審查、日誌、壓力測試 C11 這個選項驗證與偵錯

第一次做設計時,請用第 2 章掌握 C 語言的前提,用第 3~5 章決定執行單位與共用資料,再用第 6 章確認停止路徑。檢查既有程式碼時,最後的檢查清單也能派上用場。

1. 先講結論

依工作的性質,選擇執行的場所

會呼叫 CRT 的自建執行緒,要用 _beginthreadex 建立。用 CreateThread 建立的執行緒若呼叫 CRT,記憶體不足時 CRT 可能會終止整個處理程序。若要把大量短任務平行化,就不要反覆建立自建執行緒,而應使用 Windows 執行緒集區的 CreateThreadpoolWork123

不可以用 ExitThreadTerminateThread 結束集區中的執行緒。正確的用法是:把以回呼形式借來的執行場所收拾乾淨,再還回去。4

把共用資料的保護與等待合流分開

處理程序內的鎖預設選 SRW 鎖,需要遞迴取得時才選 CRITICAL_SECTION。高頻率的處理程序內互斥若使用 Mutex,就要背上核心切換的成本。5

單一變數的更新使用 Interlocked 系列函式。只加上 volatile,並不能確保原子性與必要的同步。大多數 Interlocked 函式都伴隨完整的記憶體屏障。等待合流要使用條件變數,或是事件加上等待函式,不要用 Sleep 輪詢白白消耗 CPU 與回應性。67

停止方式與釋放順序,要在啟動前訂好

不使用 TerminateThread,而要設計以停止事件和 WaitForMultipleObjects 為基礎的協調式停止。強制終止有破壞鎖・堆積・DLL 狀態之虞,也是程式碼分析警告 C6258 的偵測對象。不要只發出停止要求就去釋放資源,而要確認執行緒已經結束之後再關閉控制代碼。89

不要在 DllMain 中建立執行緒、做同步或等待執行緒結束。要在載入器鎖之外,準備明確的初始化函式與終止函式。10

需要可攜性時,C11 也是一個選項。依 2026 年 8 月時點的整理,<threads.h> 從 VS 2022 17.8 起可用,<stdatomic.h> 仍是 experimental。僅面向 Windows 的程式碼,就以本文使用 Win32 API 的方針為基本。11

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 23 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

2. 為什麼多執行緒很難 ── 競爭狀態與死結

首先要掌握的,是結果會隨執行順序而改變的競爭狀態,以及因等待成環而無法前進的死結。在記 API 之前,先把這兩者區分清楚。

競爭狀態(race condition)是這樣一類缺陷:多個執行緒抵達特定程式碼的先後順序不同,結果就會改變。

以共用計數器的 count++ 為例,即使只是一個運算式,在沒有同步時也不保證「讀取 → 相加 → 寫回」會不可分地執行。兩個執行緒讀到同一個值,各自相加再寫回,其中一次相加就會遺失。6 圖1 顯示的正是本想從 10 加兩次、結果卻是 11 的執行順序。

執行緒B共用變數 count執行緒A執行緒B共用變數 count執行緒Acount = 10明明加了兩次,count 卻是 11執行緒A的相加結果遺失了讀取(10)讀取(10)本地相加(11)本地相加(11)寫回(11)寫回(11)

圖1:共用計數器中相加結果遺失的典型競爭狀態。在 count++ 的三個步驟之間,只要有別的執行緒插入,後寫回的一方就會覆蓋先寫回的一方

死結是指兩個執行緒互相等待對方持有的鎖,導致誰都無法繼續前進的狀態。執行緒A持有鎖1並等待鎖2,執行緒B持有鎖2並等待鎖1 ── 光是這樣,兩者就會永遠停在原地。

等待鎖2釋放等待鎖1釋放執行緒A持有鎖1執行緒B持有鎖2

圖2:死結中的循環等待。等待的箭頭一旦構成一個環,環中的所有執行緒就會永遠停止

這類缺陷取決於時序。在開發機上幾乎不會出現的執行順序,在核心數與負載都不同的客戶現場卻可能反覆發生。一接上偵錯器就不再重現,也是因為觀測本身改變了時序。

因此,後面的所有原則都從「減少需要同步的地方」開始,而不是先談「正確地同步」

2.1. C 語言特有的前提 ── 語言不會替你守住任何東西

在 C 語言中,這一組原則沒有語言層面的「強制遵守機制」,因此必須以紀律的形式明文化

釋放要連函式的出口一起設計

因為沒有相當於 C++ RAII 的機制,鎖的釋放與控制代碼的 CloseHandle 都要自己保證。可以採用把函式出口收斂到一處的 goto cleanup 模式,或是規定取得與釋放成對撰寫。要點在於:即使中途新增一個提前 return,釋放也不會被遺漏。

即使單純讀寫是原子的,同步仍要另外做

必須避免資料競爭這一點與 C++ 相同。在 Windows 上,經過適當對齊的 32 位元變數之單純讀寫是原子的,但僅憑這一點並不保證存取的同步,也不保證前後記憶體操作的順序。32 位元 Windows 上的 64 位元變數、讀出後相加這類複合操作、多個變數之間的一致性,都不能與單純讀寫同等看待。12

不要把「碰巧能動」當成依據,而要用鎖或 Interlocked 明確寫出必要的同步。即使編譯器或最佳化等級變了,這條紀律依然需要。

所有權要寫到交接的那一刻

在函式註解裡寫明「這個緩衝區由哪個執行緒寫入」「從什麼時候起歸誰所有」。所有權的紀律,與同步基元的選擇同樣重要。後面會講的拆分處理與佇列,也是先訂下這條界線才能安全使用。

3. 執行緒的建立方式 ── 只選 _beginthreadex

3.1. 為什麼不能用 CreateThread

Win32 的原生 API 是 CreateThread,但官方的指引是:會呼叫 CRT(C 執行階段程式庫)函式的執行緒,要用 _beginthreadex 建立_beginthreadex 會先初始化 CRT 供每個執行緒使用的內部資料,再開始執行。2

CreateThread 建立的執行緒若呼叫 CRT 函式,在記憶體不足時 CRT 可能會終止整個處理程序。1 printfmallocstrtok 也都屬於 CRT,所以實務上可以把「用 C 撰寫的自建執行緒一律用 _beginthreadex」當作基本做法。

等待並關閉控制代碼,都是建立方的責任

_beginthread(不含 ex)也要避開。因為執行緒提早結束時,傳回的控制代碼會失效,可能指向別的執行緒。應選擇可以把控制代碼交給同步 API 的 _beginthreadex,由呼叫方等待其結束後再 CloseHandle13

以下是顯示建立與合流流程的片段。WorkerContext 的定義、ctx 的初始化與失敗處理,都假定由應用程式一側準備,它不是可以單獨編譯的完整程式。建立失敗時不要繼續進入等待;建立成功時,也要把工作執行緒使用的 ctx 一直保持有效,直到確認執行緒結束為止。

#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 執行緒集區。在 Windows Vista 之後的 API 中,用 CreateThreadpoolWork 建立工作物件,再用 SubmitThreadpoolWork 投遞。回呼由集區的工作執行緒執行,因此執行緒數量的管理可以交給 OS,也不必為每件工作建立與銷毀自建執行緒。3

借來的執行緒,狀態要在返回前復原

不要用 TerminateThread / ExitThread 結束集區中的執行緒;在回呼中變更的 TLS、執行緒優先權等要在返回前復原;等待用的控制代碼要保持有效,直到集區用完為止 ── 這些都是官方規定的紀律。4

只管理執行緒數量,壓不住投入量

即使把執行緒數量的管理交給集區,也擋不住「未執行的回呼無限堆積」這種設計。在投入持續超過處理速度的常駐處理程序中,要在應用程式一側設置號誌之類的入場限制,或是使用有容量上限的佇列。

還要訂好佇列滿時,投入方是等待還是拒絕投入。這是為了不把過載轉嫁成記憶體消耗的反壓,與第 5 章的有限緩衝區是同一種思路。

4. 把共用可變狀態最小化 ── 拆分・唯讀・傳遞

在選擇同步基元之前,先想一想能不能減少多個執行緒接觸同一份可變資料的地方。手段有三種:拆分、改為唯讀、用佇列傳遞。

依執行緒拆分,最後再合流

在平行彙總中,各執行緒不必每次都往共用計數器寫入,而是在區域變數或專用緩衝區裡先算出小計。最後只用一次 InterlockedAdd 之類的操作合流,就能把對共用資料的寫入,從「每次迭代一次」減少為「每個執行緒一次」。

這是既能降低同步成本、又能減少爭用機會的做法。2.1 節訂下的「這個緩衝區歸哪個執行緒所有」,本身就構成了拆分的設計。

初始化完成之後,就設為唯讀

啟動時建構、之後不再改寫的設定與表格,可以在初始化完成後由多個執行緒讀取。不要讓界線含糊,要麼在所有執行緒啟動之前完成初始化,要麼在需要延遲初始化時使用 InitOnceExecuteOnce5

重要的不是「自以為是唯讀的」,而是在程式碼中明確寫出初始化何時結束、從何時起不再改寫

把兩側都操作的共用變數,改為用佇列傳遞

執行緒之間的資料流動,與其讓兩側都去操作共用變數,不如收攏到生產者/消費者佇列上。在 C 與 Win32 中,有把有限循環緩衝區與 SleepConditionVariableCS 組合起來的官方實作範例。7

只要給容量設上限,生產超過消費時生產方就會等待,這本身就是自然的反壓。

5. 同步物件的選法與鎖的紀律

Win32 的同步基元種類繁多,選錯了既會損害效能,也會損害正確性。以下把官方的指引彙整成一張圖。5

互斥限制同時存取數事情發生的通知否(處理程序內)是否要跨處理程序同步?用途是?具名 Mutex具名號誌具名事件同一執行緒是否需要遞迴取得?CRITICAL_SECTION是否為重視可攜性的C++程式碼?std::mutex /std::shared_mutexSRW 鎖(預設選項)

圖3:Win32 同步基元的選法。第一個分支是「是否跨處理程序」,要點在於不跨處理程序時就不要選核心物件(Mutex)

基元 作用範圍 特性 使用時機
SRW 鎖 處理程序內 速度快(通常在使用者模式內完成)、指標大小、不可遞迴 新程式碼的預設選項。用 AcquireSRWLockShared 也能實現讀取共用
CRITICAL_SECTION 處理程序內 速度快(先自旋再進入核心等待)、可遞迴 需要同一執行緒遞迴取得的場合
Mutex 處理程序內/跨處理程序 永遠是核心物件,速度較慢 跨處理程序的互斥(具名)、與 WaitForMultipleObjects 併用
號誌 處理程序內/跨處理程序 核心物件 限制對資源池的同時存取數
事件 處理程序內/跨處理程序 核心物件 通知「發生了某件事」(不用於保護資料)
Interlocked 函式 處理程序內(若在共用記憶體上則也可跨處理程序) 不需要鎖的原子操作 計數器・旗標・指標替換6

核心物件並不是跨處理程序專用

圖3 要說明的要點是:處理程序內的鎖,不要沒有理由地選 Mutex。處理程序內的通知可以用事件,同時存取數的限制可以用號誌。第 6 章的停止事件,也是沒有名稱的核心物件。

另外,沒有名稱並不代表必然侷限在處理程序內。透過控制代碼的繼承或 DuplicateHandle 複製,同一個物件也可以被多個處理程序使用。命名只是讓別的處理程序能重新開啟同一個物件的代表性手段之一。

Interlocked 要把操作、對齊、生命期分開考慮

讓對單一變數的操作不可分割

InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange 是把對單一變數的操作不可分割地完成的函式。它們對應 .NET 篇的 Interlocked 類別、C++ 篇的 std::atomic,而且大多數函式還伴隨完整的記憶體屏障。6

不要以為只加上一個 volatile,就能得到原子性與必要的同步。要讓多個變數整體保持一致時,就用 SRW 鎖或 CRITICAL_SECTION 來保護。

守住目標變數的對齊

Interlocked 的操作對象必須對齊到自然邊界。32 位元值是 4 位元組邊界,64 位元值是 8 位元組邊界。未對齊時的行為無法預測。12

不要把 #pragma pack 過的結構體,或是直接映射通訊格式的緩衝區中的欄位當作操作對象;計數器與旗標要準備成由編譯器適當對齊的一般變數。

指標替換並不保護舊資料的生命期

InterlockedExchangePointer 等函式所保證不可分割的,只是指標替換這個動作本身。讀取方剛取到舊指標,寫入方就完成替換並把舊的記憶體區塊 free 掉,讀取方就會存取已釋放的記憶體。

替換與回收是兩個不同的問題。需要有一套讓讀取方用完之前不回收舊區塊的步驟,例如加鎖,或是安全的參照計數管理。拿不準時,就用 SRW 鎖來保護。

條件變數要在醒來之後再確認條件

在有限緩衝區的生產者/消費者佇列中使用條件變數。用 InitializeConditionVariable 初始化,消費方用與 CRITICAL_SECTION 搭配的 SleepConditionVariableCS 等待,生產方用 WakeConditionVariable 喚醒 ── 這就是官方實作範例的形式。7 若要與 SRW 鎖搭配,就用 SleepConditionVariableSRW

重要的是醒來之後要在鎖內重新確認條件,條件為偽就再次等待,形成一個迴圈。既有沒被通知就醒來的虛假喚醒,也有醒來時元素已經被別的消費者取走的情況。「被喚醒了」與「佇列非空」不是同一回事。

這是在 C 語言中搭出與 .NET 篇 4.3 節的通道、C++ 篇第 4 章的 BlockingQueue 相同結構的工具。

5.1. 鎖的紀律 ── 不論選哪一種都不會變的三項原則

即使基元選對了,沒有使用上的紀律也防不住競爭。

1. 固定資料與鎖的對應

為每一組要保護的可變資料,對應一把 SRW 鎖或 CRITICAL_SECTION。在觸碰這些資料的所有位置都取得同一把鎖。

在 C 語言中,在標頭檔寫上「這個結構體由 g_lockFoo 守護」的做法尤其有效。審查時也要把這種對應關係列成表來確認。

2. 持鎖期間不做耗時處理與外部呼叫

鎖裡面只做被保護資料的讀寫。持鎖不放地進行檔案 I/O、網路通訊、回呼呼叫,都會拉長持有時間。若被呼叫方又去取得另一把鎖,還可能造出圖2 那樣的循環等待。

3. 固定多把鎖的取得順序

要取得兩把以上的鎖時,就定義一個鎖階層,讓所有執行緒遵循同一順序。DLL 最佳實踐文件也說明了取得順序顛倒會招致死結,以及必須始終遵循階層。10

6. 停止方式的設計 ── 不使用 TerminateThread

6.1. TerminateThread 會破壞什麼

TerminateThread不讓目標執行緒執行使用者模式的收尾工作就把它結束掉。它當時持有的狀態,未必會以安全的形式被清理乾淨。8

被終止時所處的時機 可能發生的事
正持有臨界區 鎖不會被釋放,其他執行緒會一直等下去
正在從堆積配置記憶體 堆積鎖殘留,之後的記憶體配置會停住
正在操作 DLL 的全域狀態 DLL 內部的狀態被破壞

官方把它定位為「只應在最極端情況下使用的危險函式」,程式碼分析也會以警告 C6258 偵測出來。89

在調查「偶爾整個處理程序就卡住」的應用程式時翻出 TerminateThread,在實務上是真的很常見的場景。一旦發現,它就是要修的對象。

6.2. 正確的形式:停止事件 + WaitForMultipleObjects

在協調式停止中,停止方不是去把執行緒消滅,而是發出停止要求,讓工作執行緒自己收拾乾淨再結束。定石是建立一個手動重置的停止事件,讓工作執行緒同時等待工作的訊號與停止的訊號。C6258 的官方資料,也介紹了監視事件並自行結束的方法。9

請把發出停止要求 → 工作執行緒收尾 → 確認執行緒結束 → 釋放控制代碼與共用資源當成彼此分開的階段來處理。僅僅把停止事件設為訊號狀態,並不等於執行緒已經結束。

下面這段程式碼同樣是用於說明的片段。事件的建立與失敗確認、佇列的互斥控制,以及 ProcessNextItemLogLastErrorCleanup 的實作都省略了。請按「事件與佇列在等待與處理期間不會被銷毀」,以及下面要講的面向單一工作執行緒的工作通知這個前提來閱讀。

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:若單件內部要長時間等待,
               若在那裡也觀測不到停止,關閉流程就會被這一件工作綁架 */
            while (ProcessNextItem(hStopEvent)) {  /* 從佇列處理一件,為空則傳回 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 時不該繼續釋放共用資源 */
}

合流要確認等待成功之後再往前走

StopWorkers 會先確認 SetEvent 是否成功。停止要求都沒送達,就不應該進入無限期的合流。對每個執行緒也一樣,只關閉 WaitForSingleObject 傳回 WAIT_OBJECT_0 的那些控制代碼,等待失敗時不回報「全部已停止」。傳回值為 FALSE 時就不去釋放共用資源 ── 這才是正確的用法。

之所以把結束等待拆成逐一進行,是因為 WaitForMultipleObjects 一次能等待的控制代碼數有 MAXIMUM_WAIT_OBJECTS(64 個) 的上限。陣列超過上限時,等待可能會變成 WAIT_FAILED。若只是要等所有執行緒結束,逐一等待的迴圈就不受這個陣列長度的限制。

SetEvent(hStopEvent)停止端停止事件(手動重置:所有執行緒可見)工作執行緒1:用 WaitForMultipleObjects同時等待停止與工作工作執行緒2:用 WaitForMultipleObjects同時等待停止與工作清理後自行 return清理後自行 return停止端等待執行緒控制代碼完成合流到這一步才能說「已停止」

圖4:停止事件模式。停止用手動重置事件的話,一次 SetEvent 就能讓所有正在等待的工作執行緒一齊醒來。結束的方式由各執行緒自己決定,以合流完成作為停止的判定

遵守停止事件的設定與順序

停止事件要設為手動重置,讓一次 SetEvent 對所有工作執行緒都可見。它與工作通知的職責不同。

另外,在 WaitForMultipleObjects 的等待陣列裡要把停止事件放在最前面。像本範例這樣 bWaitAllFALSE 的等待中,多個物件同時處於訊號狀態時,索引較小的控制代碼優先。最後,停止端要確認執行緒控制代碼完成合流之後,再關閉該控制代碼。

單一工作執行緒:用事件喚醒,把佇列排空

上面那種「用自動重置事件喚醒,把佇列全部排空」的形式,是面向單一工作執行緒的架構。自動重置事件即使 SetEvent 多次,也不會記住工作的件數。訊號狀態只有一個,連續的訊號會合併成一次。

把這種形式原樣用到多個工作執行緒上,可能只有一個執行緒醒來,串行地處理完一整批工作。

多個工作執行緒:讓一個號誌許可對應一件工作

若要讓多個工作執行緒分擔佇列,就把工作的訊號改用號誌。生產方每積壓一件就用 ReleaseSemaphore(hSem, 1, NULL) 增加一個許可,消費方每次等待成功只取出一件。因為等待成功會消耗掉一個許可,許可數與件數的對應關係就能保持住。

此時不能保留範例中那個全部排空的迴圈。只消耗了一個許可卻把佇列清空的話,剩下的許可會讓別的工作執行緒對著空佇列醒來,或是讓生產方的 ReleaseSemaphore 因超出上限而失敗。前提是「一個許可 = 一件工作」

即使單件處理很長,也要能觀測到停止

只在項目之間確認停止是不夠的。若 ProcessNextItem 內部要做長時間的阻塞等待,就要把停止事件也傳進去一起等待,或是設一個有限的逾時。

否則,一件工作遲遲不結束,關閉流程就會一直等下去。.NET 篇的 StopAsync、C++ 篇的 jthread 與 join 也是同樣的思路。

阻塞式 I/O 也要準備 I/O 一側的停止路徑

在管線、通訊端(socket)、序列埠的 I/O 上等待的執行緒,就那樣是回不去確認停止事件的。需要在 I/O 一側也準備結束等待的路徑,包括 OVERLAPPED 與事件的疊加等待,以及用 CancelIoEx 取消 I/O。

具體範例在「序列通訊應用的陷阱」中討論。

7. DllMain 與載入器鎖 ── 撰寫 DLL 時的雷區

用 C 語言撰寫的共用元件常常會做成 DLL,而那裡存在載入器鎖這項特有的限制。DllMain 是由 OS 的載入器在持有載入器鎖的狀態下呼叫的,因此在其中做以下這些事,就會成為死結或當機的原因。10

  • 與其他執行緒同步(取得鎖・等待執行緒結束)
  • 呼叫 LoadLibrary / FreeLibrary(不論直接或間接)
  • 建立執行緒(伴隨同步就危險)或呼叫 ExitThread

在 DllMain 之外,準備明確的終止函式

「DLL 卸載時,在 DllMain 中等待工作執行緒結束」看起來像是對的,實際上卻是典型的死結。因為正在結束的執行緒也需要載入器鎖才能投遞 DLL_THREAD_DETACH,於是與 DllMain 互相等待。10

帶有執行緒的 DLL,應該公開 MyLib_Init / MyLib_Shutdown 這樣的初始化函式與終止函式,把執行緒的啟動與合流放在 DllMain 之外進行。呼叫方在終止函式中確認已經停止之後再卸載。DllMain 本身則設計成盡可能接近空殼的樁函式(stub)。10

8. C11 執行緒這個選項 ── 現況整理

若不想依賴 Win32,想與其他 OS 共用程式碼,C11 的 <threads.h><stdatomic.h> 就是選項。把 2026 年 8 月時點 MSVC 的支援情況分開來看,結果如下。11

功能 在 MSVC 中的定位 要確認的條件
<threads.h>(thrd_create / mtx_lock / cnd_wait) Visual Studio 2022 17.8 起支援 /std:c11 與對應的 Windows SDK
<stdatomic.h> experimental /experimental:c11atomics 選項

重要的是,不要把執行緒與原子操作當成處於同一個支援階段。

若與 Linux 共用程式碼是硬性要求,C11 執行緒(或 pthread 包裝層)就有其價值;但若程式碼庫僅面向 Windows,本文的 Win32 流派在資料量、實績與偵錯便利性上都更有優勢。不論選擇哪一種,到目前為止的設計原則(減少共用、鎖與資料的對應、協調式停止)都不會改變。

9. 驗證與偵錯 ── 以「無法重現」為前提做好準備

一般的測試通過了,也不能斷言沒有競爭。因為還留著「只是碰巧沒有走到有問題的執行順序」的可能性。要用確認設計、讓異常可觀測、用負載搖晃執行順序這三層來準備。

設計審查:讓資料、鎖、停止路徑一一對應

把共用的可變資料、守護它的鎖、多把鎖的取得順序、停止事件能送達的工作執行緒列成表來確認。這是檢查 5.1 節的紀律是否真的落到程式碼上的工作。

在這張對應表寫不出來的狀態下,就不能拿「現在能動」當作設計完成的依據。

觀測:用逾時、日誌、傾印留下等待發生的位置

不要把所有等待都寫成無條件的 INFINITE,而要在關鍵處設置逾時,並把逾時記入日誌。這是為了把原本只會一直等下去的狀態,轉變成可以調查的失敗。不過,發生逾時並不等於確認執行緒已經結束,僅憑這一點還不能去釋放共用資源。

發生卡死時要採集傾印,查看所有執行緒的呼叫堆疊,確認鎖的等待有沒有形成循環。針對 DLL 周邊的錯誤,還可以使用官方推薦的 Application Verifier。10 日誌與傾印的整備,請參考「Windows 應用程式當機時保留日誌與傾印的設計」。

壓力測試:試出開發機上難以出現的執行順序

用多於核心數的執行緒長時間執行、把處理順序隨機化、插入人為延遲,用這類壓力測試增加缺陷顯現的機會。也要確認經過最佳化的發行版建置與高負載的組合。

壓力測試並不能取代設計審查。把三層結合起來,才能更接近「一旦重現就能追出原因」的狀態。

10. 總結 ── C 語言版檢查清單

  1. 執行緒建立是否全部使用 _beginthreadex(有沒有混入 CreateThread / _beginthread)
  2. 執行緒控制代碼是否先合流(WaitForSingleObject)再 CloseHandle
  3. 有沒有為短命的工作濫造自建執行緒(能不能投給執行緒集區 API)
  4. 處理程序內的互斥是否用的是 SRW 鎖/CRITICAL_SECTION(有沒有誤用 Mutex)
  5. 共用計數器・旗標是否已不再依賴 volatile,而改用 Interlocked 系列
  6. 有沒有殘留的 Sleep 輪詢(是否已換成條件變數・事件等待)
  7. TerminateThread(強制終止其他執行緒)是否一處都沒有。工作執行緒是否不是呼叫 ExitThread,而是從執行緒函式 return 結束(如此 CRT 的收尾才會經由 _endthreadex 正確執行)
  8. 停止事件 + WaitForMultipleObjects 的停止路徑是否覆蓋了所有工作執行緒,阻塞式 I/O 中的執行緒能否也被喚醒
  9. 鎖・控制代碼的釋放是否在所有 return 路徑上都能得到保證(goto cleanup 的紀律)
  10. 有沒有在 DllMain 中建立執行緒、做同步或等待執行緒結束

沒有語言層面的支援作為代價,C 語言多執行緒的品質就直接取決於 API 的選擇與紀律。把 _beginthreadex・SRW 鎖・Interlocked・停止事件 ── 這四件套設為預設,即使用 C 語言,也能做出遠離「偶爾卡死」的設計。

相關文章

相關諮詢領域

合同會社小村軟體承接以 C 語言撰寫的常駐處理程序・裝置控制應用程式・DLL 的多執行緒設計審查,因 TerminateThread 或鎖洩漏所導致的卡死・當機原因調查(傾印分析),以及為既有 C 程式碼新增執行緒的技術諮詢。

參考連結

  1. Microsoft Learn, CreateThread function. 關於可執行檔內呼叫 CRT 的執行緒應該用 _beginthreadex / _endthreadex 管理,而不是 CreateThread / ExitThread;以及用 CreateThread 建立的執行緒若呼叫 CRT,在低記憶體狀態下 CRT 可能會終止整個處理程序。  2

  2. Microsoft Learn, Multithreading with C and Win32. 關於呼叫 CRT 程式庫的程式應該用 _beginthread / _beginthreadex 啟動執行緒、而不應使用 Win32 的 CreateThread / ExitThread;_beginthread 系列會初始化 CRT 依執行緒使用的變數;以及 SuspendThread 若在執行緒正在存取 CRT 內部資料結構時將其暫停,可能招致死結。  2

  3. Microsoft Learn, CreateThreadpoolWork function. 關於用 CreateThreadpoolWork 建立工作物件,每次呼叫 SubmitThreadpoolWork 時集區的工作執行緒都會執行該回呼;可透過回呼環境(TP_CALLBACK_ENVIRON)指定執行環境;以及此 API 從 Windows Vista 起可用。  2

  4. Microsoft Learn, Thread Pools. 關於執行緒集區適用於大量非同步執行短任務的應用程式,或頻繁建立短命執行緒的應用程式;Vista 重新設計後的新執行緒集區 API 的構成要素;最佳實踐包括不要用 TerminateThread 結束集區中的執行緒、也不要在回呼中呼叫 ExitThread;回呼中建立的狀態要在返回前復原;以及等待用的控制代碼要保留到集區用完為止。  2

  5. Microsoft Learn, About Synchronization. 關於 Win32 同步基元的選擇指南:SRW 鎖是新程式碼的預設選項,具有指標大小並通常在使用者模式內完成;CRITICAL_SECTION 用於需要遞迴取得的場合;Mutex 永遠是核心物件,用於跨處理程序的具名同步以及與 WaitForMultipleObjects 併用;在處理程序內同步中使用 Mutex,在高頻操作下會顯著變慢,是「常見的錯誤」;以及號誌用於限制資源池的同時存取數、事件用於通知。  2 3

  6. Microsoft Learn, Interlocked Variable Access. 關於 Interlocked 函式會同步多個執行緒對共用變數的存取,並將操作不可分割地執行;InterlockedIncrement / Decrement 把讀取・相加・寫回綁定為一個原子操作,若無同步,兩個執行緒同時遞增可能會遺失一次結果;InterlockedExchange / InterlockedCompareExchange 等函式族;若變數位於共用記憶體上,不同處理程序的執行緒之間也能使用;以及大多數 Interlocked 函式都提供完整的記憶體屏障,並可透過 Acquire / Release 版本選擇順序語義。  2 3 4

  7. Microsoft Learn, Using Condition Variables. 關於用 CRITICAL_SECTION 保護的有限循環緩衝區實作生產者/消費者佇列的範例;用 InitializeConditionVariable 建立條件變數,消費端用 SleepConditionVariableCS 等待、用 WakeConditionVariable 喚醒對方的結構;以及條件變數從 Windows Vista 起獲得支援。  2 3

  8. Microsoft Learn, TerminateThread function. 關於 TerminateThread 會在完全不讓目標執行緒執行使用者模式程式碼的情況下將其終止;若目標持有臨界區,臨界區不會被釋放;若正在從堆積配置記憶體,堆積鎖不會被釋放;kernel32 的狀態或 DLL 的全域狀態可能被破壞;以及它是「只應在最極端情況下使用的危險函式」,除非完全掌握並控制目標執行緒可能執行的程式碼,否則不應呼叫。  2 3

  9. Microsoft Learn, Warning C6258. 關於程式碼分析警告 C6258 會偵測 TerminateThread 的使用;TerminateThread 無法進行恰當的執行緒清理;以及文件中給出的正確結束方式 ── 用 CreateEvent 建立事件,讓每個執行緒用 WaitForSingleObject 監視事件狀態,一旦變為訊號狀態就自行結束執行。  2 3

  10. Microsoft Learn, Dynamic-Link Library Best Practices. 關於 DllMain 是在持有載入器鎖的狀態下被呼叫的,因而對可呼叫的 API 有重大限制;在 DllMain 內與其他執行緒同步會招致死結;呼叫 LoadLibrary 屬於禁止事項;DLL 卸載時若在 DllMain 內等待執行緒結束,會與正在結束的執行緒投遞 DLL_THREAD_DETACH 時所需的載入器鎖互相等待而死結;理想的 DllMain 應接近空殼,初始化應盡量延後;以及應定義鎖階層,把載入器鎖置於最高層。  2 3 4 5 6

  11. 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

  12. Microsoft Learn, Interlocked Variable Access. 關於經過適當對齊的 32 位元變數之單純讀寫是原子的,但不保證存取的同步(順序);64 位元變數的單純讀寫在 64 位元 Windows 上是原子的,但在 32 位元 Windows 上不保證;以及其他大小的變數在任何平台上都不保證原子性。  2

  13. Microsoft Learn, _beginthread, _beginthreadex. 關於 _beginthreadex 為何比 _beginthread 更安全:用 _beginthread 建立的執行緒若提早結束,傳回的控制代碼就會失效並可能指向別的執行緒;_beginthreadex 傳回的控制代碼必須由呼叫方用 CloseHandle 關閉,因而其有效性得到保證;用 _beginthreadex 可以把控制代碼傳給同步 API;執行緒函式以 __stdcall 呼叫慣例傳回執行緒結束代碼;以及需要連結多執行緒版 CRT。 

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

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

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

常見問題

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

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。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽