完成端口的原理和举例

来源:互联网 发布:单片机自制fc游戏机 编辑:程序博客网 时间:2024/05/16 13:07
完成端口基本上公认为一种在windows服务平台上比较成熟和高效的IO方法,利用完成端口进行重叠I/O的技术在WindowsNT和WIndows2000上提供了真正的可扩展性。完成端口和Windows Socket2.0结合可以开发出支持大量连接的网络服务程序。

  首先来看看重叠I/O(Overlapped I/O):

  重叠I/O(Overlapped I/O)机制允许发起一个操作,然后在操作完成之后接受到信息。对于那种需要很长时间才能完成的操作来说,重叠IO机制尤其有用,因为发起重叠操作的线程在重叠请求发出后就可以自由的做别的事情了。

  在WinNT和Win2000上,提供的真正的可扩展的I/O模型就是使用完成端口(Completion Port)的重叠I/O。

  接下来看看完成端口(Completion Ports )

  其实可以把完成端口看成系统维护的一个队列,操作系统把重叠IO操作完成的事件通知放到该队列里,由于是暴露 “操作完成”的事件通知,所以命名为“完成端口”(COmpletion Ports)。一个socket被创建后,可以在任何时刻和一个完成端口联系起来。

  一般来说,一个应用程序可以创建多个工作线程来处理完成端口上的通知事件。工作线程的数量依赖于程序的具体需要。但是在理想的情况下,应该对应一个CPU创建一个线程。因为在完成端口理想模型中,每个线程都可以从系统获得一个“原子”性的时间片,轮番运行并检查完成端口,线程的切换是额外的开销。在实际开发的时候,还要考虑这些线程是否牵涉到其他堵塞操作的情况。如果某线程进行堵塞操作,系统则将其挂起,让别的线程获得运行时间。因此,如果有这样的情况,可以多创建几个线程来尽量利用时间。

  总之,开发一个可扩展的Winsock服务器并非十分困难的。主要是开始一个监听socket,接收连接,并且进行重叠发送和接收的IO操作。最大的挑战就是管理系统资源,限制重叠Io的数量,避免内存危机。遵循这几个原则,就能帮助你开发高性能,可扩展的服务程序。

  socket的接收缓冲,因为接收事件仅仅在AcceptEx调用中发生。保证每个socket都有一个接收缓冲不会造成什么危害。一旦客户端/服务器在最初的一次请求(由AcceptEx完成)之后进行交互,发送更多的数据,那么取消接收缓冲更是一个很不好的做法。除非你能保证这些数据都是在每个连接的重叠IO接收里完成的 。

######完成端口(I/O completion):

异步过程调用(apcs)问题:

    只有发overlapped请求的线程才可以提供callback函数(需要一个特定的线程为一个特定的I/O请求服务)。

完成端口(I/O completion)的优点:

    不会限制handle个数,可处理成千上万个连接。I/O completion port允许一个线程将一个请求暂时保存下来,由另一个线程为它做实际服务。

并发模型与线程池:

    在典型的并发模型中,服务器为每一个客户端创建一个线程,如果很多客户同时请求,则这些线程都是运行的,那么CPU就要一个个切换,CPU花费了更多的时 间在线程切换,线程确没得到很多CPU时间。到底应该创建多少个线程比较合适呢,微软件帮助文档上讲应该是2*CPU个。但理想条件下最好线程不要切换, 而又能象线程池一样,重复利用。I/O完成端口就是使用了线程池。

理解与使用:

第一步:

在我们使用完成端口之前,要调用CreateIoCompletionPort函数先创建完成端口对象。

定义如下:

HANDLE CreateIoCompletionPort(

                                 HANDLE FileHandle,

                                HANDLE ExistingCompletionPort,

                               DWORD CompletionKey,

                               DWORD NumberOfConcurrentThreads

);

FileHandle:

文件或设备的handle, 如果值为INVALID_HANDLE_VALUE则产生一个没有和任何文件handle有关系的port.( 可以用来和完成端口联系的各种句柄,文件,套接字)

ExistingCompletionPort:

NULL时生成一个新port, 否则handle会加到此port上。

CompletionKey:

用户自定义数值,被交给服务的线程。GetQueuedCompletionStatus函数时我们可以完全得到我们在此联系函数中的完成键(申请的内存块)。在GetQueuedCompletionStatus

中可以完封不动的得到这个内存块,并且使用它。

NumberOfConcurrentThreads:

参数NumberOfConcurrentThreads用来指定在一个完成端口上可以并发 的线程数量。理想的情况是,一个处理器上只运行一个线程,这样可以避免线程上下文切换的开销。如果这个参数的值为0,那就是告诉系统线程数与处理器数相 同。我们可以用下面的代码来创建I/O完成端口。

隐藏在之创建完成端口的秘密:

1. 创建一个完成端口

CreateIoCompletionPort(INVALID_HANDLE_VALUE, 0, 0, dwNumberOfConcurrentThreads);

2. 设备列表,完成端口把它同一个或多个设备相关联。

CreateIoCompletionPort(hDevice, hCompPort, dwCompKey, 0) ;

第二步:

根据处理器个数,创建cpu*2个工作线程:

CreateThread(NULL, 0, ServerWorkerThread, CompletionPort,0, &ThreadID))

与此同时,服务器调用WSASocket,bind, listen, WSAAccept,之后,调用

CreateIoCompletionPort((HANDLE) Accept, CompletionPort... )把一个套接字句柄和一个完成端口绑定到一起。完成端口又同一个或多个设备相关联着,所以以套接字为基础,投递发送和请求,对I/O处理。接着,可以依赖 完成端口,接收有关I/O操作完成情况的通知。再看程序里:

WSARecv(Accept, &(PerIoData->DataBuf), 1, &RecvBytes, &Flags,

&(PerIoData->Overlapped), NULL)开始调用,这里象前面讲过的一样,既然是异步I/O,所以WSASend和WSARecv的调用会立即返回。

系统处理:

当一个设备的异步I/O请求完成之后,系统会检查该设备是否关联了一个完成端口,如果是,系统就向该完成端口的I/O完成队列中加入完成的I/O请求列。

然后我们需要从这个完成队列中,取出调用后的结果(需要通过一个Overlapped结构来接收调用的结果)。怎么知道这个队列中已经有处理后的结果呢,调用GetQueuedCompletionStatus函数。

工作线程与完成端口:

和异步过程调用不同(在一个Overlapped I/O完成之后,系统调用该回调函数。OS在有信号状态下(设备句柄),才会调用回调函数(可能有很多APCS等待处理了))

GetQueuedCompletionStatus

在工作线程内调用GetQueuedCompletionStatus函数。

GetQueuedCompletionStatus(

    HANDLE CompletionPort,

    LPDWORD lpNumberOfBytesTransferred,

    LPDWORD lpCompletionKey,

    LPOVERLAPPED *lpOverlapped,

    DWORD dwMilliseconds

);

CompletionPort:指出了线程要监视哪一个完成端口。很多服务应用程序只是使用一个I/O完成端口,所有的I/O请求完成以后的通知都将发给该端口。

lpNumberOfBytesTransferred:传输的数据字节数

lpCompletionKey:

完成端口的单句柄数据指针,这个指针将可以得到我们在CreateIoCompletionPort中申请那片内存。

lpOverlapped:

重叠I/O请求结构,这个结构同样是指向我们在重叠请求时所申请的内存块,同时和lpCompletionKey,一样我们也可以利用这个内存块来存储我们要保存的任意数据。

dwMilliseconds:

等待的最长时间(毫秒),如果超时,lpOverlapped被设为NULL,函数返回False.

GetQueuedCompletionStatus功能及隐藏的秘密:

GetQueuedCompletionStatus使调用线程挂起,直到指定的端口的 I/O完成队列中出现了一项或直到超时。(I/0完成队列中出现了记录)调用GetQueuedCompletionStatus时,调用线程的 ID(cpu*2个线程,每个ServerWorkerThread的线程ID)就被放入该等待线程队列中。

     等待线程队列很简单,只是保存了这些线程的ID。完成端口会按照后进先出的原则将一个线程队列的ID放入到释放线程列表中。

这样,I/O完成端口内核对象就知道哪些线程正在等待处理完成的I/O请求。当端口的I/O 完成队列出现一项时,完成端口就唤醒(睡眠状态中变为可调度状态)等待线程队列中的一个线程。线程将得到完成I/O项中的信息:传输的字节数,完成键(单 句柄数据结构)和Overlapped结构地址,线程是通过GetQueuedCompletionStatus返回这些信息,等待CPU的调度。

GetQueuedCompletionStatus返回可能有多种原因,如果传递无效完成 端口句柄,函数返回False,GetLastError返回一个错误(ERROR_INVALID_HANDLE),如果超时,返回False, GetLastError返回WAIT_TIMEOUT, i/o完成队列删除一项,该表项是一个成功完成的I/O请求,则返回True。

    调用GetQueuedCompletionStatus的线程是后进先出的方式唤醒的,比如有4个线程等待,如果有一个I/O,最后一个调用 GetQueuedCompletionStatus的线程被唤醒来处理。处理完之后,再调用GetQueuedCompletionStatus进入等 待线程队列中。

深入分析完成端口线程池调度原理:

    假设我们运行在2CPU的机器上。创建完成端口时指定2个并发,创建了4个工作线程加入线程池中等待完成I/O请求,且完成端口队列(先入先出)中有3个完成I/O的请求的情况:

工作线程运行, 创建了4个工作线程,调用GetQueuedCompletionStatus时,该调用线程就进入了睡眠状态,假设这个时候,I/O完成队列出现了三项,调用线程的ID就被放入该等待线程队列中。

I/O完成端口内核对象(第3个参数等级线程队列),因此知道哪些线程正在等待处理完成的 I/O请求。当端口的I/O完成队列出现一项时,完成端口就唤醒(睡眠状态中变为可调度状态)等待线程队列中的一个线程(前面讲过等待线程队列是后进先 出)。所以线程D将得到完成I/O项中的信息:传输的字节数,完成键(单句柄数据结构)和Overlapped结构地址,线程是通过 GetQueuedCompletionStatus返回这些信息。

在前面我们指定了并发线程的数目是2,所以I/O完成端口唤醒2个线程,线程D和线程C,另两个继续休眠(线程B,线程A),直到线程D处理完了,发现表项里还有要处理的,就唤醒同一线程继续处理。

线程并发量:

   并发量限制了与该完成端口相关联的可运行线程的数目, 它类似阀门的作用。 当与该完成端口相关联的可运行线程的总数目达到了该并发量,系统就会阻塞任何与该完成端口相关联的后续线程的执行, 直到与该完成端口相关联的可运行线程数目下降到小于该并发量为止。所以解释了线程池中的运行线程可能会比设置的并发线程多的原因。

    它的作用:

最有效的假想是发生在有完成包在队列中等待,而没有等待被满足,因为此时完成端口达到了其并 发量的极限。此时,一个正在运行中的线程调用 GetQueuedCompletionStatus时,它就会立刻从队列中取走该完成包。这样就不存在着环境的切换,因为该处于运行中的线程就会连续不 断地从队列中取走完成包,而其他的线程就不能运行了。

注意:如果池中的所有线程都在忙,客户请求就可能拒绝,所以要适当调整这个参数,获得最佳性能。

线程并发:D线程挂起,加入暂停线程,醒来后又加入释放线程队列。

线程的安全退出:

PostQueudCompletionStatus函数,我们可以用它发送一个自定义的包含了OVERLAPPED成员变量的结构地址,里面包含一个状态变量,当状态变量为退出标志时,线程就执行清除动作然后退出。

完成端口使用需要注意的地方:

1.在执行wsasend和wsarecv操作前,请先将overlapped结构体使用memset进行清零。