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

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

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

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

Go Komura(2026)。〈Windows I/O 的深層(第 2 回) ── 同步 I/O 與非同步 I/O:OVERLAPPED 的真正含義〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/windows-io-sync-async-overlapped/

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

上一回(第 1 回)看到的是,Windows 的 I/O 要求會變成名為 IRP 的封包,在裝置堆疊中流動,而且要求的發行與完成,在核心內部本來就是分開的。這一回要處理的,是從應用程式端運用這種分離的非同步 I/O(重疊 I/O)

明明加上了 FILE_FLAG_OVERLAPPED,呼叫卻還是被卡住;OVERLAPPED 一重複使用,資料就壞掉;取消之後馬上釋放緩衝區,程式就當掉。理解這些現象的關鍵,在於「模式屬於控制代碼,狀態屬於每一個操作,善後要等確認完成之後」這樣的分工。

本文會依序追蹤從開啟檔案、發行 I/O、接收結果,一直到善後的整個過程。先掌握 Win32 的機制之後,再確認 .NET 的 FileStreamReadAsyncCancellationToken 分別接到哪裡。

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

1. 先講結論:三組容易混淆的區分

非同步 I/O 如果只看 API 名稱來判斷,會愈看愈模糊。首先要把「設定的對象」與「判斷完成的時間點」分開來看。

容易混淆的地方 區分的要點
控制代碼的模式,與操作的狀態 同步/非同步的模式在 CreateFile 的當下就決定。OVERLAPPED 持有的是對該控制代碼發行的單一操作份量的狀態
發行的結果,與完成的接收 ERROR_IO_PENDING 不是失敗,而是已受理。TRUE 代表同步完成,但預設情況下通知一樣會送達。不要在兩邊都處理結果
取消要求,與可以善後的時間點 CancelIoEx 是取消的請求。要釋放結構與緩衝區,得在確認該操作已經完成之後

同步 I/O 在完成之前不會回到呼叫端。非同步 I/O 則可以使用在完成前就傳回的路徑。不過,即使是非同步模式,也可能在呼叫過程中就完成,這並不是「絕對不會被卡住」的保證。12

實作時依照決定模式 → 準備操作專用的結構與緩衝區 → 判斷發行結果 → 接收完成 → 善後的順序來思考。就算提出了取消要求,接收完成這一步也不能省略。34

如果目的已經明確,可以從下面的導引直接切入。

想知道的事、正在困擾的事 建議先讀的地方
同步 I/O 與非同步 I/O 到底差在哪裡 第 2 章:等待的機制第 3.1 節:控制代碼的模式
用了 OVERLAPPED 就資料損毀,或是離開函式之後當掉 第 3.2 節:每個操作的狀態與生命週期
ReadFile 傳回 FALSE,或是因為同步完成而重複處理 第 3.3 節:發行結果的三個分支
想挑選接收完成的方式,或是回呼一直不來 第 4 章:通知方式的比較第 4.3 節:APC 的等待方式
明明改成非同步,呼叫還是被卡住 第 5 章:同步完成的條件與回應性
取消沒有效果,或是取消之後就當掉 第 6 章:確認完成之後再善後
明明用的是 ReadAsync,執行緒卻愈來愈多 第 7 章:.NET 的控制代碼與 API 的組合

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

2. 同步 I/O:等待完成的執行緒不消耗 CPU 地沉睡

2.1. 負責等待完成的是 I/O 管理員

沒有加上 FILE_FLAG_OVERLAPPED 開啟的控制代碼就是同步模式ReadFile 要等到 I/O 完成才會傳回。1

當驅動程式為了等待硬體回應而把要求設為保留(pending)狀態時,會由 I/O 管理員等待完成之後,才把控制權交還給應用程式。在這段期間,應用程式的執行緒會在核心內部等待。

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

圖 1:要求進入保留狀態的同步 I/O。等待完成之後 ReadFile 才傳回

不過,並不是同步 I/O 就一定會沉睡。像快取命中這種可以當場完成的要求,就不會等待,直接帶著結果傳回(第 1 回圖 5 的「立即完成」路徑)。它保證的只有「完成之前不會傳回」這件事。

2.2. 不消耗 CPU 與能不能做別的事是兩回事

處於等待狀態的執行緒會從排程器的執行對象中移除,因此不消耗 CPU。與其自己持續輪詢,不如交給等待機制,這個理由在「Windows 上為什麼應先用事件等待而不是計時器等待」中也說明過。

另一方面,等待中的執行緒無法做別的事。如果是 UI 執行緒,畫面就會凍結;如果伺服器為每個連線各開一個執行緒,數百個連線就會讓執行緒不斷增加。同步 I/O 的弱點不在 CPU 使用率,而在於完成之前那個執行緒都用不了

在同步模式下,核心也會管理檔案指標(目前位置),因此連續呼叫 ReadFile 才會「接著上次讀」。持有位置的是控制代碼背後的檔案物件,所以用 DuplicateHandle 複製出來的控制代碼之間會共用同一個位置(第 1 回 3.3 節)。

另外,也有 CancelSynchronousIo 可以對其他執行緒正在執行中的同步 I/O 提出取消要求。它與非同步 I/O 專用 API 的分工,會在第 6 章整理。5

3. 非同步 I/O 的準備與發行:把模式、狀態、傳回值分開

3.1. 非同步模式在開啟檔案的時候就決定

FILE_FLAG_OVERLAPPED 傳給 CreateFile,控制代碼背後的檔案物件就會變成非同步模式。模式不是可以依每次呼叫切換的東西。同一個檔案倒是可以用同步用與非同步用兩個控制代碼分別開啟,此時檔案物件也會變成兩個。1

在非同步模式下,系統不會管理檔案指標。因為可以同時發行多個操作,所以對磁碟上的檔案,讀寫的位置每次都要用 OVERLAPPED.Offset / OffsetHigh 指定。像序列埠或具名管道這類沒有搜尋位置的裝置,則不會使用這個位置指定,保持為 0 即可。即使不指定位置,操作專用的 OVERLAPPED 仍然是必要的。6

反過來說,對同步模式的控制代碼傳入 OVERLAPPED,也不會變成非同步。雖然會從 Offset 的位置開始讀取,但直到完成為止都會被卡住這一點並不會改變。要確認的不是有沒有傳結構,而是控制代碼是以哪一種模式開啟的。6

3.2. 讓 OVERLAPPED 與緩衝區對應到單一操作

OVERLAPPED 是用來識別一個發行中的操作,並承載它的位置、狀態與結果的結構。把它想成「一個操作份量的傳票」,就能看清楚它與控制代碼之間的分工。3

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

圖 2:控制代碼持有模式,OVERLAPPED 持有每個操作的位置與狀態

這裡要守住的是數量與生命週期這兩件事。如果要同時發行 3 個 I/O,OVERLAPPED 也要準備 3 個。把同一個結構拿給多個尚未完成的操作共用,會導致無法預測的結果或資料損毀。2

另外,在完成之前,結構與資料緩衝區都要保持有效,不變更內容、不重複使用、也不釋放,因為核心還在使用那塊區域。如果用區域變數的 OVERLAPPED 發行,然後在尚未完成時就離開函式,等於是讓核心去用一塊生命週期已經結束的堆疊記憶體。32

確認完成之後要重複使用時,也要重新初始化,避免上一個操作的狀態殘留下來。如果選擇事件方式,hEvent 就要使用手動重設事件。它與等待方式的關係會在 4.2 節說明。3

3.3. 把 ReadFile 的傳回值分成三種,決定各自的處理位置

對非同步控制代碼發行 ReadFile 之後的結果,要用傳回值與 GetLastError() 的組合來判斷。重點是不要把 FALSE 一律當成失敗。6

ReadFile 的傳回值 GetLastError() 意義 呼叫端要做的事
TRUE (不看) 當場完成(同步完成) 預設情況下完成通知也會另外送達。結果處理交給通知端負責
FALSE ERROR_IO_PENDING(997) 已受理,進行中 什麼都不做。不要動 OVERLAPPED 與緩衝區,等待完成通知
FALSE 其他 發行本身就失敗了 完成通知不會送達。當場進行錯誤處理,並善後 OVERLAPPED 與緩衝區
ReadFile,非同步控制代碼,附帶 OVERLAPPED傳回值是什麼?TRUE當場完成,即同步完成預設情況下完成通知也會另外送達FALSE + ERROR_IO_PENDING已受理,完成之後才會收到通知FALSE + 其他錯誤發行本身就失敗了等待完成通知第 4 章的 4 種方式

圖 3:處理同步完成、已受理進行中、發行失敗這三個分支

下面這個函式只做這個判斷,然後把結果交回給呼叫端。控制代碼,以及操作專用的結構、緩衝區與事件的準備,還有接收完成的處理,都是另外準備的前提。

// 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 章說明。

結果的處理只能做一次。在預設情況下,就算是同步完成的操作,只要控制代碼有關聯到 IOCP,完成封包還是會被排入佇列;如果是事件方式,事件也會被觸發。若在 TRUE 的當下與收到通知時各處理一次,就可能把同一個操作重複處理,並把結構重複釋放。安全的基本形式,是把 TRUEERROR_IO_PENDING 這兩條路徑的結果處理,統一交給通知端。1

相對地,發行本身就失敗的路徑,要由發行端做錯誤處理與善後。通知根本不會來,卻把它丟給等待流程,就會永遠等下去。

也有在同步完成時省略 IOCP 通知的最佳化,但那是與預設行為不同的設計。使用 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS 時的適用範圍,會在 5.3 節另外說明。7

4. 選擇完成通知:由 I/O 的數量與處理的執行緒決定

既然發行與完成是分開的,就必須決定「完成要怎麼接收」。先用同時處理的 I/O 數量執行完成處理的執行緒這兩點,比較 4 種方式。1

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

圖 4:完成通知的 4 條路徑。發行方式不同,接收方式也跟著改變

4.1. 控制代碼觸發:無法區分多個操作

不指定 hEvent 就發行的話,完成時檔案控制代碼本身會進入觸發狀態。不過,如果同一個控制代碼上有多個操作正在進行,就無法分辨是哪一個完成了。1

除了「非同步 I/O 一次只發行一個」這種特殊情況以外,不使用它才安全。它看起來輕便,卻無法構成以操作為單位管理結果的機制。

4.2. 事件與 GetOverlappedResult:數個同時 I/O 的基本形式

在每個操作的 OVERLAPPED.hEvent 設定手動重設事件之後發行。用 WaitForSingleObject 等待之後,再用 GetOverlappedResult 取得成敗與傳輸位元組數。要一次等待多個事件就用 WaitForMultipleObjects,但同時能等待的上限是 64 個。18

GetOverlappedResultbWait 設為 TRUE,也可以等到完成之後再取出結果。如果這裡用的是自動重設事件,當觸發訊號被另一個等待動作消耗掉之後,GetOverlappedResult 就可能一直等下去。之所以要用手動重設,就是為了避開這個等待上的問題。83

要穩健處理數個同時 I/O,這是相當容易掌握全貌的方式。序列埠「邊讀邊寫」的處理也會用到它。實務案例請參考「序列通訊應用的陷阱」。

4.3. APC:在發行的執行緒上,持續 alertable wait 直到完成

ReadFileExWriteFileEx 是指定完成常式(回呼函式)的方式。I/O 完成之後,常式會被放進發行操作的執行緒的 APC 佇列,等到該執行緒透過 SleepExWaitForSingleObjectEx 等進入 alertable wait 時才會執行。91011

因為完成處理會在同一個執行緒上依序執行,在單一執行緒內就能完結的處理可以省掉鎖。另一方面,只要發行的執行緒不進入 alertable wait,完成常式就不會執行。要與 UI 的訊息迴圈併用,還需要 MsgWaitForMultipleObjectsEx,等待方式的設計會變得困難。若要通用,多半還是會選擇事件方式或 IOCP。

使用 APC 時最容易漏看的有三點:是否成功發行、等待方式是否正確、自己的操作是否已完成。下面這段程式碼是用來對比壞的等待方式與好的等待方式的節錄,並不是把兩者接連執行的範例。控制代碼與緩衝區的準備,以及更新各操作完成旗標的 OnReadCompleted,都是另外準備的前提。

// 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 傳回 0,完成常式一個都不會被排入佇列。要在緊接著的地方取得 GetLastError(),做完錯誤處理後離開。漏看這一點,completed 就永遠不會成立,變成對一個根本不存在的 I/O 不斷重複 SleepExCancelIoEx9

不要把等待逾時當成 I/O 已經結束。SleepEx 逾時就會離開 alertable wait,但已經發行的 I/O 仍可能還在。請不要就這樣離開作用域,讓 ovbuf 失效。要嘛持續等到完成,要嘛決定中止就提出取消要求,並等待那個完成被送達。3.2 節的生命週期規則,在逾時之後同樣適用。

不要只憑 WAIT_IO_COMPLETION 就認定自己的 I/O 已經結束。這個傳回值的意思是「至少執行了一個 APC」。如果同一個執行緒上還排著別的 I/O,或是 QueueUserAPC 排入的 APC,一樣會從那裡傳回。要用自己的完成常式所更新的旗標來判斷,還沒成立就繼續等待。10

遇到「APC 都不來」的情況,除了發行的成敗之外,還要檢查等待函式。用的是不是 SleepEx(..., TRUE) 而不是 SleepWaitForSingleObjectEx(..., TRUE) 而不是 WaitForSingleObject結尾的 Ex,以及 alertable 引數的 TRUE,就是檢查點。10

4.4. IOCP:用少數工作執行緒承接大量 I/O

I/O 完成埠(IOCP)的做法,是把控制代碼關聯到完成埠。完成封包會進入該埠的佇列,由工作執行緒透過 GetQueuedCompletionStatus 取出。這是用少數執行緒處理大量同時 I/O 的機制。12

它同時也是支撐 .NET 非同步 I/O 的路徑。完成通知的佇列與並行執行的執行緒數量控制要如何結合,會在下一回的第 3 回詳細討論。

5. 同步完成這個例外:「非同步」與「不會被卡住」不是同一回事

5.1. 在呼叫過程中就完成的代表性條件

就算在非同步模式下正確發行,I/O 仍可能在呼叫過程中就完成。所謂同步完成,指的是在函式傳回之前 I/O 已經完成,而不是保證很快就會傳回。要把「因為快取命中而很快結束」與「在呼叫過程中被卡住」分開來想。2

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

圖 5:即使以非同步方式發行也會同步完成的主要條件。它與呼叫傳回所花的時間要分開來看

微軟的疑難排解文件列出了下列原因。2

條件 會被同步處理的原因,以及實作上的注意事項
可以立即滿足的要求、快取命中 只要資料在記憶體上,驅動程式就能當場完成。就算很快結束,以「一定會變成 ERROR_IO_PENDING」為前提寫的程式碼還是會壞掉
啟用快取的讀取中,所需的頁面不存在 Windows 的快取是以檔案對應實作。因為沒有非同步的分頁錯誤機制,有時會改以同步方式處理
NTFS 壓縮、EFS 加密的檔案 檔案系統驅動程式會把存取轉換成同步方式
延伸檔案長度的寫入 會改變長度的寫入會變成同步

重點在於,不只是快取命中,不在快取中的時候一樣可能發生同步處理。快取本身的機制會在系列的第 4 回討論。

5.2. 把發行結果的分支與 UI 的回應性分開設計

首先必須做到的,是把 3.3 節的三個分支全部處理掉。TRUE 也要當成正常結果來預期,而在預設情況下,把結果處理統一交給通知端。

不過,就算分支寫對了,也不代表回應性就有保障。不能說「因為是非同步 I/O,所以 UI 不會凍結」,因此對於不能被卡住的執行緒,必須把 I/O 的發行本身分離出去,交給專用執行緒或執行緒集區的設計。相關的實務內容,在「在普通的 Windows 上盡可能實現軟即時的實戰指南」中也說明過。

5.3. 同步完成時省略通知,是只屬於 IOCP 的最佳化

在高頻率 I/O 的場合,可以做「同步完成時省略通知」的最佳化。用 SetFileCompletionNotificationModes 啟用 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS,立即成功的 I/O 就會變成不把完成封包排進 IOCP 的行為。這是要把設計切換成「不靠通知端,而是當場處理結果」時才會做的指定。7

被省略的只有送往 IOCP 的封包,OVERLAPPED.hEvent 的觸發並不會被抑制。請不要把同樣的最佳化套用到事件方式上。把預設的通知路徑與最佳化後的路徑混在一起,會導致 3.3 節說的重複處理,或是等錯通知。與 IOCP 的搭配會在第 3 回討論。

6. 取消與終止處理:提出要求、確認完成、關閉

6.1. 依照要取消的對象選擇 API

取消用的 API,要依照操作與發行執行緒的差異分開使用。4135

API 對象與指定方式
CancelIoEx 不論由哪個執行緒發行,都能對指定控制代碼上未完成的 I/O 提出要求。第二個引數是 OVERLAPPED 就只針對該操作,是 NULL 就針對該控制代碼的所有操作
CancelIo 只有呼叫執行緒自己發行的操作才是對象
CancelSynchronousIo 對象是指定的另一個執行緒正在執行中的同步 I/O

CancelIoEx 是在 Vista 導入的。在非同步 I/O 上,已經沒有理由特意去用受發行執行緒限制的舊版 CancelIo,應該以 CancelIoEx 為基本。

6.2. CancelIoEx 成功,不代表 I/O 已經結束

CancelIoEx 是對未完成的 IRP 提出取消要求的 API,並不是等待操作完成的 API。就算呼叫成功,也只停在提出取消要求的階段。已經接近完成的操作,可能來不及取消而正常完成。14

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

圖 6:被取消的操作同樣會以完成的形式通知。善後要在確認之後才進行

實際被取消的操作,會在完成通知中以 ERROR_OPERATION_ABORTED 傳回。不論是正常完成還是被取消,在收到通知之前都不能釋放結構與緩衝區。如果先釋放,核心就會失去正在使用的區域,導致記憶體損毀。遇到取消之後的存取違規,第一件要確認的就是這個生命週期。414

6.3. 關閉控制代碼之前,先回收已經發行的操作

終止處理的基本順序是提出取消要求 → 確認完成 → 關閉控制代碼

正如第 1 回所見,關閉最後一個控制代碼時,cleanup 處理會觸發未完成 IRP 的取消。但是,把已發行的 I/O 留著不管、只關閉控制代碼,完成通知與緩衝區生命週期的管理很容易就崩壞。不要把善後整包丟給關閉這個動作,要先把未完成的操作處理掉。

想因為逾時而中止處理時也一樣。作業系統不會連應用程式的中止條件都幫忙決定,所以要把逾時之後的取消,與接收完成的步驟當成一組來設計。如果是 APC 方式,就像 4.3 節那樣,持續 alertable wait 直到完成被送達為止。

7. 與 .NET 的對應:不只看 ReadAsync,還要看開啟檔案的地方

7.1. 讓控制代碼的模式與呼叫的 API 一致

FileStreamuseAsync,或是 FileOptions.Asynchronous,對應到 Win32 的 FILE_FLAG_OVERLAPPED。和第 1 回的對照表一樣,在 .NET 中重要的同樣是開啟檔案時的模式1516

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

圖 7:同樣是 ReadAsync,控制代碼的模式不同,作業系統一側的處理路徑也不同

控制代碼與 API 的組合 內部發生的事
非同步模式 + ReadAsync / WriteAsync 會用到作業系統非同步 I/O 的組合
同步模式 + ReadAsync / WriteAsync 由執行緒集區的執行緒代為執行同步讀寫的「假性非同步」
非同步模式 + 同步的 Read / Write 內部要等待完成,因此產生額外負擔

就算是「假性非同步」,呼叫端的執行緒也不會被卡住,但背後有另一個執行緒在等待。數量少的時候實際傷害不大,但在伺服器或高頻率處理中,就會成為執行緒集區枯竭與擴充性下降的原因。原則是讓模式與 API 保持一致1615

7.2. 用三個例子比較 FileStream 的建立方式

下面的 (A) 與 (B),ReadAsync 的呼叫方式完全相同。不同的只有開啟檔案時的 useAsync 而已。(C) 則是 .NET 6 以後,明確指定控制代碼與位置的例子。

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);
}

檢查既有程式碼時,不要只看 ReadAsyncWriteAsync 的呼叫端,還要去找建立 FileStream 的地方File.OpenReadnew FileStream(path, FileMode.Open) 這類簡短的多載都是以同步模式開啟。若是從 SafeFileHandle 建立 FileStreamisAsync 引數也要與控制代碼實際的模式一致。

7.3. 使用 RandomAccess 時,明確指定控制代碼與位移量

.NET 6 全面改寫了 FileStream 的內部實作,並新增了 File.OpenHandleRandomAccess。這是直接處理 SafeFileHandle,而且每次都傳入讀寫位置的 API。16

像 (C) 那樣明確指定模式與 fileOffset 的寫法,正好對應到本文說明的非同步控制代碼與每個操作的 OVERLAPPED.Offset這種分工。

7.4. 就算用 CancellationToken,取消也仍然只是請求

在非同步模式的控制代碼上,透過 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() 的卡點」。本文說明的,是在那之下作業系統如何進行讀寫。

8. 總結:把從發行到善後的過程連成一條線來檢查

檢查非同步 I/O 時,依下面的順序追蹤程式碼。

  1. 開啟的地方確認同步/非同步的模式。如果是磁碟上的檔案,每個非同步操作都要指定位置。
  2. 發行的地方確認有操作專用的 OVERLAPPED 與緩衝區,而且處理了 3.3 節的三個分支。
  3. 接收完成的地方確認等待方式符合事件、APC、IOCP 等各自的方式,而且沒有把同一個結果重複處理。
  4. 終止的地方確認沒有只憑逾時或取消要求就進行釋放。

同步 I/O 與非同步 I/O 並不是兩套不同的管線,差別只在於是等到完成才傳回,還是使用在完成前就傳回的路徑。不過即使是非同步模式,同步完成一樣會發生,所以發行結果的分支與回應性的設計要分開處理。12

模式屬於控制代碼,狀態屬於每一個操作,善後要等確認完成之後。這樣的分工,不論是直接處理 Win32 的 OVERLAPPED,還是使用 .NET 的 FileOptions.Asynchronous,都是共通的。就算是已取消的操作,在收到完成之前也要繼續管理。3415

接下來是第 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

常見問題

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

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

回到部落格一覽