1、串行模型:一个线程等待一个客户(通常是通过网络)发出请求,当请求到达时,线程会被唤醒并对客户请求进行处理。
并发模型:一个线程等待一个客户请求,并创建一个新的线程来处理请求。
2、完成端口背后的理论是并发运行的线程数量必须有一个上限。由于太多的线程将会导致系统花费很大的代价在各个线程cpu上下文进行切换。使用并发模型与创建进程相比开销要低很多,但是也需要为每个客户请求创建一个新的线程。这开销仍然很大。通过使用线程池可以是性能有很大的提高。
IO完成端口需要配合线程池配合使用。
IO完成端口也是一个内核对象。调用以下函数创建IO完成端口内核对象。
HANDLE CreateIoCompletionPort(
HANDLE hFile,DWORD dwNumberOfConcurrentThreads);
这个函数会完成两个任务:一是创建一个IO完成端口对象。二是将一个设备与一个IO完成端口关联起来。
---hFile就是设备句柄。
---hExistingCompletionPort是与设备关联的IO完成端口句柄。为NULL时,系统会创建新的完成端口。
---dwCompletionKey是一个对我们有意义的值,但是操作系统并不关心我们传入的值。一般用它来区分各个设备。
---dwNumberOfConcurrentThreads告诉IO完成端口在同一时间最多能有多少进程处于可运行状态。如果传入0,那么将使用默认值(并发的线程数量等于cpu数量)。
在第二章我们曾介绍说几乎所有的内核对象都需要安全属性参数。那时的几乎就是因为IO完成端口这个例外。它是唯一一个不需要安全属性的内核对象。 这是因为IO完成端口在设计时就是只在一个进程中使用。
每次调用CreateIoCompletionPort时,函数会判断hExistingCompletionKey是否为NULL,如果为NULL,会创建新的完成端口内核对象。并为此完成端口创建设备列表然后将设备加入到此完成端口设备列表中(先入先出)。
3、设备列表存储与该完成端口相关联的所有设备。
设备列表只是调用CreateIoCompletionPort函数时的一个数据结构。除此之外还有四个结构。
第二个结构是IO完成队列。当设备的一个异步IO请求完成时,系统会检查该设备是否与一个完成端口相关联,如果关联,系统会将这个已完成的IO请求添加到完成端口的IO完成队列中。每一项包括已传输字节数,完成键
(dwCompletionKey)值,以及一个指向IO请求的OVERLAPPED结构指针和错误码。
4、Windows为IO完成端口提供了一个函数,可以将线程切换到睡眠状态,来等待设备IO请求完成并进入完成端口。
BOOL GetQueuedCompletionStatus(
HANDLE hCompletionPort,DWORD dwMilliSeconds);
---hCompletionPort表示线程希望对哪个完成端口进行监视,GetQueuedCompletionStatus的任务就是将调用线程切换到睡眠状态,也就是阻塞在此函数上,直到指定的IO完成端口出现一项或者超时。
---pdwNumberOfBytesTransferred返回在异步IO完成时传输的字节数。
---pCompletionKey返回完成键。
---ppOverlapped返回异步IO开始时传入的OVERLAPPED结构地址。
---dwMillisecond指定等待时间。
函数执行成功则返回true,否则返回false。
5、第三个结构是等待线程队列。当线程池中的每个线程调用GetQueuedCompletionStatus时,调用线程的线程标识符会被添加到这个等待线程队列,这使得IO完成端口对象能知道,有哪些线程当前正在等待对已完成的IO请求进行处理。当IO完成端口的IO完成队列中出现一项时,完成端口会唤醒等待线程队列中的一个线程。这个线程会得到已完成IO项的所有信息:已传输字节数,完成键以及OVERLAPPED结构地址。这些信息是通过传给
GetQueuedCompletionStatus的参数来返回的。
IO完成队列中的各项是以先入先出方式来进行的。但是唤醒等待队列中的线程是按照后入先出的方式进行。假设有四个线程正在等待队列中等待,如果出现了一个已完成的IO项,那么最后一个由于调用GetQueuedCompletionStatus而被挂起的线程会被唤醒来处理这一项。当处理完该项后,线程会由于再次调用GetQueuedCompletionStatus而进入等待线程队列。使用这种算法,系统可以将哪些长时间睡眠的线程换出到磁盘。
6、作者一直在推崇IO完成端口。接下来我们来讨论下为什么IO完成端口这么有用!
前面我们提到过IO完成端口只有配合线程池才能发挥更大的作用。当我们创建并关联设备时,需要指定有多少个线程并发运行。一般将这个值设置为cpu的数量。当已完成的IO项被添加到完成队列中时,IO完成端口会唤醒正在等待的线程,但是唤醒的线程数最多不会超过我们指定的数量。如果有四个IO请求已完成,且有四个线程等待GetQueuedCompletionStatus而被挂起,那么IO完成端口只唤醒两个线程处理,另外两个线程继续睡眠。
此时读者可能会疑问:
既然完成端口只允许唤醒指定数量的线程,那么为什么还指定更多的线程在线程池中呢?这就涉及到IO完成端口的第四个数据结构:已释放线程列表。它存储已被唤醒的线程句柄。这使得IO完成端口能够直到哪些线程已经被唤醒并监视它们的执行情况。如果此时已释放线程由于调用某些函数将线程切换到了等待状态,完成端口会将其从已释放队列中移除,并将其添加到已暂停线程列表。
7、已暂停队列是IO完成端口第五个数据结构。
完成端口的目标是根据创建完成端口时指定的并发线程数量,将尽可能多的线程保持在已释放线程列表中。如果一个已暂停的线程被唤醒,它会离开已暂停线程列表并重新进入已释放线程列表。这意味着已释放列表中的线程数量将大于最大允许的并发线程数量。
这句话什么意思呢?正在运行的线程数量加上从暂停线程列表中被释放的线程数量使总数大于最大允许的数量。这可以使线程数量在短时间内超过指定数量。
8、模拟已完成的I/O请求:
IO完成端口并不一定要用于设备IO,它还可以进行线程间通信。在可提醒IO中我们介绍了QueueUserAPC。该函数允许线程将一个APC项添加到另一个线程的队列中。IO完成端口也存在一个类似的函数:
BOOL PostQueuedCompletionStatus(
HANDLE hCompletionPort,OVERLAPPED*pOverlapped);
这个函数用来将已完成的IO通知追加到IO完成端口的队列中。
---hCompletionPort表示我们要将已完成的IO项添加到哪个完成端口的队列中。
---剩下的三个参数表示应该返回给主调线程的值。
当线程从I/O完成队列中得到一个模拟项的时候,GetQueuedCompletionStatus会返回TRUE,表示I/O请求已成功执行。
此函数为我们提供了一种方式来与线程池中的所有线程进行通信。
例如:当用户终止服务程序时,我们想要所有线程退出。如果各个线程还在等待完成端口但有没有已完成的IO请求,那么无法将它们唤醒。我们可以为线程池中的每个线程都调用一次GetQueuedCompletionStatus,将它们都唤醒。各线程对GetQueuedCompletionStatus函数返回值进行检查,如果发现应用程序正在终止,就会正常退出。(由于线程等待队列是以栈方式唤醒各线程,为了保证线程池中每个线程都有机会得到模拟IO项,我们还必须在程序中采用其他线程同步机制)。
当对完成端口调用CloseHandle时,系统会将所有正在等待GetQueuedCompletionStatus返回的线程唤醒,并返回false。GetLastError返回ERROR_INVALID_HANDLE,此时线程就可以知道应该退出了。
9、下面的例子使用异步IO 实现了文件复制工作。
首先选择要复制的文件,并取得源文件大小。点击复制按钮创建一个线程。新线程将完成创建IO完成端口和关联设备及执行文件复制工作。将源文件和目标文件都关联到同一个完成端口,根据GetQueuedCompletionStatus返回时的完成键来区分到底是属于谁的异步IO返回。
在启动循环时采用了一个小伎俩:使用PostQueuedCompletionStatus向IO完成端口发送一个模拟的异步IO请求。完成键设置为WRITE_KEY。此时程序将执行从源文件读数据操作。这样就开动了引擎。直到文件复制完成。
注意源文件和目标文件以及GetQueuedCompletionStatus使用的OVERLAPPED结构不要使用同一个。