介紹
簡單講解下我們程序進行IO的過程,當線程進行一個同步的設備IO請求時,他會被掛起,直到設備完成IO請求,返回給阻塞線程,線程激活繼續處理。當進行一個異步的設備IO請求時,該線程可以先去做其他事,等到設備完成IO請求後通知該線程進行處理。本文討論在windows平臺下的異步設備IO。同時在一些示例中會對涉及到的知識進行講解。
1.異步IO執行
進行異步設備io時我們來做一下下準備工作,首先針對不同的設備(文件,管道,套接字,控制檯)的初始化和發出IO不太一樣,以簡單的文件爲例,別的應該都是相通的。
1.1 初始化設備(eg.CreateFile)
首先我們來說下在windows下他的api大多數有後綴爲W和A兩種情況,W表示以unicode(utf-16)字符編碼,
A表示以ANSI字符編碼,我們以W爲例,然後我們使用CreateFile創建文件設備對象,CreateFile也可以用來創建目錄,磁盤驅動器,串口,並口等設備對象。這裏我們用最簡單的文件爲例。
WINBASEAPI
HANDLE
WINAPI
CreateFileW(
_In_ LPCWSTR lpFileName,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwShareMode,
_In_opt_ LPSECURITY_ATTRIBUTES lpSecurityAttributes,
_In_ DWORD dwCreationDisposition,
_In_ DWORD dwFlagsAndAttributes,
_In_opt_ HANDLE hTemplateFile
);
- WINBASEAPI宏表示__declspec(dllimport)是用來導入導出時使用
- HANDLE類型表示內核對象,比如線程,進程,事件,設備等,操作系統來維護的。
- WINAPI 宏是__stdcall,VC編譯器的指令,可以來設置傳參的時入棧的參數順序,棧內數據清除方式,函數簽名等
- lpFileName文件名
- dwDesiredAccess訪問方式,可讀、可寫等
- dwShareMode,其他內核對象使用是的共享方式
- lpSecurityAttributes 安全屬性
- dwCreationDisposition 打開方式,創建還是打開已有等
- …
我們如果使用CreateFile來進行異步IO,我們需要將dwFlagsAndAttributes設置帶有FILE_FLAG_OVERLAPPED屬性。OVERLAPPED重疊的意思,表示內核線程和應用線程重疊運行。
1.2 執行(eg.ReadFile,WriteFile)
WINBASEAPI
_Must_inspect_result_
BOOL
WINAPI
ReadFile(
_In_ HANDLE hFile,
_Out_writes_bytes_to_opt_(nNumberOfBytesToRead, *lpNumberOfBytesRead) __out_data_source(FILE) LPVOID lpBuffer,
_In_ DWORD nNumberOfBytesToRead,
_Out_opt_ LPDWORD lpNumberOfBytesRead,
_Inout_opt_ LPOVERLAPPED lpOverlapped
);
WINBASEAPI
BOOL
WINAPI
WriteFile(
_In_ HANDLE hFile,
_In_reads_bytes_opt_(nNumberOfBytesToWrite) LPCVOID lpBuffer,
_In_ DWORD nNumberOfBytesToWrite,
_Out_opt_ LPDWORD lpNumberOfBytesWritten,
_Inout_opt_ LPOVERLAPPED lpOverlapped
);
來看下ReadFile的解釋
- hFile即爲上一節的設備對象
- lpBuffer是文件最後讀到的緩衝區,或者要寫到設備的緩衝區
- nNumberOfBytesToRead要讀取多少字節,nNumberOfBytesToWrite要寫多少字節
- lpNumberOfBytesRead指向一個DWORD的地址,表示最終讀取了多少字節,lpNumberOfBytesWritten最終寫了多少字節。
然後就是lpOverlapped了,我們來看下LPOVERLAPPED的結構
typedef struct _OVERLAPPED {
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
union {
struct {
DWORD Offset;
DWORD OffsetHigh;
} DUMMYSTRUCTNAME;
PVOID Pointer;
} DUMMYUNIONNAME;
HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;
- Internal用來保存等到已經處理完IO後的錯誤碼
- InternalHigh用來保存已傳輸的字節數
- Offset和InternalHigh構成一個64位的偏移值,表示訪問文件從哪裏開始訪問
- Pointer系統保留字
- hEvent用來接收I/O完成通知時使用,後邊會說到
2. IO請求完成通知
然後我們來看下,等到IO完成後如何通知到線程中,有四種方式來通知,摘自《windows核心編程》:
方法 | 描述 |
---|---|
觸發設備內核對象 | 允許一個線程發出IO請求,另一個線程對結果處理,只能同時發出一個IO請求 |
觸發事件內核對象 | 允許一個線程發出IO請求,另一個線程對結果處理 ,能同時發出多個IO請求 |
可提醒I/O | 只允許一個線程發出IO請求,鬚髮出請求的線程對結果處理,能同時發出多個IO請求 |
I/O完成端口 | 循序一個線程發出IO請求,另一個線程對結果處理,能同時發出多個IO請求 |
2.1 觸發設備內核對象
先來看例子:
int main()
{
HANDLE hFile = CreateFile(L"1.txt", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
std::cout << "open error";
return -1;
}
BYTE bBuffer[1024];
OVERLAPPED o = { 0 };
BOOL bReadDone = ReadFile(hFile, bBuffer, 1024, NULL, &o);
DWORD dwError = GetLastError();
if (!bReadDone && dwError == ERROR_IO_PENDING) {
DWORD dw = WaitForSingleObject(hFile, INFINITE);
bReadDone = TRUE;
}
if (bReadDone) {
std::cout << o.Internal << std::endl;
std::cout << o.InternalHigh << std::endl;
bBuffer[o.InternalHigh] = '\0';
std::cout << bBuffer << std::endl;
}
else {
std::cout << "read error";
return 0;
}
std::cout << "succ";
return 0;
}
CreateFile用可讀可寫的權限;用OPEN_ALWAYS的打開方式,表示有文件打開,沒有該文件創建文件。
這個例子對一些判斷比較完整,我們可以順便來鞏固下基礎知識,CreateFile成功返回句柄,失敗時返回INVALID_HANDLE_VALUE,而不是像許多windows返回句柄爲NULL來表示失敗了,但是CreateFile失敗返回的是INVALID_HANDLE_VALUE(-1),大家可以注意下。
然後進行初始化,聲明的BYTE數組來存放讀取到的數據;OVERLAPPED 對象初始化爲0,即中的元素值都是0,這裏要注意的是Offset爲0即爲從文件的開頭讀取數據。
調用ReadFile後,由於是異步的,所以bReadDone 是FALSE,然後獲取下錯誤信息,得知是ERROR_IO_PENDING,表示正在進行IO操作。
最後我們調用WaitForSingleObject(hFile, INFINITE)來等待hFile設備內核對象觸發,這裏我們大概講解下關於內核對象觸發。
在windows中,內核對象可以用來進行線程同步,內核對象有兩個狀態:觸發和,未觸發。比如說線程,進程,他們在創建時是未觸發的,運行結束時變爲觸發狀態。在比如Event對象,可以我們寫代碼來使他的程序變化,後邊我們再說。
這裏我們說下文件內核對象,ReadFile和WriteFile函數在將IO請求添加到設備的隊列之前,會先將狀態設爲未觸發狀態,當設備驅動程序完成了所謂請求後,會將對象狀態設爲觸發狀態。
再來說WaitForSingleObject函數,就是等待第一個參數(內核對象句柄)狀態變成觸發,等待時間是第二個參數,等待該時間後或者內核對象狀態變成觸發該函數返回。
我們先往文件中寫入“01234567899876543210”
最後我們打印出來讀取結果,依次打印出錯誤碼,讀取的字節數,讀取內容。另外我們首先在文件中寫入了內容。
這個有一個缺點就是,只能同時處理一個IO請求。
2.2 觸發事件內核對象
繼續看例子:
static bool readReady = false;
void WaitResultThd(void *param)
{
HANDLE* hh = (HANDLE*)param;
DWORD dw = WaitForMultipleObjects(2, hh, TRUE, INFINITE);
if (dw == WAIT_OBJECT_0) {
readReady = true;
}
}
int main()
{
HANDLE hFile = CreateFile(L"1.txt", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL,
OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
std::cout << "open error";
return -1;
}
BYTE bBuffer1[11] = {0};
OVERLAPPED o1 = { 0 };
o1.hEvent = CreateEvent(NULL, FALSE, FALSE, L"");
o1.Offset = 0;
ReadFile(hFile, bBuffer1, 10, NULL, &o1);
BYTE bBuffer2[11] = { 0 };
OVERLAPPED o2 = { 0 };
o2.hEvent = CreateEvent(NULL, FALSE, FALSE, L"");
o2.Offset = 10;
ReadFile(hFile, bBuffer2, 10, NULL, &o2);
HANDLE h[2];
h[0] = o1.hEvent;
h[1] = o2.hEvent;
_beginthread(WaitResultThd, 0, h);
while (1)
{
/* do somthing*/
Sleep(500);
if (readReady) {
std::cout << bBuffer1 << std::endl;
std::cout << bBuffer2 << std::endl;
break;
}
}
return 0;
}
我們看下這個和上一個的區別是用OVERLAPPED的hEvent變量來實現IO完成的通知,首先CreateEvent爲每個OVERLAPPED的變量創建事件內核對象,看下CreateEvent:
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
CreateEventW(
_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
_In_ BOOL bManualReset,
_In_ BOOL bInitialState,
_In_opt_ LPCWSTR lpName
);
- lpEventAttributes設置的安全屬性
- bManualReset,意爲是否爲手動重置對象,爲TRUE表示手動重置,事件觸發時正在等待改事件的所有線程將都變成可調度狀態。爲FALSE爲自動重置,事件觸發時只有一個線程變成可調度狀態。
- bInitialState初始狀態,TRUE是觸發狀態,FALSE爲未觸發狀態
- lpName是可以用次來共享該事件對象
當我們創建成功了時間內核對象時,可以使用SetEvent將其設置爲觸發狀態,可以使用ResetEvent將其設置爲未觸發狀態
我們繼續,當異步IO請求完成後,設備驅動程序會檢查OVERLAPPED的hEvent是不是爲空,如果不是爲空,調用SetEvent來觸發該對象。
爲了演示可以多線程來進行操作,我們開啓另一個線程來等待事件完成,使用WaitForMultipleObjects來等待多個事件觸發,我們再來看下WaitForMultipleObjects
WINBASEAPI
DWORD
WINAPI
WaitForMultipleObjects(
_In_ DWORD nCount,
_In_reads_(nCount) CONST HANDLE* lpHandles,
_In_ BOOL bWaitAll,
_In_ DWORD dwMilliseconds
);
- nCount表示等待幾個對象
- lpHandles,等待的對象句柄數組
- bWaitAll,表示是等待所有對象都變成觸發狀態再返回(TRUE),還是隻要有一個對象觸發就返回(FALSE)
- dwMilliseconds 表示等待的時間
如果bWaitAll爲TRUE,返回值爲WAIT_OBJECT_0表示全部觸發
如果bWaitAll爲FALSE,返回值爲WAIT_OBJECT_0表示lpHandles[0]對象觸發,WAIT_OBJECT_0 + 1表示lpHandles[1]觸發,以此類推。
再繼續,我們設置的兩次IO讀取請求是從文件的不同偏移開始讀的,我們來看下讀取結果:
2.3 可提醒的I/O
可提醒IO是使用回調函數來實現,同時執行IO請求的函數有點變化,這裏我們介紹RadFileEx和WriteFileEx,我們看下函數原型:
WINBASEAPI
_Must_inspect_result_
BOOL
WINAPI
ReadFileEx(
_In_ HANDLE hFile,
_Out_writes_bytes_opt_(nNumberOfBytesToRead) __out_data_source(FILE) LPVOID lpBuffer,
_In_ DWORD nNumberOfBytesToRead,
_Inout_ LPOVERLAPPED lpOverlapped,
_In_ LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
WINBASEAPI
BOOL
WINAPI
WriteFileEx(
_In_ HANDLE hFile,
_In_reads_bytes_opt_(nNumberOfBytesToWrite) LPCVOID lpBuffer,
_In_ DWORD nNumberOfBytesToWrite,
_Inout_ LPOVERLAPPED lpOverlapped,
_In_ LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
和ReadFile及WriteFile,有幾點不一樣。
- 這兩個函數沒有指向DWORD地址的指針表示已傳輸多少字節,畢竟在異步中不能立即拿到,該信息在回調函數才能得到
- lpCompletionRoutine增加了這個參數,即回調函數的函數指針,看下類型:
typedef
VOID
(WINAPI *LPOVERLAPPED_COMPLETION_ROUTINE)(
_In_ DWORD dwErrorCode,
_In_ DWORD dwNumberOfBytesTransfered,
_Inout_ LPOVERLAPPED lpOverlapped
);
錯誤碼,傳輸的字節數,及LPOVERLAPPED 結構。
然後我們來看下例子,通過此來講解下。
static bool readReady = false;
static BYTE bBuffer1[11] = { 0 };
static BYTE bBuffer2[11] = { 0 };
VOID WINAPI ReadyFunction(ULONG_PTR param)
{
static int times = 0;
times++;
if (times == 2) {
readReady = true;
}
}
VOID WINAPI DoWorkRountine(DWORD dwErrorCode, DWORD dwNumberOfBytesTransfered, OVERLAPPED* lpOverlapped)
{
if (lpOverlapped->Offset == 0) {
std::cout << bBuffer1 << std::endl;
}
else {
std::cout << bBuffer2 << std::endl;
}
QueueUserAPC(ReadyFunction, GetCurrentThread(), NULL);
}
void DoWorkThd(void *param)
{
HANDLE hFile = CreateFile(L"1.txt", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_FLAG_OVERLAPPED, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
std::cout << "open error";
return;
}
OVERLAPPED o1 = { 0 };
o1.Offset = 0;
ReadFileEx(hFile, bBuffer1, 10, &o1, DoWorkRountine);
OVERLAPPED o2 = { 0 };
o2.Offset = 10;
ReadFileEx(hFile, bBuffer2, 10, &o2, DoWorkRountine);
while (1) {
if (readReady) {
break;
}
SleepEx(500, TRUE);
}
}
int main()
{
HANDLE tHandle = (HANDLE)_beginthread(DoWorkThd, 0, NULL);
WaitForSingleObject(tHandle, INFINITE);
return 0;
}
我們將DoWorkRountine作爲IO完成的回調函數傳入,其讀出來的數據我們用兩個全局變量來緩衝,我們注意到了發起IO請求的線程使用了SleepEx函數進去睡眠,我們看下這個函數:
WINBASEAPI
DWORD
WINAPI
SleepEx(
_In_ DWORD dwMilliseconds,
_In_ BOOL bAlertable
);
和sleep相似,多了一個bAlertable參數,表示是否是可提醒的,如果是可提醒的,那麼完成了IO請求完成後就會喚醒線程去執行回調函數。
- 當系統創建一個線程,會創建一個與線程相關的待執行隊列,這個隊列被稱爲異步隊列,在此當IO請求完成後,設備驅動程序就會在調用線程的異步隊列中添加一項。當線程是可提醒的狀態就會被激活去執行相關任務。且如果隊列中至少有一項,那麼系統就不會讓線程進入到睡眠狀態,當回調函數返回時,系統判斷隊列中是否有任務,如果有就會繼續取出任務去執行,如果沒有其他項,SleepEx等可提醒的函數返回,返回值是WAIT_IO_COMPLETION
- Sleep函數內部也是調用了SleepEx,只是將bAlertable置爲FALSE。其他可以將線程置爲可提醒狀態的還有WaitForSingleObjectEx,WaitForMultipleObjectEx,SingleObjectAndWaitEx,GetQueuedCompletionStatusEx,MsgWaitForMutipleObjectEx。
QueueUserAPC是允許我們手動往編程裏添加任務。原型是:
WINBASEAPI
DWORD
WINAPI
QueueUserAPC(
_In_ PAPCFUNC pfnAPC,
_In_ HANDLE hThread,
_In_ ULONG_PTR dwData
);
- pfnAPC是待執行的函數
- hThread要添加的線程
- dwData回調函數的自定義參數
可提醒IO的確定很明顯,回調函數沒有足夠地方存放上下文信息,需要一些全局變量,如我們例子中的bBuffer;第二個就是隻能一個線程來完成IO請求和完成通知,不能用上多線程,可能對資源利用率不足。
最後我們看下運行結果:
2.4 注意事項
由於篇幅限制,我們下一篇再講述完成端口,剩下這裏我們說下關於進行異步IO的時候注意事項
- 當我們發起IO多個請求時,設備驅動程序並不會按照我們請求的順序去執行(順序是不一定的),所以大家儘量避免依靠順序編碼。
- 當我們進行IO請求時,可能會同步返回,這是有可能系統之前有了這一部分的數據就會直接返回,所以大家需要在ReadFile等要判斷返回值。
- 我們在完成IO請求完成之前,一定要保證數據緩存和OVERLAPPED結構的存活,這些是在我們發起IO請求時只會傳入地址,完成後會填充改地址的值。所以一定要保證他的存活性。
好了,就到這裏了,參考自《windows核心編程》,歡迎交流