项目(百万并发网络通信架构)9.2---单线程服务端的性能分析

一、概述

  • 前面的文章中,服务端的程序都是单线程模式的。连接客户端的请求,接收客户端的数据等等都是在一个线程中完成的,本文将要测试一下在服务端单线程模式下,影响服务端性能的因素主要是哪些

二、服务端最终代码如下

#ifndef _EasyTcpClient_hpp_
#define _EasyTcpClient_hpp_

#ifdef _WIN32
	#define FD_SETSIZE 2048
	#define WIN32_LEAN_AND_MEAN
	#define _WINSOCK_DEPRECATED_NO_WARNINGS //for inet_pton()
	#define _CRT_SECURE_NO_WARNINGS
	#include <windows.h>
	#include <WinSock2.h>
	#pragma comment(lib, "ws2_32.lib")
#else
	#include <unistd.h>
	#include <sys/socket.h>
	#include <sys/types.h>
	#include <arpa/inet.h>
	#include <netinet/in.h>
	#include <sys/select.h>
	//在Unix下没有这些宏,为了兼容,自己定义
	#define SOCKET int
	#define INVALID_SOCKET  (SOCKET)(~0)
	#define SOCKET_ERROR            (-1)
#endif

#ifndef RECV_BUFF_SIZE
#define  RECV_BUFF_SIZE 10240
#endif // !RECV_BUFF_SIZE


#include <iostream>
#include <string.h>
#include <stdio.h>
#include <vector>
#include "MessageHeader.hpp"
#include "CELLTimestamp.hpp"
using namespace std;

class ClientSocket
{
public:
	ClientSocket(SOCKET sockfd= INVALID_SOCKET) :_sock(sockfd), _lastPos(0) {
		memset(_recvMsgBuff, 0, sizeof(_recvMsgBuff));
	}

	SOCKET sockfd() { return _sock; }
	char *msgBuff() { return _recvMsgBuff; }
	int getLastPos() { return _lastPos; }
	void setLastPos(int pos) { _lastPos = pos; }
private:
	SOCKET _sock;
	char _recvMsgBuff[RECV_BUFF_SIZE * 10];
	int _lastPos;
};

class EasyTcpServer
{
public:
	EasyTcpServer() :_sock(INVALID_SOCKET), _recvCount(0){
		memset(_recvBuff, 0, sizeof(_recvBuff));
	}
	virtual ~EasyTcpServer() { CloseSocket(); }
public:
	//判断当前服务端是否在运行
	bool isRun() { return _sock != INVALID_SOCKET; }
	//初始化socket
	void InitSocket();
	//绑定端口号
	int Bind(const char* ip, unsigned short port);
	//监听端口号
	int Listen(int n);
	//接收客户端连接
	SOCKET Accept();
	//关闭socket
	void CloseSocket();
	//处理网络消息
	bool Onrun();

	/*
		使用RecvData接收任何类型的数据,
		然后将消息的头部字段传递给OnNetMessage()函数中,让其响应不同类型的消息
	*/
	//接收数据,参数:客户端的套接字
	int RecvData(ClientSocket* pClient);
	//响应网络消息
	virtual void OnNetMessage(SOCKET _cSock, DataHeader* header);

	//发送数据,单发(参数1为指定的客户端的socket)
	int SendData(SOCKET _cSock, DataHeader* header);
	//群发数据
	void SendDataToAll( DataHeader* header);
private:
	SOCKET _sock;
	std::vector<ClientSocket*> _clients;//存放客户端
	SOCKET maxSock = _sock;       //select的参数1要使用,当前最大的文件描述符值
	char _recvBuff[RECV_BUFF_SIZE];
	CELLTimestamp _tTime; //计时器
	int _recvCount; //计数
};

void EasyTcpServer::InitSocket()
{
#ifdef _WIN32
	WORD ver = MAKEWORD(2, 2);
	WSADATA dat;
	WSAStartup(ver, &dat);
#endif

	//建立socket
	_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (INVALID_SOCKET == _sock) {
		std::cout << "Server:创建socket成功" << std::endl;
	}
	else {
		std::cout << "Server:创建socket成功" << std::endl;
	}
}

int EasyTcpServer::Bind(const char* ip, unsigned short port)
{
	if (!isRun())
		InitSocket();

	//初始化服务端地址
	struct sockaddr_in _sin = {};
#ifdef _WIN32
	if (ip) 
		_sin.sin_addr.S_un.S_addr = inet_addr(ip);
	else
		_sin.sin_addr.S_un.S_addr = INADDR_ANY;
#else
	if (ip)
		_sin.sin_addr.s_addr = inet_addr(ip);
	else
		_sin.sin_addr.s_addr = INADDR_ANY;
#endif
	_sin.sin_family = AF_INET;
	_sin.sin_port = htons(port);

	//绑定服务端地址
	int ret = bind(_sock, (struct sockaddr*)&_sin, sizeof(_sin));
	if (SOCKET_ERROR == ret) {
		if (ip)
			std::cout << "Server:绑定地址(" << ip << "," << port << ")失败!" << std::endl;
		else
			std::cout << "Server:绑定地址(INADDR_ANY," << port << ")失败!" << std::endl;
	}
	else {
		if (ip)
			std::cout << "Server:绑定地址(" << ip << "," << port << ")成功!" << std::endl;
		else
			std::cout << "Server:绑定地址(INADDR_ANY," << port << ")成功!" << std::endl;
	}
	return ret;
}

void EasyTcpServer::CloseSocket()
{
	if (_sock != INVALID_SOCKET)
	{
#ifdef _WIN32
		//将所有的客户端套接字关闭
		for (int n = (int)_clients.size() - 1; n >= 0; --n)
		{
			closesocket(_clients[n]->sockfd());
			delete _clients[n];
		}
		//关闭服务端套接字
		closesocket(_sock);
		WSACleanup();
#else
		for (int n = (int)_clients.size() - 1; n >= 0; --n)
		{
			close(_clients[n]->sockfd());
			delete _clients[n];
		}
		close(_sock);
#endif
		_clients.clear();
		_sock = INVALID_SOCKET;
	}
}

int EasyTcpServer::Listen(int n)
{
	//监听网络端口
	int ret = listen(_sock, n);
	if (SOCKET_ERROR == ret)
		std::cout << "Server:监听网络端口失败!" << std::endl;
	else
		std::cout << "Server:监听网络端口成功!" << std::endl;
	return ret;
}

SOCKET EasyTcpServer::Accept()
{
	//用来保存客户端地址
	struct sockaddr_in _clientAddr = {};
	int nAddrLen = sizeof(_clientAddr);
	SOCKET _cSock = INVALID_SOCKET;

	//接收客户端连接
#ifdef _WIN32
	_cSock = accept(_sock, (struct sockaddr*)&_clientAddr, &nAddrLen);
#else
	_cSock = accept(_sock, (struct sockaddr*)&_clientAddr, (socklen_t*)&nAddrLen);
#endif
	if (INVALID_SOCKET == _cSock) {
		std::cout << "Server:接收到无效客户端!" << std::endl;
	}
	else {
		//通知其他已存在的所有客户端,有新的客户端加入
		//NewUserJoin newUserInfo(static_cast<int>(_cSock));
		//SendDataToAll(&newUserInfo);

		//将客户端的套接字存入vector内
		_clients.push_back(new ClientSocket(_cSock));
		//std::cout << "Server:接受到新的客户端(" << _clients.size() << ")连接,IP=" << inet_ntoa(_clientAddr.sin_addr)
		//	<< ",Socket=" << static_cast<int>(_cSock) << std::endl;
	}
	return _cSock;
}

bool EasyTcpServer::Onrun()
{
	if (isRun())
	{
		fd_set fdRead;
		fd_set fdWrite;
		fd_set fdExp;
		FD_ZERO(&fdRead);
		FD_ZERO(&fdWrite);
		FD_ZERO(&fdExp);
		FD_SET(_sock, &fdRead);
		FD_SET(_sock, &fdWrite);
		FD_SET(_sock, &fdExp);

		//每次select之前,将所有客户端加入到读集中(此处为了演示,只介绍客户端读的情况)
		for (int n = (int)_clients.size() - 1; n >= 0; --n)
		{
			FD_SET(_clients[n]->sockfd(), &fdRead);
			if (maxSock < _clients[n]->sockfd())
				maxSock = _clients[n]->sockfd();
		}

		struct timeval t = { 3,0 };
		int ret = select(maxSock + 1, &fdRead, &fdWrite, &fdExp, &t);
		if (ret < 0)
		{
			std::cout << "Server:select出错!" << std::endl;
			return false;
		}
		if (FD_ISSET(_sock, &fdRead))//如果一个客户端连接进来,那么服务端的socket就会变为可读的,此时我们使用accept来接收这个客户端
		{
			FD_CLR(_sock, &fdRead);
			/*
			因为我们设计的客户端测试程序是顺序连接服务端的,等到所有所有客户端连接到服务端之后,
			客户端才开始发送数据到服务端。所以为了加快服务端效率,此处接收到客户端请求之后直接退出,不会再去执行下面的for循环了
			*/
			Accept();
			return true;
		}
		
		//遍历vector数组中所有的客户端套接字,如果某个客户端的套接字在读集中,
		//那么说明相应的客户端有数据来,那么就执行processor()函数
		for (int n = (int)_clients.size() - 1; n >= 0; --n)
		{
			if (FD_ISSET(_clients[n]->sockfd(), &fdRead))
			{
				if (-1 == RecvData(_clients[n]))
				{
					//如果processor出错,那么就将该客户端从全局vector中移除
					//首先获取该套接字在vector中的迭代器位置,然后通过erase()删除
					auto iter = _clients.begin() + n;
					if (iter != _clients.end())
					{
						delete _clients[n];
						_clients.erase(iter);
					}
				}
			}
		}
		return true;
	}
	return false;
}

int EasyTcpServer::RecvData(ClientSocket* pClient)
{
	int _nLen = recv(pClient->sockfd(), _recvBuff, RECV_BUFF_SIZE, 0);
	if (_nLen < 0) {
		std::cout << "recv函数出错!" << std::endl;
		return -1;
	}
	else if (_nLen == 0) {
		std::cout << "客户端<Socket=" << pClient->sockfd() << ">:已退出!" << std::endl;
		return -1;
	}
	
	memcpy(pClient->msgBuff() + pClient->getLastPos(), _recvBuff, _nLen);
	pClient->setLastPos(pClient->getLastPos() + _nLen);
	while (pClient->getLastPos() >= sizeof(DataHeader))
	{
		DataHeader* header = (DataHeader*)pClient->msgBuff();
		if (pClient->getLastPos() >= header->dataLength)
		{
			//剩余未处理消息缓冲区的长度
			int nSize = pClient->getLastPos() - header->dataLength;
			//处理网络消息
			OnNetMessage(pClient->sockfd(), header);
			//处理完成之后,将_recvMsgBuff中剩余未处理部分的数据前移
			memcpy(pClient->msgBuff(), pClient->msgBuff() + header->dataLength, nSize);
			pClient->setLastPos(nSize);
		}
		else {
			//消息缓冲区剩余数据不够一条完整消息
			break;
		}
	}
	return 0;
}

void EasyTcpServer::OnNetMessage(SOCKET _cSock, DataHeader* header)
{
	_recvCount++;
	auto t1 = _tTime.getElapsedSecond();
	if (t1 >= 1.0)
	{
		printf("time<%lf>,client socket<%d>,client number<%d>,recvCount<%d>\n", t1, _cSock, _clients.size(), _recvCount);
		_recvCount = 0;
		_tTime.update();
	}

	switch (header->cmd)
	{
		case CMD_LOGIN: //如果是登录
		{
			Login *login = (Login*)header;
			//std::cout << "服务端:收到客户端<Socket=" << _cSock << ">的消息CMD_LOGIN,用户名:" << login->userName << ",密码:" << login->PassWord << std::endl;

			//此处可以判断用户账户和密码是否正确等等(省略)

			//返回登录的结果给客户端
			//LoginResult ret;
			//SendData(_cSock, &ret);
		}
		break;
		case CMD_LOGOUT:  //如果是退出
		{
			Logout *logout = (Logout*)header;
			//std::cout << "服务端:收到客户端<Socket=" << _cSock << ">的消息CMD_LOGOUT,用户名:" << logout->userName << std::endl;

			//返回退出的结果给客户端
			//LogoutResult ret;
			//SendData(_cSock, &ret);
		}
		break;
		default:  //如果有错误
		{
			//std::cout << "服务端:收到客户端<Socket=" << _cSock << ">的未知消息消息" << std::endl;
			//DataHeader默认为错误消息
			//DataHeader header;
			//SendData(_cSock, &header);
		}
		break;
	}
}

int EasyTcpServer::SendData(SOCKET _cSock, DataHeader* header)
{
	if (isRun() && header)
	{
		return send(_cSock, (const char*)header, header->dataLength, 0);
	}
	return SOCKET_ERROR;
}

void EasyTcpServer::SendDataToAll(DataHeader* header)
{
	//通知其他已存在的所有客户端,有新的客户端加入
	for (int n = 0; n < _clients.size(); ++n)
	{
		SendData(_clients[n]->sockfd(), header);
	}
}

#endif

三、以2000客户端连接进行测试

  • 此处我们让2000个客户端连接服务端,然后测试一下服务端的代码执行时主要的消耗是什么
  • 此处我们使用VS Studio自带的性能探查器进行分析,其可以在下面进行打开(程序运行时打开)

服务端测试程序与客户端测试程序

  • 服务端测试程序如下:
#include "EasyTcpServer.hpp"
#include "MessageHeader.hpp"

int main()
{
	EasyTcpServer server;
	server.Bind("192.168.0.105", 4567);
	server.Listen(5);

	while (server.isRun())
	{
		server.Onrun();
	}

	server.CloseSocket();
	std::cout << "服务端停止工作!" << std::endl;

	getchar();  //防止程序一闪而过
	return 0;
}
  • 客户端测试程序如下:
#include "EasyTcpClient.hpp"
#include <thread>

bool g_bRun = false;
const int cCount = 2000; //客户端的数量
const int tCount = 4;    //线程的数量
EasyTcpClient* client[cCount];//客户端的数组


void cmdThread();
void sendThread(int id);

int main()
{
	g_bRun = true;

	//UI线程
	std::thread t(cmdThread);
	t.detach();

	//启动发送线程
	for (int n = 0; n < tCount; ++n)
	{
		std::thread t(sendThread, n + 1);
		t.detach();
	}

	std::cout << "客户端停止工作!" << std::endl;
	getchar();
	return 0;
}

void cmdThread()
{
	char cmdBuf[256] = {};
	while (true)
	{
		std::cin >> cmdBuf;
		if (0 == strcmp(cmdBuf, "exit"))
		{
			g_bRun = false;
			break;
		}
		else {
			std::cout << "命令不识别,请重新输入" << std::endl;
		}
	}
}

void sendThread(int id)
{
	/*
		下面这几个变量是为了平均每个线程创建的客户端的数量:
			例如,本次测试时客户端数量为1000,线程数量为4,那么每个线程应该创建250个客户端
				线程1:c=250,begin=0,end=250
				线程2:c=250,begin=250,end=500
				线程3:c=250,begin=500,end=750
				线程4:c=250,begin=750,end=1000
	*/
	int c = cCount / tCount;
	int begin = (id - 1)*c;
	int end = id*c;

	for (int n = begin; n < end; ++n) //创建客户端
	{
		client[n] = new EasyTcpClient;
	}
	for (int n = begin; n < end; ++n) //让每个客户端连接服务器
	{
		client[n]->ConnectServer("192.168.0.105", 4567);
		printf("Connect=%d\n", n);
	}

	Login login;
	strcpy(login.userName, "dongshao");
	strcpy(login.PassWord, "123456");
	//循环向服务端发送消息
	while (g_bRun)
	{
		for (int n = begin; n < end; ++n)
		{
			client[n]->SendData(&login);
			//client[n]->Onrun();
		}
	}

	//关闭客户端
	for (int n = begin; n < end; ++n)
	{
		client[n]->CloseSocket();
	}
}

分析步骤

  • 使用VS Studio自带的性能探查器运行服务端程序。然后再打开客户端测试程序连接服务端程序(有2000个客户端会连接):
    • 备注:服务端程序和客户端程序都以release进行编译运行
    • 下图所示,左侧为服务端,右侧为客户端,客户端连接到一千八百数量之后,后面连接速度比较缓慢

  • 可以看到VS Studio性能探查器正在运行

  • 测试一段时间之后,点击停止收集,终止程序,下面开始进行分析

  • 然后点击创建分析报告,在报告中可以看到Onrun()函数时执行最多次数的

  • 点击上图箭头所指的Onrun()函数,然后进入下面的界面,开始分析代码
  • 下图可以看到240行的代码大概消耗性能占比为36.9%,分析如下:
    • 原因如下:每次select之前都需要加所有客户端加入到fd_set集合中,这样效率比较慢
    • 改进方法有:
      • 在单线程模式下,如果有新客户端加入,那么就将所有的客户端重新加入到fd_set集合中;如果没有新客户端加入,那么可以进行内存的拷贝(拷贝fd_set变量),不需要每次都调用FD_SET()函数
      • 在多线程模式下,可以在多个线程下进行select(),那么总的执行的FD_SET()的次数会被平均下来,效率加快

  • 上图可以看到246行的代码大概消耗性能占比为32.4%,,分析如下:
    • 原因如下:因为select需要遍历所有的fd_set集合,所以消耗比较高
    • 改进方法有:采用多线程模式(与上面同理)

  • 另外,就是FD_ISSET()占用12.7%性能,其原理与上面类似。还有就是recvData()函数执行比较多,因为服务端在接收数据

四、总结

  • 通过分析可以看出,在单线程模式下的服务端,其性能主要在fd_set集合的处理上与select执行上,在后面的文章中,我们将一步一步将服务端改造为多线程
发布了1594 篇原创文章 · 获赞 1190 · 访问量 57万+

猜你喜欢

转载自blog.csdn.net/qq_41453285/article/details/105455942