windows下異步IO一

介紹

簡單講解下我們程序進行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核心編程》,歡迎交流

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章