Windows的设计目标是-一个安全的、 健壮的操作系统,能够运行各种各样的应用程序来为成千上万的用户服务。回顾历史,我们能够采用以下两种模型之-来构架一个服务应用程序。
- 串行模型(serial mode)一个线程等待一个客户(通常是通过网络)发出请求。当请求到达的时候,线程会被唤醒并对客户请求进行处理。
- 并发模型(concurrent model) -一个线程等待一个客户请求,并创建一个 新的线程来处理请求。当新线程正在处理客户请求的时候,原来的线程会进入下一次循环并等待另一个客户请求。当处理客户请求的线程完成整个处理过程的时候,该线程就会终止。
串行模型的问题在于它不能很好地同时处理多个请求。那么一次只能处理一个,第二个请求必须等第一个请求的处理结束。使用串行模型设计出来的服务不能充分发挥多处理器机器的优势。显然,串行模型只能满足最简单的服务器应用程序,在这类应用程序中客户请求非常少,而且能够非常快地完成处理。Ping服务器就是串行服务器的一一个很好的例子
由于串行模型存在这样的限制,因此并发模型极其受欢迎。在并发模型中,每个客户请求都会由一个新创建的线程来对其进行处理。这种模型的优点在于等待请求的线程只有很少的工作需要做。大多数时间它都处于睡眠状态。当客户请求到达的时候,该线程会被唤醒,创建一个新的线程来处理请求,然后等待下一一个客户请求。这意味着能够对客户的请求进行快捷的处理。此外,因为每个客户请求都有自己的线程,所以服务器应用程序具备非常好的伸缩性,能够轻易地发挥多处理器机器的优势。因此,如果使用的是并发模型并对硬件进行升级(添加另一个CPU),那么服务器应用程序的性能就能相应地提高。
1.创建l/O完成端口
I/O完成端口背后的理论是并发运行的线程的数量必须有一个上限一也就是说,同时发出的500个客户请求不应该允许出现500个可运行的线程。那么,可运行线程的数量是多少才算合适呢?无须考虑太长的时间,我们就会意识到如果机器只有两个CPU,那么允许可运行线程的数量大于2一每个处理器一个线程将没有什么意义。一 旦可运行线程的数量大于可用的CPU数量,系统就必须花时间来执行线程上下文切换,而这会浪费宝贵的CPU周期,这也是并发模型的一个潜在缺点。
并发模型的另一个缺点是需要为每个客户请求创建一个新的线程。虽然和创建一个有自己的虚拟地址空间的进程相比,创建一个线程的开销要低得多,但它的开销仍然不能算小。如果能在应用程序初始化的时候创建一个线程池,并让线程池中的线程在应用程序运行期间一直保持可用状态,那么服务应用程序的性能就能够得到提高。IO完成端口的设计初衷就是与线程池配合使用。
I/O完成端口可能是最复杂的内核对象了,为了创建一个I/O完成端口.我们应该调用CreateIoCompletionPort
HANDLE CreateIoCompletionPort(
HANDLE hFile,
HANDLE hExistingCompletionPort,
ULONG_PTR CompletionKey ,
DWORD dwNumberOfConcurrentThreads) ;
这个函数执行两项不同的任务:它不仅会创建一-个I/O完成端口,而且会将一个设备 与一个I/O完成端口关联起来。依我之见,该函数已经太复杂了,Microsoft 应该将它分成两个单独的函数。当我在使用I/O完成端口的时候,会创建两个小函数来CreateIoCompletionPort调用进行抽象,其目的是将这两项任务分开。我编写的第一个函数叫CreateNewCompletionPort,它的实现如下:
HANDLE CreateNewCompletionPort (DWORD dwNumberOfConcur rentThreads) {
return (CreateIoCompletionPort (INVALID_ HANDLE_VALUE,NULL,0,dwNumberOfConcurrentThreads)) ;
}
这个函数只有一个参数dwNumberOfConcurrentThreads,它调用了CreateIoCompletionPort,并在前三个参数中传入固定的值,在最后一个参数中传入dwNumberOfConcurrentThreads的值。我们可以看到,只有当我们要将一个设备与一个IO完成端口关联在一起的时候(我们马上就会对此进行介绍),才会用到CreateIoCompletionPort的前三个参数。为了只创建I/O完成端口,我给CreateloCompletionPort的前三个参数分传入了INVALID_HANDLE_ VALUE, NULL和0。
参数dwNumberOfConcurrentThreads告诉I/O完成端口在同一时间最多能有多少线程处于可运行状态。如果给dwNumberOfConcurrentThreads参数传0,那么I/O完成端口会使用默认值,也就是允许并发执行的线程数量等于主机的CPU数量。为了避免额外的上下文切换,通常这样的设定正是我们想要的。如果处理一个客户请求需要长时间的计算,而且中间很少会被阻塞,那么我们可能想要增大这个值,但我强烈建议不要这样做。我们可以用不同的值来对dwNumberOfConcurrentThreads进行试验,并在目标硬件平台上对应用程序的性能进行比较,以找到最佳的值。
读者可能会注意到.CreateIoCompletionPort不需要我们传一个 SECURITY_ ATTRIBUTES结构给它,在所有用来创建内核对象的Windows函数中,CreateIoCompletionPort 大概是绝无仅有的。这是因为I/O完成端口的设计初衷就是只在一个进程中使用。在介绍如何使用I/O完成端口的时候我们将会对具体原因有一个清晰的认识。
将设备与I/O完成端口关联起来
当我们创建一个IO完成端口的时候,系统内核实际上会创建5个不同的数据结构,如图所示。在阅读后面的内容时,请读者随时参考这张图。第一个数据结构是一个设备列表,表示与该端口相关联的一个或多个设备。
我们通过调用CreateloCompletionPort来将设备与端口关联起来。前面已经提到过,我还创建了另-一个自己的函AssociateDeviceWithCompletionPort,用于对CreateIoCompletionPort 进行抽象:
B0OL AssociateDeviceWithCompletionPort(HANDLE hComp1etionPort, HANDLE hDevice, DWORD dwComplet ionKey) {
HANDLE h = CreateIoCompletionPort(hDevice, hCompletionPort, dwCompletionKey,0) ;
return(h == hComplet ionPort);
}
AssociateDeviceWithCompletionPort把一项添加到一个已有I/O完成端口的设备列表中。我们需要向这个函数传入一个已有I/O完成端口的句柄(由前一个CreateNewCompletionPort调用返回)、设备的句柄(可以是文件、套接字、邮件槽、管道等)以及一个完成键(即completion key, 一个对我们有意义的值,操作系统并不关心我们在这里传入的到底是什么值)。每次将-一个设备与该端口关联起来的时候,系统会将这些信息追加到I/O完成端口的设备列表中。
第二个数据结构是一个I/O完成队列。当设备的一个异步I/O请求完成时,系统会检查设备是否与一个I/O完成端口相关联,如果设备与一个I/O完成端口相关联,那么系统会将该项已完成的I/O请求追加到I/O完成端口的I/O完成队列的末尾。这个队列中的每项包含的信息有:已传输的字节数、最初将设备与端口关联在一起的时候所设的完成键的值、一个指向I/O请求OVERLAPPED结构的指针以及一个错误码。
2.I/O完成端口的周边架构
当我们的服务应用程序初始化的时候,应该调用CreateNewCompletionPort之类的函数来创建IO完成端口。应用程序接着应该创建一个线程池来处理客户请求。现在我们面临的问题是,“ 线程池中应该有多少线程?”这个问题很难回答,我们会在后面的“线程池中有多少线程?”一节中对此进行更深入地探讨。就目前而言,标准的经验法则是取主机的CPU数量并将其乘以2。因此,在一台双处理器的机器上,我们应该创建一一个有 4个线程的线程池。
线程池中的所有线程应该执行同一个函数。一般来说, 这个线程函数会先进行一些初始化工作,然后进入一个循环,当服务进程被告知要停止的时候,这个循环也应该就此终止。在循环内部,线程将自己切换到睡眠状态,来等待设备I/O 请求完成并进入完成端口。调用GetQueuedCompletionStatus可以达到这一目的:
BOOL GetQueuedComplet ionStatus (
HANDLE hCompletionPort,
PDWORD pdwNumberOfBytesTransferred,
PULONG_PTR pCompletionKey,
OVERLAPPED** ppOverlapped,
DWORD dwMi1liseconds);
第一个参数hCompletionPort表示线程希望对哪个完成端口进行监视。许多服务应用程序只使用一个I/O 完成端口,并让所有I/O 请求的完成通知进入这个端口。GetQueuedCompletionStatus的任务基本上就是将调用线程切换到睡眠状态,直到指定的完成端口的I/O完成队列中出现一项,或者等待的时间已经超出了(在dwMilliseconds参数中)指定的时间为止。
与I/O完成端口相关的第三个数据结构是等待线程队列。当线程池中的每个线程调用GetQueuedCompletionStatus的时候,调用线程的线程标识符会被添加到这个等待线程队列,这使得I/O完成端口内核对象始终都能够知道,有哪些线程当前正在等待对已完成的I/O请求进行处理。当端口的I/O完成队列中出现一项的时候, 该完成端口会唤醒等待线程队列中的一个线程。这个线程会得到已完成I/O项中的所有信息:已传输的字节数,完成键以及OVERLAPPED结构的地址。这些信息是通过传给GetQueuedCompletionStatus的pdwNumberOfBytesTransferred,pCompletionKey 以及ppOverlapped参数来返回给线程的。
移除I/O 完成队列中的各项是以先入先出的方式来进行的。但是,唤醒那些调用了GetQueuedCompletionStatus的线程是以后入先出的方式来进行的。举个例子,假设有4个线程在等待线程队列中等待。如果出现了一个已完成的I/O项,那么最后一个调用GetQueuedCompletionStatus的线程会被唤醒,来处理这一项。当最后这个线程完成对该项的处理后,线程再次调用GetQueuedCompletionStatus来进入等待线程队列。如果现在又出现了另一个已完成的I/O项,那么处理上一项的同一个线程会被唤醒,来处理这个新的项。
如果I/O 请求完成得足够慢,使得-一个线程就能够将它们全部处理完,那么系统会不断地唤醒同一个线程,而让其他线程继续睡眠。通过使用这种后入先出算法,系统可以将那些未被调度的线程的内存资源(比如栈空间)换出到磁盘,并将它们从处理器的高速缓存中清除。这意味着让许多线程等待一个完成端口并不是什么坏事。如果正在等待的线程数量大于已完成的I/O请求的数量,那么系统会将多余线程的大多数资源换出内存。
如果预计会不断地收到大量的I/O请求,那么我们可以调用下面的函数来同时取得多个I/O 请求的结果,而不必让许多线程等待完成端口,从而可以避免由此产生的上下文切换所带来的开销。
BOOL GetQueuedComp1etionStatusEx(
HANDLE hComplet ionPort,
LPOVERLAPPED_ ENTRY pComp1etionPortEntries,
ULONG ulCount,
PULONG pulNumEntriesRemoved,
DWORD dwMilliseconds,
BOOL bAlertable);
第一个参数hCompletionPort表示线程想要对哪个完成端口进行监视。当该函数被调用的时候,会取出指定的完成端口的I/O 完成队列中存在的各项,并将它们的信息复制到pCompletionPortEntries数组参数中。参数ulCount表示最多可以复制多少项到数组中,参数pulNumEntriesRemoved指向的长整型值用来接收完成队列中被移除的I/O请求的确切数量。数pCompletionPortEntries的每个元素是一个OVERLAPPED_ENTRY结构,它用来保存已完成的I/O请求的所有相关信息:完成键、OVERLAPPED结构的地址、I/O 请求的返回码(或错误码)以及已传输的字节数。
typedef struct _OVERLAPPED_ENTRY {
ULONG_PTR 1pCompletionKey;
LPOVERLAPPED lpOverlapped;
ULONG_PTR Internal;/*该字段没有明确用途,不被使用*/
DWORD dwNumberOfBytesTransferred;
} OVERLAPPED_ENTRY, *LPOVERLAPPED_ ENTRY;
如果将最后-一个参数bAlertable设为FaLse,那么函数会一直等待- 一个已完成的I/O请求被添加到完成端口,直到超出指定的等待时间(在参数dillieconds 中指定)为止。如果参数bAlertable被设为TRUE而且队列中没有已完成的I/O请求,那么正如我们在前面所介绍的那样,线程将进入可提醒状态。
3.I/O完成端口如何管理线程池
现在是讨论I/O完成端口为什么如此有用的时候了。首先,当我们创建I/O完成端口的时候,需要指定允许多少个线程并发运行(此时并没有创建线程池)。正如前面已经提到过,我们通常会将这个值设为主机的CPU数量。当已完成的I/O项被添加到队列中的时候,I/O完成端口想要唤醒正在等待的线程。但是,完成端口唤醒的线程数量最多不会超过我们指定的数量。因此,如果有4个I/O请求已完成,有4个线程正在等待GetQueuedCompletionStatus,那么I/O完成端口只会唤醒两个线程,而让其他两个线程继续睡眠。当每个线程处理完-一个已完成的I/O项时,会再次调用GetQueuedCompletionStatus. 这时系统发现队列中还有其他的项,于是会唤醒同一个线程来对剩余的项进行处理。
如果读者仔细考虑一 下,就应该注意到有些东西没有太大的意义。如果完成端口只允许同时唤醒指定数量的线程,那么为什么还要让更多的线程在线程池中等待呢?举个例子,假设我们正在一台有两个CPU的机器上运行,我们创建了一个I/O完成端口,并告诉它同时最多只能有两个线程来处理已完成的项。但我们在线程池中创建了4个线程(是CPU数量的两倍)。看起来似乎我们创建了两个多余的线程,它们永远都不会被唤醒来处理任何东西。
但I/O完成端口是非常智能的。当完成端口唤醒一个线程的时候,会将该线程的线程标识符保存在与完成端口相关联的第4个数据结构中,也就是已释放线程列表(released thread list)。这使得完成端口能够记住哪些线程已经被唤醒,并监视它们的执行情况。如果一个已释放的线程调用的任何函数将该线程切换到了等待状态,那么完成端口会检测到这一情况,此时它会更新内部的数据结构,将该线程的线程标识符从已释放线程列表中移除,并将其添加到已暂停线程列表(paused thread list)中(与I/O完成端口相关联的第5个也是最后一个数据结构)。
完成端口的目标是根据在创建完成端口时指定的并发线程的数量,将尽可能多的线程保持在已释放线程列表中。如果一个已释放线程由于任何原因而进入等待状态,那么已释放线程列表会缩减,完成端口就可以释放另一个正在等待的线程。如果一个 已暂停的线程被唤醒,那么它会离开已暂停线程列表并重新进入已释放线程列表。这意味着此时已释放线程列表中的线程数量将大于最大允许的并发线程数量。
让我们把这些总结一下。 假设我们在一 台有两个CPU的机器上运行。我们创建了一个同时最多只允许两个线程被唤醒的完成端口,还创建了4个线程来等待己完成的IO请求。如果3个已完成的IO请求被添加到端的队列中,只有两个线程会被唤醒来对请求进行处理,这降低了可运行线程的数量,并节省了上下文切换的时间。现在,如果一个可运行线程调用了Sleep,WaitForSingleObject,WaitForMultipleObjects,SignalObjectAndWait,一个异步 I/O调用或任何能够导致线程变成不可运行状态的函数, I/O 完成端口会检测到这一情况并立即唤醒第3个线程。完成端口的目标是使CPU保持在满负荷状态下工作。
最后,第一个线程将再次变成可运行状态。当发生这种情况的时候,可运行线程的数量将超过系统中CPU的数量。但是,完成端口仍然知道这一点, 在线程数量降到低于CPU数量之前,它是不会再唤醒任何线程的。I/O完成端口体系结构假定可运行线程的数量只会在很短一段时间内高于最大允许的线程数量,一旦线程进入下一次循环并调用GetQueuedCompletionStatus,可运行线程的数量就会迅速下降。这就解释了为什么线程池中的线程数量应该大于在完成端口中设置的并发线程数量。
4.模拟已完成的l/O请求
I/O完成端口是一项非常棒的技术,并不一定要用于设备I/O还可以用来进行线程间通信。I/O 完成端口有一个函数,名叫PostQueuedCompletionStatus:
BOOL PostQueuedComplet ionStatus (
HANDLE hCompletionPort,
DWORD dwNumBytes,
ULONG_PTR Comp1etionKey ,
OVERLAPPED* pOverlapped);
这个函数用来将一一个已完成的 IO通知追加到1I0 完成端口的队列中。第一一个参数hCompletionPort表示我们要将该项添加到哪个完成端口的队列中。剩下的3个参数dwNumBytes, CompletionKey和pOverlapped-表 示应该返回什么值给那个调用了GetQueuedCompletionStatus的线程。当线程从I/O完成队列中得到一个模拟项的时候,GetQueuedCompletionStatus会返回TRUE,表示I/O请求已成功执行。
PostQueuedCompletionStatus函数的有用程度令人难以置信一-它 为我们提供了一种方式来与线程池中的所有线程进行通信。例如,当用户终止服务应用程序的时候,我们想要让所有线程都干净地退出。但如果各线程还在等待完成端口但又没有已完成。的I/O请求,那么它们将无法被唤醒。通过为线程池中的每个线程都调用一次PostQueuedCompletionStatus,我们可以将它们都唤醒。每个线程会对GetQueuedCompletionStatus的返回值进行检查,如果发现应用程序正在终止,那么它就可以进行清理工作并正常地退出。