Study/C++ & C#

[C++] SendBuffer Pooling

BVM 2023. 6. 30. 01:11

이전엔 단순히 버퍼를 필요할 때 만들어 사용하는 형태였다면,

이번엔 풀링을 통해 미리 버퍼들을 풀링해 돌려쓰는 방식으로 돌아가게 할 것이다.

 

1. SendBuffer 클래스 수정

풀링을 사용할 것이기 때문에 아래의 클래스들을 새로 작성할 것이다.

 

  • SendBufferChunk
      - SendBuffer들의 덩어리(Chunk)이다. 버퍼들을 Array형태로 갖고 있을 것이다.
  • SendBufferManager
      - SendBufferChunk들을 관리할 매니저 클래스이다.

먼저 SendBufferManager부터 만들어가 보자.

 

class SendBufferManager
{
public:
	// 큰 버퍼에서 내가 일정 부분만큼 사용하기 위해 연다는 느낌
	SendBufferRef			Open(uint32 size);

private:
	SendBufferChunkRef		Pop();
	void				Push(SendBufferChunkRef buffer);

	static void			PushGlobal(SendBufferChunk* buffer);

private:
	USE_LOCK;
	Vector<SendBufferChunkRef> 	_sendBufferChunks;
};

변수로 SenfBufferChunk의 스마트 포인터를 갖고 있는 벡터를 선언한다.

 

Pop()과 Push()는 보이는 그대로의 기능을 하게 된다.

 

궁금한 건 Open()과 PushGlobal()이다.

뭘 연다는건지... 뭘 글로벌하게 푸시한다는 건지... 정말 이해가 가지 않는다.

일단 함수를 정의하기 전에 할 일이 있다.

 

SendBufferManager 클래스를 서버 코어 내에서 전역으로 사용할 수 있게 만든다.

 

#pragma once

extern class ThreadManager*		GThreadManager;
extern class Memory*			GMemory;
extern class SendBufferManager* 	GSendBufferManager;

extern class DeadLockProfiler*		GDeadLockProfiler;

이렇게 헤더에 선언하고 cpp 파일에도 그 내용을 추가한다.

 

// CoreGlobal.cpp

#include "pch.h"
#include "CoreGlobal.h"
#include "ThreadManager.h"
#include "Memory.h"
#include "DeadLockProfiler.h"
#include "SocketUtils.h"
#include "SendBuffer.h"

ThreadManager*		GThreadManager = nullptr;
Memory*				GMemory = nullptr;
SendBufferManager*	GSendBufferManager = nullptr;

DeadLockProfiler*	GDeadLockProfiler = nullptr;

class CoreGlobal
{
public:
	CoreGlobal()
	{
		GThreadManager = new ThreadManager();
		GMemory = new Memory();
		GSendBufferManager = new SendBufferManager();
		GDeadLockProfiler = new DeadLockProfiler();
		SocketUtils::Init();
	}

	~CoreGlobal()
	{
		delete GThreadManager;
		delete GMemory;
		delete GSendBufferManager;
		delete GDeadLockProfiler;
		SocketUtils::Clear();
	}
} GCoreGlobal;

 

그리고 버퍼 풀을 전역으로 두고 쓰는 것보다 TLS 환경을 이용해,

각 스레드마다 풀을 두도록 하여 락을 걸지 않아도 되게끔 할 것이다.

 

// CoreTLS.h

#pragma once
#include <stack>

extern thread_local uint32		LThreadId;
extern thread_local std::stack<int32>	LLockStack;
extern thread_local SendBufferChunkRef	LSendBufferChunk; // 스레드 별로 청크를 갖고 있음



// CoreTLS.cpp

#include "pch.h"
#include "CoreTLS.h"

thread_local uint32 LThreadId = 0;
thread_local std::stack<int32>	LLockStack;
thread_local SendBufferChunkRef	LSendBufferChunk;

코어에서 청크에 대해 TLS로 동작하게 하도록 했다.

 

이제 차례대로 함수를 정의해 보자.

SendBufferRef SendBufferManager::Open(uint32 size)
{
	if (LSendBufferChunk == nullptr)
	{
		LSendBufferChunk = Pop(); // WRITE_LOCK
		LSendBufferChunk->Reset();
	}		

	// 혹시라도 Open을 여러번 할 때를 대비해 체크
	ASSERT_CRASH(LSendBufferChunk->IsOpen() == false);

	// 다 썼으면 버리고 새거로 교체
	if (LSendBufferChunk->FreeSize() < size)
	{
		LSendBufferChunk = Pop(); // WRITE_LOCK
		LSendBufferChunk->Reset();
	}

	cout << "Addr : " << &LSendBufferChunk << "\nFREE : " << LSendBufferChunk->FreeSize() << endl;

	return LSendBufferChunk->Open(size);
}

 

 

전체 청크의 용량이 100이라고 가정하자.

이 상태에선 아직 그 어떤 것도 접근하지 않아 쓸 수 없는 상태다. 이걸 Close 된 상태라고 부른다.

여기서 갑자기 누가 찾아와서 10만큼의 버퍼가 필요하다고 요청해 왔다. 이 상태가 Open을 호출한 상태이다.

 

Open 요청을 받은 매니저는 청크가 존재하는지(LSendBufferChunk == nullptr),

청크가 이미 사용중이지 않는지(IsOpen),

요청한 용량이 여유 공간보다 많진 않은지(FreeSize())를 확인하고 문제가 없다면 청크에게 Open을 지시한다.

 

닫혀있던 공간을 열어 그 공간을 사용하기 때문에 Open이 됐다고 생각해도 좋을 것이다.

 

 

SendBufferChunkRef SendBufferManager::Pop()
{
	{
		WRITE_LOCK;
		if (_sendBufferChunks.empty() == false)
		{
			SendBufferChunkRef sendBufferChunk = _sendBufferChunks.back();
			_sendBufferChunks.pop_back();
			return sendBufferChunk;
		}
	}

	// 청크가 비었으면 생성
	// 청크의 레퍼런스 카운트가 0이 되면 메모리를 날리는 것이 아닌,
	// PushGlobal로 들어가서 청크 벡터에 넣어 보관함 (Deleter)
	// Deleter는 멤버함수로서 전달할 수 없기 때문에 Static을 붙인 것
	return SendBufferChunkRef(xnew<SendBufferChunk>(), PushGlobal);
}

Pop은 생각보다 하는게 많다.

 

먼저 여유분의 청크가 있는지 확인한다.

여유가 있다면 청크를 하나 꺼내와 청크를 리턴해 준다.

 

만약 여유분의 청크가 없다면 청크를 새로 만들어 리턴한다.

여기서 확인할 점은 청크는 레퍼런스 카운트가 0이 돼도 사라지지 않는다는 것이다.

대신 PushGlobal을 통해 다시 매니저가 갖고 있는 예비 청크로 돌려보낸다.

Shared Pointer의 생성자가 받는 Deleter 함수에 PushGlobal을 넣어놨기 때문이다.

Deleter를 넣어주면 레퍼런스 카운트가 0이 될때 그냥 사라지는 것이 아닌 저 함수를 호출하게 된다.

 

void SendBufferManager::Push(SendBufferChunkRef buffer)
{
	WRITE_LOCK;
	_sendBufferChunks.push_back(buffer);
}

void SendBufferManager::PushGlobal(SendBufferChunk* buffer)
{
	// 첫 생성 이후 계속 재활용 함
	GSendBufferManager->Push(SendBufferChunkRef(buffer, PushGlobal));
}

Push 관련 함수는 위와 같이 작성할 수 있다.

 

이제 청크 클래스를 작성한다.

// public이 없으면 문제가 생긴다!
class SendBufferChunk : public enable_shared_from_this<SendBufferChunk>
{
	enum
	{
		SEND_BUFFER_CHUNK_SIZE = 6000
	};

public:
	SendBufferChunk();
	~SendBufferChunk();

	void				Reset();
	SendBufferRef			Open(uint32 allocSize);		// allocSize만큼 할당
	void				Close(uint32 writeSize);	// wrtieSize(실제로 사용한 공간)만큼 닫음

	bool				IsOpen() { return _open; }
	BYTE*				Buffer() { return &_buffer[_usedSize]; } // 사용 가능한 공간의 주소 리턴
	uint32				FreeSize() { return static_cast<uint32>(_buffer.size()) - _usedSize; }

private:
	// Vector여도 상관없음
	Array<BYTE, SEND_BUFFER_CHUNK_SIZE>					_buffer = {};
	bool									_open = false;
	uint32									_usedSize = 0;
};

이전에 SendBuffer를 작성할 때, 클래스를 상속 받을 때 public을 안 붙여줬었는데 문제가 생길 수 있었다. 다음부턴 조심.

 

다시 돌아와서, 먼저 enum으로 청크의 크기를 정의했다. 적당히 여유가 있는 값으로 설정한다.

변수로는 청크 사이즈 만큼의 바이트 배열,

Open 여부에 대한 bool 변수,

청크가 얼마나 사용중인지 추적할 변수를 두었다.

 

IsOpen()에서는 청크가 지금 열려 있는지 여부를 리턴한다.

Buffer()는 현재 버퍼에서 남은 공간의 가장 앞의 주소를 리턴한다.

FreeSize()는 버퍼의 남은 공간의 크기를 리턴해 줄 것이다.

 

이제 Open()과 Close()를 정의하자.

void SendBufferChunk::Reset()
{
	_open = false;
	_usedSize = 0;
}

// 청크는 TLS영역에 있기 때문에
// 싱글 스레드에서 동작한다고 생각해도 됨
SendBufferRef SendBufferChunk::Open(uint32 allocSize)
{
	ASSERT_CRASH(allocSize <= SEND_BUFFER_CHUNK_SIZE);
	ASSERT_CRASH(_open == false);

	if (allocSize > FreeSize())
		return nullptr;

	_open = true;
	return ObjectPool<SendBuffer>::MakeShared(shared_from_this(), Buffer(), allocSize);
}

void SendBufferChunk::Close(uint32 writeSize)
{
	ASSERT_CRASH(_open == true);
	_open = false;
	_usedSize += writeSize;
}

Reset() 함수는 처음 청크를 뽑아먹을 때 사용하게 된다. 어찌 보면 초기화라 볼 수도 있겠다.

열려있는 게 있다면 다 닫고, 사용량을 0으로 만든다.

 

매니저에서 청크의 Open()을 호출해서 여기로 온다.

요청 사이즈의 크기와 닫힘 여부를 체크한다.

문제가 없다면 열고 여유공간의 시작점(Buffer())부터 allocSize만큼의 공간을 할당해 리턴해 준다.

 

세션에서 오픈한 후 데이터를 버퍼에 넣고 Close를 호출하게 된다.

여기서 받는 writeSize는 실제로 사용한 크기를 나타낸다.

할당한 만큼 다 쓴다는 보장이 없기 때문에 쓴 만큼만을 넘겨주는 것이다.

그만큼 _usedSize의 수치에 더해준다.

4메가를 할당했는데 항상 4메가만큼 쓸 수는 없지 않을까?

그리고 4메가를 쓰지도 않았는데 4메가를 쓴 것처럼 처리해 버리면 낭비가 생기는 것과 같다.

 

 

이제 SendBuffer 클래스를 위의 구현 사항에 맞게 고쳐준다.

class SendBufferChunk;

class SendBuffer
{
public:
	SendBuffer(SendBufferChunkRef owner, BYTE* buffer, int32 allocSize);
	~SendBuffer();

	BYTE*		Buffer() { return _buffer; }
	int32		WriteSize() { return _writeSize; }
	void		Close(uint32 writeSize);

private:
	BYTE*				_buffer;
	uint32				_allocSize = 0;
	uint32				_writeSize = 0;
	SendBufferChunkRef		_owner; // 청크가 버퍼 아래에 있기 때문에 전방선언

	// 청크가 중간에 사라지면 안되기 때문에 레퍼런스 카운팅이 필요
	// 따라서 SendBuffer가 자신을 포함하고 있는 청크에 대해서 알아야 함
	// 배열을 직접 할당하는 것이 아닌 포인터를 들고 있게 한다
};

이제 더 이상 이 클래스의 포인터를 넘겨줄 일이 없으므로 상속은 받지 않아도 된다.

기존엔 바이트형 벡터를 갖고 있었지만, 이제 통으로 갖고 있지 않고 포인터를 갖고 있게 했다.

 

청크 안의 영역을 버퍼들이 쪼개서 사용할 것이기도 하고,

청크가 갑자기 사라지는 일을 방지하기 위해선 청크의 포인터를 넘겨줘야할 필요가 있다.

예고 없이 사라져 버린다면 포인터형 변수로 버퍼를 관리하기 때문에 큰 문제가 될 것이다.

 

 

2. 세션 및 클라이언트 수정

GameSession클래스의 OnRecv를 보자.

int32 GameSession::OnRecv(BYTE* buffer, int32 len)
{
	// Echo
	cout << "OnRecv Len = " << len << endl;
	/*매번마다 SendBuffer를 MakeShared로 만드는 부분이 비효율적*/
	SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
	::memcpy(sendBuffer->Buffer(), buffer, len);
	sendBuffer->Close(len);
	
	GSessionManager.Broadcast(sendBuffer);

	return len;
}

매니저를 통해 청크로부터 버퍼를 받아온다.

Open하고, 버퍼에 데이터를 밀어 넣고, Close 하고, Broadcast(전송)한다.

 

클라이언트도 그냥 복붙해 와서 수정하면 된다.

void OnConnected() override
{
	cout << "Connected To Server" << endl;

	SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
	::memcpy(sendBuffer->Buffer(), sendData, sizeof(sendData));
	sendBuffer->Close(sizeof(sendData));

	Send(sendBuffer);
}

int32 OnRecv(BYTE* buffer, int32 len) override
{
	cout << "OnRecv Len = " << len << endl;

	this_thread::sleep_for(1s);

	SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
	::memcpy(sendBuffer->Buffer(), sendData, sizeof(sendData));
	sendBuffer->Close(sizeof(sendData));

	Send(sendBuffer);

	return len;
}

토씨 하나 다르지 않고 똑같다.

 

 

3. 실행

이제 버퍼가 잘 일을 하는지 확인해 보자.

그냥 여유 공간만 있으면 스레드 구분이 되지 않기 때문에,

다른 스레드라는 것을 구분할 수 있도록 청크의 주소를 같이 출력하도록 했다.

6000으로 시작해서 서서히 여유공간이 줄어간다는 것을 확인할 수 있다.

스레드 별로 청크의 용량이 다른 것도 확인할 수 있다.

 

그리고 이번에 TLS 영역을 활용했는데, 락 경합 없이 무언가를 처리할 수 있다는 건 확실히 큰 메리트로 다가온다.

단일 스레드인 것 처럼 코드를 작성할 수 있다는 것은 사람에게도 편리할 수밖에 없지 않을까.