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

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

上一回(第 2 回)我們看了非同步 I/O 的發出,以及接收完成通知的四條路徑。當時只點出名字、作為「用少數執行緒承接大量並行 I/O 的本命角色」登場的,就是I/O 完成埠(IOCP)

為什麼 Web 伺服器能用十幾個執行緒就處理數千個並行連線?為什麼可以斷言 async/await 的 I/O 等待「不會消耗執行緒」?為什麼 await 之後的接續,有時候在 UI 執行緒執行,有時候又在執行緒集區執行?──這三個疑問的答案,全都可以追溯到 IOCP 這一項設計。這一回是本系列中與 .NET 開發者關係最直接的一回。

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

由於文章較長,先在此說明各個疑問分別在哪個段落回收。

  • 疑問 1:數千個並行連線能靠十幾個執行緒處理的原因 → 在第 3 章(將完成佇列與執行緒數控制合而為一的 IOCP 設計)回收。
  • 疑問 2:能斷言 I/O 等待「不消耗執行緒」的原因 → 在第 5.1〜5.2 節(await 一次往返的全貌,以及「等待期間不存在執行緒」的精確含義)回收。
  • 疑問 3:決定 await 之後接續執行所在執行緒的因素 → 在第 5.3 節(被捕捉的內容與 ConfigureAwait(false) 的真正含義)回收。

1. 先講結論

  • IOCP 是把「完成通知的佇列」與「執行緒數的控制」合而為一的機制。完成封包以 FIFO 方式積存到佇列中,工作執行緒用 GetQueuedCompletionStatus 取出(第 3 章)。1
  • 執行緒以 LIFO 喚醒。直到剛才都還在工作的「溫熱的」執行緒會接著撿起下一個封包,所以只要佇列裡還有堆積,幾乎不會發生內容切換(第 3.3 節)。1
  • 並行值是「可執行的執行緒數」的上限。建議的起點是 CPU 數(指定 0 即為處理器數)。執行中的執行緒一旦阻塞,就會喚醒等待中的執行緒來補位(第 3.4 節)。12
  • 連接埠也能用來發送自訂通知。PostQueuedCompletionStatus 可以積存與 I/O 無關的封包,因此對工作執行緒的任務委派或結束指示,也能透過同一個佇列傳遞(第 4 章)。3
  • 若是全新的伺服器實作,比起原始的 IOCP,更建議使用 Windows 執行緒集區 API(CreateThreadpoolIo)。其內部仍是 IOCP,但會代為處理執行緒管理(第 4 章)。1
  • .NET 執行緒集區是工作執行緒與 I/O 完成執行緒的兩層樓結構,非同步 I/O 的控制代碼會與執行緒集區(的 IOCP)綁定。await 的 I/O 等待期間不存在執行緒,只有完成後的接續才會搭上執行緒(第 5 章)。456
  • 接續的去向由「被捕捉的內容」決定。若在 UI 執行緒 await,接續就會回到 UI 執行緒;若沒有可捕捉的內容,就會在執行緒集區(或讓其完成的那個執行緒上)接續。ConfigureAwait(false) 只是停止捕捉的指示,並不保證會移到執行緒集區(第 5.3 節)。6

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

首先,讓我們先確認 IOCP 想要解決的問題。單純的伺服器可以用「一個連線配一個執行緒」的方式撰寫:用同步 I/O 讀取、處理、寫入。這是一種容易理解的設計,但隨著連線增加,會撞上兩道牆。

IOCP 模型,非同步 I/O完成佇列彙整所有連線的完成通知工作執行緒 1工作執行緒 2工作執行緒數量與 CPU 數相當,是少數每個連線一個執行緒,同步 I/O執行緒 1,等待連線 1 的 read執行緒 2,等待連線 2 的 read執行緒 3,等待連線 3 的 read…執行緒數量隨連線數增加而增加大多數都只是在等待 I/O 而沉睡

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

  1. 執行緒不是免費的。每一個執行緒都會消耗堆疊(預設保留 1MB)與核心物件,數量越多,排程器與內容切換(context switch)的負擔就越沉重。數千個連線=數千個執行緒,即使其中大多數只是「等著可以讀取而沉睡」,代價依然高昂。
  2. 「想要執行的數量」一旦超過 CPU 數就沒有意義了。能夠同時運作的執行緒,物理上最多就是 CPU 的數量。即使讓超出這個數量的執行緒進入可執行狀態,也只會增加切換造成的損耗。

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

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

3.1. CreateIoCompletionPort 的兩張臉

CreateIoCompletionPort 這個函式的名稱雖然只提到「建立」,實際上卻兼具兩項工作:新建連接埠把控制代碼與現有連接埠建立關聯2

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

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

建立關聯時傳入的 CompletionKey,是用來告訴工作執行緒「這是來自哪個控制代碼的完成通知」的自由值(慣例做法是放入連線物件的指標)。完成封包送達時,會附帶 CompletionKey、該操作的 OVERLAPPED 指標,以及傳輸位元組數。哪個連線(CompletionKey)、哪個操作(OVERLAPPED)、進行到什麼程度(位元組數)──第 2 回看到的「操作的傳票」,就在這裡被回收利用。27

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

3.2. 完成封包的旅程

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

圖 3:完成封包以 FIFO 方式積存,工作執行緒不斷取出並處理,形成一個迴圈

非同步 I/O 完成後,完成封包會以FIFO 順序積存到連接埠的佇列中。工作執行緒呼叫 GetQueuedCompletionStatus 接收一個封包,處理完再呼叫一次──這個迴圈就是 IOCP 程式設計的骨架。17 另外還有可以一次取出多個封包的 GetQueuedCompletionStatusEx,在高頻率 I/O 的情境下能夠減少呼叫次數。8

這裡先把工作執行緒迴圈的經典臭蟲(bug)消滅掉。即使 GetQueuedCompletionStatus 回傳 FALSE,只要OVERLAPPED 指標回傳的不是 NULL,就代表「成功取出了一個失敗 I/O 的完成封包」7 失敗的操作同樣需要後續處理(錯誤處理,以及第 2 回提到的傳票與緩衝區釋放),所以這個封包必須被處理。只有在 OVERLAPPED 為 NULL 的時候,才能說「連封包本身都沒能取出」(逾時、連接埠關閉等)。如果偷懶寫成 if (!GetQueuedCompletionStatus(...)) break;,就會把所有失敗的 I/O 全部漏接並造成洩漏

以下把骨架整理成可以直接照抄的形式。判斷的順序,正好對應前面說明的內容。

/* 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 喚醒

從這裡開始才是 IOCP 設計的精妙之處。封包是以FIFO方式積存,但等待中的執行緒卻是以LIFO方式喚醒。也就是說,最近才剛工作過的執行緒,會接著撿起下一個封包1

等待中的執行緒,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:並行控制。由於上限計算的是「可執行的數量」,只要有人被阻塞,就會自動獲得補充

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

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

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

  • PostQueuedCompletionStatus ── 不需要發出 I/O,就能把自訂的完成封包積存到佇列中。3 對工作執行緒的任務委派、關機指示(積存與工作執行緒數量相同的結束用封包──俗稱「毒藥封包(poison pill)」──)、來自其他執行緒的通知──能夠用同一個佇列、同一個迴圈處理 I/O 的完成通知與自訂訊息,讓設計大幅簡化。
  • GetQueuedCompletionStatusEx ── 一次取出多個完成封包。在「一個封包對應一次呼叫」的額外負擔會造成影響的高頻率 I/O 情境下很有效。8
  • SetFileCompletionNotificationModes ── 針對第 2 回第 5 章看到的「明明是非同步發出卻同步完成」的情況,可以選擇不把封包積存到連接埠的模式(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)。既然同步完成的結果當場就已經知道了,再繞經佇列一趟只是白費工夫──這是一種加速手法。9
  • Windows 執行緒集區 API ── CreateThreadpoolIo / StartThreadpoolIo 在內部使用 IOCP,同時代為進行執行緒的產生與管理。微軟建議全新的伺服器應用程式應優先考慮這一種方式,只有在想明確控制並行值或執行緒管理時,才使用原始的 IOCP。1 而 .NET 的執行緒集區,正是把這種「IOCP + 執行緒管理自動化」以 .NET 執行環境的形式實作出來的產物。

最後再列舉三個陷阱。(1) 不要在工作執行緒中長時間阻塞(第 3.4 節的補充機制只能緩和劣化程度,無法根治)。(2) 完成封包的識別要靠 CompletionKey(以控制代碼為單位)與 OVERLAPPED(以操作為單位)這兩層來進行──第 2 回「傳票」的生命週期管理(完成前不釋放)在這裡同樣是命脈所在。(3) 不要在還有未完成 I/O 的情況下關閉控制代碼──cleanup 的行為(第 1 回第 6 章)與取消的作法(第 2 回第 6 章)在這裡同樣直接適用。

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

從這裡開始才是本文的重點──「async/await 的地下室」。

.NET 的執行緒集區裡有兩種執行緒:負責執行 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 這一側的樣貌)一起處理(舊有的 ThreadPool.BindHandle 同樣保留著相同的角色,但如果是新寫的程式碼則建議使用前者)。當 FileStreamSocket 開啟非同步模式的控制代碼時,內部就會進行這種綁定。5 也就是說:

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

透過這樣的對應關係,Win32 的圖像就直接成了 .NET 的圖像。

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

第 2 回的圖 7 中,只寫了「真正的非同步 I/O」這個方塊的內容,這一次要把它徹底打開。

接續的執行位置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 回);完成則透過中斷→完成封包這樣的事件鏈送達(本文)。維持「等待」這個狀態,並不需要用到執行緒這種昂貴的資源──整套機制就是這樣設計的。

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

5.3. 接續在哪裡執行

圖 6 最後那支箭頭──「要把接續丟到哪裡」是有明確規則的。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/await 和 UI 執行緒 - await 後的回歸處、Dispatcher、ConfigureAwait、.Result / .Wait() 的卡點」中處理過。
  • 被捕捉的不只是 SynchronizationContext,如果是在非預設的 TaskSchedulerawait,該排程器同樣是捕捉的對象。在兩者都不存在的地方(主控台、ASP.NET Core、執行緒集區上),由於沒有義務回到特定位置,接續就會在執行緒集區上執行,或是直接在讓 Task 完成的那個執行緒上同步接續
  • ConfigureAwait(false) 是明確表示「不需要回去」,而不是保證「一定會移到執行緒集區」。若 await 的是已經完成的 Task(第 2 回看到的同步完成也包含在內),就不會發生等待,會直接在目前的執行緒上繼續。函式庫程式碼中該如何區分使用,請參考「C# async/await 的最佳實踐 - Task.Run 與 ConfigureAwait 的判斷表」。

最後這一點常常被誤解,所以用程式碼並列比較。首先是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」與「繁重的處理要在哪裡做」
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();   // 不依賴呼叫端的內容
}

判斷的基準很單純。會操作 UI 的程式碼就維持讓它被捕捉想改變執行位置時就用 Task.Run 明確指定。請把 ConfigureAwait(false) 想成是函式庫端用來表明「不論呼叫端在哪種內容下都能安全運作」的工具。

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

最後,只講一種讓這座地下室堵塞的模式。若在接續或工作執行緒中同步等待(同步 I/O、Task.Result/Wait()、長時間的鎖定等待),那個執行緒就會一直被占用著。IOCP 自身的補充機制(第 3.4 節),只要還有待命的備用執行緒,就能立即發揮作用。但備用一旦耗盡,就會進入執行緒集區只會緩慢注入新執行緒的區域。一旦承受負載,就會發生「想執行接續卻沒有執行緒可用」的飢餓(starvation),導致整個應用程式變得遲鈍。

調查的切入點有兩個。用 ThreadPool.GetAvailableThreads 查看工作執行緒/I/O 完成執行緒的空閒數。4 以及用事件追蹤掌握執行緒集區與阻塞的實際狀況──步驟已整理在「用 PerfView 與 dotnet-trace 找出「變慢」的原因 ── .NET 效能調查實務入門」中。預防方法很單純,async 的路徑就讓它從頭到尾維持 async(不要混入 sync-over-async),就是這樣而已。

6. 總結

  • IOCP 是把完成佇列(FIFO)與執行緒數控制合而為一的機制。它把大量控制代碼的完成通知彙整到單一連接埠,並用 GetQueuedCompletionStatus 的迴圈進行處理。17
  • 執行緒以 LIFO 釋放,所以越忙碌時,同一個執行緒就越持續運轉,內容切換與快取未命中(cache miss)都能降到最低。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

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

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

回到部落格一覽