前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Windows网络模型之Select模型以一个聊天室服务端为例

Windows网络模型之Select模型以一个聊天室服务端为例

原创
作者头像
晨星成焰
发布2024-07-23 19:50:00
500
发布2024-07-23 19:50:00
举报
文章被收录于专栏:网络编程

引言

之前在Windows环境下用多线程模型实现了一个聊天室

多线程SOCKET聊天服务端

但是多线程模型下存在着不少缺点:

  • 例如对于公共资源的修改需要上锁,在多个线程时这无疑是一笔巨大的性能开销
  • 多个线程的上下切换会导致系统的不稳定,以及资源的耗费

而Select模型具有着

  • 低上下文切换成本:可以有效处理成千上万个并发连接,而且事件轮询的开销相对于每连接一个线程要小得多。
  • 资源利用率高:由于只有一个主线程(或少量线程)负责轮询事件,减少了内存使用和上下文切换带来的开销。

代码环节

因为服务端的在listen之前以及listen的内容几乎一样故省略,感兴趣的可以去看

windows环境下C/C++的socket相关网络编程详解

select模型及其工作流程重要的内容个人认为就这三个

fd_set select FD_ISSET

建立fd_set集合保存需要监控的套接字,并用FD_ZERO宏来初始化我们需要的fd_set。

调用select()监听套接字,它会返回就绪套接字的数量,如果一个套接字没有数据需要接收,select函数会把该套接字从可读性检查队列中删除掉

然后使用FD_ISSET()函数检查每个套接字是否在相应的集合中,从而确定该套接字是否就绪,并执行该套接字对应的内容,比如一个分配给select第一个参数的套接字句柄在select返回后仍然在select第一个参数的fd_set里,那么说明当前数据已经来了, 马上可以读取成功而不会被阻塞。

一个简单的工作流程描述
一个简单的工作流程描述

fd_set

在使用Select函数前,首先我们需要一个fd_set结构体,用作select函数的第二三四个参数。结构体原型如下所示:

代码语言:cpp
复制
#ifndef FD_SETSIZE
#define FD_SETSIZE      64
#endif /* FD_SETSIZE */

typedef struct fd_set {
        u_int   fd_count;               /* how many are SET? */
        SOCKET  fd_array[FD_SETSIZE];   /* an array of SOCKETs */
} fd_set;
//u_int   fd_count 结构体成员的个数
//SOCKET  fd_array[FD_SETSIZE]; socket类型的数组,默认最多有64个客户端连接

注意FD_SETSIZE这个宏,这个宏的意思是select模型最多处理多少个链接的数量。

同时我们需要使用两个宏设置服务端的sock绑定

  • FD_ZERO:清空或者初始化reads
  • FD_SET:向结构体中添加一个socket,绑定监听类型
代码语言:cpp
复制
		fd_set reads;

		// 清空或者初始化reads
		FD_ZERO(&reads);

		// 设置sockServer到reads
		FD_SET(sockServer, &reads);

select模型:

之后调用select函数

select原型如下:

代码语言:cpp
复制
int WSAAPI select(
  [in]      int           nfds,
  [in, out] fd_set        *readfds,
  [in, out] fd_set        *writefds,
  [in, out] fd_set        *exceptfds,
  [in]      const timeval *timeout
);

select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,const timeval *timeout);
    //    select(0, 0, 0, 0, 0);

int nfds, windows 下默认0(win下没用) linux下最大的文件描述符+1

fd_set *readfds, // 检测可读

fd_set *writefds,// 检测可写

fd_set *exceptfds,// 检测异常 一般不用

const struct timeval *timeout // 超时时间

select通过轮询来检测各个集合中的描述符(fd)的状态,如果描述符的状态发生改变,则会在该集合中设置相应的标记位;如果指定描述符的状态没有发生改变,则将该描述符从对应集合中移除。很明显,select的调用复杂度是线性的,即O(n)。

select的限制:

(1)前面FD_SET里有提到FD_SETSIZE宏,这个宏是操作系统定义的。在windows下面通常是64,也就是说select最多只能管理64个描述符。如果大于64的个描述,select将会产生不可预知的行为。那在没有poll或epoll的情况下,怎样使用select来处理连接数大于64的情况呢?答案是使用多线程技术,每个线程单独使用一个select进行检测。这样的话,你的系统能够处理的并发连接数等于线程数*64。早期的apache就是这种技术来支撑海量连接的。

(2)需要修改传入的参数数组

(3)不能指定某个有数据的socket

(4)线程不安全

FD_ISSET

接着使用FD_ISSET用于监听

FD_ISSET(fd, set)宏接受两个参数:

  • fd:要检查的文件描述符(在Windows网络编程中通常是套接字)。
  • set:fd_set类型的集合,该集合是之前传递给select()函数的readfds、writefds或exceptfds之一。 FD_ISSET宏用于检查在select()函数返回后,指定的文件描述符是否在给定的集合中。如果该描述符在select()返回时是就绪的,那么FD_ISSET将返回非零值;如果该描述符没有变为就绪状态,FD_ISSET将返回零。

糅合在一起后的代码

因为这是在之前的多线程聊天室服务端基础上更改,所以有部分没介绍的,可以参考之前的文章,或者文章之后的完整代码

代码语言:cpp
复制
	while (1)
	{
		fd_set reads;

		// 清空或者初始化reads
		FD_ZERO(&reads);

		// 设置sockServer到reads
		FD_SET(sockServer, &reads);

		for (auto v : g_clients)
		{
			FD_SET(v->clientSock, &reads);
		}
		int sRet = select(0, &reads, 0, 0, 0);

		// 表示select超时或者出错
		if (sRet <= 0) continue;

		if (FD_ISSET(sockServer, &reads)) {
			printf("服务端响应\n");
			SOCKADDR_IN clientAddr = {};
			int nAddrLen = sizeof(SOCKADDR_IN);
			SOCKET sockClient = accept(sockServer, (sockaddr*)&clientAddr, &nAddrLen);
			if (INVALID_SOCKET == sockClient) {
				printf("接收客户端连接失败\n");
				return -1;
			}
			std::cout << "开始处理客户端: " << std::endl;
			UserInfo* user = new UserInfo{ false, "undefined", sockClient };
			g_clients.push_back(user);
		}
		for (auto v : g_clients) {
			if (FD_ISSET(v->clientSock, &reads))
			{
				SelectClientConnection(v->clientSock, v);
			}
		}
	}

所有代码

前置知识准备的差不多后,我们直接写吧~

小提示建立一个数组来存放所有建立联系的套结字描述符,循环检测这些套接字是否有相应从而达到检测聊天端信息的作用

代码语言:cpp
复制
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <ws2tcpip.h>
#include <winsock2.h>
#include <windows.h>
#include <iostream>
#include <thread>
#include <vector>

#pragma comment(lib, "ws2_32.lib")

class UserInfo
{
public:
	bool isLogin = false; //是否登陆
	std::string _userName; //客户端的用户名
	SOCKET clientSock;   //客户端的socket

	UserInfo(bool isLogin, std::string _userName, SOCKET clientSock) :isLogin(isLogin), _userName(_userName), clientSock(clientSock)
	{

	}
};

std::vector<UserInfo*> g_clients; //用于服务端存储用户登陆信息

//  判定用户是否处在登陆状态函数
//  INPUIT: const std::string&  userName  用户姓名
//  RETURN: bool  true:在线 false:不在线
bool isUserLoggedIn(const std::string& _userName)
{
	for (const auto& user : g_clients)
	{
		if (user->_userName == _userName && user->isLogin)
			return true;
	}
	return false;
}

//  广播信息函数
//  INPUIT: SOCKET selfSock 客户端的Sock描述符, const char* msg  广播信息
//  广播信息给除了特定客户端以外的所有客户端信息
void SendMsg(SOCKET selfSock, const char* msg)
{
	int msglen = strlen(msg);
	for (int i = 0; i < g_clients.size(); i++)
	{
		if (g_clients[i]->clientSock == selfSock)continue;
		send(g_clients[i]->clientSock, msg, msglen, 0);
	}
}

//  分割字符串函数
//  INPUIT: const std::string& s 待分割字符串, char delimiter 分割符号
//  RETURN: std::vector<std::string>  存储分割的字符串的数组
std::vector<std::string> splitString(const std::string& s, char delimiter)
{
	std::vector<std::string> result;
	std::string path;
	for (size_t i = 0; i < s.size(); i++) 
	{
		if (s[i] != delimiter)
		{
			path.push_back(s[i]);
		}
		else if (!path.empty()) // 确保path非空时才push_back
		{
			result.push_back(path);
			path.clear();
		}
	}
	if (!path.empty()) // 处理字符串以分隔符结尾的情况
	{
		result.push_back(path);
	}
	return result;
}

//  客户端的Select处理函数
//  INPUIT: SOCKET clientSocket 客户端对应的SOCKET, UserInfo* currentUser 客户端对应的用户
void SelectClientConnection(SOCKET clientSocket, UserInfo* currentUser)
{
	char szData[1024] = {};
	int ret = recv(clientSocket, szData, sizeof(szData), 0);
	if (ret > 0)
	{
		std::cout << "收到数据: [" << szData << "]" << std::endl;
		std::vector<std::string> splits = splitString(szData, '|');

		if (splits[0] == "Login")
		{
			// 验证用户是否已登录
			if (isUserLoggedIn(splits[1]))
			{
				char loginFailedMsg[64];
				snprintf(loginFailedMsg, sizeof(loginFailedMsg), "Error|%s|LoginFailed", splits[1].c_str());
				send(clientSocket, loginFailedMsg, sizeof(loginFailedMsg), 0);
				return;
			}
			// 用户未登录,创建并登录
			currentUser->isLogin = true;
			currentUser->_userName = splits[1];

		}
		else if (splits[0] == "其它命令")
		{

		}
		else
		{
			std::string chatMsg;
			chatMsg.append(currentUser->_userName);
			chatMsg.append(":");
			chatMsg.append(szData);
			//chatMsg = currentUser->_userName + ":" + szData;
			SendMsg(INVALID_SOCKET, chatMsg.c_str());
		}
	}
	else if (ret <= 0)
	{
		std::cout << "客户端断开链接" << std::endl;
		if (currentUser->_userName != "undefined")
		{
			for (auto it = g_clients.begin(); it != g_clients.end(); ++it)
			{
				// 找到并移除对应的UserInfo对象
				if (*it == currentUser)
				{
					std::cout << "User:" << currentUser->_userName << "is erase!" << std::endl;
					delete currentUser;
					g_clients.erase(it);
					break;
				}
			}
		}
		closesocket(currentUser->clientSock);
	}
}

int main()
{
	// 0. 初始化网络环境
	WSADATA wsaData = {};
	WSAStartup(MAKEWORD(2, 2), &wsaData);
	SOCKET sockServer = socket(AF_INET, SOCK_STREAM, 0);
	if (sockServer == INVALID_SOCKET)
	{
		std::cerr << "创建服务端句柄失败" << std::endl;
		WSACleanup();
		return -1;
	}
	printf("1. 创建服务端成功\n");
	SOCKADDR_IN addr = { 0 };
	addr.sin_family = AF_INET;
	addr.sin_port = htons(9870);
	addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
	if (bind(sockServer, (SOCKADDR*)&addr, sizeof(SOCKADDR_IN)) == SOCKET_ERROR)
	{
		std::cerr << "绑定端口号失败" << std::endl;
		closesocket(sockServer);
		WSACleanup();
		return -1;
	}
	if (listen(sockServer, SOMAXCONN) == SOCKET_ERROR)
	{
		std::cerr << "监听端口号失败" << std::endl;
		closesocket(sockServer);
		WSACleanup();
		return -1;
	}
	std::cout << "服务器正在监听..." << std::endl;

	while (1)
	{
		fd_set reads;

		// 清空或者初始化reads
		FD_ZERO(&reads);

		// 设置sockServer到reads
		FD_SET(sockServer, &reads);

		for (auto v : g_clients)
		{
			FD_SET(v->clientSock, &reads);
		}
		int sRet = select(0, &reads, 0, 0, 0);

		// 表示select超时或者出错
		if (sRet <= 0) continue;

		if (FD_ISSET(sockServer, &reads)) {
			printf("服务端响应\n");
			SOCKADDR_IN clientAddr = {};
			int nAddrLen = sizeof(SOCKADDR_IN);
			SOCKET sockClient = accept(sockServer, (sockaddr*)&clientAddr, &nAddrLen);
			if (INVALID_SOCKET == sockClient) {
				printf("接收客户端连接失败\n");
				return -1;
			}
			std::cout << "开始处理客户端: " << std::endl;
			UserInfo* user = new UserInfo{ false, "undefined", sockClient };
			g_clients.push_back(user);
		}
		for (auto v : g_clients) {
			if (FD_ISSET(v->clientSock, &reads))
			{
				SelectClientConnection(v->clientSock, v);
			}
		}
	}
	closesocket(sockServer);
	WSACleanup();
	return 0;
}

运行截图

代码演示:

我们采用sokit工具作为客户端

也可以自己实现一个简单的客户端,结合本篇文章和参考我之前的文章有过简单的客户端实现

windows环境下C/C++的socket相关网络编程详解


总结

在处理大量并发连接的场景下,select模型的服务端与多线程模型的服务端相比性能有了一定的提高,然而,在每个连接处理逻辑较为复杂,且计算密集型任务较多的情况下,多线程模型可能表现得更好,并且,当select函数投递一组socket给操作系统时,操作系统将有信号的socket装进fe_set中并返回,这一过程是阻塞的,尤其是在大量连接的情况下,因为它需要轮询所有的套接字,会导致性能的下降,为了提高执行效率,可以使用事件投递模型,一个以Select为核心的事件投递模型,其实就是WSAEventSelect模型,在后续,我会从底层简单的实现一个WSAEventSelect模型

另外感兴趣的也可以自己去实现一个简单的Select模型客户端


免责声明

以上内容均属参考得知,未曾阅读过专业书籍,纯属兴趣使然,若有谬误欢迎指出

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • 代码环节
    • fd_set
      • select模型:
        • FD_ISSET
          • 糅合在一起后的代码
          • 所有代码
          • 运行截图
          • 总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档