KoreanFoodie's Study

[C++ 게임 서버] 2-6. 메모리 풀 #1 본문

Game Dev/Game Server

[C++ 게임 서버] 2-6. 메모리 풀 #1

GoldGiver 2023. 9. 11. 17:25

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

[C++ 게임 서버] 2-6. 메모리 풀 #1

핵심 :

1. 메모리 풀을 이용하면, 메모리 파편화를 프로그래밍 레벨에서 어느 정도 해소함으로써 메모리를 좀 더 효율적으로 사용할 수 있다.

2. 할당에 필요한 메모리 조각의 크기에 따라 메모리 풀을 각각 만들어 사용하는 경우가 있고, 같은 사이즈만 사용하는 메모리 풀을 활용할 수도 있다.

3. 멀티 쓰레드 환경에서 메모리 풀을 활용한다면, 풀 접근 시 Lock 을 잘 잡아주도록 하자.

오브젝트 풀링은 아주.. 오래된 유서 깊은 기법이다.

주 목적은 메모리 파편화를 최소화하기 위해 프로그래밍 레벨에서 메모리를 효율적으로 관리하기 위함인데, 사실 주 골격은 간단하다.

메모리 조각을 담을 '풀'을 만들고, 메모리가 필요할 때 새롭게 메모리를 할당하고, 할당한 메모 사용을 완료했을때 다시 풀에 반납하면 된다. 새롭게 메모리를 사용할 필요가 생겼을 때, 메모리를 그때 그때 할당하기 보다, 풀에 이미 할당된 녀석이 있으면(즉, 반납된 녀석이 있으면) 그 녀석의 공간을 활용한다.

간단히 말해서, 호텔을 운영하는데 손님이 올 때마다 침대를 만드는 것이 아니라, 비어 있는 침대가 있으면 그 침대를 손님에게 내주는.. 그런 느낌이다(침대가 꽉 차면, 침대를 새로 만든다. 혹은 방을 새로 만들어야 할 수도 있고) 🤣

 

이제 실제 코드를 보면서 어떻게 구현하면 좋은지 알아보자. 일단 메모리 풀을 위한 클래스를 선언할 것이다.

아래 코드는, 특정 크기별로 메모리 풀을 묶어서 관리하고 있다.

MemoryPool.h

/*-----------------
	MemoryHeader
------------------*/

struct MemoryHeader
{
	// [MemoryHeader][Data]
	MemoryHeader(int32 size) : allocSize(size) { }

	static void* AttachHeader(MemoryHeader* header, int32 size)
	{
		new(header)MemoryHeader(size); // placement new
		return reinterpret_cast<void*>(++header);
	}

	static MemoryHeader* DetachHeader(void* ptr)
	{
		MemoryHeader* header = reinterpret_cast<MemoryHeader*>(ptr) - 1;
		return header;
	}

	int32 allocSize;
	// TODO : 필요한 추가 정보
};

/*-----------------
	MemoryPool
------------------*/

class MemoryPool
{
public:
	MemoryPool(int32 allocSize);
	~MemoryPool();

	void			Push(MemoryHeader* ptr);
	MemoryHeader*	Pop();

private:
	int32 _allocSize = 0;
	atomic<int32> _allocCount = 0;

	USE_LOCK;
	queue<MemoryHeader*> _queue;
};

위 코드를 보면 Memory Header 와 Memory Pool 이 있는 것을 확인해볼 수 있다.

간단히 생각해서, MemoryHeader 는 MemoryPool 에서 사용할 메모리 조각을 위한 메타 정보를 담고 있는 클래스라고 보면 된다.

MemoryHeader 를 보면, AttachHeader 에서 placement new 기법으로 메모리를 할당하는 것을 알 수 있다. 즉, 시작 지점에서 데이터의 앞 부분에 데이터의 크기가 얼만큼인지를 Header 에 저장을 하고, 실제 할당된 데이터를 참조할 수 있도록 header 의 포인터 주소에 1을 더해서(++), void* 로 캐스팅해 건네준다.

void* 로 캐스팅을 하는 이유는, 실제 data 의 타입이 무엇이 될지 알 수 없기 때문이다.

DetachHeader 에서는, 단순히 header 의 메모리 영역의 시작점을 반환하는데 사용한다. DetachHeader 의 인자로는 Data 포인터의 시작점이 올 텐데, 해제를 제대로 해 주기 위해서 meta 정보로 활용한 MemoryHeader 클래스 크기 만큼 1을 빼 줌으로써, MemoryHeader 의 시작 위치를 반환하는 것이다.

 

이제 본격적으로 MemoryPool 의 구현을 보자.

MemoryPool.cpp

/*-----------------
	MemoryPool
------------------*/

MemoryPool::MemoryPool(int32 allocSize) : _allocSize(allocSize)
{
}

MemoryPool::~MemoryPool()
{
	while (_queue.empty() == false)
	{
		MemoryHeader* header = _queue.front();
		_queue.pop();
		::free(header);
	}
}

void MemoryPool::Push(MemoryHeader* ptr)
{
	WRITE_LOCK;
	ptr->allocSize = 0;

	// Pool에 메모리 반납
	_queue.push(ptr);

	_allocCount.fetch_sub(1);
}

MemoryHeader* MemoryPool::Pop()
{
	MemoryHeader* header = nullptr;

	{
		WRITE_LOCK;
		// Pool에 여분이 있는지?
		if (_queue.empty() == false)
		{
			// 있으면 하나 꺼내온다
			header = _queue.front();
			_queue.pop();
		}
	}

	// 없으면 새로 만들다
	if (header == nullptr)
	{
		header = reinterpret_cast<MemoryHeader*>(::malloc(_allocSize));
	}
	else
	{
		ASSERT_CRASH(header->allocSize == 0);
	}

	_allocCount.fetch_add(1);

	return header;
}

소멸자는 뭐... 큐에 담긴 모든 메모리를 실제로 해제해 주는 것으로 그 역할을 다한다.

push 와 pop 은, 그 이름처럼 그냥 큐에 포인터를 넣고 빼는데, Pop 에서 꺼내올 것이 없을 경우, 메모리를 할당을 하게 된다.

 

 

이제 Memory 클래스에서, 위에 생성한 Memory Pool 을 가지고 메모리를 관리해 줄 것이다.

Memory.h

/*-------------
	Memory
---------------*/

class Memory
{
	enum
	{
		// ~1024까지 32단위, ~2048까지 128단위, ~4096까지 256단위
		POOL_COUNT = (1024 / 32) + (2048 / 128) + (4096 / 256),
		MAX_ALLOC_SIZE = 4096
	};

public:
	Memory();
	~Memory();

	void*	Allocate(int32 size);
	void	Release(void* ptr);

private:
	vector<MemoryPool*> _pools;

	// 메모리 크기 <-> 메모리 풀
	// O(1) 빠르게 찾기 위한 테이블
	MemoryPool* _poolTable[MAX_ALLOC_SIZE + 1];
};

먼저 POOL_COUNT 를 보면, 풀의 갯수(방의 갯수)를 확인할 수 있다.

침대, 아니 메모리 조각의 크기는 자잘하게 쪼개는데, 세 영역마다 32, 128, 256 씩 커지게 된다. 각 조각의 영역은 1024, 2048, 4096 만큼으로 지정할 것이다.

이 부분이 조금 헷갈릴 수 있는데... 예를 들어, 32, 64, 96... 이렇게 32 바이트 크기씩 늘어나는 메모리 조각을 풀링하는 풀을, 메모리 조각의 크기가 1024가 될 때까지 만들 것이다(침대 사이즈가 32씩 늘어난다).

마찬가지로, 1024 바이트 부터는 128 바이트씩 풀링의 크기가 늘어나 1152, 1280... 씩 풀링의 크기가 조절될 것이다.

그리고 침대의 최대 크기, 아니 메모리 조각의 최대 크기는 MAX_ALLOC_SIZE 로 잡는다. 즉, 이전에 풀링을 위해 생성한 침대의 사이즈보다 더 큰 사이즈의 침대가 놓인 방이 필요하면, 새롭게 일반 할당을 할 것이다.

 

Memory.cpp

/*-------------
	Memory
---------------*/

Memory::Memory()
{
	int32 size = 0;
	int32 tableIndex = 0;

	for (size = 32; size <= 1024; size += 32)
	{
		MemoryPool* pool = new MemoryPool(size);
		_pools.push_back(pool);

		while (tableIndex <= size)
		{
			_poolTable[tableIndex] = pool;
			tableIndex++;
		}
	}

	for (; size <= 2048; size += 128)
	{
		MemoryPool* pool = new MemoryPool(size);
		_pools.push_back(pool);

		while (tableIndex <= size)
		{
			_poolTable[tableIndex] = pool;
			tableIndex++;
		}
	}

	for (; size <= 4096; size += 256)
	{
		MemoryPool* pool = new MemoryPool(size);
		_pools.push_back(pool);

		while (tableIndex <= size)
		{
			_poolTable[tableIndex] = pool;
			tableIndex++;
		}
	}
}

Memory::~Memory()
{
	for (MemoryPool* pool : _pools)
		delete pool;

	_pools.clear();
}

void* Memory::Allocate(int32 size)
{
	MemoryHeader* header = nullptr;
	const int32 allocSize = size + sizeof(MemoryHeader);

	if (allocSize > MAX_ALLOC_SIZE)
	{
		// 메모리 풀링 최대 크기를 벗어나면 일반 할당
		header = reinterpret_cast<MemoryHeader*>(::malloc(allocSize));
	}
	else
	{
		// 메모리 풀에서 꺼내온다
		header = _poolTable[allocSize]->Pop();
	}

	return MemoryHeader::AttachHeader(header, allocSize);
}

void Memory::Release(void* ptr)
{
	MemoryHeader* header = MemoryHeader::DetachHeader(ptr);

	const int32 allocSize = header->allocSize;
	ASSERT_CRASH(allocSize > 0);

	if (allocSize > MAX_ALLOC_SIZE)
	{
		// 메모리 풀링 최대 크기를 벗어나면 일반 해제
		::free(header);
	}
	else
	{
		// 메모리 풀에 반납한다
		_poolTable[allocSize]->Push(header);
	}
}

 

먼저, 생성자 쪽을 보면 각 침대의 크기, 아니 메모리의 크기별로 풀을 생성해서 풀 벡터에 넣어주고 있는 것을 확인할 수 있다. 그리고 나중에 풀을 편하게 검색할 수 있도록, _poolTable 에도 넣어주고 있다.

예를 들어, 만약 20바이트 짜리 메모리 조각을 할당하는 데 있어 풀링을 활용한다면... 생성자에서 보듯, tableIndex 가 0 에서 32 까지인 녀석은 32 바이트 짜리 풀을 참조하게 되므로, 32 바이트 짜리 메모리 풀을 활용할 것이다 😮

 

사실 핵심은 Allocate 와 Release 이다.

Allocate 에서는 메모리 풀링 최대 크기를 벗어나면 새로 할당을 하고, 아니면 메모리 풀에서 꺼내온다. 꺼내온 다음에는, MemoryHeader::AttachHeader 를 통해, header 의 위치를 다시 재설정(빈 곳으로)해 준다.

 

그리고 Release 가 되게 되면, 해당 메모리는 Pool 에 반납된다.

void Memory::Release(void* ptr)
{
	MemoryHeader* header = MemoryHeader::DetachHeader(ptr);

	const int32 allocSize = header->allocSize;
	ASSERT_CRASH(allocSize > 0);

	if (allocSize > MAX_ALLOC_SIZE)
	{
		// 메모리 풀링 최대 크기를 벗어나면 일반 해제
		::free(header);
	}
	else
	{
		// 메모리 풀에 반납한다
		_poolTable[allocSize]->Push(header);
	}
}

위에서, 해당 header 포인터가 Push 되고 있는데...

void MemoryPool::Push(MemoryHeader* ptr)
{
	WRITE_LOCK;
	ptr->allocSize = 0;

	// Pool에 메모리 반납
	_queue.push(ptr);

	_allocCount.fetch_sub(1);
}

이는 해당 메모리 풀의 큐에, 인자로 넘어온 포인터가 삽입된다고 보면 된다.

 

이제 CoreMacro 에서 우리가 사용하는 Allocator 를 Base 대신 Pool 로 바꿔주면...

/*----------------
	  Memory
-----------------*/

#ifdef _DEBUG
#define xxalloc(size)		PoolAllocator::Alloc(size)
#define xxrelease(ptr)		PoolAllocator::Release(ptr)
#else
#define xxalloc(size)		BaseAllocator::Alloc(size)
#define xxrelease(ptr)		BaseAllocator::Release(ptr)
#endif

 

main 함수에서 이를 활용할 준비가 다 되었다 🙂

int main()
{
	for (int32 i = 0; i < 5; i++)
	{
		GThreadManager->Launch([]()
			{
				while (true)
				{
					Vector<Knight> v(10);

					Map<int32, Knight> m;
					m[100] = Knight();

					this_thread::sleep_for(10ms);
				}
			});
	}

	GThreadManager->Join();
}

우리가 커스텀하게 만든 Vector 클래스를 할당하기 위해, 풀을 알아서 생성해 줄 것이다.

cf) 벡터의 메모리 사이즈(sizeof)는, 고정값으로, 이 프로젝트에서는 16 바이트이다(설명). 이 프로젝트에서는 여기에 메모리 헤더를 사이즈(4)를 포함해 20이 나오는데... 그럼 32바이트 짜리의 메모리 풀을 활용하게 된다.

5개의 쓰레드가 32바이트 크기의 메모리 풀을 사용하면서, 큐에 메모리를 반납/대여하게 될 것이다 😂

Comments