在项目10中,采用多线程技术实现了TCP协议的一对多通信,但如果客户端过多,就会导致服务器端的线程数量膨胀,使得服务器的资源占用过大。能不能让TCP程序在一个线程中同时与多个客户端进行通信呢?答案是可以的,这需要用到I/O复用模型,I/O复用模型的核心是select()函数,select()函数可以管理多个套接字,使服务器端在单个线程中仍然能够处理多个套接字的I/O事件,达到跟多线程操作类似的效果。
虽然用ioctlsocket()函数把socket设置成非阻塞的,然后用循环逐个socket查看当前套接字是否有数据到来,轮询进行,也能实现TCP的一对多通信,但这种方法需要不停地查看是否有数据到达,浪费CPU资源。
Select模型又称为I/O复用模型,因为使用select()函数来管理I/O而得名。select()函数可以管理很多个套接字,但其数量仍然是有限的,在WinSock 2中套接字集合中的元素最多只能是64个。如果需要管理更多的套接字,可以将select()函数与多线程技术相结合,每当套接字数量是64的倍数时,就新开一个线程。
为了实现一对多通信,需要在服务器端使用套接字集合,套接字集合中的每个套接字都可以与一个客户端单独进行通信。select()函数使用套接字集合fd_set来管理多个套接字,fd_set是一个结构体,用于保存一组套接字,它的定义如下:
typedef struct fd_set{
unsigned int fd_count;
SOCKET fd_array[FD_SETSIZE];
} fd_set;
其中,fd_count用来保存集合中套接字的个数,而fd_array(套接字数组)用于存储集合中所有套接字的描述符。FD_SETSIZE是一个常量,在WinSock2.h中定义,其值为64。
为了方便编程,Select模型提供了如下4个宏来对套接字集合进行操作。
下面介绍select()函数的使用,该函数的原型如下:
int select(
int nfds, //一般为0,仅为与Berkeley套接字兼容
fd_set * readfds, //一个套接字集合,用于检查可读性
fd_set * writefds, //一个套接字集合,用于检查可写性
fd_set * exceptfds, //一个套接字集合,用于检查错误
const struct timeval * timeout /*指定此函数等待的最长时间,若为NULL,则最长时间为无限大*/ )
返回值:负值表示select()函数执行出错;正值表示某些套接字可读写或出错;0表示timeout指定的时间内没有可读写或出错误的套接字。
select()函数中间的三个参数指向的三个套接字集合分别用来保存要检查可读性(readfds)、可写性(writefds)和是否出错(exceptfds)的套接字。
Select()返回时,如果有下列事件发生,对应的套接字不会被删除。
对于readfds,主要有以下事件:
对于writefds,主要有以下事件:
对于exceptfds,主要有以下事件:
可见,Select模型的优势在于,可以同时等待多个套接字,当某个或者多个套接字满足可读可写时,通知应用程序调用输入或者输出函数进行读写。
Select()函数就好像是一个消息中心,当消息到来时,通知应用程序接收和发送数据。应该看到Select模型完成一次I/O操作时需经历2次Windows Sockets函数的调用。例如,当接收对方数据时,第一步,调用Select()函数等待该套接字满足条件;第二步,调用recv()函数接收数据。
因此,使用Select函数的socket程序,其效率肯定会受到损失。因为,每一次Socket I/O调用都会经过该函数,因而会导致严重的CPU额外负担。在Socket连接数不多的情况下,这种效率损失是可接受的,但是当Socket连接数很多时,该模型肯定会产生问题。
使用Select模型编程的基本步骤如下:
①用FD_ZERO宏来初始化需要的fd_set;
②用FD_SET宏将套接字句柄分配给相应的fd_set,例如,如果要检查一个套接字是否有需要接收的数据,则可用FD_SET宏把该套接字的描述符加入可读性检测套接字集合中(第2个参数指向的套接字集合);
③调用select()函数,该函数将会阻塞直到满足返回条件,返回时,各集合中无网络I/O事件发生的套接字将被删除。例如,对可读性检查集合readfds中的套接字,如果select()函数返回时接收缓冲区中没有数据需要接收,select()函数则会把该套接字从集合中删除掉;
④用FD_ISSET对套接字句柄进行检查,如果被检查的套接字仍然在开始分配的那个fd_set里,则说明马上可以对该套接字进行相应的I/O操 作。例如,一个分配给可读性检测套接字集合readfds的套接字,在select()函数返回后仍然在该集合中,则说明该套接字有数据已经到来, 马上调用recv函数就可以读取成功。
实际上,一般的应用程序通常不会只有一次网络I/O,因此不会只有一次select()函数调用,而应该是上述过程的一个循环,因此应把select()函数的调用放到一个while循环里。
套接字创建后,在Select模型下,当发生网络I/O时,程序的执行过程是:向Select函数注册等待I/O操作的套接字,循环执行Select系统调用,阻塞等待,直到网络事件发生或超时返回,对返回的结果进行判断,针对不同的等待套接字进行对应的网络处理。
使用Select模型可以用很简洁的代码实现一个群聊软件。群聊软件分为服务器端和客户端,一个服务器端通过Select模型可连接多个客户端,服务器可以接收任何一个客户端发来的消息,然后把这个消息转发给其他客户端,该软件的运行效果如图11-1所示(启动了3个相同的客户端)。Select模型仅用在服务器端,客户端使用的仍然是6.3.2节中制作的TCP通信客户端。
图11-1 群聊软件运行效果
该群聊软件服务器端程序的实现原理是:首先将监听套接字加入到套接字集合FD_SET中,然后将与每个客户端通信的通信套接字逐个加入到套接字集合中,因此套接字集合FD_SET中的套接字如图11-2所示。
|
监听套接字 |
通信套接字1 |
通信套接字2 |
通信套接字n |
…… |
FD_SET集合 |
图11-2 套接字集合FD_SET示意图
服务器端程序的核心是在一个新开的线程中调用Select函数管理套接字集合,由于Select模型从程序启动开始,就要一直等待各个套接字的连接并通信,因此在Windows程序中,为了不阻塞主界面线程,必须把Select函数放到一个单独的线程中。该线程的伪代码如下:
while(true) { //让Select函数一直工作
FD_ZERO(&fdread); //初始化fdread
fdread=p->fdsock; //将fdsock中的所有套接字添加到fdread中
if(select(0, &fdread, NULL, NULL, NULL)>0) { //管理可读事件
for(int i=0;i
//如果有数据可读或连接到达事件
if (FD_ISSET(p->fdsock.fd_array[i], &fdread)) {
if(p->fdsock.fd_array[i]==p->sock_server) //如果是监听套接字,则表示是连接到达事件
{ …… //有客户连接请求到达,接受连接,并将返回的套接字加入到套接字集合
newsock=accept (p->sock_server, (struct sockaddr *) &client_addr, &addr_len);
FD_SET(newsock, &p->fdsock); //将新套接字加入fdsock
}
else { //说明不是监听套接字,则表示有客户发来数据,接收数据
int size=recv(p->fdsock.fd_array[i],msgbuffer,sizeof(msgbuffer),0);
if(size<0) {} //表示接收信息失败
else if(size==0){} //表示对方关闭了连接
else{ //size>0,表示接收到了信息
p->c_recvbuf.AddString( msgbuffer ); //将信息显示到列表框中
}} }}}}
因此,群聊软件服务器端程序的流程如图11-3所示。
添加监听套接字到套接字集合fdsock中 |
将fdsock添加到检查可读性集合readfds中 |
调用select()函数 |
如果存在可读事件fdread |
如果是监听套接字 |
如果是通信套接字 |
用accept()接受连接 |
将返回的套接字加入到fdsock集合中 |
用recv()接收数据 |
判断recv()的返回值 |
>0,表示接收到了数据 |
=0,表示收到了断开连接信息 |
<0,表示接收信息失败 |
分别对套接字集合中的每个套接字
|
可以用send()发送数据 |
图11-3 群聊软件服务器端程序流程图
该群聊软件服务器端程序制作的步骤如下:
1)创建一个MFC工程:新建工程,选择“MFC APPWizard(exe)”,输入工程名(如Selwins),单击“下一步”,在步骤1选择“基本对话框”,单击“完成”按钮。
2)在左侧“工作空间”中找到ResourceView选项卡,找到Dialog下的“IDD_SELWINS_DIALOG”,设置对话框的界面及各控件ID如图11-4所示。
图11-4 服务器端程序的界面及控件ID
3)按“Ctrl+W”键“建立类向导”,打开“MFC类向导”对话框,在“Member Variables”选项卡中为控件设置成员变量如图11-5所示。
图11-5 设置成员变量
4)初始化对话框界面,打开*dlg.cpp文件,在OnInitDialog()函数中加入如下代码:
BOOL CSelwinsDlg::OnInitDialog()
{……
m_ip=CString("127.0.0.1"); //默认的本机ip地址
m_port=CString("5566"); //默认的本机端口号
UpdateData(FALSE); //变量的值传到界面上
return TRUE; }
5)在*dlg.h文件中,添加如下引用头文件和定义端口号常量的代码。
#include "winsock2.h"
#pragma comment(lib,"ws2_32.lib")
#define PORT 5566 //定义端口号常量
6)在*dlg.h文件中,声明套接字变量和线程函数,以及管理套接字集合的变量,代码如下。
class CSelwinsDlg : public CDialog {
public:
CSelwinsDlg(CWnd* pParent = NULL); // standard constructor
SOCKET sock_server,newsock; //定义监听套接字和临时已连接套接字变量
fd_set fdsock; //保存所有套接字的集合
fd_set fdread; //select要检测的可读套接字集合
struct sockaddr_in addr; //存放本地地址的sockaddr_in结构变量
static UINT selectThread(LPVOID a); //声明线程函数
…… }
7)双击“启动”按钮,为该按钮编写创建套接字并监听的代码。
char msgbuffer[100],sendbuf[130],Climsg[40]; //定义用于接收客户端信息的缓冲区
void CSelwinsDlg::OnCreate() {
c_BTNCreate.EnableWindow(FALSE); //将启动按钮设置为无效
WSADATA wsaData;
if(WSAStartup(MAKEWORD(2,2),&wsaData)!=0) {
c_recvbuf.AddString("加载winsock.dll失败!\n"); }
if ((sock_server = socket(AF_INET,SOCK_STREAM,0))<0) { //创建套接字
c_recvbuf.AddString("创建套接字失败!\n");
WSACleanup(); }
int addr_len = sizeof(struct sockaddr_in);
memset((void *)&addr,0,addr_len);
addr.sin_family =AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY); //允许套接字使用本机的任何IP
if(bind(sock_server,( struct sockaddr *)&addr,sizeof(addr))!=0) {
c_recvbuf.AddString("地址绑定失败!\n");
closesocket(sock_server);
WSACleanup(); }
if(listen(sock_server,5)==0)
c_recvbuf.AddString("等待客户端连接......\n");
FD_ZERO(&fdsock); //初始化fdsock
FD_SET(sock_server, &fdsock); //将监听套接字加入到套接字集合fdsock
AfxBeginThread(&CSelwinsDlg::selectThread,(LPVOID)this); //创建线程
}
8)编写线程函数selectThread(),该函数主要功能是管理套接字集合,其功能分为三大块,即:① 接受连接;② 接收数据;③ 发送数据。其中接受连接和接收数据都是对fdread集合进行判断,如果该集合中的套接字为监听套接字,则接受连接,而如果该集合中的套接字为通信套接字,则接收数据。如果接收数据成功,则表明也可发送数据,此时使用for循环向fdsock中的所有其他套接字发送数据。代码如下:
UINT CSelwinsDlg::selectThread(LPVOID a){
fd_set fdread; //select要检测的可读套接字集合
fd_set writefds; //select要检测的可写套接字集合
SOCKET newsock; //声明通信套接字
struct sockaddr_in client_addr; //存放客户端地址的sockaddr_in变量
CSelwinsDlg*p; //获得窗口的句柄
int addr_len = sizeof(struct sockaddr_in);
p=( CSelwinsDlg*)a;
while(true) { //循环:接收连接请求并收发数据
FD_ZERO(&fdread); //初始化fdread
fdread=p->fdsock; //将fdsock中的所有套接字添加到fdread中
writefds=p->fdsock;
if(select(0, &fdread, NULL, NULL, NULL)>0){ //管理fdread集合
for(int i=0;i
if (FD_ISSET(p->fdsock.fd_array[i], &fdread)) {
if(p->fdsock.fd_array[i]==p->sock_server) //如果是监听套接字
{ //有客户连接请求到达,接收连接请求
newsock=accept (p->sock_server, (struct sockaddr *) &client_addr, &addr_len);
if(newsock==INVALID_SOCKET) { //accept出错则终止所有通信
p->c_recvbuf.AddString("accept函数调用失败!\n");
for(int j=0;j
closesocket(p->fdsock.fd_array[j]); //关闭所有套接字
WSACleanup(); } //注销WinSock动态链接库
else { //接受客户端连接成功
sprintf(Climsg,"客户端%s:%d连接成功",inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
p->c_recvbuf.AddString(Climsg); //提示连接成功
send(newsock,Climsg,strlen(Climsg)+1,0) ; //发送提示信息
FD_SET(newsock, &p->fdsock); //将新套接字加入fdsock
}}
else { //有客户发来数据,接收数据
memset((void *) msgbuffer,0, sizeof(msgbuffer)); //缓冲区清零
int size=recv(p->fdsock.fd_array[i],msgbuffer,sizeof(msgbuffer),0);
if(size<0) //接收信息
p->c_recvbuf.AddString("接收信息失败!");
else if(size==0)
p->c_recvbuf.AddString("对方已关闭!\n");
else { //显示收到信息
//获取对方IP地址
getpeername(p->fdsock.fd_array[i], (struct sockaddr *)&client_addr, &addr_len);
sprintf(sendbuf,"%s说:%s",inet_ntoa(client_addr.sin_addr),msgbuffer);
p->c_recvbuf.AddString(sendbuf );
for(int j=0;j
//去掉监听套接字和发送消息的套接字
if(p->fdsock.fd_array[j]!=p->sock_server && j!=i)
send(p->fdsock.fd_array[j],msgbuffer,strlen(msgbuffer)+1,0) ;//向所有成员转发收到的信息
}
break; }
closesocket(p->fdsock.fd_array[i]); //关闭套接字
FD_CLR(p->fdsock.fd_array[i],&(p->fdsock));//清除已关闭套接字
} } } }
else {
p->c_recvbuf.AddString("Select调用失败!");
break; //终止循环退出程序
} }
return 0; }
本例中,发送数据前也可对writefds集合进行判断,如果该集合不为空,则表示可以发送数据。因此,上述代码也可改写成如下形式:
if(select(0, &fdread, &writefds, NULL, NULL)>0) {
for(int i=0;i
……
if (FD_ISSET(p->fdsock.fd_array[i], &writefds)) { //对writefds集合进行判断
for(int j=0;j
if(p->fdsock.fd_array[j]!=p->sock_server && j!=i)
send(p->fdsock.fd_array[j],msgbuffer,strlen(msgbuffer)+1,0) ;
} }
}}
1. 以下哪一项不会触发select()函数中的可读事件 ( )
A. 有数据可接收 B. 有连接请求到达
C. 有连接断开 D. 有数据可发送
2. 以下哪个宏可用来将一个套接字加入到select()函数的集合中 ( )
A. FD_ZERO B. FD_SET C. FD_CLR D. FD_ISSET
3. select()函数有 个参数 ( )
A. 3 B. 4 C. 5 D. 6
4. select()函数可以管理的套接字集合有 , , 。
5. select()函数的返回值等于0表示 。
6. 简述使用select模型实现TCP一对多通信的步骤。
7.(实验)将9.3.2节的网络用户登录程序服务器端用Select模型改写成一对多通信的。