介绍
简单讲解下我们程序进行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核心编程》,欢迎交流