最近做了一个项目,是一个关于局域网内的网络分发程序,程序采用C/S结构,简单的描述就是:
服务器端从一个USB设备中采集数据,然后要分发给三个客户端,其中一个客户端是本机,这三个客户端和服务器是在同一个局域网内。服务器端每秒从USB设备中采集90次数据,发给每个客户端30次数据,服务器端根据客户端连接的IP地址不同来发送不同的数据,同时,程序的实时性要求比较高,服务器从USB设备上采集到数据后要及时的发送到客户端,每次发送的数据量是32字节。
程序的逻辑是:
服务器端:
异步非阻塞,当客户端连接上后,服务器端只需要向socket中写入数据即可,对于每一个连接上的socket,每秒服务器端向socket中写入30次数据。所以服务器端只需要写就行。服务器端有界面。
客户端:
为了保证使用的简单和可移植性,客户端做成dll动态链接库,应用程序再去使用这个dll,根据服务器端发送数据的频率,在客户端每秒钟需要解析30次数据,但是应用程序通过dll获取数据的频率有时会超过30次,所以如果采用recv函数,每次应用程序都会被阻塞,直到socket中有数据为止。
客户端有几种设计思路:
方案1:设计成多线程程序,定义一个全局变量,一个线程专门负责从socket中读数据,数据解析后放入这个全局变量中,这里使用轮训即可,即没有数据就阻塞住,一旦socket中有数据就马上读出;应用程序的主线程一旦需要通过dll获取数据,直接返回这个全局变量中的值。
(这个也是最后采用的方案,但是这里会有一个潜在的问题,这里对全局变量没有加锁,这就可能造成数据的不一致,但是考虑到系统本身,因为相邻两次数据变化不大,而且频率比较高,每秒中30次,这样做问题不大,最终的实际效果也证明了这点。)
方案2:客户端采用非阻塞的方式,使用单线程,一旦应用程序通过dll获取数据,则马上到socket中读取数据,并将数据放入一个全局变量中,如果socket中没有数据,则返回上一次的数据。
(对于这个方案,如果应用程序通过dll获取数据的频率超过30次/s,那么程序没有问题;但是一旦频率小于30次,那么socket就可能会拥堵,这样实时性就不能保证,可取的做法是每次应用程序通过dll获取数据,都需要把socket中的数据读空为止,然后返回最新的值,这样理论上就不会造成拥塞。这个方案也是可行的,只是最后因为第一种方案的实现更简单,最后就没有采用该方案。)
测试过程中碰到的问题:
上面的设计方案看起来很完美,但是最终实现过后总会有延迟现象发生,在经过艰苦的测试后,最终发现原来是TCP协议本身的问题。
TCP/IP默认情况下采用Nagle算法,Nagle算法通过将未确认的数据存入缓冲区直到蓄足一个包一起发送的方法,来减少主机发送的零碎小数据包的数目,如果一定时间内包的数量还不够大,那么默认情况下为200ms发送一次(给数据包打上时间戳后发现的)。
对于Nagle算法,如果是因特网上程序,这样做无疑减少了数据收发的次数,提高了网络性能。但是对于实时性要求较高的应用,这样做无疑就会造成延迟,而且程序本身处于局域网中,并且数据量不大,因此我们可以通过setsockopt()来禁止使用Nagle算法,代码如下:
BOOL bVal= TRUE;
setsockopt(remoteSocket[0], IPPROTO_TCP,TCP_NODELAY,(char *) &bVal,sizeof(BOOL));
至此,就解决了数据发送接收实时性的问题。
(这个问题耗费了我和另外几个哥们大量的时间)
其实,对于该问题,在《TCP/IP卷一》和《UNIX网络编程卷一》当中都有明确的说明,只是以前做项目并没有做到这么底层,所以给忽略了,经过这个项目,经验值暴增。
下面是具体的服务器端的实现,对于客户端,这里就略掉了。
对于这样一个项目需求,首先要选择的是网络模型。Windows的网络模型很大程度上参考了Berkeley的socket实现,很多Unix上的socket系统调用也能在Windows下使用,但是Windows也提供了一些自身特有的模型,查看《Windows网络编程技术》可以知道,主要有下面几种I/O模型:
选择(Select)、异步选择 (WSAAsyncSelect)、事件选择(WSAEventSelect)、重叠I/O(Overlapped I/O)和完成端口(Completion Port)共五种I/O模型。
到目前为止,使用过的有Select模型和WSAAsyncSelect模型,以前实习的时候见到别人的代码使用过Completion Port模型,WSAEventSelect和Overlapped I/O则没有使用过。
因为服务器端有窗口,而且需要异步非阻塞的发送,于是就采用WSAAsyncSelect来进行实现。
8.2.2 WSAAsyncSelect
Wi n s o c k提供了一个有用的异步I / O模型。利用这个模型,应用程序可在一个套接字上,接收以Wi n d o w s消息为基础的网络事件通知。具体的做法是在建好一个套接字后,调用W S A A s y n c S e l e c t函数。该模型最早出现于Wi n s o c k的1 . 1版本中,用于帮助应用程序开发者面向一些早期的1 6位Wi n d o w s平台(如Windows for Wo r k g r o u p s),适应其“落后”的多任务消息环境。应用程序仍可从这种模型中得到好处,特别是它们用一个标准的Wi n d o w s例程(常称为“ w i n p r o c”),对窗口消息进行管理的时候。该模型亦得到了Microsoft Foundation Class(微软基本类,M F C)对象C S o c k e t的采纳。
消息通知
要想使用W S A A s y n c S e l e c t模型,在应用程序中,首先必须用C r e a t e Wi n d o w函数创建一个窗口,再为该窗口提供一个窗口例程支持函数( Wi n p r o c)。亦可使用一个对话框,为其提供一个对话例程,而非窗口例程,因为对话框本质也是“窗口”。考虑到我们的目的,我们打算用一个简单的窗口来演示这种模 型,采用的是一个支持窗口例程。设置好窗口的框架后,便可开始创建套接字,并调用W S A A s y n c S e l e c t函数,打开窗口消息通知。该函数的定义如下:
int WSAAsyncSelect(
SOCKET s,
HWND hWnd,
unsigned int wMsg,
long lEvent
);
其 中, s参数指定的是我们感兴趣的那个套接字。h W n d参数指定的是一个窗口句柄,它对应于网络事件发生之后,想要收到通知消息的那个窗口或对话框。w M s g参数指定在发生网络事件时,打算接收的消息。该消息会投递到由h W n d窗口句柄指定的那个窗口。通常,应用程序需要将这个消息设为比Wi n d o w s的W M _ U S E R大的一个值,避免网络窗口消息与预定义的标准窗口消息发生混淆与冲突。最后一个参数是l E v e n t,它指定的是一个位掩码,对应于一系列网络事件的组合(请参考表8 - 3),应用程序感兴趣的便是这一系列事件。大多数应用程序通常感兴趣的网络事件类型包括: F D _ R E A D、F D _ W R I T E、F D _ A C C E P T、F D _ C O N N E C T和F D _ C L O S E。当然,到底使用F D _ A C C E P T,还是使用F D _ C O N N E C T类型,要取决于应用程序的身份到底是一个客户机呢,还是一个服务器。如应用程序同时对多个网络事件有兴趣,只需对各种类型执行一次简单的按位O R(或)运算,然后将它们分配给l E v e n t就可以了。举个例子来说:
WSAAsyncSelect(s,hWnd,WM_SOCKET,FD_CONNECT|FD_READ|FD_WRITE|FD_CLOSE);
这样一来,我们的应用程序以后便可在套接字s上,接收到有关连接、发送、接收以及套
接字关闭这一系列网络事件的通知。特别要注意的是,多个事件务必在套接字上一次注册!
另 外还要注意的是,一旦在某个套接字上允许了事件通知,那么以后除非明确调用c l o s e s o c k e t命令,或者由应用程序针对那个套接字调用了W S A A s y n c S e l e c t,从而更改了注册的网络事件类型,否则的话,事件通知会永远有效!若将l E v e n t参数设为0,效果相当于停止在套接字上进行的所有网络事件通知。
若应用程序针对一个套接字调用了W S A A s y n c S e l e c t,那么套接字的模式会从“锁定”自动变成“非锁定”,我们在前面已提到过这一点。这样一来,假如调用了像W S A R e c v这样的Wi n s o c k I / O函数,但当时却并没有数据可用,那么必然会造成调用的失败,并返回W S A E W O U L D B L O C K错误。为防止这一点,应用程序应依赖于由W S A A s y n c S e l e c t的u M s g参数指定的用户自定义窗口消息,来判断网络事件类型何时在套接字上发生;而不应盲目地进行调用。
表8-3 用于W S A A s y n c S e l e c t函数的网络事件类型
F D _ R E A D 应用程序想要接收有关是否可读的通知,以便读入数据
F D _ W R I T E 应用程序想要接收有关是否可写的通知,以便写入数据
F D _ O O B 应用程序想接收是否有带外( O O B)数据抵达的通知
F D _ A C C E P T 应用程序想接收与进入连接有关的通知
F D _ C O N N E C T 应用程序想接收与一次连接或者多点j o i n操作完成的通知
F D _ C L O S E 应用程序想接收与套接字关闭有关的通知
F D _ Q O S 应用程序想接收套接字“服务质量”(Q o S)发生更改的通知
F D _ G R O U P _ Q O S 应用程序想接收套接字组“服务质量”发生更改的通知(现在没什么用处,为未来套接字组的使用保留)
F D _ R O U T I N G _ I N T E R FA C E _ C H A N G E 应用程序想接收在指定的方向上,与路由接口发生变化的通知
F D _ A D D R E S S _ L I S T _ C H A N G E 应用程序想接收针对套接字的协议家族,本地地址列表发生变化的通知
应用程序在一个套接字上成功调用了W S A A s y n c S e l e c t之后,应用程序会在与h W n d窗口句柄参数对应的窗口例程中,以Windows消息的形式,接收网络事件通知。窗口例程通常定义如下:
LRESULT CALLBACK WindProc(
HWND hWnd,
UINT uMsg,
WPARAM wParam,
LPARAM lParam
);
其 中,h W n d参数指定一个窗口的句柄,对窗口例程的调用正是由那个窗口发出的。u M s g参数指定需要对哪些消息进行处理。就我们的情况来说,感兴趣的是W S A A s y n c S e l e c t调用中定义的消息。w P a r a m参数指定在其上面发生了一个网络事件的套接字。假若同时为这个窗口例程分配了多个套接字,这个参数的重要性便显示出来了。在l P a r a m参数中,包含了两方面重要的信息。其中, l P a r a m的低字(低位字)指定了已经发生的网络事件,而l P a r a m的高字(高位字)包含了可能出现的任何错误代码。
网 络事件消息抵达一个窗口例程后,应用程序首先应检查l P a r a m的高字位,以判断是否在套接字上发生了一个网络错误。这里有一个特殊的宏: W S A G E T S E L E C T E R R O R,可用它返回高字位包含的错误信息。若应用程序发现套接字上没有产生任何错误,接着便应调查到底是哪个网络事件类型,造成了这条Wi n d o w s消息的触发—具体的做法便是读取l P a r a m之低字位的内容。此时可使用另一个特殊的宏:W S A G E T S E L E C T E V E N T,用它返回l P a r a m的低字部分。
在程序清单8 - 5中,我们向大家演示了如何使用W S A A s y n c S e l e c t这种I / O模型,来实现窗口消息的管理。在源程序中,我们着重强调的是开发一个基本服务器应用要涉及到的基本步骤,忽略了开发一个完整的Wi n d o w s应用需要涉及到的大量编程细节。
程序清单8-5 WSAAsyncSelect服务器示范代码
#define WM_SOCKET WM_USER+1
#inlude <windows.h>
int WINAPI WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,
LPSTR lpCmdline,int nCmdShow)
{
SOCKET listen;
HWND window;
//create a window and assign the serverWinProc blew to it
window = CreateWindow();
//start winsock and create a socket
WSAStarup(...);
listen = Socket();
//Bind the socket to port 5150
// and begin listening for connection
InternetAddr.sin_family = AF_INET;
InternetAddr.sin_addr.s_addr=htonl(INADDR_ANY);
InternetAddr.sin_port = htons(5150);
bind(listen,(PSOCKETADDR)&InternetAddr,sizeof(InternetAddr));
//set up window message notification on the new socket using the WM_SOCKET define above
WSAAsyncSelect(listen,window,WM_SOCKET,FD_ACCEPT|FD_CLOSE);
listen(listen,5);
//translate and dispatch window messages
//until the appliation terminates
}
BOOL CALLBACK ServerWinProc(HWND hDlg,WORD wMsg,WORD wParam,WORD lParam)
{
SOCKET accept;
switch(wMsg)
{
case WM_PAINT:
break;
case WM_SOCKET:
//determine whether an error occured on the socket
//by using the WSAGETSELECTERROR() macro
if(WSAGETSELECTERROR(lWparam))
{
//display the error and close the socket
closesocket(wParam);
break;
}
//determine what event occured on the socket
switch(WSAGETSELECTEVENT(lParam)
{
case FD_ACCEPT:
//ACCEPT an incoming connection
Accept = accept(wParam,NULL,NULL);
//prepare accepted socket for read
//write,and close notifation
WSAAsyncSelect(Accept,hWnd,WM_SOCKET,FD_READ|FD_WRITE|FD_CLOSE);
case FD_READ:
//RECEIVE data from the socket in wParam
break;
case FD_WRITE:
//THE socket in wParam is ready for sending data
break;
case FD_CLOSE:
//THE connection is now closed
closesocket(wParam);
break;
}
break;
}
return TRUE;
}
最后一个特别有价值的问题是应用程序如何对F D _ W R I T E事件通知进行处理。只有在三种条件下,才会发出F D _ W R I T E通知:
■ 使用c o n n e c t或W S A C o n n e c t,一个套接字首次建立了连接。
■ 使用a c c e p t或W S A A c c e p t,套接字被接受以后。
■ 若s e n d、W S A S e n d、s e n d t o或W S A S e n d To操作失败,返回了W S A E W O U L D B L O C K错误, 而且缓冲区的空间变得可用因此,作为一个应用程序,自收到首条F D _ W R I T E消息开始,便应认为自己必然能在一个套接字上发出数据,直至一个s e n d、W S A S e n d、s e n d t o或W S A S e n d To返回套接字错误W S A E W O U L D B L O C K。经过了这样的失败以后,要再用另一条F D _ W R I T E通知应用程序再次发送数据