文章目录
- SELECT模型简介
- SELECT模型流程
- SELECT原理
- SELECT代码实现
- fd_set 数组及基本操作
- SELECT函数
- 参数2(重点)
- 参数3
- 参数4
- 关闭所有SOCKET句柄
- 处理控制台窗口关闭事件
- 整体代码
- 思考:
- 升级vs2019说明
https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-socket
基于TCP/IP的网络编程还有5种模型:
SELECT模型
事件选择模型
异步选择模型
重叠IO模型
完成端口模型
这次先讲第一种。
SELECT模型简介
针对多个客户端连接服务器时,服务器不能同时响应多个客户端的情况,SELECT模型就是用来解决服务器的accept、recv函数等待阻塞的问题的(客户端不需要使用这个模型)。注意这里函数执行时阻塞这个问题并没有解决,只是不等待阻塞了。(执行阻塞和等待阻塞的区别)
换句话解释,recv执行过程中,从内存拷贝东西的过程中,是执行阻塞状态,无法中途响应别的请求;但是执行recv函数的时候,如果没有消息,这个时候是等待阻塞状态,SELECT可以让SOCKET句柄在等待阻塞的时候响应别的有消息的SOCKET请求。当然执行阻塞也是有解决方案的,后面讲。
SELECT模型流程
-
打开网络库
-
校验版本
-
创建SOCKET
-
绑定地址与端口
-
开始监听
-
SELECT
可以看到除了最后一步,其他和普通模型是一样的,因此可以直接看最后一个步骤如何做。
SELECT原理
1、每个客户端都有SOCKET(多个),服务器也有自己的SOCKET(单个),将所有的socket装进SOCKET数组里
2、通过select函数,遍历1中的SOCKET数组,当某个SOCKET有响应,SELECT就会通过其参数/返回值反馈出来。
3、根据SOCKET类型进行不同操作:
如果是服务器SOCKET,说明有客户端连接,调用accept
如果是客户端SOCKET,说明有客户端请求通信,调用send或者recv
SELECT代码实现
使用上一节的基本CS通信中的server代码,并将监听成功到最后清理内存之间的代码删除掉。
fd_set 数组及基本操作
先定义一个SOCKET数组,这个在SOCKET库中提供了相应的类型:
typedef struct fd_set {
u_int fd_count;
SOCKET fd_array[FD_SETSIZE];
} fd_set, FD_SET, *PFD_SET, *LPFD_SET;
fd_count表示数组中包含多少个SOCKET句柄
数组大小默认最大值为:FD_SETSIZE=64
#ifndef FD_SETSIZE
#define FD_SETSIZE 64
#endif
根据上面的说明,我们可以在调用winsock2.h前预定义FD_SETSIZE新的值
#define FD_SETSIZE 100
#include <winsock2.h>
虽然修改FD_SETSIZE可以让服务器处理更多的客户端,但是考虑到内存,响应时间等限制,还是不要玩太大,否则容易出现莫名其妙的错误。理论上来看,由于这个玩意是数组,里面进行遍历操作,只能按位置进行轮询,数组太大当然响应时间会变长。64去掉一个服务器SOCKET,能和63个客户端同时玩,应该够了。大量并发用户后面会有专门的模型来处理。
对于fd_set 数组,WINSOCK也相应的提供了4个函数来进行操作:
FD_ZERO();
其原理并不去删除数组内容,而是直接把fd_set中的fd_count设置为0。
FD_SET(socketServer,&clientSockets)
FD_SET添加相同的句柄会被忽略,如果数组满了则无法添加。
FD_CLR(socketServer,&clientSockets)
FD_CLR删除指定SOCKET句柄后,会将在该句柄后面所有的元素依次向前移动一位。但是SOCKET句柄内存需要手动释放(要接着调用closesocket)。
FD_ISSET(socketServer,&clientSockets);
其实现原理是调用__WSAFDIsSet内部函数来判断
int __WSAFDIsSet(
SOCKET fd,
fd_set *
);
存在返回非零,不存在返回零。
SELECT函数
https://docs.microsoft.com/en-us/windows/win32/api/winsock2/nf-winsock2-select
看下调用方式:
int WSAAPI select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
const timeval *timeout
);
参数1nfds:为兼容Berkeley socket标准而保留的无用参数,默认写0即可。
参数2:*readfds,检查是否有可读的SOCKET,当select函数调用时,将要轮询的SOCKET数组放到FD_SET(这里是传址调用)后丢给系统,系统将有请求的SOCKET句柄(需要服务器recv)再填到FD_SET里面丢回来(当然这里的请求有两种,前面提到过,如果是服务器句柄,就需要accept,如果是客户端句柄就需要recv);
参数3: *writefds,检查是否有可写的SOCKET,和上面功能类似,只不过针对send,调用select后,这个地址里面就是可以send数据的客户端SOCKET句柄,但是由于对于send这个功能,服务器是没有执行阻塞的(我想怎么发送就怎么发送消息,无需经过客户端同意)因此这个参数用得不多。
参数4:*exceptfds,检查是否有出错的SOCKET,当select函数调用时,将要轮询的SOCKET数组放到FD_SET(这里是传址调用)后丢给系统,系统将有错误的SOCKET句柄再填到FD_SET里面丢回来。然后可以用下面这个函数得到具体错误码:
int WSAAPI getsockopt(
SOCKET s,
int level,
int optname,
char *optval,
int *optlen
);
参数5:*timeout,是timeval结构体,用来设置轮询FD_SET完无请求情况下的最大等待时间间隔(最大的含义就是最多等这么久,如果等待中间发生请求则终止等待,直接返回)
typedef struct timeval {
long tv_sec;
long tv_usec;
} TIMEVAL, *PTIMEVAL, *LPTIMEVAL;
两个成员都是用来设置时间间隔的,第一个是秒;第二个是微秒(百万分之一秒,千分之一毫秒)
状态 | *timeout设置值 | select状态 |
---|
非阻塞 | 0 0 | 轮询FD_SET完无请求后,无等待,立刻返回 |
半阻塞 | 4 2 | 轮询FD_SET完无请求后,最多等待4秒2微秒后返回(等待期间有请求则立刻返回) |
全阻塞 | NULL | 轮询FD_SET完无请求后,不返回,等待有请求后才返回 |
select的全阻塞状态并不代表服务器在等待某个客户端请求,而是等待FD_SET里面所有的客户端请求。select函数除了有等待之外,每个状态都有执行阻塞。
select返回值:
0:客户端在等待时间内无请求,可以进行下一次select操作;
>0:有客户端请求,此时要分服务器或者客户端分别进行判断;
SOCKET_ERROR:发生错误,利用WSAGetLastError()获取错误码。
注意:select函数的2.3.4的参数是传址引用,因此不能直接把要轮询的FD_SET丢进去,否则会被覆盖。
参数2(重点)
为了加深理解,这里将select(0,&readSocket,NULL,NULL,&st);的参数3和参数4设置为NULL,来观察参数2的使用。
fd_set allSockets;
FD_ZERO(&allSockets);
FD_SET(socketServer,&allSockets);
struct timeval st;
st.tv_sec = 3;
st.tv_usec = 4;
while(1)
{
fd_set readSocket = allSockets;
int nRes = select(0,&readSocket,NULL,NULL,&st);
if (nRes == 0)
{
continue;
}
else if (nRes > 0)
{
for(int i = 0; i < readSocket.fd_count; i++)
{
if(readSocket.fd_array[i] == socketServer)
{
SOCKET clientSocket = accept(socketServer,NULL,NULL);
if (clientSocket == INVALID_SOCKET)
{
int err = WSAGetLastError();
printf("获取客户端句柄失败错误码为:%d\n",err);
continue;
}
FD_SET(clientSocket,&allSockets);
printf("获取客户端句柄成功,数组中共有%d个句柄\n",allSockets.fd_count);
}
else
{
char buff[1500] = {0};
int recverr = recv(readSocket.fd_array[i],buff,1500,0);
if (recverr == 0)
{
printf("客户端已下线\n");
SOCKET freeSocket = readSocket.fd_array[i];
FD_CLR(readSocket.fd_array[i],&allSockets);
closesocket(freeSocket);
}
else if(recverr == SOCKET_ERROR)
{
int err = WSAGetLastError();
printf("循环处理客户端句柄失败错误码为:%d\n",err);
SOCKET freeSocket = readSocket.fd_array[i];
FD_CLR(readSocket.fd_array[i],&allSockets);
closesocket(freeSocket);
continue;
}
else
{
printf("客户端收到消息为:%s\n",buff);
}
printf("接收客户端消息成功,数组中共有%d个句柄\n",allSockets.fd_count);
}
}
}
else
{
}
}
参数3
上面其实也有提到过,参数3是用来获取可写的客户端SOCKET集合的,这里,当客户端连接服务器后,就是可写的,因此可以不需要使用参数3来进行send操作。
fd_set sendSockets = allSockets;
int nRes = select(0,&recvSockets,&sendSockets,NULL,&st);+
for(i = 0; i < sendSockets.fd_count; i++)
{
if( send(sendSockets.fd_array[i],"hello",5) == SOCKET_ERROR)
{
int senderr = WSAGetLsatError();
}
}
这里注意:
send函数如果成功则返回发送的字节数
失败则返回小于0的SOCKET_ERROR,一般不会出现等于0 的情况(除非发送消息长度为0)
参数4
这里要用到一个常用函数,这个函数在这里是用来获取错误信息,实际应用中还可以用来获取很多相关的其他信息(是否广播、连接时间等):
int WSAAPI getsockopt(
SOCKET s,
int level,
int optname,
char *optval,
int *optlen
);
参数1:要获取信息的SOCKET句柄
参数2:获取信息的level(我感觉可以理解为类型)
参数3:获取信息的具体属性,这个和上面的level有关,不同level对应的属性不一样
参数4:传址调用,错误信息
参数5:传址调用,错误信息长度
fd_set errorSockets = allSockets;
int nRes = select(0,&recvSockets,&sendSockets,&errorSockets,&st);
for(i = 0; i < errorSockets.fd_count; i++)
{
char errbuf[100] = {0};
int errbuflen = 99;
if(getsockopt(errorSockets.fd_array[i],SOL_SOCKET,SO_ERROR,errbuf,&errbuflen) == SOCKET_ERROR);
{
printf("getsocketopt无法获取相关信息");
int senderr = WSAGetLastError();
}
}
关闭所有SOCKET句柄
释放数组中所有的句柄,由于服务器句柄也在里面,因此服务器句柄不需要再写代码单独关闭。
for(u_int i = 0; i < allSockets.fd_count; i++)
{
closesocket(allSockets.fd_array[i]);
}
处理控制台窗口关闭事件
当在运行程序的过程中,如果是直接用鼠标关闭窗口,属于非正常关闭,这个时候程序最后的释放句柄代码没有得到执行,容易造成内存泄露,这个时候,我们需要使用hook请求,通过操作系统来处理关闭窗口事件。
https://docs.microsoft.com/en-us/windows/console/setconsolectrlhandler
BOOL WINAPI SetConsoleCtrlHandler(
_In_opt_ PHANDLER_ROUTINE HandlerRoutine,
_In_ BOOL Add
);
第一个参数是程序关闭时额外执行的函数名称
第二个参数通常设置为TRUE,代表系统是否执行额外的函数名称
函数(回调函数callback function)定义要遵守一定的规则:
BOOL WINAPI HandlerRoutine(
_In_ DWORD dwCtrlType
);
整体代码
#include <WinSock2.h>
#include <stdio.h>
#pragma comment(lib, "Ws2_32.lib")
fd_set allSockets;
BOOL WINAPI cls(DWORD dwCtrlType)
{
switch (dwCtrlType)
{
case CTRL_CLOSE_EVENT:
for (u_int i = 0; i < allSockets.fd_count; i++)
{
closesocket(allSockets.fd_array[i]);
}
WSACleanup();
}
return TRUE;
}
int main(void)
{
SetConsoleCtrlHandler(cls, TRUE);
WORD wdVersion = MAKEWORD(2, 2);
int a = *((char*)&wdVersion);
int b = *((char*)&wdVersion + 1);
WSADATA wdScoket;
int nRes = WSAStartup(wdVersion, &wdScoket);
if (0 != nRes)
{
switch (nRes)
{
case WSASYSNOTREADY:
printf("解决方案:重启。。。\n");
break;
case WSAVERNOTSUPPORTED:
break;
case WSAEINPROGRESS:
break;
case WSAEPROCLIM:
break;
case WSAEFAULT:
break;
}
return 0;
}
printf("打开网络库成功!\n");
if (2 != HIBYTE(wdScoket.wVersion) || 2 != LOBYTE(wdScoket.wVersion))
{
printf("版本有问题!\n");
WSACleanup();
return 0;
}
printf("版本校验成功!\n");
SOCKET socketServer = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (INVALID_SOCKET == socketServer)
{
int err = WSAGetLastError();
printf("服务器创建SOCKET失败错误码为:%d\n", err);
WSACleanup();
return 0;
}
printf("服务器创建SOCKET成功!\n");
struct sockaddr_in si;
si.sin_family = AF_INET;
si.sin_port = htons(12345);
si.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
if (SOCKET_ERROR == bind(socketServer, (const struct sockaddr*)&si, sizeof(si)))
{
int err = WSAGetLastError();
printf("服务器bind失败错误码为:%d\n", err);
closesocket(socketServer);
WSACleanup();
return 0;
}
printf("服务器端bind成功!\n");
if (SOCKET_ERROR == listen(socketServer, SOMAXCONN))
{
int err = WSAGetLastError();
printf("服务器监听失败错误码为:%d\n", err);
closesocket(socketServer);
WSACleanup();
return 0;
}
printf("服务器端监听成功!\n");
FD_ZERO(&allSockets);
FD_SET(socketServer, &allSockets);
while (1)
{
fd_set recvSockets = allSockets;
fd_set sendSockets = allSockets;
FD_CLR(socketServer, &sendSockets);
fd_set errorSockets = allSockets;
struct timeval st;
st.tv_sec = 3;
st.tv_usec = 4;
int nRes = select(0, &recvSockets, &sendSockets, &errorSockets, &st);
if (0 == nRes)
{
continue;
}
else if (nRes > 0)
{
for (u_int i = 0; i < recvSockets.fd_count; i++)
{
if (recvSockets.fd_array[i] == socketServer)
{
SOCKET socketClient = accept(socketServer, NULL, NULL);
if (INVALID_SOCKET == socketClient)
{
int err = WSAGetLastError();
printf("accept客户端句柄失败错误码为:%d\n", err);
continue;
}
FD_SET(socketClient, &allSockets);
printf("服务器获取客户端句柄成功,fd_set中共有%d个句柄!\n", allSockets.fd_count);
}
else
{
char strBuf[1500] = { 0 };
int nRecv = recv(recvSockets.fd_array[i], strBuf, 1500, 0);
if (0 == nRecv)
{
SOCKET socketTemp = recvSockets.fd_array[i];
FD_CLR(recvSockets.fd_array[i], &allSockets);
closesocket(socketTemp);
printf("客户端已经掉线,fd_set中共有%d个句柄!\n", allSockets.fd_count);
}
else if (0 < nRecv)
{
printf("客户端收到消息为:%s\n", strBuf);
}
else
{
int recverr = WSAGetLastError();
switch (recverr)
{
case 10053:
{
SOCKET socketTemp = recvSockets.fd_array[i];
FD_CLR(recvSockets.fd_array[i], &allSockets);
closesocket(socketTemp);
printf("10053客户端已经强关,fd_set中共有%d个句柄!\n", allSockets.fd_count);
}
case 10054:
{
SOCKET socketTemp = recvSockets.fd_array[i];
FD_CLR(recvSockets.fd_array[i], &allSockets);
closesocket(socketTemp);
printf("10054客户端已经强关,fd_set中共有%d个句柄!\n", allSockets.fd_count);
}
}
}
}
}
for (u_int i = 0; i < errorSockets.fd_count; i++)
{
char str[100] = { 0 };
int len = 99;
if (SOCKET_ERROR == getsockopt(errorSockets.fd_array[i], SOL_SOCKET, SO_ERROR, str, &len))
{
printf("getsocketopt无法获取相关信息!\n");
int getopterr = WSAGetLastError();
printf("服务器getsocketopt失败错误码为:%d\n", getopterr);
}
printf("%s\n", str);
}
for (u_int i = 0; i < sendSockets.fd_count; i++)
{
if (SOCKET_ERROR == send(sendSockets.fd_array[i], "hello", 5, 0))
{
int senderr = WSAGetLastError();
}
}
}
else
{
break;
}
}
for (u_int i = 0; i < allSockets.fd_count; i++)
{
closesocket(allSockets.fd_array[i]);
}
WSACleanup();
system("pause");
return 0;
}
思考:
1.FD_SET数组中删除元素使用的是逻辑删除,即直接设置fd_count属性,这样做的好处是什么?
效率高,速度快
2.如何防止客户端异常退出?
可以在发送代码时通过判断输入特殊字符串就跳出循环,从而执行WSACleanup();
scanf("%s",sendbuff);
if (sendbuff == '0')
{
break;
}
3.select虽然解决了等待问题,但是没有解决执行阻塞问题,为什么要专门设置模型来解决执行阻塞?
因为recv、send函数在客户端数量较大的时候,调用次数非常多,因此执行阻塞必须要解决。
4.客户端可以使用之前的基本通信模型的代码,但是在客户端接收消息这里:
char recvbuff[1500]={0};
int res = recv(socketServer,recvbuff,sizeof(recvbuff),0);
由于服务器端发送过来的消息是:hello,长度只有5,客户端非要用长度为1500的长度的字符数据进行接收,就会出现:
因此要把客户端接收消息的代码中,长度改成具体消息的长度(这里是5)
int res = recv(socketServer,recvbuff,5,0);
或者把服务器发送消息部分代码也改成对应长度的字符数组。
升级vs2019说明
可能会出现
error D8016: “/ZI”和“/Gy-”命令行选项不兼容
的提示。
点击【项目】→【属性】→【C/C++】
两种解决方案:
法1、【常规】页面下,选下面红线中两个中的其中一个即可
法2、【代码生成】页面下
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)