Windows I/O 的深層(第 2 回) ── 同步 I/O 與非同步 I/O:OVERLAPPED 的真正含義

· · Windows, Win32, I/O, 非同步, OVERLAPPED, 核心, .NET, C#

上一回(第 1 回),我們看到 Windows 的 I/O 要求會化為名為 IRP 的封包,在裝置堆疊中流動,而且發行與完成,早在核心最底層就已經是分離的

這一回要挖掘的,是應用程式端用來運用這種分離狀態的機制──非同步 I/O(重疊 I/O)。明明加上了 FILE_FLAG_OVERLAPPED,卻同步傳回;OVERLAPPED 重複使用後資料就壞了;呼叫了 CancelIoEx 卻停不下來;一取消就因為存取違規而當掉──這類「非同步 I/O 的恐怖故事」,全都來自於沒有把整套機制當成一張圖來掌握。這篇文章,就是要畫出那張圖。

本文是系列文章「Windows I/O 的深層」的第 2 回,整體架構列在第 1 回的開頭

1. 先講結論

  • 同步 I/O 與非同步 I/O 的分界,不在核心,而在「是否等待」。同步 I/O 的保證是「完成之前不會傳回」。只有在要求變成保留(pending)狀態時,I/O 管理員才會等待完成,執行緒則進入不消耗 CPU 的等待狀態沉睡(第 2 章)。1
  • FILE_FLAG_OVERLAPPED 是控制代碼(檔案物件)的模式。CreateFile 當下就已決定,無法依每次呼叫切換。非同步控制代碼下系統不會管理檔案指標,因此磁碟上的檔案每次都要透過 OVERLAPPEDOffset 指定位置(第 3 章)。21
  • OVERLAPPED 結構是「一個操作份量的傳票」。需要的數量與發行中的操作數相同,官方文件明確指出共用或重複使用會導致資料損毀。完成之前,結構與緩衝區都不能去動(第 3 章)。34
  • 接收完成通知的方式實質上有 4 種。控制代碼觸發(不建議)、OVERLAPPED 的事件 + GetOverlappedResult、APC(alertable wait),以及 I/O 完成埠(下一回)(第 4 章)。156
  • 就算以非同步方式發出,也可能同步完成。資料已在快取中、NTFS 壓縮/加密、延伸檔案長度的寫入──都是代表性的例子。「非同步=絕對不會被卡住」並不成立(第 5 章)。3
  • 取消是一種「請求」。即使呼叫了 CancelIoEx,操作仍會以 ERROR_OPERATION_ABORTED 的形式當作完成傳回。在確認這個完成之前,不能進行善後處理(第 6 章)。78
  • .NET 的 FileOptions.Asynchronous 是這個模式的直通開關。控制代碼的模式與呼叫的 API 一旦不一致,就會出現由執行緒集區代勞的「假性非同步」,或是產生無謂的等待(第 7 章)。910

2. 同步 I/O ── 執行緒究竟睡在哪裡

先從預設的樣貌看起。沒有加上 FILE_FLAG_OVERLAPPED 開啟的控制代碼就是同步模式。呼叫 ReadFile 時,函式要等到 I/O 完成才會傳回。1

正如第 1 回所見,驅動程式會把要求設為保留(pending)狀態,等待硬體回應。那麼在同步 I/O 的情況下,究竟是誰在等待?答案是 I/O 管理員會等待完成之後,才把控制權交還給應用程式。

驅動程式(堆疊)I/O 管理員應用程式執行緒驅動程式(堆疊)I/O 管理員應用程式執行緒執行緒會在核心中進入等待狀態不消耗 CPU 地沉睡ReadFile(同步控制代碼)發出 IRPSTATUS_PENDING(等待回應)完成(IoCompleteRequest)傳回結果並喚醒ReadFile 以 TRUE/FALSE 傳回

圖 1:同步 I/O(要求變成保留狀態時)。I/O 管理員等待完成之後才傳回應用程式

另外,這張圖畫的是要求變成保留狀態的情況。如果驅動程式可以當場完成要求(例如快取命中,也就是第 1 回圖 5 中「立即完成」的路徑),就完全不會發生等待,直接帶著結果傳回。同步 I/O 的保證是「完成之前不會傳回」,而不是「一定會睡著」。

有兩點需要掌握:

  • 「等待」不會消耗 CPU。處於等待狀態的執行緒會從排程器的執行對象中移除。與其用輪詢把自己勒緊脖子,不如乾脆等待──這個道理,我們在「Windows 上為什麼應先用事件等待而不是計時器等待」中寫過。
  • 同步模式的控制代碼下,核心會管理檔案指標(目前位置)。因此連續呼叫 ReadFile 才能「接著上次讀」。這個狀態存在於檔案物件而非控制代碼上,所以用 DuplicateHandle 複製出來的控制代碼,會共用同一個位置(第 1 回 3.3 節)。

同步 I/O 的弱點,說穿了就是等待期間,該執行緒無法做別的事。在 UI 執行緒上做同步 I/O 畫面就會凍結,伺服器若每個連線都開一個執行緒,數百個連線就會變成執行緒滿天飛。另外,若想從外部救出因同步 I/O 而卡住的其他執行緒,也有一個叫做 CancelSynchronousIo 的專用 API(第 6 章)。11

3. 非同步 I/O ── 控制代碼的模式與操作的傳票

3.1. 模式以控制代碼為單位決定

FILE_FLAG_OVERLAPPED 傳給 CreateFile,該控制代碼背後的檔案物件就會以非同步模式開啟。1 這裡重要的是,這是以控制代碼為單位的屬性。無法做到「只有這一次呼叫是非同步」。可以把同一個檔案用兩個控制代碼分別開成同步用與非同步用(這樣只是多出了兩個檔案物件而已)。

非同步模式的控制代碼還有另一個重大差異:系統不會管理檔案指標。2 因為在多個操作同時飛行的狀態下,「目前位置」本身沒有意義。對於像磁碟上檔案那樣具有位置概念的裝置,讀寫位置每次都要透過 OVERLAPPED 結構的 OffsetOffsetHigh 明確指定。另一方面,對於序列埠或具名管道這類沒有搜尋位置概念的裝置,Offset 不會被當作位置指定使用(保持為 0 即可)。即便如此,正如下一節所見,OVERLAPPED 結構本身仍然是每個操作都必須準備一個。

3.2. OVERLAPPED 是「一個操作份量的傳票」

OVERLAPPED 結構的角色,是識別一個發行中的操作,並承載它的狀態4

成員 角色
Offset / OffsetHigh 這個操作要讀寫的檔案位置(發行時指定。沒有位置概念的裝置不使用)
hEvent 完成時會被觸發的事件(選用,建議使用手動重設)
Internal 操作的狀態。完成前會存放相當於 STATUS_PENDING 的值(系統使用)
InternalHigh 完成時的傳輸位元組數(系統使用)
OVERLAPPED 結構 = 一個操作份量的傳票Offset,要讀取的位置hEvent,如何得知完成Internal/InternalHigh,狀態與結果,系統寫入控制代碼,檔案物件 = 模式同步模式核心管理目前位置完成前 ReadFile 不會傳回非同步模式,FILE_FLAG_OVERLAPPED不管理目前位置發行與完成分離只在 CreateFile 時決定一次每次發行 ReadFile/WriteFile 都準備一個

圖 2:模式屬於控制代碼,狀態屬於操作(傳票)。混淆這種分工就會出事

由此可以自然導出官方文件明確列出的兩項禁止事項。3

  1. 同時飛行的操作有幾個,就需要幾個 OVERLAPPED發出 3 個就要 3 個。重複使用會導致「無法預測的結果或資料損毀」。
  2. 完成之前,OVERLAPPED 與資料緩衝區都要保持存活,不能去動它。因為核心會來寫入那塊記憶體。用區域變數的 OVERLAPPED 發行、然後函式就結束──這是讓核心踩到堆疊的典型事故。

3.3. 發行的結果有三種

對非同步控制代碼呼叫 ReadFile,會有三種傳回方式。2

ReadFile,非同步控制代碼,附帶 OVERLAPPED傳回值是什麼?TRUE當場完成,即同步完成預設情況下完成通知也會另外送達FALSE + ERROR_IO_PENDING已受理,完成之後才會收到通知FALSE + 其他錯誤發行本身就失敗了等待完成通知第 4 章的 4 種方式

圖 3:非同步發行的三個分支。唯有同時正確處理 TRUE(立即完成)與 ERROR_IO_PENDING 兩種情況,非同步 I/O 才能真正運作

落實到程式碼時的判斷依據,是傳回值與 GetLastError 的組合。下面這張表可以直接對應到分支邏輯。

ReadFile 的傳回值 GetLastError() 意義 呼叫端要做的事
TRUE (不看) 當場完成(同步完成) 預設情況下完成通知也會另外送達。結果處理交給通知端負責
FALSE ERROR_IO_PENDING(997) 已受理,進行中 什麼都不做。不要動 OVERLAPPED 與緩衝區,等待完成通知
FALSE 其他 發行本身就失敗了 完成通知不會送達。當場進行錯誤處理,並善後 OVERLAPPED 與緩衝區
// C++ / Win32
// hFile : 以 FILE_FLAG_OVERLAPPED 開啟的控制代碼
// ov    : 為這個操作專用配置的 OVERLAPPED(Offset 與 hEvent 已設定)
// buf/len: 這個操作專用的緩衝區。在收到完成通知之前不能釋放
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
    // 非同步發行時 lpNumberOfBytesRead 傳入 NULL,
    // 傳輸位元組數要在完成後透過 GetOverlappedResult 取得
    if (ReadFile(hFile, buf, len, nullptr, ov))
    {
        // (1) 同步完成。預設情況下完成通知也會送達,所以這裡不處理結果
        return ERROR_SUCCESS;
    }

    DWORD err = GetLastError();
    if (err == ERROR_IO_PENDING)
    {
        // (2) 已受理。不要動 ov 與 buf,等待完成通知
        return ERROR_IO_PENDING;
    }

    // (3) 發行本身失敗。不會有完成通知送達,呼叫端要在這裡做善後
    return err;
}

ERROR_IO_PENDING 不是錯誤,而是「已受理」。把這個當成一般錯誤來處理,或是反過來完全沒有考慮 TRUE(同步完成)的情況就寫程式碼──是兩個最典型的錯誤。同步完成為什麼會發生,留到第 5 章討論。

還有一個重要的注意事項。在預設情況下,就算操作以同步方式完成(TRUE),完成通知仍會另外送達。如果控制代碼有關聯到 I/O 完成埠,完成封包就會被送進佇列;如果是事件方式,事件也會被觸發。因此,如果寫成「TRUE 就當場處理結果,通知來了再處理一次」,就會變成同一個操作被重複處理、傳票被重複釋放的事故。安全的基本寫法,是把「TRUE(同步完成)」與 ERROR_IO_PENDING 這兩條路徑的結果處理,統一交給通知端負責。不過,第三條路徑──發行本身就失敗的情況(FALSE + 其他錯誤)不會有完成通知送達。如果把這種情況也丟給等待通知的流程,就會變成永遠等下去,因此要由發行端當場進行錯誤處理與傳票的善後。只有在想切換成「同步完成時跳過通知,當場處理」的時候,才明確啟用 SetFileCompletionNotificationModes(FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)──不過它抑制的只有送往 I/O 完成埠的封包OVERLAPPED.hEvent 的觸發並不會被抑制。這是無法用於事件方式、只能用在 IOCP 路徑上的專屬最佳化(第 5 章)。12

另外,若對同步模式的控制代碼傳入 OVERLAPPED,讀取確實會從 Offset 指定的位置開始,但完成前會被卡住這一點並不會改變。2 並不是「因為傳了 OVERLAPPED 所以就變成非同步」。模式終究是屬於控制代碼的。

4. 如何得知完成 ── 四條通知路徑

既然發行與完成已經分離,「如何接收完成」就成了設計的核心。路徑實質上有四條。1

核心中 I/O 完成IoCompleteRequest,經 APC 確定結果1. 檔案控制代碼進入觸發狀態接收方式,WaitForSingleObject,控制代碼2. OVERLAPPED 的 hEvent 進入觸發狀態接收方式,WaitForSingleObject + GetOverlappedResult3. 完成常式被放進發行執行緒的 APC 佇列接收方式,在 SleepEx 等 alertable wait 期間執行4. 完成封包進入 I/O 完成埠接收方式,GetQueuedCompletionStatus,第 3 回

圖 4:完成通知的四條路徑。要用哪一種接收,取決於發行方式(是否有 hEvent、是否用 ReadFileEx、是否關聯到完成埠)

先用一張表整理整體樣貌,各小節就是這張表內容的說明。

方式 完成處理執行所在的執行緒 可同時發出的 I/O 數量 適合的場合
(1) 控制代碼觸發 等待中的任一執行緒 實質上只有 1 個。同時發出多個就無法分辨是哪個完成了 幾乎沒有適用場合(4.1)
(2) 事件 + GetOverlappedResult 等待中的任一執行緒 每個操作都需要一個事件。用 WaitForMultipleObjects 一次等待多個時上限是 64 個 數個以內的同時 I/O。裝置通訊(4.2)
(3) APC(ReadFileEx) 發行操作的執行緒。而且僅限於處於 alertable wait 期間 數量沒有限制,但完成處理全部在那一個執行緒上依序執行 想在單一執行緒內完結的通訊處理(4.3)
(4) I/O 完成埠 綁定到完成埠上的工作執行緒群 可以用少數執行緒承接大量 I/O 伺服器、執行緒集區(4.4)

4.1. 控制代碼觸發 ── 不要使用

不設定 hEvent 直接發行的話,完成時檔案控制代碼本身就會進入觸發狀態。乍看之下很方便,但如果同一個控制代碼上同時有多個操作在飛行,就無法分辨哪一個完成了1 除了「非同步 I/O 一次只發出一個」這種特殊情況以外,不使用這種方式才是安全的做法。

4.2. 事件 + GetOverlappedResult ── 基本形式

OVERLAPPED.hEvent 放入手動重設事件後發行,用 WaitForSingleObject(或以 WaitForMultipleObjects 同時等待多個)等待,再用 GetOverlappedResult 取出結果(成敗與傳輸位元組數)。13 只要把 GetOverlappedResultbWait 傳入 TRUE,就能做到「等到完成後再取出」。若把事件設成自動重設,一旦另一個等待動作消耗掉了觸發訊號,就會讓 GetOverlappedResult 卡住,因此建議使用手動重設。134

這是穩健處理數個同時 I/O 時,最容易掌握全貌的方法。對於像序列埠這類必須「邊讀邊寫」的裝置,這種形式至今仍是現役做法(參見「序列通訊應用的陷阱」)。

4.3. APC ── 送達到發行的執行緒

ReadFileExWriteFileEx 不是接收事件,而是接收完成常式(回呼函式)。完成之後,該常式會被放進發行操作的執行緒的 APC 佇列,等到該執行緒進入 SleepExWaitForSingleObjectExalertable wait 時才會執行。14515

這種方式的特徵在於「完成處理一定會在發行的執行緒上執行」。好處是不需要鎖,但代價是只要發行的執行緒不進入 alertable wait,完成常式就永遠不會執行。想與 UI 執行緒的訊息迴圈搭配使用,還需要 MsgWaitForMultipleObjectsEx 之類的手段,等待方式的設計並不容易,一般用途多半還是會選擇事件方式或 IOCP。

而「APC 都不來」正是這種方式的典型錯誤。原因幾乎只有一個:等待方式不是 alertable

// C++ / Win32。hFile 是以 FILE_FLAG_OVERLAPPED 開啟的控制代碼,
// ov 與 buf 前提是要保持有效直到完成為止(3.2 節)

// 壞例子:完成常式永遠不會被呼叫
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // 不是 alertable 的等待。APC 不會被送達

// 好例子:持續進行 alertable 等待,直到這個 I/O 結束為止
//
// 在完成常式那一側設定這個旗標(可以放在 ov 所附掛的結構等位置)
volatile bool completed = false;

// 一定要確認是否成功發行。傳回 0 時代表完成常式沒有被排入佇列
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // 立刻取得。之後呼叫其他 API 就會被覆蓋
    ReportError(err);                   // 裝置移除、控制代碼無效等原因
    return;                             // ★ 不能進入下面的等待迴圈
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // 第二個參數 TRUE 代表 alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // 有某個 APC 執行了。但不一定是自己的 I/O,
        // 所以要看 completed 來判斷,不是的話就繼續等待
        continue;
    }
    // 因逾時而傳回。I/O 仍然還在進行中,
    // 若要中止,就用 CancelIoEx 取消,並等待完成送達
    CancelIoEx(hFile, ov);
}

請不要在沒看 ReadFileEx 傳回值的情況下就進入等待迴圈。如果因為裝置剛被拔除、控制代碼已經失效之類的原因導致發行本身失敗,ReadFileEx 會傳回 0,完成常式一個都不會被排入佇列。在這種狀態下進入 while (!completed)completed 就永遠不會成立,變成對一個根本不存在的 I/O,不斷重複 SleepExCancelIoEx 的迴圈。而且表面上看起來只是「裝置沒有回應」,要追到真正原因得花很久。傳回 0 時,要當場取得 GetLastError()(只要中間再呼叫一個其他 API 就會被覆蓋),不進入等待就直接離開。

只呼叫一次 SleepEx 是不夠的。因逾時而傳回時,執行緒在那個當下就會離開 alertable wait。此時 I/O 仍然還在進行,如果之後離開作用域讓 ovbuf 消失,核心就會踩到一塊它以為還活著的緩衝區(3.2 節)。請務必由完成常式那一側記錄是否已完成,並持續等待直到它成立為止,或是先用 CancelIoEx 取消,再等待完成送達,兩者擇一。

傳回 WAIT_IO_COMPLETION 只代表「至少執行了一個 APC」,不代表那一定是自己這個 I/O 的完成常式。如果同一個執行緒上還排著別的 I/O,或是 QueueUserAPC 排入的 APC,也會從那邊傳回。所以不能只看傳回值判斷,要看自己設定的旗標。

另外,等待函式的選擇本身很單純:把 Sleep 換成 SleepEx(..., TRUE),把 WaitForSingleObject 換成 WaitForSingleObjectEx(..., TRUE)。如果已經寫了完成常式卻什麼都沒發生,請先檢查等待函式的名稱結尾是否有 Ex,以及 alertable 的引數是否為 TRUE5

4.4. I/O 完成埠 ── 具擴充性的本命方案(下一回)

用少數執行緒承接大量同時 I/O 的機制,就是 I/O 完成埠(IOCP)。事先把控制代碼關聯到完成埠,完成時就會進入該埠的佇列,由工作執行緒透過 GetQueuedCompletionStatus 取出。6 這也是 .NET 的 async/await I/O 最終會抵達的地方。下一回會整篇專門深入挖掘。

5. 「明明是非同步卻同步完成」的問題

設計非同步 I/O 時,最先卡關的地方就在這裡。即使以非同步模式正確發行,I/O 仍然可能同步完成,這是很平常的事。微軟在疑難排解文件中明確列出了具代表性的原因。3

以上都不是對非同步控制代碼發行 ReadFile/WriteFile是否符合同步完成的條件可以立即滿足的要求例如資料已經在快取中NTFS 壓縮的檔案壓縮檔案不會以非同步方式進行NTFS 加密,EFS,的檔案延伸檔案長度的寫入以 TRUE 立即傳回= 在呼叫過程中就執行到完成以 ERROR_IO_PENDING 傳回= 真正以非同步方式進行中

圖 5:導致同步完成的主要條件。快取、壓縮、加密、延伸寫入都會「無法變成非同步」

各自都有原因。3

  • 快取命中。許多驅動程式都有「能立即完成的要求就當場完成」這種特別待遇。以磁碟來說,就是資料已經在記憶體上的快取中的時候。速度變快理應沒什麼好抱怨的,但是「一定會傳回 ERROR_IO_PENDING」這種前提寫成的程式碼,就會在這裡壞掉。
  • 反過來,不在快取中的時候也有陷阱。Windows 的快取是以檔案對應(file mapping)實作,而在頁面不存在時的分頁錯誤處理並沒有非同步機制,因此啟用快取的非同步讀取,有時也會被以同步方式處理。快取機制本身留待第 4 回討論。
  • NTFS 壓縮、EFS 加密。檔案系統驅動程式會把對壓縮/加密檔案的存取轉換成同步方式。
  • 延伸檔案長度的寫入。會改變檔案長度的寫入會變成同步方式。

對實務的意涵很單純。

  1. 一定要寫出「以 TRUE 立即傳回」這條路徑。圖 3 的三個分支全部都是正常路徑。不過在預設情況下,就算是同步完成,完成通知也會另外送達,因此把結果處理統一交給通知路徑,才是安全的做法(3.3 節)。
  2. 不能拿來保證回應性。「因為是非同步所以 UI 不會凍結」這種說法並不成立。對於不能被卡住的執行緒,一開始就需要設計成不在那裡發行 I/O(拆分到專用執行緒或執行緒集區)。這方面的實務內容,我們也在「普通的 Windows 上盡可能實現軟即時的實戰指南」中討論過。
  3. 在高頻率 I/O 的場合,同步完成反而是最佳化的機會。有一個叫做 SetFileCompletionNotificationModes 的 API,可以在同步完成時省略送往 I/O 完成埠的封包,搭配 IOCP 使用會很有效果(第 3 回)。12

6. 取消與善後 ── 「請幫我停下來」是一種請求

想要停止耗時很長的 I/O(沒有回應的網路對象、遲遲不來的序列資料)時,正統的做法就是 CancelIoEx7

驅動程式I/O 管理員應用程式驅動程式I/O 管理員應用程式對符合條件的未完成 IRP提出取消要求(做記號)若處於可取消的狀態就中斷已接近完成時,也可能正常完成確認這個通知之後才釋放 OVERLAPPED 與緩衝區CancelIoEx(控制代碼, OVERLAPPED)呼叫取消常式IoCompleteRequest(STATUS_CANCELLED)完成通知送達GetOverlappedResult 會是 ERROR_OPERATION_ABORTED

圖 6:取消的實際運作。就算操作被取消,也會以「完成」的形式傳回

只要了解機制,就會自然導出以下三個結論。

  • 取消是非同步的「請求」。就算 CancelIoEx 呼叫成功,那也只是「做了記號」而已。已經接近完成的操作,仍有可能正常完成。8
  • 被取消的操作,也會透過完成通知以 ERROR_OPERATION_ABORTED 的形式傳回。在收到那則通知之前,OVERLAPPED 與緩衝區都還在被核心使用。先釋放的話就會造成記憶體損毀。「一取消程式就開始崩潰」的原因,幾乎都是這個。78
  • 關閉控制代碼之前,要先善後未完成的 I/O。正如第 1 回所見,最後一個控制代碼關閉時,cleanup 處理會觸發未完成 IRP 的取消,但「已發行的 I/O 還留著,卻只關閉控制代碼」這種寫法,很容易讓完成通知與緩衝區生命週期的管理失控。原則是取消 → 確認完成 → 關閉的順序。

補充兩點。舊版的 CancelIo 只能取消呼叫執行緒自己發出的操作(這是在 Vista 加入 CancelIoEx 之前的限制,現在沒有理由特意使用)。16 另外,對於因同步 I/O 而卡住的其他執行緒,要使用 CancelSynchronousIo11 「逾時,OS 不會幫你處理,取消要靠自己來設計」──這正是非同步 I/O 實務的核心。

7. 從 .NET 的角度來看 ── 模式不一致會產生「假性非同步」

到目前為止的內容,直接對應到 .NET 的程式碼。FileStream 建構函式的 useAsync(或 FileOptions.Asynchronous),正是 FILE_FLAG_OVERLAPPED 的直通開關(參見第 1 回的對照表)。

await fs.ReadAsync控制代碼是否為非同步模式FileOptions.Asynchronous?真正的非同步 I/O發行相當於 OVERLAPPED 的操作完成經由 IOCP 送往執行緒集區,第 3 回假性非同步執行緒集區的執行緒代為執行同步 Read 並等待

圖 7:即使同樣是 ReadAsync,依控制代碼的模式不同,底層的運作方式完全不一樣

差異只出現在開啟檔案的那一行ReadAsync 的呼叫端寫法完全相同,所以光看程式碼是察覺不出來的。

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];

// (A) 假性非同步。省略 useAsync 或設為 false,控制代碼就會以同步模式開啟
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // 呼叫端不會被卡住,但背後有一個執行緒集區的執行緒代為執行同步 Read 並等待
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (B) 真正的非同步。useAsync: true 會直接對應到 FILE_FLAG_OVERLAPPED
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: true))
{
    // 完成經由 IOCP 送往執行緒集區(第 3 回)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) .NET 6 以後。明確指定模式與位移量的直觀寫法
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

(A) 與 (B) 的差異,只在 useAsync 這一個詞(寫成 FileOptions.Asynchronous 也一樣),而這正是「假性非同步」的重現條件本身。檢查既有程式碼時,不要看 ReadAsyncWriteAsync 那一側,要去找建立 FileStream 的地方。像 File.OpenReadnew FileStream(path, FileMode.Open) 這類簡短的多載,全部都會以同步模式開啟。另外,若是要從 SafeFileHandle 建立 FileStreamisAsync 引數必須與控制代碼實際的模式一致。

  • 同步模式的控制代碼 + ReadAsync,是由執行緒集區的執行緒進行同步讀取的「假性非同步」。呼叫端雖然不用等待,但背後有一個執行緒在沉睡。數量少的話實際傷害不大,但在伺服器或高頻率處理中,會成為執行緒集區枯竭的原因。9
  • 非同步模式的控制代碼 + 同步 Read,則是反方向的不一致,內部等待完成的部分會產生浪費。讓模式與呼叫的 API 保持一致是原則。10
  • .NET 6 以後FileStream 的內部實作全面改寫,還加入了 File.OpenHandle + RandomAccess 這種「明確指定 SafeFileHandle 與位移量來讀寫」的 API。9 這種每次都要傳入位移量的形式,正是本文所看到的非同步控制代碼 + OVERLAPPED.Offset 這種 Win32 原始樣貌本身。
  • 只要控制代碼是非同步模式,透過 CancellationToken 取消檔案 I/O,內部最終都會走到 CancelIoEx傳入 token 的 ReadAsyncOperationCanceledException 結束時,背後其實就是第 6 章那張圖在運作。取消是一種「請求」、即時性不被保證,這一點也完全一樣。另一方面,在同步模式控制代碼的「假性非同步」中,並不存在可被取消的重疊操作,所以這條路徑用不上。近年的 .NET 執行環境,對於這種在同步呼叫執行期間的情況,也加入了嘗試以 CancelSynchronousIo 取消的機制,但是否有效取決於執行環境版本與操作種類,並不保證一定能可靠中斷。若要把取消當作設計上的前提,讓模式保持一致、確實使用真正的非同步 I/O,才是正道。

另外,關於 async/await 該怎麼寫這種上層實務(ConfigureAwait、與 UI 執行緒的關係),請參考「C# async/await 的最佳實踐 - Task.Run 與 ConfigureAwait 的判斷表」與「以一頁整理 WPF / WinForms 的 async/await 和 UI 執行緒 - await 後的回歸處、Dispatcher、ConfigureAwait、.Result / .Wait() 的卡點」。本文是那個世界的地下 1 樓,下一回(IOCP)則是地下 2 樓。

8. 總結

  • 同步 I/O 與非同步 I/O 並不是不同的管線,差別只在於 I/O 管理員是否等待完成才傳回。同步 I/O 的執行緒會在等待狀態下沉睡,不消耗 CPU。1
  • 模式屬於控制代碼(檔案物件),狀態屬於操作(OVERLAPPED)。非同步控制代碼不會管理檔案指標,因此具有位置概念的檔案,每次都要透過 Offset 指定。24
  • OVERLAPPED 與緩衝區要保持有效並且不要去動,直到收到完成通知為止。要準備的數量與同時發行的操作數相同。重複使用就是資料損毀。3
  • 完成通知有控制代碼、事件、APC、IOCP四條路徑。同時發出多個 I/O 時不要用控制代碼觸發,事件要用手動重設,APC 則以 alertable wait 為前提。1135
  • 就算以非同步方式發出,在快取、NTFS 壓縮、加密、延伸寫入的情況下仍會同步完成。「以 TRUE 立即傳回」這條路徑一定要當作正常路徑寫進去,而且不能拿來保證回應性。3
  • 取消是一種請求。呼叫 CancelIoEx 之後,也要確認完成通知(ERROR_OPERATION_ABORTED)才能進行善後。順序是取消 → 確認完成 → 關閉78
  • .NET 的 FileOptions.Asynchronous 直通 FILE_FLAG_OVERLAPPED,模式與 API 的不一致,會產生「假性非同步」。CancellationToken 的底層,動的其實是 CancelIoEx910

接下來是第 3 回「I/O 完成埠(IOCP)與 .NET 執行緒集區 ── async/await 的地下室」。這一回在 4.4 節只提到名字的 IOCP,為什麼會是把「完成通知的佇列」與「執行執行緒數的控制」合而為一的設計,以及 await 之後的程式碼究竟會在哪個執行緒上執行,都會一路深入下去。

相關文章

相關諮詢領域

合同會社小村軟體承接使用非同步 I/O 的 Windows 業務應用程式、裝置通訊應用程式的設計,以及「當機」「取消後崩潰」「執行緒集區枯竭」等問題的原因調查。

參考連結

  1. Microsoft Learn, Synchronous and asynchronous I/O. 說明同步 I/O 中函式會阻塞直到 I/O 完成,非同步 I/O 中發出要求的函式會立刻傳回、讓執行緒可以繼續做其他工作;非同步 I/O 必須指定 FILE_FLAG_OVERLAPPED 來開啟控制代碼;完成的通知方式包括檔案控制代碼觸發、指定給 OVERLAPPED 結構的事件觸發、在 alertable wait 期間執行的完成常式(APC),以及 I/O 完成埠;並說明多個操作同時發行時,控制代碼觸發無法分辨是哪個操作完成的。  2 3 4 5 6 7 8 9

  2. Microsoft Learn, ReadFile function. 說明以 FILE_FLAG_OVERLAPPED 開啟的控制代碼必須指定 lpOverlapped,讀取起始位置要透過 OVERLAPPED 結構的 Offset/OffsetHigh 指定;以非同步方式處理時會傳回 FALSE 與 ERROR_IO_PENDING;系統不會為非同步控制代碼維護檔案指標;以及若對沒有 FILE_FLAG_OVERLAPPED 開啟的控制代碼傳入 OVERLAPPED,雖然會從指定位移開始讀取,但 ReadFile 要等到讀取完成才會傳回。  2 3 4 5

  3. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. 說明就算以非同步方式撰寫程式碼,I/O 仍會同步完成的原因,包括:NTFS 壓縮的檔案(檔案系統驅動程式不會以非同步方式存取壓縮檔案,所有操作都會變成同步)、NTFS 加密的檔案、延伸檔案長度的寫入,以及要求可以立即被滿足時(例如資料已在記憶體上的快取中)驅動程式會當場完成操作並傳回 TRUE;也說明 Windows 的快取是以檔案對應實作,頁面不存在時沒有非同步的分頁錯誤機制;此外還說明,若要發出 3 個 I/O 就需要 3 個 OVERLAPPED 結構,重複使用會導致無法預測的結果或資料損毀,以及在操作完成之前不能讀寫對應的資料緩衝區。  2 3 4 5 6 7

  4. Microsoft Learn, OVERLAPPED structure. 說明 OVERLAPPED 結構保存非同步輸入輸出所需的資訊:Offset/OffsetHigh 保存檔案位置,hEvent 保存完成時要觸發的事件,Internal/InternalHigh 保存操作的狀態碼與傳輸位元組數;操作執行期間不能變更結構,必須維持有效;以及使用事件時的注意事項。  2 3 4

  5. Microsoft Learn, Alertable I/O. 說明在 alertable I/O 中,指向完成常式的項目會被放進執行緒的 APC 佇列;執行緒透過 SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx 等進入 alertable 狀態時 APC 才會執行;以及 APC 一定會在發行操作的執行緒的內容中執行。  2 3 4

  6. Microsoft Learn, I/O Completion Ports. 說明 I/O 完成埠為在多處理器系統上處理大量非同步 I/O 要求,提供了一套高效率的執行緒模型;把檔案控制代碼關聯到完成埠後,完成封包會進入佇列,由工作執行緒透過 GetQueuedCompletionStatus 取出;以及完成埠會控制並行執行的執行緒數量。  2

  7. Microsoft Learn, CancelIoEx function. 說明 CancelIoEx 不論由哪個執行緒發行,都會對指定控制代碼上未完成的 I/O 標記取消;指定 lpOverlapped 時只鎖定該操作,傳入 NULL 則所有未完成的 I/O 都是對象;被取消的操作會以 ERROR_OPERATION_ABORTED 完成;以及並不保證一定能取消所有操作,仍需等待完成處理完畢。  2 3 4

  8. Microsoft Learn, Canceling pending I/O operations. 說明未完成 I/O 的取消機制,以及即使要求取消,操作也可能已經正在走向完成;應該在確認被取消操作的完成之後,才釋放資源;並整理同步操作要用 CancelSynchronousIo、非同步操作要用 CancelIo/CancelIoEx 的分工方式。  2 3 4

  9. Microsoft .NET Blog, File IO improvements in .NET 6. 說明 .NET 6 全面改寫了 FileStream 的內部實作,依控制代碼是否以非同步模式開啟而採用不同的策略;透過 File.OpenHandle 直接取得 SafeFileHandle,並用 RandomAccess 做到明確指定位移量的讀寫(具執行緒安全性);以及對非非同步模式控制代碼的非同步呼叫,會被卸載到執行緒集區執行。  2 3 4

  10. Microsoft Learn, Asynchronous file I/O (.NET). 說明 .NET 中非同步檔案 I/O 的思考方式,以及在 FileStream 使用非同步 I/O 時,要在建構函式指定 useAsync(FileOptions.Asynchronous)以啟用作業系統層級的非同步 I/O;並說明同步方法與非同步方法的使用區分。  2 3

  11. Microsoft Learn, CancelSynchronousIo function. 說明 CancelSynchronousIo 會對指定執行緒正在執行中的同步 I/O 操作標記取消;以及被取消的操作會以 ERROR_OPERATION_ABORTED 當作失敗傳回。  2

  12. Microsoft Learn, SetFileCompletionNotificationModes function. 說明可以透過 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS,選擇在 I/O 立即成功時不把完成封包送進 I/O 完成埠;以及可以透過 FILE_SKIP_SET_EVENT_ON_HANDLE 省略設定檔案控制代碼的事件。  2

  13. Microsoft Learn, GetOverlappedResult function. 說明 GetOverlappedResult 用於取得非同步操作的結果(成敗與傳輸位元組數);bWait 傳入 TRUE 會等到操作完成為止;以及若在 OVERLAPPED 的 hEvent 指定自動重設事件、又被其他等待動作消耗掉觸發訊號時,bWait=TRUE 的呼叫可能偵測不到完成而一直等下去,因此應該使用手動重設事件。  2 3

  14. Microsoft Learn, ReadFileEx function. 說明 ReadFileEx 會接收讀取完成時呼叫的完成常式(FileIOCompletionRoutine);完成常式只有在呼叫端執行緒處於 alertable wait 狀態時才會執行;以及必須使用以 FILE_FLAG_OVERLAPPED 開啟的控制代碼。 

  15. Microsoft Learn, Asynchronous Procedure Calls. 說明 APC 是在特定執行緒的內容中以非同步方式執行的函式,每個執行緒都擁有自己的 APC 佇列;以及使用者模式 APC 只有在執行緒處於 alertable 狀態時才會執行。 

  16. Microsoft Learn, CancelIo function. 說明 CancelIo 只能取消呼叫執行緒自己發行的 I/O 操作;若要連同其他執行緒發行的操作一起取消,要使用 CancelIoEx。 

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

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

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

常見問題

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

加上 FILE_FLAG_OVERLAPPED 之後會有什麼改變?
控制代碼背後的檔案物件會以「非同步模式」開啟。這是在 CreateFile 當下就決定的控制代碼層級屬性,無法依每次呼叫切換同步或非同步。在非同步模式的控制代碼上,呼叫 ReadFile/WriteFile 時必須一律傳入 OVERLAPPED 結構。系統不會為這個控制代碼管理檔案指標(目前位置),因此對於像磁碟上檔案那樣具有位置概念的裝置,讀寫位置也必須每次透過 OVERLAPPED 的 Offset 指定(對於序列埠這類沒有位置概念的裝置則不使用 Offset)。已發出的操作有可能在完成前就把控制權交還,此時 ReadFile 會傳回 FALSE,GetLastError 會傳回 ERROR_IO_PENDING。完成則透過事件、APC、I/O 完成埠等通知方式接收。
明明發出的是非同步 I/O,為什麼卻馬上完成傳回了?
因為非同步模式代表的是「不必等待完成」,而不是「絕對不會被卡住」。微軟的文件列舉了幾個具代表性的原因,說明就算以非同步方式發出,I/O 仍會同步完成:要求可以立刻被滿足(例如資料已經在快取中)、NTFS 壓縮的檔案、NTFS 加密(EFS)的檔案,以及延伸檔案長度的寫入。這種情況下 ReadFile/WriteFile 會傳回 TRUE,結果在呼叫當下就已經確定。因此使用非同步 I/O 的程式碼,必須同時考慮「傳回 ERROR_IO_PENDING 的情況」與「當場完成的情況」這兩種,回應性也並非絕對有保障。另外要注意的是,在預設情況下,就算是同步完成的操作,完成通知(事件觸發或送入 I/O 完成埠的封包)仍會另外送達,因此把結果處理統一交給通知端來做比較安全。
OVERLAPPED 結構可以重複使用嗎?
不可以同時讓多個操作共用同一個。OVERLAPPED 結構代表的是「一個發行中操作份量的狀態」,微軟的文件也明確指出,若要發出 3 個 I/O,就需要 3 個 OVERLAPPED 結構,重複使用會導致無法預測的結果或資料損毀。在操作完成之前,結構本身與讀寫緩衝區都必須保持有效,不能去動它。完成之後若要重新使用,每次都要重新初始化,以免上一次殘留的資料造成影響。放進 hEvent 的最好使用手動重設事件,會比較安全。
要如何在執行中途取消一個正在進行的 I/O?
使用 CancelIoEx,不論是哪個執行緒發出的,都可以對指定控制代碼上尚未完成的 I/O 要求取消。第二個參數傳入 OVERLAPPED,就只鎖定該單一操作;傳入 NULL,則該控制代碼上的所有操作都會是對象。舊版的 CancelIo 只能取消「呼叫執行緒自己發出的操作」。重要的是,取消是一種「請求」,而不是立即的「保證」。已經接近完成的操作有可能正常完成,被取消的操作則會以 ERROR_OPERATION_ABORTED 的形式當作完成收到通知。不論是哪一種情況,在收到完成通知之前,都不能釋放 OVERLAPPED 結構與緩衝區。如果是另一個執行緒卡在同步 I/O 動彈不得,則有專用的 CancelSynchronousIo API。
.NET 的 FileStream 若不指定 FileOptions.Asynchronous(useAsync)會怎麼樣?
由於控制代碼會以同步模式開啟,即使呼叫 ReadAsync/WriteAsync,也不會變成真正的非同步 I/O,而是變成由執行緒集區的執行緒代為執行同步讀寫的「假性非同步」。呼叫端的執行緒雖然不會被卡住,但背後有另一個執行緒在等待,因此會成為執行緒集區枯竭與可擴充性下降的原因。反過來,若以非同步模式開啟卻呼叫同步的 Read/Write,內部就會額外產生等待完成的多餘負擔。原則是讓「控制代碼的模式」與「呼叫的 API」保持一致,若是 .NET 6 以後,可以用 File.OpenHandle 與 RandomAccess,寫出明確指定模式與位移量的直觀寫法。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽