Windows核心编程:同步设备I/O与异步设备I/O

作者:shenzi

链接:http://blog.csdn.net/shenzi

Windows核心编程:同步设备I/O与异步设备I/O
     在Microsoft Windows应用程序中,线程时我们最好的工具,可以用来对工作进行划分。我们可以给每个线程指定一个处理器,这样在多处理器的机器上就可以同时执行多 个操作,从而提高吞吐量。当线程发出一个异步设备I/O请求的时候,它会被临时挂起,直到设备完成I/O请求为止。此类挂起会损害性能,这是因为线程无法 进行有用的工作,比如开始对另一个客户请求进行处理。因此,简而言之,我们希望线程不会被阻塞住,这样它们就能始终进行有用的工作。
1.打开和关闭设备

    Windows的优势之一是它所支持的设备数量。就我们的讨论而言,我们把设备定义为能够与之进行通信的任何东西。表1列出了一些设备及其常见用途。

表1:各种设备及其常见用途

设备

常见用途

文件

永久存储任何数据

目录

属性和文件压缩的设置

逻辑磁盘驱动器

格式化驱动器

物理磁盘驱动器

访问分区表

串口

通过电话线传输数据

并口

将数据传输至打印机

邮件槽

一对多数据传输,通常是通过网络传到另一台运行Windows的机器上

命名管道

一对一数据传输,通常是通过网络传到另一台运行Windows的机器上

匿名管道

单机上的一对一数据传输(绝对不会跨网络)

套接字

报文或数据流的传输,通常是通过网络传到任何支持套接字的机器上(机器不一定要运行Windows操作系统)

控制台

文本窗口的屏幕缓存

                                               表2:用来打开各种设备的函数

设备

用来打开设备的函数

文件

CreateFile (pszName 为路径或UNC路径名).

目录

CreateFile (pszName 为路径或UNC路径名). 如果在调用CreateFile的时候指定 FILE_ FLAG_BACKUP_SEMANTICS 标志,

那么Windows允许我们打开一个目录。打开目录使我们能够改变目录的属性和它的时间戳。

逻辑磁盘驱动器

CreateFile (pszName is "//./x :"). 如果指定的字符串是 "//./x :"的形式,那么Windows允许我们打开一个逻辑磁盘驱动器,

其中的x是驱动器的盘符。打开驱动器使我们能够格式化驱动器或检测驱动器媒介的大小。

无力磁盘驱动器

CreateFile (pszName 为 "//./PHYSICALDRIVEx "). 如果指定的字符串是"//./PHYSICALDRIVEx " 的形式,那么Windows允许

我们打开一个屋里磁盘驱动器,其中的x是物理驱动器号。例如,为了读写用户的第一个物理驱动器的扇区,我们应该制定 "//./PHYSICALDRIVE0".

打开物理驱动器使我们能够直接访问硬盘的分区表。打开物理驱动器有潜在的危险,错误地写入设备可能会导致操作系统的文件系统无法访问磁盘的内容

串口

CreateFile (pszName 为 "COMx ").

并口

CreateFile (pszName 为 "LPTx ").

邮件槽服务器

CreateMailslot (pszName 为 "//./mailslot/mailslotname ").

邮件槽客户端

CreateFile (pszName 为 "//servername /mailslot/mailslotname ").

命名管道服务器

CreateNamedPipe (pszName 为 "//./pipe/pipename ").

命名管道客户端

CreateFile (pszName 为 "//servername /pipe/pipename ").

匿名管道

CreatePipe 用来打开服务器和客户端

套接字

socket , accept , 或AcceptEx .

控制台

CreateConsoleScreenBuffer GetStdHandle

                                                                  
    CreateFile函数当然可以用来创建和打开磁盘文件,它同样可以打开许多其它设备:
   
HANDLE CreateFile(
        PCTSTR pszName,//即表示设备的类型,也表示该类设备的某个实例;
        DWORD dwDesiredAccess,//指定我们想以何种方式来和设备进行数据传输;
        DWORD dwShareMode,//指定设备共享特权;
        PSECURITY_ATTRIBUTES psa,
        DWORD dwCreationDisposition,
        DWORD dwFlagsAndAttributes,
        HANDLE hFileTemplate);

    大多数以句柄为返回值的Windows函数在失败的时候会返回NULL。但是,CreateFile返回INVALID_HANDLE_VALUE。
2.使用文件设备
    在使用文件的时候,我们经常需要得到文件的大小。要达到这一目的,最简单的方法是调用GetFileSizeEx:
    BOOL GetFileSizeEx(
        HANDLE hFile,
        PLARGE_INTEGER pliFileSize);

    可以用来取得文件大小的另一个非常有用的函数是GetCompressedFileSize:
    DWORD GetCompressedFileSize(
        PCTSTR pszFileName,
        PDWORD pdwFileSizeHigh);

    这个函数返回的是文件的物理大小,而GetFileSizeEx返回的是文件的逻辑大小。
    ULARGE_INTEGER ulFileSize;
    ulFileSize.LowPart = GetCompressedFileSize(TEXT("SomeFile.dat"), &ulFileSize.HighPart);

    调用CreateFile会使系统创建一个文件内核对象来管理对文件的操作。在这个内核对象内部有一个文件指针,它是一个64位偏移量,表示应该在哪里执行下一次同步读取或写入操作。
    BYTE pb[10];
    DWORD dwNumBytes;
    HANDLE hFile = CreateFile(TEXT("MyFile.dat"), ...); // Pointer set to 0
    ReadFile(hFile, pb, 10, &dwNumBytes, NULL); // Reads bytes 0 - 9
    ReadFile(hFile, pb, 10, &dwNumBytes, NULL); // Reads bytes 10 - 19

    BOOL SetFilePointerEx(
        HANDLE hFile,
        LARGE_INTEGER liDistanceToMove,
        PLARGE_INTEGER pliNewFilePointer,
        DWORD dwMoveMethod);

3.执行同步设备I/O
    最方便和最常用的堆设备数据进行读/写的函数是ReadFile和WriteFile:
    BOOL ReadFile(
        HANDLE hFile, PVOID pvBuffer,
        DWORD nNumBytesToRead,
        PDWORD pdwNumBytes,
        OVERLAPPED* pOverlapped);

     BOOL WriteFile(
        HANDLE hFile,
        CONST VOID *pvBuffer,
        DWORD nNumBytesToWrite,
        PDWORD pdwNumBytes,
        OVERLAPPED* pOverlapped);

    执行同步I/O的时候,最后一个参数pOverlapped应该被设为NULL。
    如果我们想要强制系统将缓存数据写入到设备,将数据刷新至设备:
    BOOL FlushFileBuffers(HANDLE hFile);

    同步I/O的取消:
    BOOL CancelSynchronousIo(HANDLE hThread);

4.异步设备I/O基础
    与计算机执行的大多数其它操作相比,设备I/O是其中最慢、最不可预测的操作之一。但是,使用异步设备I/O使我们能够更好地使用资源并创建出更高效的应用程序。
    假 设一个线程向设备发出一个异步I/O请求。这个I/O请求被传给设备驱动程序,后者负责完成实际的I/O操作。当驱动程序在等待设备响应的时候,应用程序 的线程并没有因为要等待I/O请求完成而被挂起,线程会继续运行并执行其它有用的任务。到了某一时刻,设备驱动程序完成了对队列中的I/O请求的处理,这 时它必须通知应用程序数据已发送,数据已收到,或发生了错误。
    把异步I/O请求加入队列是设计高性能、可伸缩好的应用程序的本质所在。
    为了以异步的方式来访问设备,我们必须先调用CreateFile,并在dwFlagsAndAttributes参数中指定 FILE_FLAG_OVERLAPPED标志来打开设备。这个标志告诉系统我们想要以异步的方式来访问设备。为了将I/O请求加入设备驱动程序的队列 中,我们必须使用ReadFile 和WriteFile 函数。
    当我们调用这两个函数的任何一个时,函数会检查hFile参数标识的设备是否是用FILE_FLAG_OVERLAPPED标志打开的。如果打开设备时制 定了这个标志,那么函数会执行异步设备I/O。顺便提一下,当调用者两个函数来进行异步I/O的时候,我们可以(也通常会)传NULL给 pdwNumBytes参数。毕竟我们希望这两个函数在I/O请求完成之前就返回,因此这时就检查已经传输的字节数是没有意义的。
    在执行异步设备I/O的时候,我们必须在pOverlapped参数中传入一个已初始化的OVERLAPPED结构。“overlapped”在这里的意 思是执行I/O请求的时间与线程执行其它任务的事件是重叠的(overlapped)。下面是OVERLAPPED结构的定义:
   
typedef struct _OVERLAPPED {
    DWORD Internal; // [out] Error code
    DWORD InternalHigh; // [out] Number of bytes transferred
    DWORD Offset; // [in] Low 32-bit file offset
    DWORD OffsetHigh; // [in] High 32-bit file offset
    HANDLE hEvent; // [in] Event handle or data
  } OVERLAPPED, *LPOVERLAPPED;

    这个结构包含5个成员。其中的三个成员(即 Offset ,OffsetHigh ,hEvent )必须在调用ReadFile和WriteFile之前进行初始化,其它两个成员( Internal, InternalHigh )由驱动程序来设置,当I/O操作完成的时候我们可以检查它们的值。
    Offset ,OffsetHigh:
  
这 两个成员构成一个64位的偏移量,它们表示当访问文件的时候应该从哪里开始进行I/O操作。在执行异步I/O操作的时候,系统会忽略文件指针。为了避免在 对同一个对象进行多个异步调用的时候出现混淆,所有异步I/O请求必须在OVERLAPPED结构中指定起始偏移量。 
   
注意:非文件设备会忽略 Offset ,OffsetHigh —— 我们必须将这两个成员都初始化为0,否则I/O请求会失败,这是调用GetLastError会返回ERROR_INVALID_PARAMETER。
    hEvent:
   
在接收I/O完成通知的方法—使用I/O完成端口时会用到这个成员。当使用可提醒I/O通知函数时,许多开发人员会在hEvent中保存一个C++对象的地址。
    Internal:
    这个成员用来保存已处理的I/O请求的错误码。一旦发出一个异步I/O请求,设备驱动程序会立即将 Internal 设为 STATUS_PENDING ,表示没有错误,因为操作尚未开始。WinBase.h中定义的HasOverlappedIoCompleted 宏允许我们呢检查一个异步I/O操作是否已经完成。如果请求还处在等待状态,那么该宏会返回FALSE,如果I/O请求已经完成,那么该宏会返回TRUE。
    
#define HasOverlappedIoCompleted(pOverlapped) /
    ((pOverlapped)->Internal != STATUS_PENDING)

    InternalHigh:
   
当异步I/O请求完成的时候,这个成员用来保存已传输的字节数。

异步设备I/O的注意事项:
    在执行异步I/O的时候,我们应该意识到一些问题。
    首先,设备驱动程序不必以先入先出的方式来处理队列中的I/O请求。如果不按顺序来执行I/O请求能够提高性能,那么设备驱动程序一般都会这样做。例如, 为了降低磁头的移动和寻道时间,文件系统驱动程序可能会在I/O请求队列中寻找那些要访问的位置在物理硬盘撒谎那个相邻的请求。
    其次,如何用正确的方式来检查错误。当 我们试图将一个异步I/O请求添加到队列中的时候,设备驱动程序可能会选择以同步的方式来处理请求。当我们从文件中读取数据时,系统会检查我们想要的数据 是否已经在系统的缓存中,这时可能发生这种情况。如果数据已经在缓存中,那么系统不会讲我们的I/O请求添加到设备驱动程序的队列中,而会将高速缓存中的 数据复制到我们的缓存中,从而完成这个I/O操作。驱动程序总是会以同步的方式来执行某些操作,比如NTFS文件的压缩,增大文件的长度,或向文件追加信 息。
    如果请 求的I/O操作时以同步方式执行的,那么ReadFile和WriteFile会返回非零值。如果请求的I/O操作时以异步方式执行的,或者在调用 ReadFile或WriteFile的时候发生了错误,那么这两个函数返回FALSE,我们必须调用GetLastError来检查到底发生了什么。 如果返回的是ERROR_IO_PENDING,那么I/O请求已经被成功地加入了队列,会在晚些时候完成。如果返回的是ERROR_IO_PENDING以外的值,那么表示I/O请求无法被添加到设备驱动程序的队列中。
    第三个问题是在异步I/O请求完成之前,一定不能移动或是销毁在发生I/O请求时所使用的数据缓存和OVERLAPPED结构。当系统将I/O请求加入设 备驱动程序的队列中时,会将数据缓存的地址和OVERLAPPED结构的地址传给驱动程序。注意,传的只是地址而不是实际的数据块。这样做的原因是显而易 见的:内存复制是非常费时的,会浪费大量的CPU时间。

取消队列中的设备I/O请求:
    有时候,我们可能想要在设备驱动程序对一个已经加入队列的设备I/O请求进行处理之前将其取消。Windows提供了多种方式来达到这一目的:

  • 我们可以调用CancelIo来取消由给定句柄所标识的线程添加到队列中的所有I/O请求: 

        BOOL CancelIo(HANDLE hFile);

  • 我们可以关闭设备句柄,来取消已经添加到队列中的所有I/O请求,而不管它们是由哪个县城添加的;
  • 当线程终止的时候,系统会自动取消该线程发出的所有I/O请求,但如果请求被发往的设备句柄具有与之相关联的I/O完成端口,那么它们不在被取消之列;
  • 如果需要将发往给定文件句柄的一个指定的I/O请求取消,那么我们可以调用CancelIoEx:

         BOOL CancelIoEx(HANDLE hFile, LPOVERLAPPED pOverlapped);
       使用CancelIoEx,我们能够将调用线程之外的其它线程发出的待处理的I/O请求取消。如果pOverlapped为NULL,那          么CancelIoEx会将hFile指定的设备的所有待处理I/O请求都取消掉。
5.接收I/O请求完成通知
    Windows提供了4种不同的方法来接收I/O请求已经完成的通知。

表3:用来接收I/O完成通知的方法

技术

摘要

触发设备内核对象

当向一个设备同时发出多个I/O请求的时候,这种方法没什么用。它允许一个线程发出I/O请求,另一个线程对结果进行处理。

触发事件内核对象

这种方法允许我们向一个设备同时发出多个I/O请求。它允许一个线程发出I/O请求,另一个线程对结果进行处理。

使用可提醒I/O                  

这种方法允许我们向一个设备同时发出多个I/O请求。发出I/O请求的线程必须对结果进行处理。

使用I/O 完成端口

这种方法允许我们向一个设备同时发出多个I/O请求。它允许一个线程发出I/O请求,另一个线程对结果进行处理。这项技术具有高度的伸缩性和最佳的灵活性。


触发设备内核对象:
    一旦一个线程触发了一个异步I/O请求,该线程将会继续运行,以执行其它有用的任务。但即便如此,线程最终海华丝需要与I/O操作的完成状态进行同步。我 们会继续运行到线程代码中的一个点,在这个点上,除非设备数据已经被载入到缓存中,否则线程将无法执行后继操作。在Windows中设备内核对象可以用来 进行线程同步,因此对象既可能处于触发状态,也可能处于未触发状态。ReadFile和WriteFile函数在将I/O请求添加到队列之前,会先将设备 内核对象设为未触发状态,当设备驱动程序完成了请求之后,驱动程序会将设备内核对象设为触发状态。
    线程可以通过嗲用WaitForSingleObject或WaitForMultipleObjects来检查一个异步I/O请求是否已经完成。
    下面一个简单例子:
   
HANDLE hFile = CreateFile(..., FILE_FLAG_OVERLAPPED, ...); //指定以异步方式打开
    BYTE bBuffer[100];
    OVERLAPPED o = { 0 };
    o.Offset = 345;

    BOOL bReadDone = ReadFile(hFile, bBuffer, 100, NULL, &o); //
bReadDone 指定I/O请求是不是以同步方式打开
    DWORD dwError = GetLastError();

    if (!bReadDone && (dwError == ERROR_IO_PENDING)) { //异步方式打开
        // The I/O is being performed asynchronously; wait for it to complete
        WaitForSingleObject(hFile, INFINITE);
        bReadDone = TRUE;
    }
    if (bReadDone) {
           // o.Internal contains the I/O error
           // o.InternalHigh contains the number of bytes transferred
           // bBuffer contains the read data
    } else {
            // An error occurred; see dwError
    }

触发事件内核对象:
    上面描述的触发设备内核对象并不怎么用,因为它不能处理多个I/O请求。我们不能通过等待设备内核对象被触发的方式来对线程进行同步,这是因为一旦任何一个操作完成,该内核对象就会被触发。
    OVERLAPPED结构的最后一个成员hEvent用来标识一个事件内核对象。我们必须通过CreateEvent来创建这个事件对象。当一个异步 I/O请求完成的时候,设备驱动程序会检查OVERLAPPED结构的hEvent成员是否为NULL。如果hEVent不为NULL,那么驱动程序会调 用SetEvent来触发事件。驱动程序仍然会像以前那样,将设备对象设为触发状态。但是,如果我们使用事件来检查一个设备操作是否已经完成,那么我们就不应该等待设备对象被触发,我们应该等待的是事件对象。
    说明:向
BOOL SetFileCompletionNotificationModes(HANDLE hFile, UCHAR uFlags);传入文件对象句柄和在I/O操作完成时的正常行为进行何种方式的定制。如果向参数uFlags传入 FILE_SKIP_SET_EVENT_ON_HANDLE,那么当文件操作完成时不会触发文件句柄。这可以略微提高点性能。
    如 果想要同时执行多个异步设备I/O请求,我们必须为每个请求创建不同的事件对象,并初始化每个请求的OVERLAPPED结构中的hEvent成员,然后 再调用ReadFile或WriteFile。当运行到代码中的那个点,必须与I/O请求的完成状态进行同步的时候,我们只需要调用 WaitForMultipleObjects,并传入与每个待处理I/O请求的OVERLAPPED结构相关联的事件句柄。
可提醒I/O:
    当系统创建一个线程的时候,会同时创建一个与线程相关联的队列。这个队列被称为异步过程调用(asynchronous procedure call,APC)队列。当发出一个I/O请求的时候,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项。 为了将I/O完成通知添加到线程的APC队列中,我们应该调用ReadFileEx和WriteFileEx函数:
   
BOOL ReadFileEx(
        HANDLE hFile,
        PVOID pvBuffer,
        DWORD nNumBytesToRead,
        OVERLAPPED* pOverlapped,
        LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);

     BOOL WriteFileEx(
        HANDLE hFile,
        CONST VOID *pvBuffer,
        DWORD nNumBytesToWrite,
        OVERLAPPED* pOverlapped,
        LPOVERLAPPED_COMPLETION_ROUTINE pfnCompletionRoutine);

    首先,*Ex函数没有一个指向DWORD的指针作为参数来保存已传输的字节数,该信息只有回调函数才能得到。其次,*Ex函数要求我们传入一个回调函数的地址,这个回调函数被称为完成函数(completion routine)。
    
VOID WINAPI CompletionRoutine(
        DWORD dwError,
        DWORD dwNumBytes,
        OVERLAPPED* po);

    当 我们用ReadFileEx和WriteFileEx发出一个I/O请求的时候,这两个函数会将回调函数的地址传给设备驱动程序。当设备驱动程序完成I /O请求的时候,会在发出I/O请求的线程的APC队列中添加一项。该项包含了完成函数的地址,以及在发出I/O请求时所使用的OVERLAPPED结构 的地址。
    当线程处于可提醒状态的时候,系统会检查它的APC队列,对队列中的每一项,系统会调用完成函数,并传入I/O错误码,已传输的字节数,以及OVERLAPPED结构的地址。Windows提供了6个函数,可以将线程置为可提醒状态:
    
DWORD SleepEx(
        DWORD dwMilliseconds,
        BOOL bAlertable);

    DWORD WaitForSingleObjectEx(
        HANDLE hObject,
        DWORD dwMilliseconds,
        BOOL bAlertable);

    DWORD WaitForMultipleObjectsEx(
        DWORD cObjects,
        CONST HANDLE* phObjects,
        BOOL bWaitAll,
        DWORD dwMilliseconds,
        BOOL bAlertable);

    BOOL SignalObjectAndWait(
        HANDLE hObjectToSignal,
        HANDLE hObjectToWaitOn,
        DWORD dwMilliseconds,
        BOOL bAlertable);

    BOOL GetQueuedCompletionStatusEx(
        HANDLE hCompPort,
        LPOVERLAPPED_ENTRY pCompPortEntries,
        ULONG ulCount,
        PULONG pulNumEntriesRemoved,
        DWORD dwMilliseconds,
        BOOL bAlertable);

    DWORD MsgWaitForMultipleObjectsEx(
        DWORD nCount,
        CONST HANDLE* pHandles,
        DWORD dwMilliseconds,
        DWORD dwWakeMask,
        DWORD dwFlags);

    当我们调用以上6个函数之一并将线程置为可提醒状态时,系统会首先检查线程的APC队列。如果队列中至少有一项,那么系统不会让线程进入睡眠状态。
I/O完成端口:
    I/O 完成端口背后的理论是并发运行的线程的数量必须有一个上限——也就是说,同时发出的500个客户请求不应该允许出现500个可运行的线程。如果能在应用程 序初始化的时候创建一个线程池,并让线程池中的线程在应用程序运行期间一直保持可用状态,那么服务应用程序的性能就能得到提高。I/O完成端口的设计初衷 就是与线程池配合使用。
    I/O完成端口可能是最复杂的内核对象了。为了创建一个I/O端口,我们应该调用CreateIoCompletionPort:
    
HANDLE CreateIoCompletionPort(
        HANDLE hFile,
        HANDLE hExistingCompletionPort,
        ULONG_PTR CompletionKey,
        DWORD dwNumberOfConcurrentThreads);//I/O完成端口在同一时间最多能有多少线程处于可运行状态。0:默认CPU数量

    这个函数执行两个不同的任务:它不仅会创建一个I/O完成端口,而且会将一个设备与一个I/O完成端口关联起来。若给 CreateIoCompletionPort的前三个参数分别传入INVALID_HANDLE_VALUE,NULL,0只创建I/O完成端口。
    线程通过调用GetQueuedCompletionStatus来将自己切换到睡眠状态,来等待设备I/O请求完成并进入完成端口。
    
BOOL GetQueuedCompletionStatus(
        HANDLE hCompletionPort,
        PDWORD pdwNumberOfBytesTransferred,
        PULONG_PTR pCompletionKey,
        OVERLAPPED** ppOverlapped,
        DWORD dwMilliseconds);

    
GetQueuedCompletionStatus的任务基本上就是将调用线程切换到睡眠状态,直到指定的完成端口的I/O完成队列中出现一项,或者等待的事件已经超出了(在 dwMilliseconds参数中 )指定的时间为止。

设备列表
每条记录包含:
hDevice
dwCompletionKey
当满足以下条件时,会在列表中添加新项:
  • CreateIoCompletionPort被调用
当满足以下条件时,会将列表中的项删除:
  • 设备句柄被关闭
I/O完成队列(先入先出)
每条记录包含:
dwBytesTransferred
dwCompletionKey
pOverlapped
dwError
当满足以下条件时,会在列表中添加新项:
  • I/O请求完成
  • PostQueuedCompletionStatus被调用
当满足以下条件时,会将列表中的项删除:
  • 完成端口从等待线程队列中删除一项
等待线程队列(后入先出)
每条记录包含:
dwThreadId
当满足以下条件时,会在列表中添加新项:
  • 线程调用GetQueuedCompletionStatus
当满足以下条件时,会将列表中的项删除:
  • I/O完成队列不为空,而且正在运行的线程数小于最大并发线程数(GetQueuedCompletionStatus会先从I/O完成队列中删除对应的项,接着将dwThreadId转移到已释放线程列表,最后函数返回)
已释放线程列表
每条记录包含:
dwThreadId
当满足以下条件时,会在列表中添加新项:
  • 完成端口在等待线程队列中唤醒一个线程
  • 已暂停的线程被唤醒
当满足以下条件时,会将列表中的项删除:
  • 线程再次调用GetQueuedCompletionStatus(dwThreadId再次回到等待线程队列)
  • 线程调用一个函数将自己挂起(dwThreadId转移到已暂停线程列表中)
已暂停线程队列
每条记录包含:
dwThreadId
当满足以下条件时,会在列表中添加新项:
  • 已释放的线程调用一个函数将自己挂起
当满足以下条件时,会将列表中的项删除:
  • 已挂起的线程被唤醒(dwThreadId回到已释放线程队列)

图1:I/O完成端口的内部运作




你可能感兴趣的:(Windows核心编程:同步设备I/O与异步设备I/O)