Overlapped I/O 和 完成端口等异步IO在内核中的通知方式

                                                      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函数里调用 IoSetCompletionRoutineIRP 设置一个回调例程,当调用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里得到处理。

你可能感兴趣的:(windows,内核)