如何用Socket实现TCP/IP客户端
——实例浅析
1. 引言时下,互联网平民化,家电信息化,办公自动化,城市数字化。通信在人们的生活、工作、娱乐等各个方面起到了至关重要的作用。如今大多数程序,都可能要涉及到通信方面,可能是与自己开发的程序通信,也可能是与网络上的其它程序通信。TCP/IP是应用最为广泛的协议之一,下面我就如何用Socket实现TCP/IP客户端通信模块,将我个人在这方面实践的心得与读者分享。
2. WinSocket简介 2.1. 从Socket 1.1到Socket 2.0最初Window推出的Socket1.1只应用于当时流行的TCP/IP,不支持其它的传输协议。用Socket1.1实现的程序只支持两种类型的Socket,即面向连接的SOCK_STREAM类型和面向无连接的SOCK_DGRAM。
鉴于1.1版本的局限性,微软又开发了Socket 2.0。其中一个主要目的是想提供一个与协议无关的接口,全面满足多媒体实时通信的需要。这里注意到,Socket 2.0并不是一种协议,而是一种接口,它能发现多种可用的传输协议。因此,在Socket 2.0中加入了一些新函数,这些函数都是以WSA打头的。
Socket 2.0改变了它的体系结构,以便更容易访问多种传输协议。按照Windows的开放系统体系结构模型(WOSA)的原则,Socket 2.0定义出了标准的服务提供商接口(Service Provider Interface,SPI)。这些接口介于应用程序接口(Application Programming Interface, API)及协议栈(Protocol Stacks)之间,是给不同的服务提供商用的。程序中用的是API。如图 2‑1所示
图 2‑1 Socket 2.0体系结构
2.2. MFC对Socket API的封装在MFC中,有两个类对Socket API进行了封装,一个是CAsyncSocket,另一个是CSocket,其中CSocket是从CAsyncSocket继承而来的,因此,准确地说,只有CAsyncSocket才是真正对Socket API进行了封装。封装后的Socket抽象成为一个对象,使得程序员可以将Socket和MFC的其它类融合在一起。
如果要单独使用CAsyncSocket类,要求编程人员对Windows的Socket有较深刻的了解,因为它只是对Socket API进行封装,编程人员还要处理应用当中可能碰到的问题,如阻塞,字节顺序以及Unicode至多字节字符集(MBCS)的转换等。而且这些问题处理起来并不容易。
庆幸的是,CSocket使用起来就方便多了。一方面,它从CAsyncSocket类继承而来,相当于封装了Socket API;另一方面,该类处理了上述的问题,大大减少了编程人员的工作量。除此之外,CSocket还可以与CSocketFile和CArchive类一起使用,在某些场合极其方便。
3. 实例浅析用Socket实现TCP/IP通信的方法有多种,从大体上来说,可分为MFC和非MFC两种。顾名思义,MFC方式就是利用MFC中定义的两个类来实现,而非MFC则是指不利用那两个类实现。就方式而言,有同步和异步之别。而且,客户端与服务端的实现也大不相同。
接下来,我要谈到的例子就是笔者实现的一个客户端通信模块。该模块功能不多,一个是数据的接收和发送,另一个是重连机制,因为考虑到应用场合,不允许出现网络连接断开后,重新启动程序,而要求程序有自动重连的能力。
这个实例中一共有涉及到三个类(如图 3‑1所示),其名称及功能如下:
图 3‑1模块类图
CSockClient,完成连接建立,数据的接收和发送功能;CCommSvr,完成自定义的协议功能,数据打包、数据包解析及数据的分发等;CSyncData,处理线程间的数据同步问题。
在这里,我想结合一个客户端实例(MFC方式)以及实现过程中遇到的一些问题和读者一起探讨一下。
3.1. 由消息驱动数据接收接收Socket数据时,一般来说有几种方法,一是用一个线程循环调用Select函数,通过返回值判断是否有数据到来或是出现Socket错误。另外一种方式就是实现CAsyncSocket提供的虚拟函数OnRecevie,在该函数中调用其它函数接收数据,还有就是通过调用AsyncSelect函数,请求Socket将消息发送给指定的窗口,然后在窗口的消息处理函数中调用相应的函数接收数据。
对于第一种方法,大多数在非MFC方式下使用。而在MFC方式下,主要用后两种方法。对后两种方法而言,从根本上来说是一样的,只是接收消息的窗口不一样而已。也许大多数读者已经发现,实际上Socket本身有一个叫Socket Notification窗口,它是不可见的,但是可以接收并处理消息。
那么究竟如何区别后两种方法的使用呢?笔者认为,对于简单的客户端来说,可能数据的接收与发送都是在一个线程中完成的,这种情况下,可以考虑请求Socket将消息发送到主窗口,然后在主窗口中处理这些消息,如连接、接收数据等。即采用第三种方法
而对于得杂一点的客户端来说,由于数据的接收一般来说是在与主线程不同的线程中处理的,此时考虑到程序的模块化,不宜将Socket消息发送到主窗口中处理,因此应采用第二种方法,即实现CAsyncSocket提供的虚拟函数OnRecevie。
3.2. 线程间的数据同步线程同步是一个多线程编程中的一个重点和难点。线程间的数据同步有一个原则,就是要把要同步的数据集中起来考虑,尽量减少要同步的数据。同步的目的是要保证数据的有效性和完整性,直观的说,就是不出现两个或两个以下线程同时访问同一数据块。
在这个实例中,我专门用了一个类来实现数据的同步,叫CSyncData。实现的思想是这样的。通信模块只负责数据的解析和分发,不负责数据的处理。当通信模块接收到数据时,经通信模块解析确认为有效数据后,通信模块根据数据类型进行分发,一方面将数据放入到CSyncData实例中的相应链表中(比如,调用InsertAck)。这时就要进行同步,因为有可能将要访问的链表正在被其它线程访问。另一方面,发送消息给其它线程,通知有新的数据要处理。
其它线程接收到消息后,则要对数据进行处理。此时,首先要从链表中得到相应的数据(比如,GetAck),此时也要进行同步,因为也可能有其它线程在访问链表。
这样就把数据的同步集中在CSyncData中,由该类集中处理,结构清晰,而且不容易出错。具体的同步过程很简单,如:
// 将回执信息加入列表
void CSyncData::InsertAck(SACK* pAck)
{
CSingleLock lock(&m_csAck);
lock.Lock();
if (!lock.IsLocked()) return;
// 将数据加入列表
m_ackList.AddTail(pAck);
if (m_ackList.GetCount() > MAX_LIST_COUNT)
delete m_ackList.RemoveHead();
lock.Unlock();
}
// 从列表中取出一个到发点信息
SACK* CSyncData::GetAck()
{
SACK* pAck = NULL;
CSingleLock lock(&m_csAck);
lock.Lock();
if (!lock.IsLocked()) return NULL;
// 将数据从列表中取出
if (m_ackList.GetCount())
pAck = m_ackList.RemoveHead();
lock.Unlock();
return pAck;
}
3.3. 处理Pending消息也许读者碰到过这样的问题。当你的客户端程序试图与远端的程序建立连接,而目标主机IP地址又不存在时,程序会出现暂时的阻塞。当然,如果在用多线程的话,用户可能感觉不到。但如果此时,用户要退出程序呢?就会出现程序不能马上退出的现象。
原因在哪?是这样的。当你调用Connect函数试图与远端主机连接时,如果主机不存在,则Connect不会立即返回,大概要20秒左右。如果主机存在则不会出现这种情况。那么,此时如果要快速退出程序,怎么办呢?
CSocket类中提供了一个虚拟函数,OnMessasgePending。当Socket所在线程出现等待消息时,框架会调用该函数,让程序处理其它消息。这样就可以在实现该函数,以便快速退出程序了。代码如下:
BOOL CSockClient::OnMessagePending()
{
MSG msg;
if (::PeekMessage(&msg, NULL, WM_QUIT, WM_QUIT, PM_NOREMOVE))
{
if (IsBlocking()) CancelBlockingCall();
return TRUE;
}
return FALSE;
}
这样,如果在调用其它函数的情况下,出现阻塞了,也可以快速退出。
3.4. 如何快速定位帧头如果通信中涉及到的只是文本信息,可能不存在这种问题。但大多情况下,可能会用到自己定义的协议,这样就会出现帧头定位的问题。
通常的做法是,当有数据时,首先找帧头,一个接一个地检测,直到找到帧头。在正常的情况下,这种方法是可行的,但是有可能数据出错了,数据读完了,也没找到帧头。这样程序一直在等待数据,直到下一帧数据到来。此时如果远端程序出错异常了,客户端就会无限的等待。当然,要退出程序还是问题。
笔者在碰到这种问题时,采取了以下方法:每当有数据到来时,我读一个字节,判断与帧头的定义是否一致,如果是,继续。一旦发现不对,马上退出接收函数,等下一次消息时再检测。这样处理的好处是,基本上可以保证不会等待远端的数据,因为接收到消息后,至少有一个字节的数据。而且,在正常情况下,其效果与上面提到的方法一样。不足的是,如果数据出错了,会出现频繁地调用接收函数。但总比“死等”要好。
3.5. Release版本下运行出错如果你在除主线程以外的线程中使用了CSocket,Release版本下可能无法运行,但Debug版本没问题。这是微软的问题。
解决的办法很简单,只要在线程初始化的时候,加入以下代码即可:
//在线程中初始化Socket。
#ifndef _AFXDLL
#define _AFX_SOCK_THREAD_STATE AFX_MODULE_THREAD_STATE
#define _afxSockThreadState AfxGetModuleThreadState()
_AFX_SOCK_THREAD_STATE* pState = _afxSockThreadState;
if (pState->m_pmapSocketHandle == NULL)
pState->m_pmapSocketHandle = new CMapPtrToPtr;
if (pState->m_pmapDeadSockets == NULL)
pState->m_pmapDeadSockets = new CMapPtrToPtr;
if (pState->m_plistSocketNotifications == NULL)
pState->m_plistSocketNotifications = new CPtrList;
#endif
4. 总结通信模块对很多程序来说,可能是一个很重要的模块。一个好的通信模块不仅要高效,而且要健壮。通信是一个动态过程,情况比较复杂,调试起来也比较费劲,因此,要写出一个好的通信模块,必须下一定的苦功。
以上谈到的是我个人对Socket的一些浅薄的认识以及在实践过程的一点小小的经验。不足或不对之处,恳请各位同行多多指教。共勉!
来自北京三棵树软件技术有限公司
作者:HC.Kang
2002-07-02
本文地址:http://com.8s8s.com/it/it2420.htm