十、基于I/O模型的网络开发
接着上次的博客继续分享:基于I/O模型的网络开发
10.7 选择模型
10.7.1 基本概念
选择 (select) 模型是一种比较常用的I/O 模型。利用该模型可以使Windows socket应 用 程序同时管理多个套接字。使用select 模型,可以使当执行操作的套接字满足可读可写条件时 给应用程序发送通知。收到这个通知后,应用程序再去调用相应的Windows socket API去执行 函数调用。
select 模型的核心是select 函数。调用select 函数检查当前各个套接字的状态。根据函数 的返回值判断套接字的可读可写性,然后调用相应的Windows Sockets API完成数据的发送、 接收等。
select 模型的原理图如图10-1所示。
select 模 型 是Windows sockets中常见的I/O 模型,利用select 函数实现I/O 管理。通过对 select函数的调用,应用程序可以判断套接字是否存在数据、能否向该套接字写入数据。比如, 在调用recv 函数之前,先调用select函数,如果系统没有可读数据,select函数就会阻塞在这 里。当系统存在可读或可写数据时,select 函数返回,就可以调用recv 函数接收数据了。
可以看出使用select 模型需要两次调用函数。第一次调用select 函数,第二次调用收发函 数。使用该模式的好处是可以等待多个套接字。
select也有几个缺点:
- (1)I/O 线程需要不断地轮询套接字集合状态,浪费了大量CPU 资源。
- (2)不适合管理大量客户端连接。
- (3)性能比较低下,要进行大量查找和复制。
10.7.2 10.7.2 select函数
select 模型利用select 函数实现I/O 管理。通过对select 函数的调用,应用程序可以判断套 接字是否存在数据、能否向该套接字写入数据。例如,在调用 recv 函数之前,先调用 select 函数,如果系统没有可读数据,那么 select 函数会阻塞在这里。当系统存在可读数据时,select 函数返回,就可以调用recv 函数接收数据了。发送数据的形式也是如此。
select 函数声明如下:
int select(
Int nfds, // 被忽略,传入0即可
fd set *readfds, // 可读套接字集合
fd set *writefds, // 可写套接字集合
fd_set *exceptfds, // 错误套接字集合
const struct timeval *timeout); // select函数等待时间
- 其中,参数nfds 被忽略;
- 参数readfds 为可读性套接字集合指针;
- 参数writefds 为可写性 套接字集合指针;
- 参数 exceptfds为检查错误套接字集合指针;
- 参数timeout表示 select的等待 时间,定义如下:
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 毫秒
};
-
当 timeval为空指针时,select会一直等待,直到有符合条件的套接字时才返回。
-
当tv_sec 和 tv_usec 之和为0时,无论是否有符合条件的套接字,select 都会立即返回。
-
当tv sec 和 tv usec 之和为非0时,如果在等待的时间内有套接字满足条件,那么该函数 将返回符合条件的套接字。
-
如果在等待的时间内没有套接字满足设置的条件,那么 select会 在 时间用完时返回,并且返回值为0。select函数返回处于就绪态并且已经被包含在fd_set 结构 中的套接字总数,如果超时就返回0。
fd_set结构是一个结构体,声明如下:
typedef struct fd_set
{
u_int fd_count;
socket fd_array[FD SETSIZE];
} fd_set;
- 其 中 ,fd_count 表示该集合套接字数量,最大为64;
- fd_array 为套接字数组。 我们可以看到,select 函数中需要3个fd_set 结构:
- readfds: 准备接收数据的套接字集合,即可读性集合。
- writefds: 准备发送数据的套接字集合,即可写性集合。
- exceptfds: 出错的套接字集合。
在select函数返回时,会在fd_set结构中填入相应的套接字。
其中,readfds数组将包括满 足以下条件的套接字:
- (1)有数据可读。此时在此套接字上调用recv, 立即收到对方的数据。
- (2)连接已经关闭、重设或终止。
- (3)正在请求建立连接的套接字。此时调用accept 函数会成功。
writefds数组包含满足下列条件的套接字:
- (1)有数据可以发出。此时在此套接字上调用send, 可以向对方发送数据。
- (2)调用connect 函数,并连接成功的套接字。
exceptfds 数组将包括满足下列条件的套接字:
- (1)调用connection 函数,但连接失败的套接字。
- (2)有带外 (out of band) 数据可读。
当select 函数返回时,它通过移除没有未决I/O 操作的套接字句柄来修改每个fd_set 集 合。(这里解释下未决I/O, 它意思是你没有做出决定的I/O。比如套接字上可以读数据了, 即调用recv 会成功,而你没有在那个socket 上做出recv 调用,那这个socket 就叫作未决I/O 套接字。)
使用select 的好处是程序能够在单个线程内同时处理多个套接字连接,避免了阻塞模式下的线程膨胀问题。
-
但是,添加到 fd set 结构的套接字数量是有限制的,默认情况下, 最大值是FD SETSIZE, 在 winsock2.h 文件中定义为64。
-
为了增加套接字数量,应用程序可以将FD_SETSIZE定义为更大的值(这个定义必须在包含winsock2.h 之前出现)。
-
不过,自定义的值也不能超过Winsock 下层提供者的限制(通常是1024)。
-
另外,FD_SETSIZE值太 大的话,服务器性能就会受到影响。例如,有1000个套接字,那么在调用select 之前就不得 不设置这1000个套接字,select 返回之后又必须检查这1000个套接字。
10.7.3 实战select 模型
-
在调用select 函数对套接字进行监视之前,必须将要监视的套接字分配给上述3个数组(即 readfds 、writefds 和 exceptfds) 中的一个。
-
然后调用select 函数,当select 函数返回时,判断 需要监视的套接字是否还在原来的集合中,就可以知道该集合是否正在发生I/O 操作。比如, 应用程序想要判断某个套接字是否存在可读的数据,需要进行如下步骤:
-
- (1)将该套接字加入 readfds 集合。
-
- (2) 以readfds作为第二个参数调用select函数。
-
- (3) 当select 函数返回时,应用程序判断该套接字是否仍然存在于readfds 集合。
-
- (4)如果该套接字存在于readfds 集合,就表明该套接字可读。此时可以调用recv 函数 接收数据;否则,该套接字不可读。
-
在调用select 函 数 时 ,readfds 、writefds 和 exceptfds 这3个参数至少有一个为非空,并且 在该非空的参数中,必须至少包含一个套接字,否则select 函数将没有任何套接字可以等待。
-
为了方便使用,Windows sockets 提供了下列宏,用来对fd set 进行一系列操作。使用以 下宏可以使编程工作简化。
-
- FD_CLR(s,*set): 从 set 集合中删除s 套接字。
-
- FD_ISSET(s,*set): 检 查s 是否为set 集合的成员。
-
- FD_SET(s,*set): 将套接字加入到set 集合中。
-
- FD_ZERO(*set): 将 set 集合初始化为空集合。
在开发Windows sockets 应用程序时,通过下面的步骤可以完成对套接字的可读写判断:
- (1)使用FD_ZERO 初始化套接字集合,如“FD_ZERO(&readfds);”。
- (2)使用FD_SET 将某套接字放到readfds内,如“FD_SET(s,&readfds);”。
- (3) 以readfds 为第二个参数调用select 函 数 。select 在返回时会返回所有fd_set 集合中
在开发Windows sockets 应用程序时,通过下面的步骤可以完成对套接字的可读写判断:
- (1)使用FD_ZERO 初始化套接字集合,如“FD_ZERO(&readfds);”。
- (2)使用FD_SET 将某套接字放到readfds内,如“FD_SET(s,&readfds);”。
- ( 3 ) 以readfds 为第二个参数调用select 函 数 。select 在返回时会返回所有fd set 集合中套接字的总个数,并对每个集合进行相应的更新。将满足条件的套接字放在相应的集合中。
- (4)使用FD_ISSET判断s 是否还在某个集合中,如“FD_ISSET(s,&readfds);”。
- (5)调用相应的Windows socket api函数对某套接字进行操作。
select 返回后会修改每个fd_set 结构。删除不存在的或没有完成I/O 操作的套接字。这也 正是在第四步中可以使用FD _ISSET 来判断一个套接字是否仍在集合中的原因。
下面看一个例子,演示一个服务器程序如何使用select模型管理套接字。
服务端
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <iostream>
#include <WinSock2.h>
using namespace std;
#pragma comment(lib, "ws2_32")
int main(int argc, char** argv) {
WSADATA wsaData;
WSAStartup(WINSOCK_VERSION, &wsaData);
USHORT uPort = 6000;
SOCKET sListen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (INVALID_SOCKET == sListen)
{
cout << "socket error : " << GetLastError() << endl;
return 0;
}
SOCKADDR_IN sin;
sin.sin_family = AF_INET;
sin.sin_port = htons(uPort);
sin.sin_addr.S_un.S_addr = INADDR_ANY;
if (SOCKET_ERROR == bind(sListen, (PSOCKADDR)&sin, sizeof(sin)))
{
cout << "Bind error : " << WSAGetLastError() << endl;
closesocket(sListen);
WSACleanup();
return 0;
}
if (SOCKET_ERROR == listen(sListen, 5))
{
cout << "listen error : " << WSAGetLastError() << endl;
closesocket(sListen);
WSACleanup();
return 0;
}
fd_set fdSocket;
FD_ZERO(&fdSocket);
FD_SET(sListen, &fdSocket);
while (TRUE)
{
fd_set fdRead = fdSocket;
int iRet = select(0, &fdRead, NULL, NULL, NULL);
if (iRet > 0)
{
for (size_t i = 0; i < fdSocket.fd_count; i++)
{
if (FD_ISSET(fdSocket.fd_array[i], &fdRead))
{
if (fdSocket.fd_array[i] == sListen)
{
if (fdSocket.fd_count < FD_SETSIZE)
{
SOCKADDR_IN addrRemote;
int iAddrLen = sizeof(addrRemote);
SOCKET sNew = accept(sListen, (PSOCKADDR)&addrRemote, &iAddrLen);
FD_SET(sNew, &fdSocket);
cout << "接收到连接(" << inet_ntoa(addrRemote.sin_addr) << ")" << endl;
}
else
{
cout << "连接太多!" << endl;
continue;
}
}
else
{
char szText[256];
int iRecv = recv(fdSocket.fd_array[i], szText, strlen(szText), 0);
if (iRecv > 0)
{
szText[iRecv] = '\0';
cout << "接收到数据:" << szText << endl;
}
else
{
closesocket(fdSocket.fd_array[i]);
FD_CLR(fdSocket.fd_array[i], &fdSocket);
}
}
}
}
}
else
{
cout << "select error : " << WSAGetLastError() << endl;
closesocket(sListen);
WSACleanup();
break;
}
}
shutdown(sListen, SD_RECEIVE);
WSACleanup();
return 0;
}
客户端
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include<stdlib.h>
#include<WINSOCK2.H>
#include <windows.h>
#include <process.h>
#include<iostream>
#include<string>
using namespace std;
#define BUF_SIZE 64
#pragma comment(lib,"WS2_32.lib")
int main(){
WSADATA wsd;
SOCKET sHost;
SOCKADDR_IN servAddr;//服务器地址
int retVal;//调用Socket函数的返回值
char buf[BUF_SIZE];
//初始化Socket环境
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0)
{
printf("WSAStartup failed!\n");
return -1;
}
sHost = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
//设置服务器Socket地址
servAddr.sin_family = AF_INET;
servAddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
//在实际应用中,建议将服务器的IP地址和端口号保存在配置文件中
servAddr.sin_port = htons(6000);
//计算地址的长度
int sServerAddlen = sizeof(servAddr);
//调用ioctlsocket()将其设置为非阻塞模式
int iMode = 1;
retVal = ioctlsocket(sHost, FIONBIO, (u_long FAR*) & iMode);
if (retVal == SOCKET_ERROR)
{
printf("ioctlsocket failed!");
WSACleanup();
return -1;
}
printf("client is running....\n");
//循环等待
while (true)
{
//连接到服务器
retVal = connect(sHost, (LPSOCKADDR)&servAddr, sizeof(servAddr));
if (SOCKET_ERROR == retVal)
{
int err = WSAGetLastError();
//无法立即完成非阻塞Socket上的操作
if (err == WSAEWOULDBLOCK || err == WSAEINVAL)
{
Sleep(1);
printf("check connect!\n");
continue;
}
else if (err == WSAEISCONN)//已建立连接
{
break;
}
else
{
printf("connection failed!\n");
closesocket(sHost);
WSACleanup();
return -1;
}
}
}
while (true)
{
//向服务器发送字符串,并显示反馈信息
printf("\ninput a string to send:\n");
std::string str;
//接收输入的数据
std::cin >> str;
//将用户输入的数据复制到buf中
ZeroMemory(buf, BUF_SIZE);
strcpy_s(buf, str.c_str());
if (strcmp(buf, "quit") == 0)
{
printf("quit!\n");
break;
}
while (true)
{
retVal = send(sHost, buf, strlen(buf), 0);
if (SOCKET_ERROR == retVal)
{
int err = WSAGetLastError();
if (err == WSAEWOULDBLOCK)
{
//无法立即完成非阻塞Socket上的操作
Sleep(5);
continue;
}
else
{
printf("send failed!\n");
closesocket(sHost);
WSACleanup();
return -1;
}
}
break;
}
}
return 0;
}
前面提到,在select函数返回时会在fd set结构中填入相应的套接字。其中,readfds数组将包括满足以下条 件的套接字:
- 有数据可读。此时在此套接字上调用recv, 立即收到对方的数据。
- 连接已经关闭、重设或终止。
- 正在请求建立连接的套接字,此时调用accept函数会成功。
我们把监听套接字sListen 放到fdSocket 集合中,但然后阻塞在select 函数,当有请求连 接的时候,select 函数返回,然后调用accept 接受连接,并把客户套接字放到fdSocket 集合中。 以后select 再次返回的时候,可能是有数据要接收了,我们通过下列判断来确定是有连接请求 还是有数据可读。如果数据可读,就调用recv 接收数据,并打印出来。
客户端与服务端通信如下
参考书籍:《Visual C++2017 网络编程实战》