By Fanxiushu 2013, 引用和转载请注明原作者
写这篇文章的动机:是因为最近在应用程序中调用DeviceIoControl读写自己做的一个驱动程序的数据,传输的数据包很多。
老的处理方式只是简单的采用多线程阻塞读写数据,这几天突然想到了完成端口,于是换成 完成端口后,
只用一个线程,投递上百个Overlapped读写操作,效率比原来高了许多,
因此就想从windows内核层里去理解完成端口等异步IO究竟干了些什么事情。
完成端口的使用资料,网络上说得很多。完成端口对服务器开发人员来说,可以说是个”神器“一样的东西。
记得以前公司面试也老爱拿它的知识来说事。
这里所说的 Overlapped I/O和完成端口,是windows平台特有的概念,是为了提高IO效率引入的异步IO的概念。
linux平台也有类似高效率的东西,就是 epoll,可惜epoll比完成端口出现得晚。
Overlapped I/O翻译为重叠I/O,意思是当系统为我们执行IO操作的时候,我们可同时干别的工作,
也就是两份工作重叠在一起做,这也就是异步了,
我们可以在一个线程里投递非常多的读写请求,然后接着干别的事,读写完成之后,系统会采用某种方式通知我们IO完成。
我们尝试着从windows内核方面去理解这种异步IO行为。
先看看在应用层,我们调用 ReadFile,ReadFileEx等函数(其他比如WSARecv等函数基本是类似的)的时候,有哪几种IO模型。
可以归纳为如下四种:
1)阻塞IO,ReadFile阻塞,一直到有数据才返回。
2)Overlapped IO,构造一个 OVERLAPPED,并且设置一个事件EVENT,ReadFile马上返回,通过Wait这个事件来确定IO是否完成。
3)设置APC回调函数的IO,构造一个OVERLAPPED,并同时设置一个APC回调函数,ReadFileEx马上返回,IO完成时,回调函数被调用。
4)完成端口的 IO,构造一个OVERLAPPED,ReadFile马上返回,通过调用GetQueuedCompletionStatus 函数来确定IO完成情况。
以上四种IO模型,在内核中都有不同的通知处理方式。
我们知道Windows内核中IRP是通讯的基础,
几乎所有交互的信息,都是通过IRP来完成。IRP跟我们在应用层的Windows Message消息一个地位。
再简单看看 一个IRP的调用序列,拥有这个IRP的调用者,构造出这个IRP,初始化它,然后调用 IoCallDriver,
IoCallDriver里会调用我们驱动里真正的派遣函数,执行实际的IO数据传输,
如果我们在派遣函数能完成任务,就直接调用 IoCompleteRequest完成这个IRP,
如果不能立刻完成,则在适当时间在其他地方调用IoCOmpleteRequest完成它。写出简单伪代码如下:
void call_read_irp( HANDLE hFile )
{
PIRP irp = IoAllocateIrp( stacksize, 0);
//初始化
ObReferenceObjectByHandle ( handle, ... &file_object ... ); //根据文件句柄获得文件对象指针
irpStack = IoGetNextIrpStackLocation(irp);
irpStack->MajorFunction = IRP_MJ_READ; ///
irpStack->FileObject = file_object; /// 设置文件对象
......
status = IofCallDriver( device, irp );
.....
}
NTSTATUS IofCallDriver( PDEVICE_OBJECT device, PIRP irp )
{
IoSetNextIrpStackLocation(irp);
irpStack = IoGetCurrentIrpStackLoaction(irp);
irpStack->DeviceObject = device;
driver_object = device->DriverObject;
.....
status = driver_object->MajorFunction[ irpStack->MajorFunction]( device, irp ); ///调用我们自己开发的驱动中的派遣函数
.....
}
NTSTATUS DIspatch_Read_XXXX( device, irp ) //我们驱动中的派遣函数
{
///
执行实际的数据传输
。。。。
IoCompleteRequest ( irp , ,,); //完成IRP,如果不能立即完成,可以先把IRP入队,派遣函数返回 PENDING状态,
//然后在其他地方调用 IoCompleteRequest完成这个IRP。
.....
}
大致的调用序列就如上所说,现在有个问题就是call_read_irp如何能知道 IRP已经完成了,
如果只是简单从 IofCallDriver函数返回之后,就立刻断定已经使用完这个IRP了,那就错了,
因为大部分驱动在她们的派遣函数里都不能立刻完成IRP请求的。
因此必须要有别的办法来进行判断,办法一,也是内核中各个驱动通讯经常用的办法:
在call_read_irp函数里调用 IoSetCompletionRoutine为IRP 设置一个回调例程,当调用IoCompleteRequest 完成IRP时,
这个回调例程就会被调用,从而就能知道这个IRP已经完成了。
但是在跟ring3层(应用层)交互,却不用这个办法,我们再看看 IRP结构中一些通知应用层的数据字段:
struct _IRP {
......
//
// User parameters.
PIO_STATUS_BLOCK UserIosb;
PKEVENT UserEvent;
union {
struct {
PIO_APC_ROUTINE UserApcRoutine;
PVOID UserApcContext;
} AsynchronousParameters;
LARGE_INTEGER AllocationSize;
} Overlay;
..........................
};
UserIosb存储的是传输结果,传输多少字节等信息
UserEvent是一个事件,调用IRP可以设置这个事件,当 IoCompleteRequest调用时会设置这个事件为有信号,
这样调用者就知道IRP已经完成了
AsynchronousParameters 是APC回调例程参数,当IoCompleteRequest调用时候,这个例程会被在调用IRP的线程环境里被调用,
从而也能知道IRP已经完成了。
看到 UserEvent和AsynchronousParameters ,我们就应该想到了,
他们对应着 2),3)两种应用层的IO模型,一个是事件,一个是APC回调。
那1),4)两种模型如何通知的呢, 我们再看看 IRP跟她相关 的 IO_STACK_LOCATION结构
struct _IO_STACK_LOCATION
{
.......
union{
.....
}Parameters;
PFILE_OBJECT FileObject; ///文件对象指针
//下面是完成例程参数
PIO_COMPLETION_ROUTINE CompletionRoutine;
PVOID Context;
};
再看看文件对象 FILE_OBJECT 结构
struct _FILE_OBJECT{
.....
KEVENT Event;
....
PIO_COMPLETION_CONTEXT CompletionContext; //完成端口句柄
};
IO_STACK_LOCALTION 里有个 FileObject指针,保存了发起IRP请求的文件对象。
文件对象结构里,又有 Event事件和 CompletionContext 参数。
同样的,调用 IoCompleteRequest时候,会设置 FileObject文件对象里的 Event事件为有信号,
这样在ReadFile的阻塞调用里,Wait这个Event直到IO完成。
而文件对象结构里的 CompletionContext 参数,就跟 完成端口有关系了。
平时CompletionContext 都是空值,直到我们在应用层调用 CreateIoCompletionPort把句柄绑定到一个完成端口句柄。
这时CompletionContext 指向完成端口对象。
在调用 IoCompleteRequest的时候,如果发现 CompletionContext 不为空,说明本IRP所属的文件对象已经绑定到一个完成端口对象。
于是就调用 KeInsertQueue 函数 把 IRP 插入到 完成端口队列里。
我们在应用层调用 GetQueuedCompletionStatus ,
最终会进入到内核调用KeRemoveQueue从队列里取出在 IoCompleteRequest函数入队的IRP,如果队列空,此函数就等待。
这样应用层的GetQueuedCompletionStatus就知道IO结果了。
所以 KeInsertQueue 和 KeRemoveQueue 是 GetQueuedCompletionStatus里的灵魂级别的函数。
可以看到,所有这些IRP完成通知,都跟IoCompleteRequest脱不了关系。
我们把 IoCompleteRequest的处理大致思路理顺。(IoCompleteRequest只是个宏,真正的函数是 IofCompleteRequest.)
函数首先做些参数判断检测等,接着回溯整个设备堆栈(IO_STACK_LOCATION),
如果有回调例程(就是调用IoSetCompletionRoutine设置的回调函数)要调用,于是就调用。
如果发现回调例程要自己处理这个IRP的完成结果,IoCompleteRequest立刻返回。
接着对一些特殊的IRP,比如关联的IRP做些处理,以及一些特殊标志做些处理,
最后进入正题,函数会初始化IRP里的KAPC结构,调用KeInsertQueueApc 发起一个内核APC回调。
如此做的原因一是为了让耗时的处理稍后接着处理,另外一个重要原因,是让接下来的处理在原来调用IRP的线程上下文环境里处理。
因为这里牵涉到数据复制,返回结果等信息,都必须在相同的线程环境里进行。
当原始发起IRP调用的线程得到时间片运行时候,这个APC得到调用( 此APC函数取名为IopCompleteRequest )。
IopCompleteRequest里取出这个IRP,接着处理,首先做些判断检测,如果是BUFERED_IO,就把数据COPY到用户层空间。
如果MDL不为空,就释放等等。。。
然后把返回的结果信息复制到IRP指向的 UserIosb 结构里。
如果UserEvent不为空,就设置为有信号状态,根据情况设置 FILE_OBJECT对象指针里的Event事件。
如果IRP里的 UserApcRoutine 不为空,说明有用户层的APC回调函数,于是就接着调用 KeInsertQueueApc 再次发起APC回调。
(总觉得这种回调机制效率不会太高)。
否则如果发现 FILE_OBJECT的完成端口对象指针CompletionRoutine不为空,于是调用 KeInsertQueue函数把IRP插入到完成端口对象里。
以上就是 整个 IoCompleteRequest处理的大致流程。
可以看到这种通知机制分散出多个块来做,幸好都是IoCompleteRequest已经帮我们做了,
我们开发驱动时候,只需简单 调用 IoCompleteRequest就可以了。
来点实际的,用 ReadFile做例子,内核中ReadFile对应的服务函数是 NtReadFile。
看看 NtReadFile的参数申明:
NTSTATUS NtReadFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
OUT PVOID Buffer,
IN ULONG Length,
IN PLARGE_INTEGER ByteOffset OPTIONAL,
IN PULONG Key OPTIONAL
);
于是,根据上面说了这么一大篇,我们就很能理解 Event, ApcRoutine, ApcContext,IoStatusBlock是干什么的了,
我们看看ReadFile调用NtReadFile的伪代码:
BOOL ReadFile(
HANDLE hFile,
LPVOID lpBuffer,
DWORD nNumberOfBytesToRead,
LPDWORD lpNumberOfBytesRead,
LPOVERLAPPED lpOverlapped
)
{
if( lpOverlapped != NULL ){ //异步方式读
apcContext = (ULONG_PTR)lpOverlapped->hEvent & 0x01 ? NULL : lpOverlapped; //
//这里比较特别,MSDN文档在介绍 GetQueuedCompletionStatus 使用说明时候有提及。
status = NtReadFile( hFile,
lpOverlapped->hEvent,
NULL,
apcContext,
(PIO_STATUS_BLOCK)lpOverlapped, lpBuffer, nNumberOfBytesToRead, ...);
.......
}
else{ //同步读取
IO_STATUS_BLOCK ios;
status = NtReadFile( hFile, NULL, NULL, NULL, &ios, lpBuffer, nNumberOfBytesToRead, NULL, NULL);
if( status ==STATUS_PENDING)
status = NtWaitForSingleObject( hFile, FALSE, FALSE); //等待直到操作完成
.......
}
...................
}
BOOL ReadFileEx(
HANDLE hFile,
LPVOID lpBuffer,
DWORD nNumberOfBytesToRead,
LPOVERLAPPED lpOverlapped,
LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
)
{ //APC回调方式的异步读取
status = NtReadFile( hFile,
NULL,
BasepIoCompletion, //定义一个基础的回调例程,在 BasepIoCompletion里才调用我们真正的 lpCompletionRoutine.
(PVOID) lpCompletionRoutine, //
(PIO_STATUS_BLOCK)&lpOverlapped->Internal,
lpBuffer,
nNumberOfBytesToRead, ... );
........
}
NtReadFile的实现有点类似上面的 call_read_irp 函数,在 NtReadFile构造一个 IRP_MJ_READ 的IRP,然后做些其他方面的事情,
最后用 IoCallDriver调用下层驱动读取数据,
在驱动里,调用 IoCompleteRequest 完成IRP,于是各种形式的结果通知,都在 IoCompleteRequest里得到处理。