Windosw系统下IOCP模型的网络收发效率高,CPU占用低,是前端网络服务器开发最推荐使用的方式,但是对于初学者来说,特别是我这种英文原版资料理解吃力的人来说,刚开始会觉得很简单, 就那么几个有限的函数,启动几个工作线程,就跑起来了。
但是无论是做tcp-server端还是tcp-client,套接字资源的释放都是一个令人头痛的问题,稍微不注意就是系统内存访问出问题,程序崩溃。
最近搞流媒体数据的分发,数据量大,并发多,需要用到IOCP,就遇到了这个问题,查了一些资料,有一些教训。希望与朋友们分享一点经验,希望帮到有缘人。毕竟我也是从广大网友的经验中不断吸取经验,才对IOCP运用有了点粗浅认识。
代码是最好的老师, 对我理解学习IOCP帮助最大的应该是博主TTGuoying了,大家可以移步:
https://www.cnblogs.com/tanguoying/p/8439701.html
https://github.com/TTGuoying/IOCPServer
(作为IOCP学习,这篇也值得一看:https://blog.csdn.net/canlynetsky/article/details/108646680)
代码提供了一个完整的TCP-SERVER的网络框架。作者封装了一个IOCPBase类,IOCPBase以虚成员方式,定义了一系列的网络事件回调函数:
// 新连接
virtual void OnConnectionEstablished(IOCP_SocketContext *connSockContext) = 0;
// 连接关闭
virtual void OnConnectionClosed(IOCP_SocketContext *connSockContext) = 0;
// 连接上发生错误
virtual void OnConnectionError(IOCP_SocketContext *connSockContext, int error) = 0;
// 读操作完成
virtual int OnRecvCompleted(IOCP_SocketContext *connSockContext, IOCP_IOContext *ioContext, DWORD dwRxLen) = 0;
// 写操作完成
virtual void OnSendCompleted(IOCP_SocketContext *connSockContext, IOCP_IOContext *ioContext, DWORD dwTxLen) = 0;
用户层只需要继承这个类,在回调函数中处理自己的业务逻辑就可以了。
我觉得作者设计中,最出色的地方是在IOCPBase的设计中, 把iocp_key值和IOCP_IOContext定义的非常清晰,让人易于理解,下面是我的一个总结:
/**************************************************************************************************************
**
** 系统中的SOCKET分为: listen_socket 和 peerSocket
** 每个socket( listen_socket 或 peerSocket)对应一个IOCP_SocketContext
** 每发起一个IO请求,分配一个新的IOCP_IOContext
** IOCP采用的预分配peerSocket的方式,每Post一个Accept之前, 都预先新分配一个socket【IOCP_IOContext::peerSocket】,所以AcceptEx函数使用的IOCP_IOContext::peerSocket是一个预分配的peerSocket, 不是isten_socket本身
** 所有IOCP_SocketContext共享一个静态成员IOContextPool, IOCP_IOContext位于IOContextPool中
*** 通过IOCP_SocketContext统一进行申请,IOCP_SocketContext销毁时,所有分配给该IOCP_SocketContex的IOCP_IOContext归还到共享的IOContextPool中
**
** IOContextPool在程序退出时, 通过析构函数中释放所有IOCP_IOContext
** IOContextPool 的使用,让释放的 IOCP_IOContext得以重复利用,减少的内存的分配释放
** *************************************************************************************************************/
本文的重点在于, 如何处理那些因为网络原因(掉电,防火墙屏蔽),网络链路事实本身已经不存在的套接字的释放。当然, 最好的办法是增加一个线程, 对每个套接字进行检查,一旦心跳信号超时,就判定为网络资源可以释放了。我也是这样做的, 我使用了一个全局的std::set来记录所有客户端对应的 IOCP_SocketContext, 新建了一个线程对这个全局std::set<IOCP_SocketContext*>进行定期扫描,检查心跳是否超时。
问题来了: 当检测到心跳是否超时, 我如何释放该IOCP_SocketContext对应的资源?
网上有很多建议:
1)有说在心跳线程使用shutdown优雅的通知客户端关闭, 我方就可以收到客户端close信号
但问题是本身就是考虑网络上出现的故障, 所以对方的close信号是没指望了。
还有就是,万一对方故意不执行close呢?所以这个适用于正常的通信操作,通过shutdown保证发出的网络数据完整对到达对方后在关闭套接字。
2) 在心跳线程中直接close套接字, 那么处于IOPending状态的icop-handle就会被唤醒,GetQueuedCompletionStatus返回FALSE, GetLastError()=64(64,指定的网络名不再可用)
3) 使用CancIOEx唤醒处于IOPending状态GetLastError()=ERROR_OPERATION_ABORTED
我使用的是2, 但是问题来了,我在做收发测试时, 则程序老是容易Crash掉。
反复debug发现,作者的IOCPBase类中, 如果心跳线程中close调server端的socket时,如果有多个PendingIO存在, 就会释放多少次OCP_SocketContext资源,程序Crash问题就在这里了,DoClose函数中出现了OCP_SocketContext资源多次释放的bug;
为此, 我给OCP_SocketContext增加了个引用计数器, 每次投递PendingIO,就在增加一次,收到一次回调,就减少一个引用计数。当引用计数=0时, 才执行真正的资源释放。
常规的做法是, 系统中总是保持唯一一个PendingRecv的存在,当 PendingRecv有信号时,不要释放应用计数,而是继续投递一个PendingRecv, 这样PendingRecv始终占用一个应用计数,OCP_SocketContext不会被释放, 当然如果投递失败子计数器减1。
心跳程序中检查有无数据需要发送, 有就投递一个PendingSend,投递成功则OCP_SocketContext引用计数加1,投递完成后应用计数减1.
如果采用使用CancIOEx唤醒处于IOPending状态,应该也可以, 只是唤醒后GetLastError()返回的值不通而已;
前者返回 ERROR_NETNAME_DELETED(64)
后者返回 ERROR_OPERATION_ABORTED(995)