Windows I/O 的深層(第 3 回) ── I/O 完成埠(IOCP)與 .NET 執行緒集區:async/await 的地下室

· · Windows, Win32, I/O, IOCP, 非同步, 執行緒集區, .NET, C#

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

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

Go Komura(2026)。〈Windows I/O 的深層(第 3 回) ── I/O 完成埠(IOCP)與 .NET 執行緒集區:async/await 的地下室〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/windows-iocp-dotnet-threadpool/

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

要用少數執行緒處理大量連線,I/O 的完成通知該如何彙整?await 等待期間是誰在工作,完成之後的程式碼又會在哪一個執行緒上恢復執行?

這一回要從I/O 完成埠(IOCP)彙整完成通知的機制開始,一路追到.NET 接收這些通知、並執行 await 之後的程式碼為止。把「接收完成通知」與「決定後續程式碼在哪裡執行」分開來看,ConfigureAwait(false) 與執行緒集區飢餓也能在同一條脈絡中理解。

上一回(第 2 回)看了非同步 I/O 的發出,以及接收完成通知的四條路徑。這一回要展開的 IOCP,就是其中「用少數執行緒承接大量並行 I/O」的那套機制。

本文是系列文章「Windows I/O 的深層」的第 3 回。整體架構請見第 1 回的開頭

從想了解的問題開始讀

依序閱讀的話,先在第 2 章確認「為每個連線準備一個執行緒」這種設計的極限,接著在第 3〜4 章看 Win32,第 5 章看它與 .NET 的對應關係。如果正在排查問題,可以從下面的索引直接跳到需要的說明。

想了解的事、遇到的困難 閱讀位置
為什麼數千個並行連線不需要同樣數量的執行緒 第 2 章:連線數與執行緒數第 3 章:完成佇列與執行緒控制
想識別是哪個控制代碼的哪個 I/O 完成了 第 3.1 節:CompletionKey 與 OVERLAPPED
取得完成通知時傳回 FALSE,不知道該不該做後續處理 第 3.2 節:區分失敗的 I/O 與取不到封包
想了解 FIFO、LIFO 與並行值之間的關係 第 3.3〜3.4 節:等待執行緒的挑選方式與可執行數
結束通知、批次取出、同步完成分別該怎麼處理 第 4 章:依用途選擇 API
想完整追蹤 await ReadAsync 從發出到恢復的過程 第 5.1 節:await 的一次往返
想了解「I/O 等待不消耗執行緒」的適用範圍 第 5.2 節:等待的執行緒與處理的執行緒
加了 ConfigureAwait(false),UI 還是凍結,或是無法操作 UI 第 5.3 節:區分接續去向與 CPU 處理
負載一增加,整個非同步處理就變慢 第 5.4 節:同步等待與執行緒集區飢餓

1. 先講結論

IOCP 負責的事

  • IOCP 是把「完成通知的佇列」與「執行緒數的控制」合而為一的機制。完成封包以 FIFO 順序積存到佇列中,工作執行緒用 GetQueuedCompletionStatus 取出(第 3 章)。1
  • 執行緒以 LIFO 順序被喚醒。直到剛才都還在工作、已經「溫熱」的執行緒會接著撿起下一個封包,所以只要佇列裡還有堆積,幾乎不會發生內容切換(第 3.3 節)。1
  • 並行值是「可執行的執行緒數」的上限。建議的起點是 CPU 數(指定 0 即為處理器數)。執行中的執行緒一旦阻塞,就會喚醒等待中的執行緒來填補空缺(第 3.4 節)。12

選擇 API 時的要點

  • 連接埠也能用來傳遞自訂通知。PostQueuedCompletionStatus 可以積存與 I/O 無關的封包,因此對工作執行緒的工作委派、結束指示,都能走同一個佇列(第 4 章)。3
  • 若要新寫伺服器實作,官方建議的不是直接使用 IOCP,而是 Windows 執行緒集區 API(CreateThreadpoolIo)。它內部仍然是 IOCP,只是代為承擔了執行緒管理(第 4 章)。1

在 .NET 與 await 層面要區分的事

  • .NET 執行緒集區是工作執行緒與 I/O 完成執行緒的兩層結構,非同步 I/O 的控制代碼會與執行緒集區(自身的 IOCP)綁定。await 的 I/O 等待期間不存在執行緒,只有完成之後的接續才會搭上執行緒(第 5 章)。456
  • 接續的去向由「被捕捉的內容」決定。在 UI 執行緒上 await,後續程式碼就回到 UI 執行緒;沒有可捕捉的對象時,就在執行緒集區(或讓 Task 完成的那個執行緒)上繼續。ConfigureAwait(false) 是「停止捕捉」的指示,並不保證一定移到執行緒集區(第 5.3 節)。6

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

2. 用執行緒硬扛的設計會在哪裡崩潰

先確認 IOCP 想解決的問題。單純的伺服器可以寫成「一個連線配一個執行緒」:用同步 I/O 讀取、處理,再寫回,是一種很好理解的設計。

問題出在連線變多之後。請在圖 1 中比較隨著等待中的連線數一起增加執行緒的設計只讓少數執行緒處理已完成工作的設計

IOCP 模型(非同步 I/O)完成佇列(彙整所有連線的完成通知)工作執行緒 1工作執行緒 2工作執行緒只有 CPU 數量級的少數每個連線一個執行緒(同步 I/O)執行緒 1: 等待連線 1 的 read執行緒 2: 等待連線 2 的 read執行緒 3: 等待連線 3 的 read…執行緒數隨著連線數一起增加大多數只是在 I/O 等待中沉睡

圖 1:左邊的執行緒數與連線數成正比增加。右邊只用少數執行緒處理「已經發生的事件」

只是在等待的執行緒也要占用資源

每一個執行緒都會消耗堆疊(預設保留 1MB)與核心物件,數量越多,排程器與內容切換的負擔就疊得越高。數千個連線=數千個執行緒,即使其中大多數只是「等著有資料可讀而沉睡」,代價依然高昂。

增加可執行的執行緒並不會增加 CPU

能夠同時運作的執行緒,在物理上最多就是 CPU 的數量。讓超出這個數量的執行緒進入可執行狀態,也只會增加切換造成的損耗。

到第 2 回為止,我們已經看過用「事件」或「APC」接收 I/O 完成通知的做法。但事件方式會受到 WaitForMultipleObjects 64 個的上限所限,等待邏輯的設計也很繁瑣;APC 則被綁在發出要求的那個執行緒上。而 IOCP 從一開始就是照著大量 I/O × 少數執行緒這種形態設計出來的。1

3. IOCP 的設計 ── 佇列與執行緒控制的一體化

3.1. CreateIoCompletionPort 的兩張臉

CreateIoCompletionPort 的名稱雖然只提到建立,實際上卻做兩件事:新建連接埠,以及把控制代碼與現有連接埠建立關聯2

工作執行緒群I/O 完成埠建立關聯的控制代碼(數量不限)控制用 GetQueuedCompletionStatus 等待用 GetQueuedCompletionStatus 等待完成封包的佇列(FIFO)封包 = 傳輸位元組數 +CompletionKey + OVERLAPPED 指標並行控制可執行執行緒數 ≦ 上限檔案通訊端具名管道

圖 2:IOCP 的組成。大量控制代碼的完成通知彙整到同一個佇列,連取出端的執行緒數也一併受控

建立關聯時傳入的 CompletionKey,是一個可以自由取值的量,用來告訴工作執行緒「這是來自這個控制代碼的完成通知」。慣例做法是放入連線物件的指標。2

在完成封包中,要組合下面這三項來識別是哪一個操作。27

收到的資訊 用來識別或確認的事
CompletionKey 是來自哪個控制代碼、哪個連線的完成通知
OVERLAPPED 指標 該連線上的哪一個操作完成了
傳輸位元組數 傳輸了多少資料

這正是把第 2 回看到的「操作的傳票」在完成時回收起來的機制。

對象並不侷限於「檔案」。通訊端、具名管道、郵件位置等,只要是能進行重疊 I/O 的控制代碼都可以建立關聯。1 第 1 回看到的「萬物皆檔案」設計,在這裡同樣發揮著作用。

3.2. 完成封包的旅程

工作執行緒連接埠的佇列(FIFO)核心(IRP 完成)工作執行緒連接埠的佇列(FIFO)核心(IRP 完成)用 GetQueuedCompletionStatus 等待查看封包並進行完成處理(執行接續、發出下一個 I/O 等)若佇列中仍有封包不必等待即可取得下一個積存完成封包(位元組數 / CompletionKey / OVERLAPPED)喚醒一個等待中的執行緒並交付處理結束後再次呼叫 GetQueuedCompletionStatus

圖 3:完成封包以 FIFO 順序積存,工作執行緒不斷重複取出後處理的迴圈

非同步 I/O 完成後,完成封包會以FIFO 順序積存到連接埠的佇列中。工作執行緒不斷重複下面這個迴圈。17

  1. GetQueuedCompletionStatus 接收一個封包。
  2. 對收到的那個操作進行完成處理。
  3. 處理結束後,再次呼叫 GetQueuedCompletionStatus

另外還有可以一次取出多個封包的 GetQueuedCompletionStatusEx,在高頻率 I/O 的情境下能減少呼叫次數。8

不要只看到 FALSE 就跳出迴圈

即使 GetQueuedCompletionStatus 的傳回值是 FALSE也可能已經成功取出了一個失敗 I/O 的完成封包。請搭配 OVERLAPPED 指標一起判斷。7

傳回值 OVERLAPPED 含義與處理
FALSE 非 NULL 取到了一個失敗 I/O 的完成通知。需要進行錯誤處理,並回收傳票與緩衝區
FALSE NULL 沒能取到封包。例如逾時或連接埠被關閉

失敗的操作同樣需要後續處理。如果只寫成 if (!GetQueuedCompletionStatus(...)) break; 就了事,就會漏接失敗的 I/O 而造成洩漏。第 2 回看到的傳票與緩衝區生命週期管理,並不是只針對正常完成的情況。

在工作執行緒迴圈中看判斷順序

下面這段骨架程式碼先判斷上表的兩種情況,之後再處理結束用封包與一般的完成。

/* IOCP 工作執行緒迴圈的骨架 (C / Win32) */
for (;;) {
    DWORD        bytes = 0;
    ULONG_PTR    key   = 0;
    OVERLAPPED  *ov    = NULL;

    BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);

    if (!ok && ov == NULL) {
        /* 沒能取出封包(連接埠被關閉等)。只有這個條件可以離開迴圈 */
        break;
    }
    if (!ok) {
        /* ov != NULL → 取出了「失敗的 I/O 完成封包」。
           後續處理(錯誤處理、釋放傳票與緩衝區)仍然必要,所以不離開迴圈,繼續處理 */
        DWORD err = GetLastError();
        handle_failed_io(key, ov, err);
        continue;
    }
    if (key == SHUTDOWN_KEY) {
        /* 用 PostQueuedCompletionStatus 積存的結束用封包(第 4 章) */
        break;
    }
    handle_completed_io(key, ov, bytes);   /* 一般的完成處理。保持簡短(第 3.4 節) */
}

要點在於不要用單一分支處理掉 ok == FALSE,而要ov 是否為 NULL 分成兩種情況。即使改成帶逾時(INFINITE 以外的值),判斷方式也一樣,時間到會以 ok == FALSEov == NULL 的形式呈現。

另外,某個執行緒第一次呼叫 GetQueuedCompletionStatus 時,該執行緒就會與那個連接埠建立關聯(一個執行緒同時只能與一個連接埠建立關聯)。1 用「連接埠配有一支專屬的工作執行緒團隊」這樣的圖像來記,會比較準確。

3.3. 執行緒以 LIFO 順序被喚醒

這一節請把封包進入佇列的順序喚醒等待中執行緒的順序分開來看。1

對象 順序 關注點
完成封包 FIFO 完成通知積存到佇列的順序
等待中的執行緒 LIFO 從最後進入等待的執行緒開始喚醒

由於封包與執行緒的順序相反,實際的行為就是直到剛才都還在工作的那個執行緒會接著撿起下一個封包。

等待中的執行緒(LIFO 堆疊)不論 P1、P2、P3,只要有空就先給執行緒 A只有 A 被占滿時才輪到很少會被喚醒執行緒 A(直到剛才都在執行,溫熱的)執行緒 B(已經沉睡一段時間)執行緒 C(一直在沉睡)佇列: P1 → P2 → P3 (FIFO)

圖 4:LIFO 釋放。越忙碌時,同一個執行緒就越持續運轉,閒置的執行緒則可以繼續沉睡

這種設計帶來兩項好處。

  • 不會發生內容切換。只要佇列中還有封包,處理完的執行緒呼叫 GetQueuedCompletionStatus 時就會不必等待、直接取得下一個封包並繼續運轉。文件在並行值為 1 的情境下明確寫著「不會發生執行緒切換」。1
  • 快取能保持溫熱繼續使用。由於是同一個執行緒持續運轉,堆疊與排程相關的狀態留在 CPU 快取中的機率更高。沉睡中的執行緒,則作為因應負載尖峰的備援,能以低成本維持下去。

3.4. 並行值 ── 計算「可執行」的數量

建立連接埠時的 NumberOfConcurrentThreads 就是並行值。它計算的是與該連接埠建立關聯的可執行(runnable)執行緒。在達到上限的期間,額外的執行緒無法接收封包。1

傳入 0 時會使用系統的處理器數。文件舉出的「整體而言最理想的上限值」也是 CPU 數,所以就以它作為起點。21

它不是包含等待中執行緒在內的總數上限。 請在圖 5 中看有執行緒進入等待狀態時會發生什麼事。

低於已達上限有封包送達佇列可執行的執行緒數是否低於並行值喚醒等待中的執行緒進行處理不喚醒任何執行緒,先留在佇列中(由執行中的執行緒自己前來取用)執行中的執行緒因其他原因進入等待狀態可執行數減少多少就喚醒多少等待中的執行緒來補充

圖 5:並行控制。由於上限計算的是「可執行的數量」,只要有執行緒被阻塞就會自動補充

進入等待的工作執行緒,由另一個等待中的執行緒補上

執行中的工作執行緒一旦進入某種等待(鎖定、分頁錯誤、不小心寫成的同步 I/O),可執行數就會減少,於是系統會喚醒等待中的執行緒來填補空缺1 所以慣例做法並不是只建立「剛好等於 CPU 數」的工作執行緒,而是讓待命的執行緒數多於並行值。如果處理中混有耗時較長的計算,也可以選擇直接調高並行值本身;文件的立場是最終應搭配剖析(profiling)來調整。1

上限有可能被暫時突破,所以完成處理要短

不過補充機制並非萬能。被阻塞的執行緒稍後醒來時,那一瞬間可執行數就會超過上限(文件也提到了這種超額情況)。1 讓完成處理保持簡短是大原則,這一點在第 5.4 節的 .NET 場景中也會以相同的形式發揮作用。

4. 工具箱 ── 支撐連接埠的那些 API

4.1. 讓工作委派與結束指示走同一個佇列

PostQueuedCompletionStatus ── 不必發出 I/O,就能把自訂的完成封包積存到佇列中。3 對工作執行緒的工作委派、關閉指示(依工作執行緒的數量積存相同數量的結束用封包──俗稱「毒藥封包(poison pill)」)、來自其他執行緒的通知──能用同一個佇列、同一個迴圈處理 I/O 的完成通知與自訂訊息,讓設計大幅簡化。

4.2. 批次接收高頻率 I/O 的完成通知

GetQueuedCompletionStatusEx ── 一次取出多個完成封包。在「一個封包對應一次呼叫」的額外負擔開始造成影響的高頻率 I/O 情境下很有效。8

4.3. 省略同步完成時的通知

SetFileCompletionNotificationModes ── 針對第 2 回第 5 章看到的「明明是非同步發出卻同步完成」的情況,可以選擇不把封包積存到連接埠的模式(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)。既然同步完成的結果當場就已經知道,再繞經佇列一趟只是白費工夫──這是一種加速手法。9

4.4. 把執行緒的建立與管理交出去

Windows 執行緒集區 API ── CreateThreadpoolIo / StartThreadpoolIo 在內部使用 IOCP,同時代為進行執行緒的建立與管理。微軟建議全新的伺服器應用程式先考慮這一套,只有在想明確控制並行值或執行緒管理時,才直接使用 IOCP。1 而 .NET 的執行緒集區,正是把這套「IOCP + 執行緒管理自動化」以 .NET 執行階段的形式實作出來的產物。

4.5. 換哪一種 API 都不會改變的三條生命週期管理

  • 不要在工作執行緒中長時間阻塞。 第 3.4 節的補充機制只能緩和劣化。
  • 以控制代碼為單位與以操作為單位分開識別。 CompletionKey 是控制代碼層級,OVERLAPPED 是操作層級。第 2 回的「傳票」在完成之前不能釋放。
  • 不要在還有未完成 I/O 的情況下關閉控制代碼。 cleanup 的行為(第 1 回第 6 章)與取消的作法(第 2 回第 6 章)在這裡原樣適用。

5. .NET 執行緒集區 ── 建在 IOCP 之上的兩層樓

從這裡開始,把 Win32 的完成通知與 .NET 的程式碼對應起來。觀察的順序是接收完成通知的執行緒 → await 的一次往返 → 等待時間 → 接續的執行位置

.NET 執行緒集區提供下面兩種角色的執行緒。4

種類 本文關注的角色
工作執行緒 執行 Task.Run 與接續
I/O 完成執行緒 接收非同步 I/O 的完成通知

ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) 也會把這兩者當成兩個獨立的數字回傳。以下就區分「接收完成通知的一側」與「執行工作的一側」來追蹤。4

在 Windows 上,執行緒集區擁有自己專屬的 I/O 完成埠。把作業系統的控制代碼與這個連接埠建立關聯的低階 API 就是 ThreadPoolBoundHandle.BindHandle。對已綁定控制代碼發出的非同步 I/O,要搭配 NativeOverlapped 一起處理,它就是第 2 回的 OVERLAPPED 在 .NET 這一側的對應機制。5

歷史更久的 ThreadPool.BindHandle 也以相同的角色保留著,但新寫的程式碼就用 ThreadPoolBoundHandle.BindHandle。當 FileStreamSocket 開啟非同步模式的控制代碼時,內部就會進行這類綁定。5

把它與 Win32 的對應關係依發出與完成分開來看,就是下面這樣。

  • 第 2 回的「非同步模式控制代碼 + OVERLAPPED」是發出要求的機制
  • 本文的 IOCP 是接收完成通知的機制
  • .NET 執行緒集區的 I/O 完成執行緒,就是負責運轉 GetQueuedCompletionStatus 迴圈的工作執行緒團隊

依這樣的對應關係,Win32 的圖像就原樣變成了 .NET 的圖像。

5.1. await ReadAsync 一次往返的完整版

第 2 回圖 7 中標著「真正的非同步 I/O」的那個方塊,這一回要把裡面的內容一路追到完成之後的接續。請在圖 6 中把發出要求時的執行緒、接收完成通知的執行緒,以及執行後續程式碼的位置分開來看。

接續的執行位置I/O 完成執行緒執行緒集區的 IOCP核心(IRP 發出〜完成)呼叫端執行緒(UI 執行緒等)接續的執行位置I/O 完成執行緒執行緒集區的 IOCP核心(IRP 發出〜完成)呼叫端執行緒(UI 執行緒等)await 把接續註冊到尚未完成的 Task 上並釋出執行緒(若是 UI 則轉去處理下一則訊息)裝置正在工作中這段期間,任何地方都不存在正在等待的執行緒確定結果(位元組數、狀態)讓 Task 完成,並排程接續await 之後的程式碼開始執行ReadAsync 發出非同步讀取(附上相當於 OVERLAPPED 的資訊)ERROR_IO_PENDING(立即返回)積存完成封包以 LIFO 喚醒一個並交付投遞到已捕捉的內容(丟回 UI 執行緒 / 若無則在執行緒集區執行)

圖 6:await 一次往返的全貌。執行緒只在「發出」與「完成之後」工作,等待時間裡的執行緒使用量是零

5.2. 「I/O 等待不消耗執行緒」的精確含義

不會只為了等待時間就配置專用執行緒

這張圖想強調的是,從發出到完成的這段期間,不論在使用者模式還是核心中,都不存在任何只是為了等待這個完成通知而存在的執行緒。微軟的 async 解說(async in depth)在談 I/O 密集型的 Task 時,也一路下探到裝置驅動程式與中斷這一層來說明。6

另一方面,核心中確實存在驅動程式把部分處理委託給系統工作執行緒的情形。那是為了讓要求向前推進而做的工作,與阻塞在完成上、持續等待用的執行緒是兩回事。「不消耗執行緒」講的正是這段等待時間。

用第 1 回以來累積的內容換句話說──IRP 並不是執行緒,而是以資料結構的形式停留在裝置堆疊中(第 1 回);發出要求時以 ERROR_IO_PENDING 立即返回(第 2 回);完成則透過「中斷 → 完成封包」這條事件鏈送達(本文)。維持「等待」這個狀態,本身就不需要執行緒這種昂貴的資源──整套機制就是這樣設計的。

要與 CPU 處理和假性非同步區分開

所以,正確使用 async/await 的應用程式,能用十幾個執行緒維持「同時有 10,000 件 I/O 在飛」的狀態。反過來說,這項性質只屬於 I/O 密集型的 Task。用 Task.Run 包起來的 CPU 處理理所當然會占用一個工作執行緒;第 2 回第 7 章的「假性非同步」,同樣是在背地裡讓執行緒沉睡。

5.3. 接續在哪裡執行

圖 6 最後那支箭頭講的是「收到完成通知之後,要把接續放在哪裡執行」。要把是否回到原本的內容把耗用 CPU 的處理送到哪裡分開考慮。6

是(例如 WPF/WinForms 的 UI 執行緒)否(例如主控台或 ASP.NET Core)Task 完成了,想要執行接續await 當下是否捕捉了 SynchronizationContext或非預設的 TaskScheduler是否加上了ConfigureAwait(false)投回捕捉到的內容例如在 UI 執行緒的訊息迴圈或該 TaskScheduler 上執行沒有回到特定位置的義務可能在讓它完成的執行緒上同步接續或在執行緒集區的執行緒上執行

圖 7:接續的去向。「await 之後可以直接操作 UI」,是因為把它投回了被捕捉的內容

被捕捉的內容,以及同步接續的情況

  • 在 WPF/WinForms 的 UI 執行緒上 await 時,SynchronizationContext 會被捕捉,後續程式碼會回到 UI 執行緒。所以在 await 之後馬上操作控制項也不會違反跨執行緒規則。這項設計在實務面的討論,請見「以一頁整理 WPF/WinForms 的 async 與 UI 執行緒」。
  • 被捕捉的不只是 SynchronizationContext,如果是在非預設的 TaskSchedulerawait,該排程器同樣是捕捉的對象。在兩者都不存在的地方(主控台、ASP.NET Core、執行緒集區之上),由於沒有回到特定位置的義務,接續就會在執行緒集區上執行,或是直接在讓 Task 完成的那個執行緒上同步接續
  • ConfigureAwait(false) 只是明確表示「不必回去」,而不是保證「一定會移到執行緒集區」。若 await 的是已經完成的 Task(第 2 回看到的同步完成也包含在內),就不會發生等待,會在目前的執行緒上直接繼續。函式庫程式碼中該如何取捨,請參考「C# async/await 實務判斷表」。

錯誤示範:以為加了 ConfigureAwait(false) 就換了地方執行

用程式碼確認最後這一點。下面這個例子,是把 ConfigureAwait(false) 當成「移到執行緒集區的指示」寫出來的。

// 錯誤示範:誤以為「加了 ConfigureAwait(false),接下來就會在執行緒集區執行」
private async void OnLoadClick(object sender, EventArgs e)
{
    string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);

    // 預期:這裡是執行緒集區,所以 UI 不會凍結
    // 實際:如果 await 當下 Task 已經完成,就不會發生等待,
    //       會留在 UI 執行緒上繼續 → 這段繁重的處理會讓 UI 凍結
    var rows = ParseHeavy(csv);

    // 而且,若是以非同步方式完成,接續並不在 UI 執行緒上
    resultLabel.Text = $"{rows.Count} 筆";   // → 可能變成跨執行緒例外
}

ConfigureAwait(false) 所表達的,只是「不必回到被捕捉的內容」而已。它並沒有指定「要在哪裡執行」,所以既可能留在 UI 執行緒上繼續,也可能在 I/O 完成執行緒或執行緒集區的執行緒上繼續。兩種情況都有可能,正是這段程式碼會出問題的原因。

正確示範:分別指定回到 UI 與繁重處理的執行位置

把 UI 程式碼與函式庫程式碼分開,各自把意圖寫清楚。用來回到 UI 的 await,與把 CPU 處理送到執行緒集區的 Task.Run,是兩種不同的角色。

// 正確示範:分別指示「要不要回到 UI」與「繁重的處理要在哪裡做」
private async void OnLoadClick(object sender, EventArgs e)
{
    // UI 程式碼中維持讓它被捕捉(後續程式碼會回到 UI 執行緒)
    string csv = await File.ReadAllTextAsync(path);

    // 若想把耗用 CPU 的處理送到執行緒集區,就用 Task.Run 明確指定
    var rows = await Task.Run(() => ParseHeavy(csv));

    // 這裡確定是 UI 執行緒,可以安全地操作控制項
    resultLabel.Text = $"{rows.Count} 筆";
}

// 而在函式庫這一側(不持有 UI 的程式碼)則相反,要表明「不需要回去」
public async Task<string> ReadConfigAsync(string path)
{
    string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
    return text.Trim();   // 不依賴呼叫端的內容
}

判斷可以分成下面三種情況。

目的 在這段程式碼裡的選擇
await 之後要操作 UI 在 UI 程式碼中維持讓內容被捕捉
把繁重的 CPU 處理送到執行緒集區 Task.Run 明確指定
在函式庫內部,不需要回到呼叫端的內容 ConfigureAwait(false) 停止捕捉

ConfigureAwait(false) 並不是用來指定執行緒的。請把它當成讓函式庫不依賴呼叫端的內容也能運作的工具,與 UI 處理和 CPU 處理的安置分開看待。

5.4. 卡住的真面目 ── 執行緒集區飢餓

同步等待會堵住執行接續的執行緒

在接續或工作執行緒中同步等待,那個執行緒就會一直被堵住。同步 I/O、Task.Result/Wait()、長時間的鎖定等待,都屬於這種模式。

IOCP 自身的補充機制(第 3.4 節),只要還有待命的備用執行緒就會立即生效。但備用執行緒耗盡之後,就進入了執行緒集區只會緩慢注入新執行緒的區域。

一旦在負載壓上來的瞬間發生「想執行接續卻沒有執行緒可用」的飢餓(starvation),整個應用程式就會變得遲鈍。

把空閒數量與實際的阻塞位置合起來調查

調查的切入點有兩個。

  1. ThreadPool.GetAvailableThreads 查看工作執行緒與 I/O 完成執行緒的空閒數。4
  2. 用事件追蹤掌握執行緒集區與阻塞的實際狀況。

追蹤的具體步驟整理在「用 PerfView 與 dotnet-trace 找出「變慢」的原因」中。

預防的原則是讓 async 的路徑一路維持 async 走到底,不要混入 sync-over-async。即使在 I/O 等待期間交出了執行緒,只要在接續中同步等待,就會在那裡重新堵住一個執行緒。

6. 總結

  • IOCP 是把完成佇列(FIFO)與執行緒數控制合而為一的機制。它把大量控制代碼的完成通知彙整到單一連接埠,用 GetQueuedCompletionStatus 的迴圈進行處理。17
  • 由於執行緒以 LIFO 順序釋放,越忙碌時同一個執行緒就越持續運轉,內容切換與快取未命中都能降到最低。1
  • 並行值是「可執行執行緒數」的上限,起點是 CPU 數(指定 0)。執行中的執行緒一旦阻塞,就會由等待中的執行緒補充,但讓完成處理保持簡短始終是大原則。12
  • PostQueuedCompletionStatus 也能傳送自訂封包。全新實作時,Windows 執行緒集區 API(內部仍是 IOCP)是第一候選。31
  • .NET 執行緒集區是工作執行緒 + I/O 完成執行緒的兩層結構,非同步控制代碼會與集區的 IOCP 綁定。await 的 I/O 等待期間不存在執行緒,只有完成之後的接續才會搭上執行緒。456
  • 接續的去向取決於被捕捉的內容(回到 UI 執行緒,或在執行緒集區上繼續)。ConfigureAwait(false) 就是停止這種捕捉的指示。6
  • 卡住的真面目,幾乎都是因為混入同步等待而造成的執行緒集區飢餓。請讓 async 的路徑從頭到尾都維持 async。

接下來的第 4 回是「快取管理員 ── 你的 WriteFile 究竟何時送達磁碟」。第 2 回寫過「只要載入了快取,就會同步完成」,這一回也好幾次瞥見了快取的影子。下一回要正面面對快取本體──延遲寫入、預先讀取、FILE_FLAG_NO_BUFFERING,以及「明明寫入了,資料卻在斷電時消失」的條件。

相關文章

相關諮詢領域

合同會社小村軟體承接需要處理大量並行連線、並行 I/O 的 Windows 應用程式/伺服器設計,以及「執行緒集區卡住」「明明非同步化了卻沒有變快」這類效能問題的原因調查。

參考連結

  1. Microsoft Learn, I/O Completion Ports. 關於 I/O 完成埠在多處理器系統上提供有效率的執行緒模型來處理大量非同步 I/O 要求、非同步 I/O 完成時完成封包會以 FIFO 順序積存到連接埠的佇列中、對象並不侷限於磁碟上的檔案而是任何支援重疊 I/O 的控制代碼(通訊端、具名管道、郵件位置等)、在連接埠上等待的執行緒會以 LIFO 順序釋放且並行值為 1 時若佇列有堆積則不會發生執行緒切換、執行緒第一次呼叫 GetQueuedCompletionStatus 時會與該連接埠建立關聯且同時只能與一個連接埠建立關聯、並行值限制可執行執行緒數且整體而言最理想的上限值是 CPU 數、執行中的執行緒若因其他原因進入等待狀態則等待中的執行緒可以處理完成封包(以及被阻塞的執行緒醒來時可能暫時超過上限)、全新的伺服器應用程式應優先考慮 Windows 執行緒集區 API(CreateThreadpoolIo 等,內部使用 IOCP)等內容。  2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

  2. Microsoft Learn, CreateIoCompletionPort function. 關於 CreateIoCompletionPort 同時進行 I/O 完成埠的新建與把控制代碼與現有連接埠建立關聯這兩件事、建立關聯時可以指定 CompletionKey(使用者定義的值)並包含在完成封包中、NumberOfConcurrentThreads 是可以並行處理完成封包的執行緒數上限且指定 0 時會使用系統的處理器數等內容。  2 3 4 5 6

  3. Microsoft Learn, PostQueuedCompletionStatus function. 關於 PostQueuedCompletionStatus 可以在不啟動非同步 I/O 的情況下,把應用程式自訂的完成封包積存到 I/O 完成埠的佇列中,因此連接埠除了接收 I/O 完成通知之外,也能作為處理程序內其他執行緒通訊的接收窗口等內容。  2 3

  4. Microsoft Learn, The managed thread pool. 關於 .NET 的執行緒集區會提供工作執行緒與用於非同步 I/O 完成的執行緒、可以用 ThreadPool.GetAvailableThreads 分別取得工作執行緒與 I/O 完成執行緒的可用數量、不應該在執行緒集區的執行緒上進行長時間阻塞等內容。  2 3 4 5

  5. Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. 關於 ThreadPoolBoundHandle.BindHandle 會傳回一個把作業系統控制代碼綁定到系統執行緒集區(的 I/O 完成埠)的 ThreadPoolBoundHandle、對已綁定的控制代碼進行低階非同步 I/O 時要搭配 NativeOverlapped 一起使用、非同步 I/O 的完成處理會因此改由執行緒集區負責等內容。  2 3 4

  6. Microsoft Learn, Async in depth (.NET). 關於 I/O 密集型的 Task,呼叫交給作業系統之後就不存在任何專門用來等待其完成的執行緒(也就是所謂的「There is no thread」)、完成通知會經過裝置驅動程式與中斷送達並執行先前註冊的接續、await 在預設情況下會捕捉目前的內容(SynchronizationContext 等)並在該處執行接續,沒有應該捕捉的內容時則會在執行緒集區執行、可以用 ConfigureAwait(false) 停用這種捕捉等內容。  2 3 4 5 6

  7. Microsoft Learn, GetQueuedCompletionStatus function. 關於 GetQueuedCompletionStatus 會從完成埠的佇列中取出一個完成封包(沒有則等待)、取出的結果可以得到傳輸位元組數、CompletionKey 與 OVERLAPPED 指標,以及即使傳回值為 FALSE,只要 OVERLAPPED 指標不是 NULL,就代表「取出了失敗 I/O 操作的完成封包」,只有在 OVERLAPPED 為 NULL 時(逾時等情況)才代表沒能取出封包等內容。  2 3 4

  8. Microsoft Learn, GetQueuedCompletionStatusEx function. 關於 GetQueuedCompletionStatusEx 可以一次取出多個完成封包、並會傳回取出的項目數等內容。  2

  9. Microsoft Learn, SetFileCompletionNotificationModes function. 關於透過 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS,可以在 I/O 立即成功、結果當場就確定的情況下,選擇不把封包積存到完成埠的行為等內容。 

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

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

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

常見問題

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

什麼是 I/O 完成埠(IOCP)?
這是 Windows 核心的一項機制,它把大量非同步 I/O 的完成通知彙整到單一佇列,並連同處理這些通知的執行緒的同時執行數一起控制。用 CreateIoCompletionPort 建立連接埠,並將檔案或通訊端等控制代碼與之關聯後,每當非同步 I/O 完成,完成封包就會積存到連接埠的 FIFO 佇列中。工作執行緒則用 GetQueuedCompletionStatus 從佇列中取出封包並處理。重點在於,這並不只是單純的通知佇列,同時還兼具「把可執行的執行緒數維持在並行值以下」的排程機制。它能用少數執行緒有效率地處理大量並行 I/O,是 Windows 伺服器實作與 .NET 執行緒集區的基礎。
IOCP 的並行值(同時執行數)應該設為多少?
微軟的文件指出,整體而言最理想的上限值就是電腦的 CPU 數。將 CreateIoCompletionPort 的 NumberOfConcurrentThreads 傳入 0,就會使用系統的處理器數,因此拿不定主意時可以 0 作為起點。這個值是「處於可執行狀態的執行緒數」的上限,而不是等待中執行緒數的上限。執行中的執行緒若因某種原因進入等待狀態,系統會喚醒另一個等待中的執行緒來補位,所以如果處理中混有耗時的計算或阻塞,也可以選擇把並行值設得大一些,藉此增加同時處理的封包數。最終建議的做法是搭配剖析(profiling)進行調整。
為什麼可以說 async/await 的 I/O 等待不會消耗執行緒?
因為從 I/O 發出到完成的這段期間,根本不存在任何專責照看該操作的執行緒。正如系列第 1 回、第 2 回所見,發出的要求會以 IRP 的形式流入裝置堆疊,呼叫端則以 ERROR_IO_PENDING 立即返回。await 在此時只是把接續註冊到尚未完成的 Task 上,然後釋出執行緒。裝置作為硬體在工作的這段期間,使用者模式或核心中都沒有任何「只是在等待」的執行緒。完成之後,完成封包會積存到執行緒集區的 IOCP 中,此時 I/O 完成執行緒才會短暫動作,排程先前註冊的接續。也就是說,執行緒只在發出的瞬間與完成後的後續處理才被用到,等待本身的這段時間,執行緒使用量是零。
await 之後的接續(continuation)會在哪個執行緒執行?
在預設情況下,await 當下的 SynchronizationContext(或 TaskScheduler)會被捕捉,接續會被丟回該處執行。若是在 WPF 或 WinForms 的 UI 執行緒上 await,接續就會在 UI 執行緒上執行,所以 await 之後才能直接操作控制項。如果沒有應該捕捉的內容(例如主控台應用程式、ASP.NET Core、執行緒集區上的程式碼等),接續就會在執行緒集區的執行緒上執行,或是直接在讓 Task 完成的那個執行緒上原地接續。加上 ConfigureAwait(false) 會停止這種捕捉,但這並不是「一定會移到執行緒集區」的保證,而只是「不需要回到特定位置」的指示。若 await 的是已經完成的 Task,就不會發生等待,會在目前的執行緒上同步接續。函式庫程式碼之所以建議使用 ConfigureAwait(false),是為了避免不必要地往返 UI 執行緒,並斬斷對特定內容的依賴與死結的根源。
如果在 IOCP 的工作執行緒或 .NET 的 I/O 完成執行緒中長時間阻塞,會發生什麼事?
不會立刻壞掉,但會偏離機制原本的設計前提,導致效能劣化。IOCP 在執行中的執行緒進入等待狀態時,會喚醒等待中的執行緒來補充,但補充進來的執行緒會讓同時執行數膨脹,進而增加內容切換(context switch)。若阻塞成為常態,封包就會在佇列中堆積,使整體完成處理延遲。.NET 也是同樣的道理,如果在 I/O 完成執行緒或接續中進行同步 I/O、Task.Result 之類的等待,就會招致執行緒集區的飢餓(starvation)。原則是讓完成處理與接續保持簡短,把繁重的工作切分出去。可以用 ThreadPool.GetAvailableThreads 觀察工作執行緒與 I/O 完成執行緒的空閒數,用來調查卡住的原因。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽