异步I/0基础
2014-01-13 20:37
295 查看
一、异步设备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函数:
[cpp]
view plaincopyprint?
BOOL WINAPI ReadFile(
_In_ HANDLE hFile,
_Out_ LPVOID lpBuffer,
_In_ DWORD nNumberOfBytesToRead,
_Out_opt_ LPDWORD lpNumberOfBytesRead,
_Inout_opt_ LPOVERLAPPED lpOverlapped
);
[cpp]
view plaincopyprint?
BOOL WINAPI WriteFile(
_In_ HANDLE hFile,
_In_ LPCVOID lpBuffer,
_In_ DWORD nNumberOfBytesToWrite,
_Out_opt_ LPDWORD lpNumberOfBytesWritten,
_Inout_opt_ LPOVERLAPPED lpOverlapped
);
当调用这两个函数中任何一个时,函数会检查hFile参数标识的设备是否用FILE_FLAG_OVERLAPPED标志打开的。如果打开设备时指定了这个标志,那么函数会执行异步设备I/O。顺便提一下,当调用者两个函数来进行异步I/O的时候,可以(通常也会)穿NULL给pdwNumBytes参数。比较我们希望这两个函数在I/O请求完成之前就返回,因此这时就检查已经传输的字节数是没有意义的。
OVERLAPPED结构
意思是执行I/O请求的时间与线程执行其他任务的时间是重叠的(overlapped)。下面是OVERLAPPED结构(MSDN给出最新版,与本书中讲解有点点不同)定义:
[cpp]
view plaincopyprint?
typedef struct _OVERLAPPED {
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
union {
struct {
DWORD Offset;
DWORD OffsetHigh;
};
PVOID Pointer;
};
HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;
参数解释:
Offset和OffsetHigh成员,构成一个64位的偏移量,它们表示当访问文件的时候应该从哪里开始进行I/O操作。每个文件内核对象都有一个与之相关联的文件指针。在执行异步I/O的时候,系统会忽略文件指针。这是为了避免在对同一个对象进行多个异步调用的时候出现混淆,所有异步I/O请求必须在OVERLAPPED结构中指定起始偏移量。非文件设备会忽略这两个参数,必须将其初始化为0,否则I/O请求会失败。
异步设备I/O的注意事项
(1)设备驱动程序不必以先入先出的方式来处理队列中的I/O请求。
如果不按顺序来执行I/O请求能够提高性能,那么设备驱动程序一般都会这么做。例如,为了减低磁头的移动和寻道时间,文件系统驱动程序可能会在I/O请求队列中寻找那些要访问的位置在物理硬盘上相邻的请求。
(2)如何用正确的方式来检查错误。
例如,当我们试图将一个异步I/O请求添加到队列中的时候,设备驱动程序可能会选择以同步的方式来处理请求。当我们从文件中读取数据的时候,系统会检查我们想要的数据是否已经在系统的缓存中,这时就可能发生这种情况。如果数据已经在缓存中,那么系统不会将我们的I/O请求添加到设备驱动程序的队列中,而会将高速缓存中的数据复制到我们的缓存中,从而完成这个I/O操作。驱动程序总是会以同步的方式来执行某些操作,比如NTFS文件的压缩,增大文件的长度。如果请求的I/O操作是以同步方式执行的,那么ReadFile和WriteFile会返回非零值。如果请求的I/O操作是以异步方式执行的,或者在调用ReadFile或WriteFile的时候发生了错误,那么这两个函数会返回FALSE,必须调用GetLastError来检查到底发生了什么。如果GetLastError返回的是ERROR_IO_PENDING,那么I/O请求已经被成功添加入队列,会在晚些时候完成。
(3)在异步I/O请求完成之前,一定不能移动或是销毁在发出I/O请求时所使用的数据缓存和OVERLAPPED结构。
系统将I/O请求加入设备驱动程序的队列中时,会讲数据缓存的地址和OVERLAPPED结构的地址传给驱动程序。注意,传的只是地址而不是实际的数据块。因为,内存复制是非常耗时的,会浪费大量的CPU时间。
取消队列中设备I/O请求的方式
(1)调用CancelIo来取消由给定句柄所标识的线程添加到队列中的所有I/O请求(除非该句柄具有与之相关联的I/O完成端口)。
(2)可以关闭设备句柄,来取消该线程发出的所有I/O请求,而不管它们是由哪儿线程添加的。
(3)当线程终止的时候,系统会自动取消该线程发出的所有I/O请求,但如果请求被发往的设备句柄具有与之相关联的I/O完成端口,那么它们就不在被取消之列。
(4)需要将发往给定文件句柄的一个指定的I/O请求取消,那么可以调用CancelIoEx。
二、接收I/O请求完成通知
Windows提供了4种不同的方法来接收I/O请求已经完成的通知。
用来接收I/O完成通知的方法
触发设备内核对象
在Windows中,设备内核对象可以用来进行线程同步,因此对象既可能处于触发状态,也可能处于未触发状态。ReadFile和WriteFile函数在将I/O请求添加到队列之前,会先将设备内核对象设为未触发状态。当设备驱动程序完成了请求之后,驱动程序会讲设备内核对象设为触发状态。
线程可以通过调用WaitForSingleObject或WaitForMultipleObjects来检查一个异步I/O请求是否已经完成。
实例代码:
[cpp]
view plaincopyprint?
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。如果不为NULL,那么驱动程序就会调用SetEvent来触发事件。驱动程序仍然会像从前那样,将设备对象设为触发状态。这样,我们就可以通过等待事件对象触发来判断。
为了略微提高性能,可以告诉Windows在操作完成时候不要触发文件对象,调用SetFileCompletionNotificationModes函数。
如果想要同时执行多个异步设备I/O请求,必须为每个请求创建不同的事件对象,并初始化给每个请求的OVERLAPPED结构中的hEvent成员,然后再调用ReadFile或者WriteFile,当运行到代码中的那个点的时候,调用WaitForMultipleObjects。
可提醒I/O
可提醒I/O非常糟糕,应该避免使用。但是,为了是可提醒I/O能够正常工作,MS在操作系统中添加了一些基础设施,而这些基础设施非常有用,也很有价值。
当系统创建一个线程的时候,会同时创建一个与线程相关联的队列。这个队列被称为异步过程调用(asynchronous procedure call,APC)队列。当发出一个I/O请求的时候,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项。为了将I/O完成通知添加到线程的APC队列中,应该调用ReadFileEx和WriteFileEx函数:
[cpp]
view plaincopyprint?
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);
[cpp]
view plaincopyprint?
VOID WINAPI CompletionRoutine(
DWORD dwError,
DWORD dwNumBytes,
OVERLAPPED* po);
当一个可提醒I/O完成时,设备驱动程序不会试图去触发一个事件对象。事实上,设备就没有用到OVERLAPPED结构的hEvent成员。因此,如果需要,可以将hEvent据为己用。
当现场处于可提醒状态的时候,系统会检查它的APC队列,对队列中的每一项,系统会调用完成函数,并传入I/O错误码,已传输的字节数,以及OVERLAPPED结构的地址。(错误码和已传输的字节数可以通过OVERLAPPED结构成员得到)
APC队列是由系统内部维护的。队列中的I/O请求顺序和发出I/O请求的顺序没有直接关系。为了对线程APC队列中的项进行处理,线程必须将自己置为可提醒状态。这只不过意味着我们的线程在执行的过程中已经到达了一个点,在这个点上它能够处理被中断的情况。Windows提供了6个函数可以将线程置为可提醒状态:SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx、SignalObjectAndWait、GetQueuedCompletionStatusEx和MsgWaitForMultipleObjectsEx:
[cpp]
view plaincopyprint?
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);
有两个糟糕的问题:回调函数和线程问题。回调函数会使得代码实现变得更加复杂,由于这些回调函数一般来说并没有足够的与某个问题有关的上下文信息,因此最终不得不将大量的信息放在全局变量中。线程问题是大问题:发出I/O请求的线程必须同时对完成通知进行处理。如果一个线程发出多个请求,那么即使其他线程完全处于空闲状态,该线程也必须对每个请求的完成通知做出响应。由于不存在负载均衡机制,因此应用程序的伸缩性不会太好。
QueueUserAPC允许我们手动地将一项添加到APC队列中。可以使用其进行非常高效的线程间通信,甚至能跨越进程的界限。但遗憾的是,我们只能传递一个值。也可以用来强制让线程退出等待状态(干净退出)。
三、I/O完成端口
在历史上,架构一个服务应用程序的模型有两种:
(1)串行模型。一个线程等待一个客户发出请求,当请求到达的时候被唤醒并对客户请求进行处理。
(2)并发模型。一个线程等待一个客户请求,并创建一个新的线程来处理请求。当新线程正在处理客户请求的时候,原来的线程会进入下一次循环并等待另一个客户的请求。当处理客户请求的线程完成整个处理过程的时候,该线程就会终止。
Windows中便使用了并发模型的服务应用程序,但是发现性能并不如预期的高。开发组意识到同时处理多个客户请求意味着系统中有许多线程并发执行,由于所有这些线程都处于可运行状态,因此Windows内核在各个可运行的线程之间进行上下文切换花费了太多时间,以至于各个线程都没有多少CPU时间来完成他们的任务了。为了解决这个问题,便出现了I/O完成端口。
1、创建I/O完成端口
I/O完成端口背后的理论是并发运行的线程的数量必须有一个上限。可运行的线程的数量一般约等于CPU的数量,一旦可运行的线程数量大于可用的CPU的数量,系统必须花时间来执行线程的上下文切换,这会浪费宝贵的CPU时间---这也是并发模型的一个潜在缺点。
并发模型的另一个缺点是需要为每个客户请求创建一个新的线程。虽然和重建一个新的进程相比,开销小得多,但是仍然不能算小。如果能在应用程序初始化的时候创建一个线程池,并让线程池中的线程在程序运行期间一直保持可用状态,那么服务应用程序的性能就能得到提高。I/O完成端口的设计初衷就是雨线程池配合使用。
创建一个I/O完成端口的函数是:
[cpp]
view plaincopyprint?
HANDLE CreateIoCompletionPort(
HANDLE FileHandle, // handle to file
HANDLE ExitingCompletionPort,
// handle to I/O completion port
ULONG_PTR CompletionKey, // completion key
DWORD NumberOfConcurrentThreads
// number of threads to execute concurrently
);
这个函数比较复杂,我们可以分两步来分析:
(1)只创建I/O完成端口:
[cpp]
view plaincopyprint?
HANDLE CreateNewIoCompletionPort(DWORD dwNumberOfConcurrentThreads){
return (CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0,
dwNumberOfConcurrentThreads));
}
如果给参数dwNumberOfConcurrentThreads传值0,那么I/O完成端口会使用默认值,允许并发执行的线程数量等于主机的CPU数量。
(2)将一个设备和一个I/O完成端口关联起来
创建一个I/O完成端口的时候,系统内核实际上会创建5个不同的数据结构。如图所示:
第一个数据结构是设备列表,表示与该端口关联的一个或多个设备。将设备和I/O完成端口关联起来调用函数:
[cpp]
view plaincopyprint?
BOOL AssociateDeviceWithCompletionPort(
HANDLE hCompPort, HANDLE hDevice,
DWORD dwCompKey) {
HANDLE h = CreateIoCompletionPort(hDevice, hCompPort, dwCompKey, 0);
return(h == hCompPort);
}
AssociateDeviceWithCompletionPort在已存在的完成端口的设备列表添加一个。调用时需要将已有的完成端口的句柄(由先前的CreateNewCompletionPort调用返回),设备句柄(可以是一个文件,一个SOCKET端口,邮件槽,管道等等),和完成键值(有意义的值;操作系统不会关心究竟是什么东西)传入该函数。每次将设备和端口相关联,系统就会将信息添加到完成端口的设备列表中。
第二个数据结构是I/O完成队列。
当设备的异步I/O请求完成时,系统检查设备是否与完成端口相关联,如果是,系统就将已完成的I/O请求添加到完成端口的I/O完成队列中。队列中的每个包含了传输的字节数,在设备与端口关联时设置的完成键值,I/O请求的OVERLAPPED结构指针,以及错误代码。后面将讨论如何将从队列中删除。
一个重要的问题是:线程池中应该有多少个线程?
一个标准的经验法则是:取主机的CPU数量乘以2.在后面将会详细讨论这一问题。
线程池中的线程应执行同一个函数。典型地,该线程函数进行初始化工作,然后进行循环,直到服务进程收到停止的指令。在循环内,线程进入休眠状态,等待完成端口的设备I/O请求完成。调用GetQueeudCompletionStatus可以达到这个目的:
[cpp]
view plaincopyprint?
BOOL GetQueuedCompletionStatus(
HANDLE hCompPort,
PDWORD pdwNumBytes,
PULONG_PTR CompKey,
OVERLAPPED** ppOverlapped,
DWORD dwMilliseconds);
第三个与I/O完成端口关联的数据结构是线程等待队列。
线程池内每个调用GetQueuedCompletionStatus的线程的ID被放到正在线程等待队列中,以使I/O完成端口内核对象能够知道当前哪些线程正在等待处理完成的I/O请求。当在该完成端口的I/O完成队列中出现新的项时,完成端口从正在线程等待队列中挑出一个线程唤醒。
正如所期望的那样,I/O完成队列中的项是以先进先出(FIFO)的方式删除的。但是,出乎意料的是,调用GetQueuedCompletionStatus的线程却是以后进先出(LIFO)的方式被唤醒。这么做的原因是为了提高性能。比如说,在线程等待队列中有四个线程,当已完成的I/O项出现时,最后一个调用GetQueuedCompletionStatus的线程将被唤醒来处理该项。这个最后的线程在处理完成后,又调用GetQueuedCompletionStatus重新进入线程等待队列。现在如果又出现了一个I/O完成项,同一线程又会被唤醒来处理新项。
当I/O请求的完成慢到单个线程都能够处理时,系统将一直唤醒同一线程进行处理,其他三个线程持续休眠。通过使用LIFO算法,没有被调度的线程可以将它们的内存资源(如堆栈空间)对换到磁盘并从进程的缓冲区内清空。这意味着即使众多线程在完成端口上等待也并非坏事。如果有几个线程在等待,但只有很少的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函数:
[cpp]
view plaincopyprint?
BOOL WINAPI ReadFile(
_In_ HANDLE hFile,
_Out_ LPVOID lpBuffer,
_In_ DWORD nNumberOfBytesToRead,
_Out_opt_ LPDWORD lpNumberOfBytesRead,
_Inout_opt_ LPOVERLAPPED lpOverlapped
);
BOOL WINAPI ReadFile( _In_ HANDLE hFile, _Out_ LPVOID lpBuffer, _In_ DWORD nNumberOfBytesToRead, _Out_opt_ LPDWORD lpNumberOfBytesRead, _Inout_opt_ LPOVERLAPPED lpOverlapped );
[cpp]
view plaincopyprint?
BOOL WINAPI WriteFile(
_In_ HANDLE hFile,
_In_ LPCVOID lpBuffer,
_In_ DWORD nNumberOfBytesToWrite,
_Out_opt_ LPDWORD lpNumberOfBytesWritten,
_Inout_opt_ LPOVERLAPPED lpOverlapped
);
BOOL WINAPI WriteFile( _In_ HANDLE hFile, _In_ LPCVOID lpBuffer, _In_ DWORD nNumberOfBytesToWrite, _Out_opt_ LPDWORD lpNumberOfBytesWritten, _Inout_opt_ LPOVERLAPPED lpOverlapped );
当调用这两个函数中任何一个时,函数会检查hFile参数标识的设备是否用FILE_FLAG_OVERLAPPED标志打开的。如果打开设备时指定了这个标志,那么函数会执行异步设备I/O。顺便提一下,当调用者两个函数来进行异步I/O的时候,可以(通常也会)穿NULL给pdwNumBytes参数。比较我们希望这两个函数在I/O请求完成之前就返回,因此这时就检查已经传输的字节数是没有意义的。
OVERLAPPED结构
意思是执行I/O请求的时间与线程执行其他任务的时间是重叠的(overlapped)。下面是OVERLAPPED结构(MSDN给出最新版,与本书中讲解有点点不同)定义:
[cpp]
view plaincopyprint?
typedef struct _OVERLAPPED {
ULONG_PTR Internal;
ULONG_PTR InternalHigh;
union {
struct {
DWORD Offset;
DWORD OffsetHigh;
};
PVOID Pointer;
};
HANDLE hEvent;
} OVERLAPPED, *LPOVERLAPPED;
typedef struct _OVERLAPPED { ULONG_PTR Internal; ULONG_PTR InternalHigh; union { struct { DWORD Offset; DWORD OffsetHigh; }; PVOID Pointer; }; HANDLE hEvent; } OVERLAPPED, *LPOVERLAPPED;
参数解释:
Offset和OffsetHigh成员,构成一个64位的偏移量,它们表示当访问文件的时候应该从哪里开始进行I/O操作。每个文件内核对象都有一个与之相关联的文件指针。在执行异步I/O的时候,系统会忽略文件指针。这是为了避免在对同一个对象进行多个异步调用的时候出现混淆,所有异步I/O请求必须在OVERLAPPED结构中指定起始偏移量。非文件设备会忽略这两个参数,必须将其初始化为0,否则I/O请求会失败。
异步设备I/O的注意事项
(1)设备驱动程序不必以先入先出的方式来处理队列中的I/O请求。
如果不按顺序来执行I/O请求能够提高性能,那么设备驱动程序一般都会这么做。例如,为了减低磁头的移动和寻道时间,文件系统驱动程序可能会在I/O请求队列中寻找那些要访问的位置在物理硬盘上相邻的请求。
(2)如何用正确的方式来检查错误。
例如,当我们试图将一个异步I/O请求添加到队列中的时候,设备驱动程序可能会选择以同步的方式来处理请求。当我们从文件中读取数据的时候,系统会检查我们想要的数据是否已经在系统的缓存中,这时就可能发生这种情况。如果数据已经在缓存中,那么系统不会将我们的I/O请求添加到设备驱动程序的队列中,而会将高速缓存中的数据复制到我们的缓存中,从而完成这个I/O操作。驱动程序总是会以同步的方式来执行某些操作,比如NTFS文件的压缩,增大文件的长度。如果请求的I/O操作是以同步方式执行的,那么ReadFile和WriteFile会返回非零值。如果请求的I/O操作是以异步方式执行的,或者在调用ReadFile或WriteFile的时候发生了错误,那么这两个函数会返回FALSE,必须调用GetLastError来检查到底发生了什么。如果GetLastError返回的是ERROR_IO_PENDING,那么I/O请求已经被成功添加入队列,会在晚些时候完成。
(3)在异步I/O请求完成之前,一定不能移动或是销毁在发出I/O请求时所使用的数据缓存和OVERLAPPED结构。
系统将I/O请求加入设备驱动程序的队列中时,会讲数据缓存的地址和OVERLAPPED结构的地址传给驱动程序。注意,传的只是地址而不是实际的数据块。因为,内存复制是非常耗时的,会浪费大量的CPU时间。
取消队列中设备I/O请求的方式
(1)调用CancelIo来取消由给定句柄所标识的线程添加到队列中的所有I/O请求(除非该句柄具有与之相关联的I/O完成端口)。
(2)可以关闭设备句柄,来取消该线程发出的所有I/O请求,而不管它们是由哪儿线程添加的。
(3)当线程终止的时候,系统会自动取消该线程发出的所有I/O请求,但如果请求被发往的设备句柄具有与之相关联的I/O完成端口,那么它们就不在被取消之列。
(4)需要将发往给定文件句柄的一个指定的I/O请求取消,那么可以调用CancelIoEx。
二、接收I/O请求完成通知
Windows提供了4种不同的方法来接收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请求是否已经完成。
实例代码:
[cpp]
view plaincopyprint?
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
}
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。如果不为NULL,那么驱动程序就会调用SetEvent来触发事件。驱动程序仍然会像从前那样,将设备对象设为触发状态。这样,我们就可以通过等待事件对象触发来判断。
为了略微提高性能,可以告诉Windows在操作完成时候不要触发文件对象,调用SetFileCompletionNotificationModes函数。
如果想要同时执行多个异步设备I/O请求,必须为每个请求创建不同的事件对象,并初始化给每个请求的OVERLAPPED结构中的hEvent成员,然后再调用ReadFile或者WriteFile,当运行到代码中的那个点的时候,调用WaitForMultipleObjects。
可提醒I/O
可提醒I/O非常糟糕,应该避免使用。但是,为了是可提醒I/O能够正常工作,MS在操作系统中添加了一些基础设施,而这些基础设施非常有用,也很有价值。
当系统创建一个线程的时候,会同时创建一个与线程相关联的队列。这个队列被称为异步过程调用(asynchronous procedure call,APC)队列。当发出一个I/O请求的时候,我们可以告诉设备驱动程序在调用线程的APC队列中添加一项。为了将I/O完成通知添加到线程的APC队列中,应该调用ReadFileEx和WriteFileEx函数:
[cpp]
view plaincopyprint?
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);
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函数和普通Read或Write函数不同之处有两方面:一是没有指向DWORD的指针作为参数来保存已传输的字节数,该信息只有回调函数才能得到。其次,Ex函数要求传入一个回调函数的地址,这个回调函数被称为完成函数(completion routine):
[cpp]
view plaincopyprint?
VOID WINAPI CompletionRoutine(
DWORD dwError,
DWORD dwNumBytes,
OVERLAPPED* po);
VOID WINAPI CompletionRoutine( DWORD dwError, DWORD dwNumBytes, OVERLAPPED* po);当用Ex函数发出一个I/O请求的时候,该函数会将回调函数的地址传给设备驱动程序。当设备驱动程序完成I/O请求的时候,会在发出I/O请求的线程的APC队列中添加一项。该包含了完成函数的地址,以及在发出I/O请求时所使用的OVERLAPPED结构的地址。
当一个可提醒I/O完成时,设备驱动程序不会试图去触发一个事件对象。事实上,设备就没有用到OVERLAPPED结构的hEvent成员。因此,如果需要,可以将hEvent据为己用。
当现场处于可提醒状态的时候,系统会检查它的APC队列,对队列中的每一项,系统会调用完成函数,并传入I/O错误码,已传输的字节数,以及OVERLAPPED结构的地址。(错误码和已传输的字节数可以通过OVERLAPPED结构成员得到)
APC队列是由系统内部维护的。队列中的I/O请求顺序和发出I/O请求的顺序没有直接关系。为了对线程APC队列中的项进行处理,线程必须将自己置为可提醒状态。这只不过意味着我们的线程在执行的过程中已经到达了一个点,在这个点上它能够处理被中断的情况。Windows提供了6个函数可以将线程置为可提醒状态:SleepEx、WaitForSingleObjectEx、WaitForMultipleObjectsEx、SignalObjectAndWait、GetQueuedCompletionStatusEx和MsgWaitForMultipleObjectsEx:
[cpp]
view plaincopyprint?
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);
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队列。如果队列中至少有一项,那么系统不会让线程进入睡眠状态。系统会将APC队列中的那一项去除,让线程调用回调函数,并传入数据。当回调函数返回时,系统会检查APC队列中是否还有其他的项,如果还有,会继续处理。如果没有,对可提醒函数的调用会返回。(调用这些函数的时候APC队列中至少有一项,线程就不会进入睡眠状态。)
有两个糟糕的问题:回调函数和线程问题。回调函数会使得代码实现变得更加复杂,由于这些回调函数一般来说并没有足够的与某个问题有关的上下文信息,因此最终不得不将大量的信息放在全局变量中。线程问题是大问题:发出I/O请求的线程必须同时对完成通知进行处理。如果一个线程发出多个请求,那么即使其他线程完全处于空闲状态,该线程也必须对每个请求的完成通知做出响应。由于不存在负载均衡机制,因此应用程序的伸缩性不会太好。
QueueUserAPC允许我们手动地将一项添加到APC队列中。可以使用其进行非常高效的线程间通信,甚至能跨越进程的界限。但遗憾的是,我们只能传递一个值。也可以用来强制让线程退出等待状态(干净退出)。
三、I/O完成端口
在历史上,架构一个服务应用程序的模型有两种:
(1)串行模型。一个线程等待一个客户发出请求,当请求到达的时候被唤醒并对客户请求进行处理。
(2)并发模型。一个线程等待一个客户请求,并创建一个新的线程来处理请求。当新线程正在处理客户请求的时候,原来的线程会进入下一次循环并等待另一个客户的请求。当处理客户请求的线程完成整个处理过程的时候,该线程就会终止。
Windows中便使用了并发模型的服务应用程序,但是发现性能并不如预期的高。开发组意识到同时处理多个客户请求意味着系统中有许多线程并发执行,由于所有这些线程都处于可运行状态,因此Windows内核在各个可运行的线程之间进行上下文切换花费了太多时间,以至于各个线程都没有多少CPU时间来完成他们的任务了。为了解决这个问题,便出现了I/O完成端口。
1、创建I/O完成端口
I/O完成端口背后的理论是并发运行的线程的数量必须有一个上限。可运行的线程的数量一般约等于CPU的数量,一旦可运行的线程数量大于可用的CPU的数量,系统必须花时间来执行线程的上下文切换,这会浪费宝贵的CPU时间---这也是并发模型的一个潜在缺点。
并发模型的另一个缺点是需要为每个客户请求创建一个新的线程。虽然和重建一个新的进程相比,开销小得多,但是仍然不能算小。如果能在应用程序初始化的时候创建一个线程池,并让线程池中的线程在程序运行期间一直保持可用状态,那么服务应用程序的性能就能得到提高。I/O完成端口的设计初衷就是雨线程池配合使用。
创建一个I/O完成端口的函数是:
[cpp]
view plaincopyprint?
HANDLE CreateIoCompletionPort(
HANDLE FileHandle, // handle to file
HANDLE ExitingCompletionPort,
// handle to I/O completion port
ULONG_PTR CompletionKey, // completion key
DWORD NumberOfConcurrentThreads
// number of threads to execute concurrently
);
HANDLE CreateIoCompletionPort( HANDLE FileHandle, // handle to file HANDLE ExitingCompletionPort, // handle to I/O completion port ULONG_PTR CompletionKey, // completion key DWORD NumberOfConcurrentThreads // number of threads to execute concurrently );这个函数执行两个任务:I/O完成端口、将一个设备与一个I/O完成端口关联起来。
这个函数比较复杂,我们可以分两步来分析:
(1)只创建I/O完成端口:
[cpp]
view plaincopyprint?
HANDLE CreateNewIoCompletionPort(DWORD dwNumberOfConcurrentThreads){
return (CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0,
dwNumberOfConcurrentThreads));
}
HANDLE CreateNewIoCompletionPort(DWORD dwNumberOfConcurrentThreads){ return (CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, dwNumberOfConcurrentThreads)); }可以看出,我们调用了前面的函数,但是前三个参数使用的是默认值,因为只有当我们需要将一个设备和一个I/O完成端口关联在一起的时候才会用到前三个参数。
如果给参数dwNumberOfConcurrentThreads传值0,那么I/O完成端口会使用默认值,允许并发执行的线程数量等于主机的CPU数量。
(2)将一个设备和一个I/O完成端口关联起来
创建一个I/O完成端口的时候,系统内核实际上会创建5个不同的数据结构。如图所示:
第一个数据结构是设备列表,表示与该端口关联的一个或多个设备。将设备和I/O完成端口关联起来调用函数:
[cpp]
view plaincopyprint?
BOOL AssociateDeviceWithCompletionPort(
HANDLE hCompPort, HANDLE hDevice,
DWORD dwCompKey) {
HANDLE h = CreateIoCompletionPort(hDevice, hCompPort, dwCompKey, 0);
return(h == hCompPort);
}
BOOL AssociateDeviceWithCompletionPort( HANDLE hCompPort, HANDLE hDevice, DWORD dwCompKey) { HANDLE h = CreateIoCompletionPort(hDevice, hCompPort, dwCompKey, 0); return(h == hCompPort); }
AssociateDeviceWithCompletionPort在已存在的完成端口的设备列表添加一个。调用时需要将已有的完成端口的句柄(由先前的CreateNewCompletionPort调用返回),设备句柄(可以是一个文件,一个SOCKET端口,邮件槽,管道等等),和完成键值(有意义的值;操作系统不会关心究竟是什么东西)传入该函数。每次将设备和端口相关联,系统就会将信息添加到完成端口的设备列表中。
第二个数据结构是I/O完成队列。
当设备的异步I/O请求完成时,系统检查设备是否与完成端口相关联,如果是,系统就将已完成的I/O请求添加到完成端口的I/O完成队列中。队列中的每个包含了传输的字节数,在设备与端口关联时设置的完成键值,I/O请求的OVERLAPPED结构指针,以及错误代码。后面将讨论如何将从队列中删除。
一个重要的问题是:线程池中应该有多少个线程?
一个标准的经验法则是:取主机的CPU数量乘以2.在后面将会详细讨论这一问题。
线程池中的线程应执行同一个函数。典型地,该线程函数进行初始化工作,然后进行循环,直到服务进程收到停止的指令。在循环内,线程进入休眠状态,等待完成端口的设备I/O请求完成。调用GetQueeudCompletionStatus可以达到这个目的:
[cpp]
view plaincopyprint?
BOOL GetQueuedCompletionStatus(
HANDLE hCompPort,
PDWORD pdwNumBytes,
PULONG_PTR CompKey,
OVERLAPPED** ppOverlapped,
DWORD dwMilliseconds);
BOOL GetQueuedCompletionStatus( HANDLE hCompPort, PDWORD pdwNumBytes, PULONG_PTR CompKey, OVERLAPPED** ppOverlapped, DWORD dwMilliseconds);
第三个与I/O完成端口关联的数据结构是线程等待队列。
线程池内每个调用GetQueuedCompletionStatus的线程的ID被放到正在线程等待队列中,以使I/O完成端口内核对象能够知道当前哪些线程正在等待处理完成的I/O请求。当在该完成端口的I/O完成队列中出现新的项时,完成端口从正在线程等待队列中挑出一个线程唤醒。
正如所期望的那样,I/O完成队列中的项是以先进先出(FIFO)的方式删除的。但是,出乎意料的是,调用GetQueuedCompletionStatus的线程却是以后进先出(LIFO)的方式被唤醒。这么做的原因是为了提高性能。比如说,在线程等待队列中有四个线程,当已完成的I/O项出现时,最后一个调用GetQueuedCompletionStatus的线程将被唤醒来处理该项。这个最后的线程在处理完成后,又调用GetQueuedCompletionStatus重新进入线程等待队列。现在如果又出现了一个I/O完成项,同一线程又会被唤醒来处理新项。
当I/O请求的完成慢到单个线程都能够处理时,系统将一直唤醒同一线程进行处理,其他三个线程持续休眠。通过使用LIFO算法,没有被调度的线程可以将它们的内存资源(如堆栈空间)对换到磁盘并从进程的缓冲区内清空。这意味着即使众多线程在完成端口上等待也并非坏事。如果有几个线程在等待,但只有很少的I/O请求完成,多余的线程一定会将它们的大部分资源对换出系统。
相关文章推荐
- telnet服务器源码安装
- URAL 1023 Buttons 博弈
- URAL 1023 Buttons 博弈
- iPhone学习第一天,iphoen基本控件的使用
- Octave对Windows操作系统的支持从3.6.1版本开始就已经全面升级了
- java 正则表达式(转)
- hpple使用几个注意点
- 机器学习&数据挖掘笔记_25(PGM练习九:HMM用于分类)
- 游戏开发随手记:cocos2d ccLayer响应触摸事件方法
- 套用今天的签名“最新的VS+最新的MATLAB+最新的WINDOWS=无尽的烦恼 ”,用了一天的时间去配置相关的环境,确实很囧。好在现在问题解决了,特发布过程如下,希望能给有需要的朋友带来帮助。
- 远程无法访问mysql Can't connect to MySQL server on "host" (111)
- 【leetcode】 Single Number
- 动态加载的 CTreeCtrl 消息响应
- 浏览器助手,请求拦截,后台模拟键鼠操作,页内嵌入JS
- daemontools管理fast-fail的zookeeper
- SPSS做单因素方差分析
- iOS开发-模式视图
- 上传附件<input type="file">规则
- 《数字图像处理》学习笔记(八)--全局阈值分割
- 对Java开发的思考(代码组织、设计模式、开发流程)