KoreanFoodie's Study

[C++ 게임 서버] 4-9. SendBuffer Pooling 본문

Game Dev/Game Server

[C++ 게임 서버] 4-9. SendBuffer Pooling

GoldGiver 2023. 12. 14. 04:18

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 4-9. SendBuffer Pooling

핵심 :

1. SendBuffer 를 보낼 때마다 메모리 할당을 하지 말고, Memory Pooling 기법을 이용해서 큰 초기에 1 번만 할당을 하는 기법을 사용해 보자.

2. 실제 Session 에서는 SendBufferManager 를 이용해 버퍼를 사용할 준비(Open 함수)를 한다. SendBufferManager -> SendBufferChunk -> SendBuffer 순으로 계층이 잡혀 있다.

3. SendBufferChunkRef 타입의 인자를 각 쓰레드별로 TLS(Thread-Local Space)에 잡아 두면, 각 쓰레드 끼리 Chunk 에 접근하는 데 있어 경합을 벌일 필요가 없어 효율적이다. 또한 Lock-Free 설계와도 궁합이 좋다.

이번 시간에는, SendBuffer 와 관련하여 풀링을 이용한 최적화를 구현해 보겠다.

사실 이전에는 Send 를 할 때마다 SendBuffer 를 위한 메모리 할당을 했었는데...

int32 GameSession::OnRecv(BYTE* buffer, int32 len)
{
	// Echo
	cout << "OnRecv Len = " << len << endl;

	SendBufferRef sendBuffer = MakeShared<SendBuffer>(4096);
	sendBuffer->CopyData(buffer, len);
	
	GSessionManager.Broadcast(sendBuffer);

	return len;
}

여기서 보면, GameSession 클래스에서 OnRecv 가 불릴 때마다 4096 사이즈의 SendBuffer 를 만들어 주고 있다(4096 으로 임의로 정한 건, 일단은 임의로 큰 값을 정해서 할당을 한 것이라고 받아들이자).

하지만 SendBuffer 를 필요할 때마다 만들지 않고, 미리 큰 영역을 할당해 놓은 다음, 필요할 때마다 SendBuffer 를 일부 가져와서 쓰면 어떨까? 사실 그게 기본적인 메모리 풀링의 아이디어이긴 하다. 😅

즉, 이번에 우리가 만들 것은, 엄~청 큰 메모리 버퍼(SendBufferChunk 라고 하자)를 만들고, SendBuffer 가 필요할 때마다 잘라서 쓴 다음, 사용이 완료되면 반납하는(스마트 포인터 활용) 방식이다. 😉

위 그림은 그냥 Chunk 를 블록처럼 잘라 쓴다는 것을 보여주려고 갖고 온 그림인데, 너무 신경 쓰지는 말자 😂

 

일단, SendBuffer 를 담는 대빵(?), SendBufferChunk 를 정의할 것이다.

SendBufferChunk.h

/*--------------------
	SendBufferChunk
--------------------*/

class SendBufferChunk : public enable_shared_from_this<SendBufferChunk>
{
	enum
	{
		SEND_BUFFER_CHUNK_SIZE = 6000
	};

public:
	SendBufferChunk();
	~SendBufferChunk();

	void				Reset();
	SendBufferRef			Open(uint32 allocSize);
	void				Close(uint32 writeSize);

	bool				IsOpen() { return _open; }
	BYTE*				Buffer() { return &_buffer[_usedSize]; }
	uint32				FreeSize() { return static_cast<uint32>(_buffer.size()) - _usedSize; }

private:
	Array<BYTE, SEND_BUFFER_CHUNK_SIZE>		_buffer = {};
	bool						_open = false;
	uint32						_usedSize = 0;
};

일단, SEND_BUFFER_CHUNK_SIZE 가 눈에 띈다. 이 녀석은 아래 Array 를 보면... 버퍼를 배열로 만들어서 처음에만 할당할 건데, 이 배열의 사이즈를 의미한다. 즉, 여기서는 6000 개 분량의 SendBuffer 크기만큼 배열로 한꺼번에 할당을 한 후, 이 녀석을 이용해 SendBuffer 가 필요한 경우마다 빌려줄 것이다.

여기서 흥미롭게 봐야 할 부분은 Open 과 관련된 부분인데... 아래 상세 구현을 보자.

 

SendBufferChunk.cpp

SendBufferChunk::SendBufferChunk()
{
}

SendBufferChunk::~SendBufferChunk()
{
}

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

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 은 그냥 뭔가 보기만 해도 초기화의 향기가 나고, Close 는 사용을 완료했다는 느낌을 준다.

Open 은 ObjectPool 클래스를 이용해 allocSize 크기의 SendBuffer 를 만들어 준다. 혹여나 ObjectPool 클래스의 자체 MakeShared 함수를 까먹었을 수도 있는데, 이 녀석은 그냥 해당 클래스의 생성자의 인자와 동일한 녀석들을 넣어주면 된다.

참고로, SendBuffer 는 아래처럼 변형할 것이다 :

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;
};


/////////////////////////////////////
		Implementation
/////////////////////////////////////

SendBuffer::SendBuffer(SendBufferChunkRef owner, BYTE* buffer, int32 allocSize)
	: _owner(owner), _buffer(buffer), _allocSize(allocSize)
{
}

SendBuffer::~SendBuffer()
{
}

void SendBuffer::Close(uint32 writeSize)
{
	ASSERT_CRASH(_allocSize >= writeSize);
	_writeSize = writeSize;
	_owner->Close(writeSize);
}

SendBuffer 의 생성자를 잘 보면... owner 로 SendBufferChunkRef 가 잡혀 있는 것을 볼 수 있는데, 바로 이 녀석이 아까 ObjectPool<SendBuffer>::MakeShared(shared_rrom_this(), Buffer(), allocSize); 에서의 첫 인자, 'shared_from_this()' 되시겠다.

즉, 메모리 풀링을 해주면서, 해당 SendBuffer 의 소유주가 현재의 SendBufferChunk 임을 확실히 해 주는 것이다. 😎

 

이제 위의 SendBuffer, SendBufferChunk 를 별도의 매니저로 관리해 주면 되는데...

SendBufferManager.h

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;
};

SendBufferManager 는, 또 다시 SendBufferChunkRef 의 벡터를 가지고 있다. 자세한 구현을 보면...

 

SendBufferManager.cpp

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

	ASSERT_CRASH(LSendBufferChunk->IsOpen() == false);

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

	cout << "FREE : " << LSendBufferChunk->FreeSize() << endl;

	return LSendBufferChunk->Open(size);
}

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

	return SendBufferChunkRef(xnew<SendBufferChunk>(), PushGlobal);
}

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

void SendBufferManager::PushGlobal(SendBufferChunk* buffer)
{
	GSendBufferManager->Push(SendBufferChunkRef(buffer, PushGlobal));
}

뭔가 생소한, LSendBufferChunk 라는 것이 등장했다. 이 녀석은 무엇일까?

/* CoreTLS.cpp */
thread_local SendBufferChunkRef	LSendBufferChunk;

사실 이 녀석은 Thread-Local 하게(각 쓰레드마다 별도로 할당) 선언된 SendBufferChunkRef 타입의 인자이다.

즉, Open 을 보면.. 현재 쓰레드의 Chunk 가 nullptr 면 Pop 을 통해 새로 Chunk 를 할당받고, 필요한 크기에 비해 여유공간이 부족해도 Chunk 를 새로 할당할 것임을 알 수 있다. 그리고 Chunk 할당이 끝났으면 해당 Chunk 에 대해 Open 을 한 번 더 불러서 내부적으로도 풀링 처리를 한다.

이제 Pop 을 보면... _sendBufferChunks 배열이 비어 있지 않다는 건, 뭔가 재활용할 수 있는 Chunk 가 있다는 것이므로 스택을 사용하듯이 이를 다시 꺼내준다. 물론 배열이 비어있다면(기존에 만들고 나서 다 써서 Array 로 돌아온 녀석이 없다면), 새로 만들게 된다. 이때 소멸자를 PushGlobal 로 잡아 주기 때문에, 사용이 완료된 Chunk 가 _sendBufferChunks 벡터로 되돌아 올 수 있는 것이다! 😮

 

실제로 디버깅을 해 보면,

Chunk 내부의 FREE 한 영역(미사용 영역)이 줄어들고 있음을 알 수 있다!

Comments