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

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

「用 C 語言撰寫裝置控制用的常駐行程」「要為一個用了 20 年的 C 應用程式加上執行緒」「一直用 TerminateThread 來停止執行緒,但行程偶爾會整個卡死」── C 語言的多執行緒,是語言支援最少的世界。沒有例外、沒有 RAII、也沒有樣板(template),同步是否正確,完全取決於 API 的選擇與呼叫紀律。

本文是多執行緒實務系列的C 語言篇。面向以 C × Win32 API 撰寫程式的開發者,將多執行緒設計的原則 ── 停止濫造執行緒、減少共用可變狀態、鎖的紀律、把停止方式放在最前面設計 ── 落實到 Win32 的具體工具上,從執行緒的建立方式(_beginthreadex)、同步物件的選擇、不使用 TerminateThread 的停止設計,到 DllMain 的限制,依據截至 2026 年 8 月的一手資料進行整理。本文寫成單篇即可完整閱讀。把同樣的原則在其他語言中展開的文章還有「.NET 篇」「C++ 篇」「Java 篇」。

1. 先講結論

  • 建立執行緒不要用 CreateThread,而要用 _beginthreadex若呼叫 CRT 的執行緒用 CreateThread 建立,CRT 在記憶體不足時可能會終止整個行程。12
  • 行程內的鎖預設用 SRW 鎖,只有需要遞迴取得時才用 CRITICAL_SECTION。在行程內互斥中使用 Mutex,是一種永遠伴隨核心切換的「常見錯誤」。3
  • 單一變數的更新要用 Interlocked 系列函式。volatile 既不保證原子性,也不保證順序。大多數 Interlocked 函式都伴隨完整的記憶體屏障。4
  • 等待要用條件變數(SleepConditionVariableCS 系列),或是事件 + 等待函式。Sleep 做輪詢迴圈,會同時浪費 CPU 與回應性。5
  • 不使用 TerminateThread這是會破壞鎖・堆積・DLL 狀態的危險函式,是程式碼分析警告 C6258 的偵測對象。停止應以「停止事件 + WaitForMultipleObjects」的協作式停止來設計。67
  • 短任務的平行化不要自建執行緒,而要投給 Windows 執行緒集區(CreateThreadpoolWork)。不要用 ExitThread / TerminateThread 來結束集區中的執行緒。89
  • 不要在 DllMain 中建立・同步執行緒或等待執行緒結束。因為它是在持有載入器鎖的狀態下被呼叫的,是死結的溫床。10
  • C11 的 <threads.h> 從 VS 2022 17.8 起可用,但 <stdatomic.h> 仍是 experimental。若程式碼只面向 Windows,Win32 API 的寫法更為務實。11

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

多執行緒帶來的問題,不論語言為何,追根究柢只有兩種。

競爭狀態(race condition)是這樣一類臭蟲:結果會因為多個執行緒抵達特定程式碼的先後順序不同而改變。最經典的例子是共用計數器 ── count++ 這一個運算式,在機器碼層級會被拆成「讀取 → 相加 → 寫回」三個步驟。若兩個執行緒同時進入這三個步驟,一方的相加結果就會被另一方的寫回覆蓋而消失。4 每次執行的結果都可能不同,也無法預測會得到哪一種結果。

執行緒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 語言中的典型事故。

第二,資料競爭的處理方式與 C++ 相同。經過適當對齊的 32 位元變數之單純讀寫,在 Windows 上確實是原子的,但除此之外 ── 64 位元變數(32 位元 Windows)、複合操作、多個變數之間的一致性 ── 什麼都得不到保證。12 那些「碰巧能動」的程式碼,會因為編譯器或最佳化等級的變動而崩壞。

第三,明確決定所有權。「這個緩衝區由哪個執行緒寫入、從何時起歸誰所有」,把這件事明文寫在函式註解裡的文化,在 C 語言的多執行緒中,其效果不亞於同步基元的選擇。

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

3.1. 為什麼不能用 CreateThread

Win32 的原生 API 是 CreateThread,但官方指南是:會呼叫 CRT(C 執行階段程式庫)函式的執行緒,要用 _beginthreadex 建立_beginthreadex 會先初始化 CRT 供每個執行緒使用的內部資料,再啟動執行緒。用 CreateThread 建立的執行緒若呼叫了 CRT 函式,在記憶體不足的情況下,CRT 有可能會終止整個行程。12 printfmallocstrtok 都屬於 CRT,所以在實務上可以說「用 C 撰寫的執行緒,一律使用 _beginthreadex」。

_beginthread(不含 ex)也要避免使用。它有一個陷阱:若建立的執行緒提早結束,傳回的控制代碼就會失效(可能指向另一個執行緒),而能把控制代碼傳給同步 API 的 _beginthreadex 更加安全。_beginthreadex 傳回的控制代碼,由呼叫方負責用 CloseHandle 關閉。13

#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 執行緒集區(Vista 以後提供的執行緒集區 API)。用 CreateThreadpoolWork 建立的工作物件,透過 SubmitThreadpoolWork 投遞後,集區的工作執行緒就會平行執行回呼。8 執行緒數量的管理可以交給 OS,執行緒的建立・銷毀成本也隨之消失。「不要自己建立執行緒」這項原則,在 C 語言中的答案就是它。

使用執行緒集區時的紀律,官方也有明確規定:不要用 TerminateThread / ExitThread 來結束集區中的執行緒,回呼中建立的狀態(TLS、執行緒優先權等)要在返回前復原,等待用的控制代碼要保留到集區用完為止,諸如此類。9 還有一項實務上的注意事項:執行緒集區限制的只是工作執行緒的數量,透過 SubmitThreadpoolWork 投遞、尚未執行的回呼可以無限堆積。在投遞量持續超過處理能力的常駐架構中,請在應用程式端用號誌之類的手段限制入場,或使用有容量上限的佇列,讓投遞端在佇列滿時等待或拒絕(這是一種把過載轉移到記憶體之外的反壓機制,與第 5 章的佇列設計是同一個原則)。

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

競爭只有在「多個執行緒」與「被共用的可變資料」同時具備時才會發生。在選擇同步基元(下一章)之前,先想想能否從根本上減少共用。手段大致分為三類。

拆分。在平行彙總時,不要讓各執行緒直接寫入共用計數器,而是在每個執行緒各自的區域變數(或按執行緒配置的緩衝區)中先算出小計,結束時只用一次 InterlockedAdd 之類的操作合併。對共用資料的寫入從「每次迭代一次」減少為「每個執行緒一次」,同步成本與競爭視窗都會以數量級縮小。2.1 節中「這個緩衝區歸哪個執行緒所有」的所有權明文化,直接就是拆分的設計圖。

唯讀化。啟動時建構、之後不再改寫的設定・表格類資料,初始化完成後,不論多少執行緒讀取都是安全的。要麼「在所有執行緒啟動前就完成初始化」,要麼若需要延遲初始化,就用 Win32 的一次性初始化(InitOnceExecuteOnce),用程式碼明確劃出「從何時起變為唯讀」的界線。3

傳遞。執行緒之間的資料流動,不要讓兩側都去碰共用變數,而應歸攏到生產者/消費者佇列。用 C 語言實作時,第 5 章的條件變數(有限循環緩衝區 + SleepConditionVariableCS)本身就是官方給出的實作範例,容量有上限的緩衝區,還能自然形成「生產超過消費時,生產端就等待」的反壓機制。5

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

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

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

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

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

補充一點表格與流程圖之外的內容。事件、號誌、Mutex 這類核心物件,只要以無名方式建立,一樣能正常用於行程內同步(第 6 章的停止事件正是無名事件)。核心物件並不等於「專供跨行程使用」。而且「無名 = 絕對僅限行程內」也不成立:只要讓控制代碼被子行程繼承,或用 DuplicateHandle 複製到其他行程,即使沒有名稱,同一個核心物件也能被多個行程使用。準確的理解是,命名只是讓行程之間能夠重新開啟同一物件的代表性手段之一。圖3的分支所示的要點,是「行程內的不要選擇核心物件」,行程內的通知(事件)或同時數限制(號誌),無名核心物件依然是正確答案。

Interlocked 系列對應 .NET 篇的 Interlocked 類別、C++ 篇的 std::atomicInterlockedIncrement / InterlockedExchange / InterlockedCompareExchange 會對單一變數進行不可分割的操作,而且大多數函式都伴隨完整的記憶體屏障,因此同時也能取得順序上的保證。4 「加了 volatile 就沒問題」是一種既不保證原子性也不保證順序的誤解(參見 FAQ)。另一個前提條件是對齊(alignment)。Interlocked 函式的目標變數必須對齊到自然邊界(32 位元值需 4 位元組邊界,64 位元值需 8 位元組邊界),不對齊的話行為就無法預測。12 不要把 #pragma pack 過的結構體,或是直接映射通訊格式的緩衝區上的欄位,當作 Interlocked 的操作對象。計數器與旗標請限定為一般宣告(亦即由編譯器負責對齊)的變數。此外,用 InterlockedExchangePointer 等函式進行的指標替換,還有它特有的注意事項:不可分割的只是替換動作本身,替換之後舊記憶體區塊的生命週期沒有任何人來保障。如果讀者剛載入完舊指標,寫者就立刻替換並 free 掉它,那就是存取已釋放的記憶體。用指標替換來更新共用資料的設計,只有在配合鎖、參照計數等回收協定的情況下才能成立(拿不準的話,用 SRW 鎖來保護是穩妥的做法)。

等待方面有條件變數。用 InitializeConditionVariable 建立,消費端用 SleepConditionVariableCS(與 CRITICAL_SECTION 搭配)進入睡眠,生產端用 WakeConditionVariable 喚醒 ── 有限緩衝區的生產者/消費者佇列,官方實作範例正是這種形式。5 一項重要的做法是:喚醒後務必在鎖內重新確認條件(佇列是否非空),若為偽就回到等待迴圈。條件變數存在無通知就喚醒的偽喚醒(spurious wakeup),而且醒來時可能已經有別的消費者搶先取走了元素,所以「被喚醒」不等於「條件成立」。這是在 C 語言中搭建與 .NET 篇 4.3 節的通道(channel)、C++ 篇第 4 章的 BlockingQueue 相同結構的工具。若要與 SRW 鎖搭配,就用 SleepConditionVariableSRW

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

即使基元選對了,若沒有使用上的紀律,也無法防止競爭。

  • 一對一決定「哪個資料由哪把鎖守護」。為每一組想要保護的可變資料對應一把鎖(SRW 鎖或 CRITICAL_SECTION),並在觸碰這些資料的所有位置都取得同一把鎖。在標頭檔註解中明確寫上「這個結構體由 g_lockFoo 守護」的做法,在 C 語言中特別有效。
  • 持鎖期間不做耗時的事、不做外部的事。持鎖期間只該做被保護資料的讀寫。持鎖不放地進行檔案 I/O、網路存取、回呼呼叫,不僅會拉長持鎖時間,還可能因為被呼叫方試圖取得另一把鎖,而構成圖2那樣的循環等待。
  • 固定多把鎖的取得順序。在需要取得兩把以上鎖的地方,規定所有執行緒都按同一順序(鎖階層)取得。順序顛倒(lock order inversion)會導致難以除錯的死結,應定義階層並始終遵守 ── 這一點 DLL 最佳實踐文件已有明文規定。10

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

6.1. TerminateThread 會破壞什麼

TerminateThread 會在完全不讓目標執行緒執行任何使用者模式程式碼的情況下將其消滅。官方文件列舉的後果十分嚴重:若目標執行緒持有臨界區,臨界區就永遠不會被釋放;若正在進行堆積操作,堆積鎖就會一直被握住(此後所有呼叫 malloc 的執行緒都會掛起);若正在操作 DLL 的全域狀態,DLL 的狀態就會被破壞。官方對它的定位是「只應在最極端情況下使用的危險函式」,程式碼分析也會以警告 C6258 偵測出它的使用。67

在調查「行程偶爾會整個卡死」的應用程式時發現 TerminateThread,這在實務上是相當常見的場景。一旦發現,它就是需要修復的對象。

6.2. 正確的做法:停止事件 + WaitForMultipleObjects

C 語言中協作式停止的定式,是建立一個手動重置的停止事件,讓每個工作執行緒同時等待「工作訊號」與「停止訊號」。警告 C6258 的文件本身,也把這種做法(建立事件,讓每個執行緒用 WaitForSingleObject 監視該事件並自行結束)作為正確的結束方式來說明。7

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 時不該繼續釋放共用資源 */
}

停止端的合流之所以逐一使用 WaitForSingleObject,是有理由的。WaitForMultipleObjects 一次能等待的控制代碼數上限是 MAXIMUM_WAIT_OBJECTS(64 個),一旦傳入超過此數量的陣列,等待本身就會以 WAIT_FAILED 失敗,結果就是在「以為等了所有人、實際上誰都沒等到」的狀態下關閉控制代碼。若只是單純地等待所有執行緒結束,沒有數量上限的逐一迴圈更加安全。

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

圖4:停止事件模式。停止用手動重置事件,只要呼叫一次 SetEvent,所有正在等待的工作執行緒就會一齊醒來。結束的時機由各執行緒自行決定,以合流完成作為「已停止」的判定標準

要點有三個:停止事件要設為手動重置(一次 SetEvent 就能讓所有工作執行緒都看到);把停止事件放在等待陣列的最前面(當多個物件同時處於訊號狀態時,停止會被優先處理);停止端必須先等待執行緒控制代碼合流,再關閉控制代碼

這裡補充兩點適用範圍上的注意事項。第一,這種「事件 + 全部排空」的形式適合單一工作執行緒的架構。自動重置事件無論呼叫多少次 SetEvent,都只能表示「有一個訊號狀態」(連續的訊號會合併),若工作執行緒有多個,就只會有一個被喚醒,把突發的一批任務串行處理掉。若由多個工作執行緒分擔同一個佇列,工作訊號就該改用號誌,每積壓一件就用 ReleaseSemaphore(hSem, 1, NULL) 增加計數。號誌等待成功會消耗一次計數,因此能得到「積壓了多少件,就有多少個正在等待的工作執行緒各醒來一次」這種正確的對應關係(這種用途和圖3表格中「限制資源池的同時存取數」一樣,屬於號誌的守備範圍)。不過改用號誌之後,消費端也要相應改成「等待成功一次 = 從佇列只處理一件」。若原樣保留上面範例中的全部排空迴圈,一次等待明明只消耗了一個許可,卻會把整個佇列清空,結果就是剩餘的許可會讓其他工作執行緒對著空佇列醒來、生產端的 ReleaseSemaphore 因超出上限而失敗,帳目就此錯亂。遵守「一個許可 = 一件工作」的對應關係,是號誌方式成立的前提。第二,要讓停止路徑也貫穿到單件處理本身。若 ProcessNextItem 內部有較長的阻塞等待,就該把停止事件也傳進去做疊加等待,或附加一個有限的逾時。僅在項目之間做檢查,仍會留下「因一件處理不完,關閉流程就永遠等待」這個漏洞。這與 .NET 篇的 StopAsync、C++ 篇的 jthread + join 所說的是同一件事。

在阻塞式 I/O(管線・通訊端(socket)・序列埠)上等待的執行緒無法回頭查看事件,因此 I/O 一側也需要設計成 OVERLAPPED + 事件的疊加等待,或用 CancelIoEx 把 I/O 喚起(序列通訊的具體範例請參見「序列通訊應用的陷阱」)。

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

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

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

「在 DLL 卸載時,於 DllMain 中等待工作執行緒結束」看起來相當合理,實際上卻是典型的死結(正在結束的執行緒為了投遞 DLL_THREAD_DETACH 而試圖取得載入器鎖,雙方就此互相等待)。帶有執行緒的 DLL,應該公開如 MyLib_Init / MyLib_Shutdown 這樣明確的初始化・結束函式,把執行緒的啟動與合流放在那裡進行。DllMain 理想的樣貌,是接近空殼的樁函式(stub)。10

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

若「想寫不依賴 Win32、可攜的 C 程式碼」,可選方案是 C11 的 <threads.h>(thrd_create / mtx_lock / cnd_wait)與 <stdatomic.h>。根據官方的合規性對應表,MSVC 的支援狀況是:<threads.h> 從 Visual Studio 2022 17.8 起獲得支援(需要 /std:c11 及對應的 SDK),而 <stdatomic.h> 仍屬 experimental,需要 /experimental:c11atomics 選項。11

若需要與 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. 共用計數器・旗標是否已改為 Interlocked 系列,而非依賴 volatile
  6. 是否還殘留 Sleep 輪詢(是否已替換為條件變數・事件等待)
  7. 是否完全沒有使用 TerminateThread(強制終止其他執行緒)。工作執行緒是否透過從執行緒函式 return 結束,而非呼叫 ExitThread(如此 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, About Synchronization. 關於 Win32 同步基元的選擇指南:SRW 鎖是新程式碼的預設選項,具有指標大小並通常在使用者模式內完成;CRITICAL_SECTION 用於需要遞迴取得的場合;Mutex 永遠是核心物件,用於跨行程的具名同步以及與 WaitForMultipleObjects 併用;在行程內同步中使用 Mutex,在高頻操作下會顯著變慢,是「常見的錯誤」;以及號誌用於限制資源池的同時存取數、事件用於通知。  2 3

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

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

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

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

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

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

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

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

回到部落格一覽