DIOCP 运作核心探密
来自网友天地弦的DIOCP早已经广为人知了,有很多的同学都用上了它,甚至各种变异、修改版本也出了不少。我最近也在学习DIOCP,打算将它用于自己的服务端,今天让我们来一起探密它(DIOCP)的运作核心吧。
DIOCP作为对Windows的IOCP完成端口封装,拥有了很高的性能,经过对ECHO示例的测试,它能轻松应对几万连接和并发。网络通讯一般分为6大阶段:请求连接、接受连接、接收数据、处理数据、回复数据、断开连接,下面我就从这6大阶段入手,来看看DIOCP是如何实现的。
一、 请求连接
实际上这第一阶段由客户端发起,指定HOST和Port请求连接我们的DIOCP服务。
二、 接受连接
在第一阶段,客户端请求连接后,我们的DIOCP服务会收到一个连接请求,这时默认会接受连接。在iocp.Sockets中,可以看到我们的服务类TIocpCustomTcpServer,它继承自TIocpCustom,就是TIocpCustomTcpServer完成了这整个网络通讯的各种请求的管理。
TIocpCustomTcpServer是一个用户能直接使用的DIOCP服务端类,在TIocpCustomTcpServer被调用Open(或Start)方法后,它先是开启IOCP任务引擎IocpEngine,初始化监听Socket,绑定监听端口,开始监听并将Socket绑定到IOCP句柄。接下来它会初始化指定数量的请求接受对象,然后再调用TiocpAcceptExRequest.PostRequest(内部调用IocpAcceptEx),像望夫石一样的守候着监听端口,等着客户端的连接。有人可能会问了,任务引擎怎么知道任务是什么,让谁来处理?好吧,我们可以看看TiocpRequest,它内部有一个Foverlapped,在Create时,Foverlapped.iocpRequest被设定为Self, TiocpAcceptExRequest是继承自TiocpRequest的。在PostRequest方法中调用AcceptEx时,有一个参数就是@FOverlapped,在任务引擎中GetQueuedCompletionStatus函数会返回Overlapped,这下明白了吧。
监听Socket:用于监听客户连接请求,开启指定的端口进入侦听状态,并调用任务引擎中的IocpCore对象Bind自己的Socket句柄到任务引擎的IOCP句柄(实际就是调用CreateIoCompletionPort函数来实现),这样监听Socket就可以在接收到IO请求时,由内核将请求加入IOCP任务队列,在IOCP引擎的工作线程中就可以通过GetQueuedCompletionStatus函数来直接取到任务进行处理了。请求响应、分配工作线程都是由任务引擎完成的。
在监听Socket收到连接请求时,会对Request进行必要的初始化(如状态设置、记录工作开始时间等),然后调用Request的FonResponse或HandleResponse。这里会优先调用FonResponse,目的是如果有指定外部的响应函数,就完全由外部接管,这样的封装增加了整体的灵活性。
在TIocpAcceptExRequest.HandleResponse中,会调用getPeerInfo函数获取远程客户端的IP地址和当前连接通讯端口,再产生一个OnAcceptedEx事件。接着调用Owner(TiocpCustomTcpServer)的DoAcceptExResponse方法,这时如果设置了OnContextAccept事件,则会产生此事件,你可以在这个事件中确定是否接受连接,默认会接受连接。接受连接后,根据KeepAlive开关判断是否设置TCP心跳。再调用IocpCore.Bind将当前连接的SocketHandle绑定到IOCP端口,如果成功会调用Context的DoConnected方法,在DoConnected里面会为当前连接分配一个标识句柄(实际上是一个计数器),设置Active状态为True,添加到在线列表,然后产生OnContextConnected事件,并调用OnConnected方法(你可以在子类中在这个地方做额外的处理),Context将状态设置为连接成功状态,并请求接收数据。如果在建立连接的过程中发生了错误,会关闭当前连接,产生OnContextError事件。
另外,在TiocpCustomTcpServer中内设了一个连接请求管理器IocpAcceptorMgr,它内部有一个TIocpAcceptExRequest对象池,目的是为了提升性能。IocpAcceptorMgr还能控制并发的最大请求数,超过上限时不再立即接受连接,而是等待对像池有空闲的对象时才返回。这里实际上就是一个排队效果了。
三、 接收数据
在第二阶段,连接成功后会马上调用当前连接的PostWSARecvRequest方法,请求接受数据。在TiocpCustomContext中,本身会初始化一个TIocpRecvRequest对象,它的作用就是产生一个数据接收请求,并在收到请求时,在HandleResponse方法中进行初步处理。
先来看看PostWSARecvRequest,它的实现很简单,就是直接去调用TiocpRecvRequest. PostRequest。 PostRequest函数会调用系统WSARecv函数产生一个接收数据的请求。这个请求当然也是异步的。由于TiocpRecvRequest也是继承于TiocpRequest,所以在调用WSARecv时也通过Foverlapped参数将自己与一次IO事件绑定了,在任务引擎中接收到数据时,会自动进入TiocpRecvRequest的HandleResponse方法。如果PostRequest失败,会触发OnContextError事件。
在TiocpRecvRequest. HandleResponse中,如果前面没有出错,会调用DoReceiveData,触发Context.OnRecvBuffer虚方法和IocpTcpServer的OnDataReceived事件,在这两个地方,用户可以对数据进行处理。紧接着,HandleResponse函数会再次产生一个接收请求,用于接收新的数据。(必须的哦,比如1G的文件,显然不能一个包就发送完^_^)
四、 处理数据
我们在使用TiocpCustomTcpServer时,可以通过注册一个被重载OnRecvBuffer的Context类来处理数据,也可以在OnDataReceived事件中处理。整体来说这个封装还是很自由的。
五、 回复数据
在处理数据时,我们经常需要回复一些东西给客户端。以使用OnDataReceived处理数据为例,我们先看看这个事件的声明:
TOnBufferReceived = procedure(Context: TIocpCustomContext; buf: Pointer; len: Cardinal; ErrorCode: Integer) of object;
在事件中,有当前接收的数据缓冲区地址、长度,还有一个Context。我们要回复数据或是查看远程客户端的IP端口等信息,就需要用到它。使用Context.Send()函数就可以发送我们的数据了。Send函数有几个重载版本,其中有一个里面包含BufReleaseType参数的,是指定正要被发送的数据缓冲区释放方式,默认dtNone,即不管它。如果使用dtFreeMem和dtDispose,则分别是调用FreeMem或Dispose来自动释放缓冲区内存。
Send函数同样也是异步的,会立即返回。在内部实际上是产生一个PostWSASendRequest请求。当然你也可以直接调用PostWSASendRequest请求,Send只是一个简化使用的封装。在PostWSASendRequest函数中,首先通过调用Owner(TIocpTcpServer).GetSendRequest函数从FsendRequestPool池中获取一个请求对象,将数据与这个请求对象绑定,即调用SendRequest. SetBuffer函数来初始化。在初始化完成后,将这个请求加入待发送队列中,成功后立即调用CheckNextSendRequest函数一次。
为何要调用CheckNextSendRequest? 其实CheckNextSendRequest是一个触发函数,它会从队列中Pop一个发送请求,成功后再调用TiocpSendRequest. ExecuteSend函数,在ExecuteSend里面会再次判断要发送的数据长度是否为0,然后再通过内部的InnerPostRequest来真正产生一个发送IO请求。这里面是通过WSASend来产生IO请求的,由于TiocpSendRequest也是基于TiocpRequest,所以WSASend时使用的Foverlapped参数就将对象与本次IO事件绑定了。在系统内核发送完数据或出错时,任务引擎会自动调用这个请求的HandleResponse方法。
在TIocpSendRequest.HandleResponse中,如果数据发送失败会产生OnContextError事件,成功则调用Context的虚方法DoSendRequestCompleted。然后立即调用Conetext. PostNextSendRequest方法,处理队列中的下一个发送请求。从这里可以看出,我们在Send之后调用CheckNextSendRequest时,可能并不是当前投递的待发送请求被响应。我们Send的请求可能会在前面的请求HandleResponse后才真正发送。
在Send数据时,我们用到了队列,其目的一是保证数据的发送顺序,二是能通过设置队列的大小来增强系统的稳定性,三是我们随时能观测到服务的状态。另外我们还使用了发送请求对象池,用来提升性能。
六、断开连接
IOCP服务在与客户建立连接后,内部只在发生错误或系统退出的时候才主动断开连接。平常时候默认由客户端来断开。在处理数据时,我们也可以直接调用Context. Disconnect来断开当前连接。在调用Disconnect时,会关闭当前连接的Socket,产生OnContextDisconnected事件,调用Context. OnDisconnected虚方法,并从在线列表中删除这个连接。在删除时Context会被还回到ContextPool中。
我们可以在OnContextDisconnected事件,或重载的Context的OnDisconnected方法对断开连接作额外的处理。
需要注意的是,前面的接受连接、接收数据、回发数据等都是异步的,只有断开连接是立即的。
七、结束语
至此,本文已经差不多结束了。在上面我们分析了DIOCP整个网络通讯的运作流程和基本使用方法。通过这些分析,你会发现,DIOCP到现在的V5阶段,整体流程已经很合理高效了,至少我暂时没发现明显的沉余。至于优化我想的是,在Send拷贝时,可以将GetMem换成环形内存,降低内存碎片的产生。
另外,本文讲的DIOCP为自己修改后的版本,已经将原来diocp-v5中的diocp.sockets.pas和diocp.tcp.server、diocp.tcp.client合并,部分类名有些细小的改变。将在线列表、HashTable换成了我自己的TYXDHashMapLinkTable,它是一个将HashTable和双向链表综合起来的怪物,个人感觉还是比较好用的。
本文只是我个人的一些初步理解,必竞才学习Diocp三天(三天打鱼两天晒网 :( ),很可能存在一些错误,欢迎大家指正。
最后感谢弦弦哥,能将这么好的东西奉献给大家,真是辛苦了。
有兴趣了解更多DIOCP的同学还可以直接访问它的官方网站: www.diocp.org
也可以直接加入QQ群: 320641073
(此文最初发表于QDAC官方网站,现在转回家里来)