摘 要:本文分析了非阻塞模式下,用Winsock编写TCP/IP程序的一些问题,并给
出了一种解决办法。
关键词:非阻塞 Winsock TCP/IP CAsyncSocket
一、概述
TCP/IP的Winsock编程有两种模式:阻塞及非阻塞。Visual C++ 通过MFC类
CAsyncSocket提供对后者的支持。但是在用"流"方式(即TCP)传输数据时,非阻
塞模式会带来一些问题。
为了把问题描述清楚,先简要介绍一下"流"方式下用CAsyncSocket编写TCP
程序的步骤:
客户端:
(1) 从CAsyncSocket派生自己的类并构造对象;
(2) 调用成员函数Create创建SOCKET;
(3) 调用成员函数Connect发起连接;
(4) 重载虚函数OnConnect,当连接成功时,系统会调用该函数。
服务器端:
(1) 从CAsyncSocket派生自己的类并构造对象;
(2) 调用成员函数Create创建SOCKET;
(3) 调用成员函数Listen进行"监听";
(4) 重载虚函数OnAccept,当有客户端请求连接时,系统调用此函数,用成
员函数Accept接受请求并建立连接。调用Accept时,要构造一个新的 CAsyncSocket派生类对象作为函数参数,Accept用它创建连接客户端的 SOCKET,原来的对象仍然保持监听状态。
连接成功后,无论是客户端,还是服务器端,都需要重载虚函数OnSend及
OnReceive:当可以发送数据时,系统调用OnSend,这时可以用成员函数Send发
送数据; 当有数据接收时,系统会调用OnReceive,可以用Receive函数接收数
据。需要关闭连接时,任意一方调用成员函数Close即可。
有关TCP/IP及CAsyncSocket 编程的详情,请查阅Visual C++的联机帮助。
二、非阻塞模式存在的问题
现在假设用户想发送一个4096字节的数据块,于是他使用语句Send(pBuf,4096),
实际发送的字节数可能和预期的一样,为4096,但也可能是1至4095中的任何一
个数。如果是后者,还要用 Send来发送剩下的数据。在最坏的情况下,也许不
得不执行4096次Send。
同样,即使对方成功地一次性发送过来4096字节,用Receive(pBuf,4096)接收时,实际接收的字节数可以是1至4096中的任何一个数,需要反复执行Receive。如果对方连续用Send函数发送少量数据, 比如每次发送10个字节,连续发10次,与刚才的情况正好相反,可能一次Receive 就把这些数据全部接收下来。(在接收数据时,阻塞式编程也存在同样的问题)
三、解决办法
从用户的角度考虑,最理想的情况是: 建立连接后,想发数据时就可以发送,毋须等待系统调用OnSend,而且用一次发送就能搞定; 接收时,不论数据量多少,只要对方发送一次,这边就接收一次。
为了解决这些问题,笔者从CAsyncSocket派生出一个子类CTransferSocket_hawk,
它通过使用一个队列(用数组实现)来缓存待发送的数据块, 克服了基类发送数据的不便。用户只需把要发送的数据块指针传给新定义的成员函数TransferData,然后就不用管了。该函数把数据复制一份送入缓存队列,并负责发送。(TransferData返回后,用户可以安全地删除指针释放内存)。
CTransferSocket_hawk重载了OnSend函数,当可以发送数据时,调用CAsyncSocket::Send最多发送4096字节,根据函数返回值(即实际发送的字节数)计算下一次发送时的偏移量。 因为正常情况下,系统调用OnSend后,不会再调用它,为了发送剩余的数据,要用CAsyncSocket::AsyncSelect提名FD_WRITE事件,使系统再次调用OnSend。发送完一个数据块时,会用虚函数OnOneDataSent通知用户。如果调用TransferData时,队列中已有数据,将按先后顺序排队发送(如果用户希望确认自己的数据发送完毕,最好等队列空时再调用TransferData,否则系统调用OnOneDataSent时,用户无从知道发送完的究竟是哪一个数据块)。
如果发送出错,SOCKET被关闭,并用虚函数OnTransferClose通知用户。
另外,为了解决CAsyncSocket接收数据的缺陷, CTransferSocket_hawk发送数据时,自动在正文前面添加当前数据块尺寸(以字节为单位,long 型,占4个字节)和起始标记BEGIN_TAG(占BEGIN_TAG_LENGTH个字节), 本文中使用字符串begin做起始标记,占5个字节。注意:数据块尺寸不包括这4+BEGIN_TAG_LENGTH个字节的额外开销。
CTransferSocket_hawk重载了OnReceive函数,当有数据接收时,调用CAsyncSocket::Receive。 开始4个字节被当作即将接收的数据块尺寸,如果紧跟着BEGIN_TAG_LENGTH个字节不是BEGIN_TAG,则关闭SOCKET,并调用虚函数OnTransferClose。然后开始接收数据正文,每次最多接收4096字节,根据函数返回值(即实际接收到的字节数)计算下一次接收时的偏移量。 与OnSend不同,只要数据没收完,系统就会自动调用OnReceive。收满指定的字节数后,调用虚函数OnOneDataReceived,通知用户收到一个数据块。再次收到数据时,重复上述步骤。如果接收时出错,丢弃已收到的数据,关闭SOCKET,并调用虚函数OnTransferClose。
四、类CTransferSocket_hawk的用法
1. 成员函数说明:
BOOL Create(UINT nSockPort=0,LPCTSTR lpszSocketAddress=NULL); 用于创建SOCKET,功能基本与CAsyncSocket::Create一致,但SOCKET类型总是"流"类型。函数返回TRUE,表示成功,否则创建 SOCKET失败。参数说明如下:
nSockPort:绑定端口号,如果是客户端,一般取缺省值0;
lpszSocketAddress:IP地址,一般取缺省值NULL。
进一步说明参见CAsyncSocket::Create。
BOOL TransferData(const void *data,long size);
用于传送数据(实际上只是把数据送入队列,等待发送)。返回TRUE,表示数据已送入队列;返回FASLE,表示SOCKET还没有建立。执行完该函数后,用户可以安全地删除指针释放内存,因为数据已经备份下来。参数说明如下:
pData:要传送的数据块指针。
size:数据块尺寸。
int GetQueueCount();
用于返回队列中待传数据块的数目。
virtual void Close();
用于关闭SOCKET,中止数据收发。
virtual void OnTransferClose(int reason,int nErrorCode=0);
当连接正常关闭,或者数据传送错误时,调用此虚函数。调用时, 已关闭SOCKET。该函数什么也不做。用户可以重载它,以便出错时得到通知。参数说明如下:
reason:表明关闭SOCKET的原因,可以为以下值:
NORMAL 正常关闭;
SENDERR Send数据时出错导致关闭;
RECVERR Receive数据时出错导致关闭;
RECVBEGINTAGERR 接收到的"起始"标记不正确导致关闭。
nErrorCode:当reason为SENDERR或RECVERR时,为SOCKET的
出错代码;当reason为其它值时,忽略此参数。
virtual void OnOneDataSent();
每发送完一个数据块,会调用此虚函数,该函数什么也不做。用户可以重载它,以便在数据发送完毕时得到通知。
virtual void OnOneDataReceived(void *pData,long size);
每接收完一个数据块,会调用此虚函数,该函数什么也不做。用户可以重载它,以便在接收到数据时得到通知。注意:用户重载该函数时,需要备份接收到的数据。因为执行完该函数,接收到的数据将被丢弃。参数说明如下:
pData:指向接收到的数据。
size: 接收到的数据尺寸(以字节为单位)。
还有一些类成员,如GetLastError、GetPeerName、GetSockName、 Accept、
Connect、Listen、OnAccept、OnConnect、m_hSocket等都是直接继承自基类,
说明参见联机帮助中CAsyncSocket的相关部分。
2. 如何在自己的程序中使用CTransferSocket_hawk
首先把类的头文件及实现文件加到项目(Project)中, 然后用它派生自己的
类,建立连接的步骤与CAsyncSocket基本一致,只不过调用Accept接受客户端连
接请求时,必须使用CTransferSocket_hawk的派生类对象作为函数参数。连接成
功后,双方就可以调用TransferData发送数据。通过重载虚函数OnOneDataSent
和OnOneDataReceived,在数据发送/接收完毕后得到通知。
3.注意事项:
(1) 因为CTransferSocket_hawk覆写了基类CAsyncSocket的一些函数,用户
在从CTransferSocket_hawk派生自己的类时,最好不要覆写以下函数:
Create、Close、OnClose、OnSend、OnReceive。 如果必须覆写,别忘
了调用基类版函数;
(2) 服务器端用于监听的SOCKET不涉及收发数据,可以直接从CAsyncSocket
派生,但在接受客户端连接请求时,必须使用CTransferSocket_hawk的
派生类对象作为函数Accept的参数。
五、结束语
本来还可以给CTransferSocket_hawk添加以下两个功能:
(1) 在数据传输中,每收/发一次数据(即执行一次OnReceive/OnSend),都 调用一个虚函数,方便用户作一些工作,比如显示进度条什么的;
(2) 如果接收时出错,已收到的数据并不丢弃,还传给用户。
后来考虑这两个功能跟用户联系比较紧密,在用户程序中实现更合适一些。比如为了用进度条指示传输进度,可以先把数据大小预先送给对方(这里的尺寸是一个独立的数据块,不是数据块的组成部分,类型可由用户自己定义), 然后把数据拆分成若干小块,一块发送完毕后,才发下一块,进度条的更新放在虚函数OnOneDataSent中进行。而接收方则在OnOneDataReceived中更新进度条。
如果用户希望在传输出错时,保留已收到的数据,上述拆分原始数据的方法也同样适用,这样出错时,只有当前正在接收的那一小块数据被丢弃。
类CTransferSocket_hawk的源代码附在本文末尾,另外,我还编写了一个应用例程,需要的话可以从网上下载。
URL http://grwy.online.ha.cn/oldsong/example1.zip
所有代码均在 Win95OSR2、VC++ 5.0上调试通过。
参考文献:
(1) 刘彦明 王鹏,实用网络编程技术,西安电子科技大学出版社,1998 (2) 蒋东兴等,Windows Sockets 网络程序设计大全,清华大学出版社,1999
|