一个服务应用程序的结构可以有两种方式:
串行模型的问题在于它不能很好地处理好多个同时的请求,只适用于最简单的服务程序。Ping服务器是串行服务器的一个很好的例子。
因此并发模型就是最普通的了。它为每个请求都创建了一个新线程。而且通过增加硬件能力,会很容易使它的性能提高。
当并发模型实现在 NT 上时,微软 NT 小组注意到这些应用程序的性能没有预料得那么高。特别是有很多线程运行着的时候。因为所有这些线程都是可运行的(没有被挂起或等待什么事),微软意识到NT内核花了太多的时间来转换运行线程的上下文(context),而真正留给线程来做它们自己的工作的时间却被压缩了。
// 这个情况可以以我的一个例子来说明,我曾经花了一个下午去兵马俑,结果来去花在路上的时间有4个小时,而在兵马俑只呆了40分钟。这个例子有点夸张,不过夸张有助于理解 ;)
要使NT成为一个强大的服务器环境,微软就需要解决这个问题。解决的方法是一个称为I/O完成端口的内核对象,它首次在NT3.5中被引入。I/O完成端口的理论基础是并行运行的线程的数目必须有一个上限。500个同时的客户请求,并不意味着500个运行的线程。但并发运行的合适的线程数是多少呢?只要可运行的线程数多于CPU数,操作系统一定要花时间来进行线程上下文的切换的。
并行模型的一个低效之处是为每一个客户请求创建了一个新线程。创建线程比起创建进程来开销要小,但也远不是没有开销。如果当应用程序初始化时创建了一个线程池,而这些线程在应用程序执行期间是空闲的,程序的性能就能进一步提高。I/O完成端口就使用线程池。
I/O完成端口可能是Win32提供的最复杂的内核对象。要创建I/O完成端口,应调用 CreateIoCompletionPort:
HANDLE CreateIoCompletionPort(HANDLE hFileHandle, HANDLE hExistingCompletionPort, DWORD dwCompletionKey, DWORD dwNumberOfConcurrentThreads);
前三个参数只在把完成端口同设备相关联的时候才有用。如果不关联设备,只创建完成端口,那么前三个参数可以为:INVALID_HANDLE_VALUE,NULL,0。最后一个参数指示I/O完成端口同时能运行的最多线程数。如果为0,那么默认为机器上的CPU数。不过你可以用几个不同的值做实验来确定哪个值有最佳的性能。顺便说一句,这个函数是唯一一个创建了内核对象,而没有 LPSECURITY_ATTRIBUTES 参数的 Win32 函数。这是因为完成端口只应用于一个进程内。
当你创建一个I/O完成端口时,内核实际上创建了5个不同的数据结构。
第一个是设备列表。所有与完成端口相关联的设备都会出现在这个列表里,结构就是:
hDevice | dwCompletionKey |
当调用 CreateIoCompletionPort 关联设备时,表项就增加;当设备句柄被关闭时,表项被删除。
设备可以是:一个文件,socket,邮件槽或管道等等。完成键可以自定义。
第二个数据结构是一个I/O完成队列。当一个设备的异步I/O请求完成时,系统检查该设备是否关联了一个完成端口。如果是,系统就向该完成端口的I/O完成队列里加入完成的I/O请求项。该队列中的每条表项给出了传输的字节数,32位完成键,I/O请求的OVERLAPPED结构的指针和一个错误码。
dwBytesTransferred | dwCompletionKey | pOverlapped | dwError |
当I/O请求完成时或当PostQueuedCompletionStatus被调用时,表项被增加;当“等待线程队列”中删除一条表项时,表项被删除。
当服务应用程序初始化时,它应该创建I/O完成端口,而后应该创建一个线程池来处理客户请求。现在的问题在于池中应该有多少线程。这是一个很难回答的问题。一个标准的答案是将计算机上的CPU的数目乘以2。
池中的所有线程应该执行同一个线程函数。一般说来,该线程函数执行一些初始化后进入一个循环,该循环在服务进程终止时才结束。在循环中,线程使自己睡眠来等待完成端口的设备I/O请求的完成。这是通过 GetQueuedCompletionStatus 来实现的:
BOOL GetQueuedCompletionStatus(HANDLE hCompletionPort, LPDWORD lpdwNumberOfBytesTransferred, LPDWORD lpdwCompletionKey, LPOVERLAPPED* lpOverlapped, DWORD dwMilliseconds);
第一个参数指出线程要监视哪个完成端口。很多服务应用程序只使用一个I/O完成端口,所有的I/O请求完成通知都发给了该端口。简单地说,GetQueuedCompletionStatus 使调用线程进入睡眠,直到指定的完成端口的I/O完成队列中出现了一项或直到超时。
dwThreadId |
当线程池中的一个线程调用 GetQueuedCompletionStatus 时,调用线程的 ID 就被放入等待线程队列中。这样,I/O完成端口对象总是知道哪个线程正在等待处理完成的I/O请求。当完成队列里出现一项时,完成端口就唤醒等待线程队列里的一个线程,并把所有信息通过参数传过去。
要注意如何处理 GetQueuedCompletionStatus 的返回:
I/O 完成队列里的表项是按照先进先出(FIFO)方式删除的。但是调用 GetQueuedCompletionStatus 的线程却是按照后进先出(LIFO)方式被唤醒的。原因也是为了提高性能。比如,有4个线程等在线程队列中。如果出现了一个I/O项,最后一个调用 GetQueuedCompletionStatus 的线程被唤醒来处理这一项。当处理完后,它再次调用 GetQueuedCompletionStatus 进入等待线程队列。这时如果出现了另一个I/O完成项,同一线程将被唤醒来处理这一新项。只要I/O请求完成的足够慢,使得一个线程能处理它们,系统就总是唤醒同一个线程,其它三个线程将继续休眠。通过使用LIFO算法,不被调度的线程的内存资源(如栈空间)可以被交换到磁盘上和从处理器的缓存中清除。这意味着有多个线程等待一个完成端口也没有什么坏处。
如果认真想一下,就会发现这里有问题:如果完成端口只能允许指定数目的线程并发地醒来,那么线程池中为什么要有多余的线程等待呢?
I/O完成端口是非常智能的。当完成端口唤醒一个线程时,它把线程的ID放在了同它相关联的第四个数据结构——一个释放线程列表中:
dwThreadId |
这使得完成端口能记住它唤醒了哪个线程并允许它监视这些线程的执行。如果一个释放线程调用了某个函数使自己进入等待状态,完成端口检测到这一情况,就更新它的内部数据结构,把线程的ID从释放线程列表移到暂停线程列表(I/O完成端口的最后一个数据结构):
dwThreadId |
完成端口的目标是使在释放线程列表中的线程数与它被创建时指定的并发线程数相同。如果一个释放线程因某种原因进入了等待状态,释放线程列表变小,完成端口就释放另一个等待的线程。如果一个暂停线程醒来,它就离开暂停线程列表,重新进入释放线程列表。这就意味着释放线程列表中的线程数可能比允许的最大并发线程数要大。
现在让我们把这些合在一起。假设运行在一台双CPU的计算机上。我们创建了一个完成端口允许最多2个线程并发醒来,又创建了4个线程等待完成的I/O请求。如果端口队列中有3个完成的I/O请求,只有2个线程醒来处理这些请求。这减少了可运行线程的数目,节省了上下文切换的时间。现在,如果第一个运行线程调用了 Sleep,WaitforSingleObject 等使它不能运行的函数,I/O完成端口检测到这一点,就立刻唤醒第3个线程。
最终,第一个线程会再次运行。这使得运行线程数目大于系统中的CPU数。不过,完成端口会再次意识到这一点,在线程数目不超过CPU数之前,不会再唤醒其它线程。假定运行线程数超过最大值的时间会很短,当线程再次循环调用 GetQueuedCompletionStatus 时,数目会降下来。这就说明了为什么线程池中的线程数要比完成端口的并发线程数设置要多。
现在该讨论线程池中应该有多少线程。首先,当服务应用程序初始化时,你要创建一组最小数目的线程,这样就不必在运行时创建和释放线程了。要记住,创建和释放线程是浪费CPU时间的,所以最好减少这类事情发生。其次,你还要设置线程的最大数目,因为创建太多的线程会浪费系统资源。
你可能要用不同的线程数目做实验。IIS 服务器使用了一个相当复杂的算法来管理它的线程池。IIS 创建的最大线程数目是动态的。当IIS初始化时,对每个CPU,它至多允许创建10个线程。不过,根据客户请求,这一最大值可能还会增加。IIS 设的最大值是计算机上的内存数量的MB数的2倍。(* Jeffrey 询问过IIS小组他们如何得到这一最大值的公式,被告知是感觉正确。你也应该为你的应用程序找到一个“感觉正确”的公式。)
刚才,我们讨论的是增加池中能有的最大线程数目。当数目改变时,新线程不会立刻被加到池里。如果一个客户请求到达时,池中所有线程都在忙,才会创建一个新线程。(假设现有的线程数小于现在的最大值)IIS 通过一个计数器来知道有多少线程忙。在调用 GetQueuedCompletionStatus 之前,计数器增加;在 GetQueuedCompletionStatus 返回之后,计数器减小。(* 你可以使用 InterlockedIncrement 和 InterlockedDecrement 函数来实现这一点)
要记住的很重要的一件事是,你应该使池中至少有一个线程能接受到来的客户请求。
模拟完成I/O请求
BOOL PostQueuedCompletionStatus(HANDLE hCompletionPort, DWORD dwNumberOfBytesTransferred, DWORD dwCompletionKey, LPOVERLAPPED lpOverlapped);
该函数允许你人工地向一个完成端口的I/O完成队列里加入一个完成I/O请求。这是非常有用的,它使你能同池中的所有线程通信。例如,如果用户想要终止一个服务应用程序,你就要让所有的线程干净地退出。但如果线程等在完成端口上,而又没有I/O请求到来,线程就不会醒来。通过对池中的每个线程调用一次 PostQueuedCompletionStatus ,线程就会醒来查看 GetQueuedCompletionStatus 的返回值,发现应用程序正在终止,就能正确地清除和结束。
在使用这一技术时必须小心。上面的例子行得通是因为池中的所有线程都在终止,不会再次调用 GetQueuedCompletionStatus 。不过,如果你想要通知线程某件事后,让它们再循环回去调用 GetQueuedCompletionStatus,就可能会有问题。这是因为线程是按LIFO顺序被唤醒的。所以你必须在应用程序中使用一些额外的线程同步技术来确保每个线程都有机会看到模拟的 I/O 表项。否则,一个线程可能会见到几次同样的通知。