KoreanFoodie's Study

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

Game Dev/Game Server

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

GoldGiver 2023. 9. 14. 17:41

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

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

핵심 :

1. 마이크로소프트에서는, LockFreeStack 을 구현해서 이미 제공하고 있다 : SLIST_ENTRY

2. LockFreeStack 은 실질적으로 Lock-Based 구현에 비해 엄청 빠르거나 한 건 아니지만, 일단 사용하게 되면 전문용어로 '간지'가 난다.

저번 시간에는 LockFreeStack 을 이용해 Memory Pool 을 사용할 때, 각각의 entry 에 불필요한 메타 정보를 담는 것을 방지할 수 있었다. 메타 정보는 Header 에만 있으면 되기 때문이다(엔트리의 갯수가 너무 많지만 않으면).

 

그런데 일반적으로는 우리가 직접 만든 것을 사용하지 않고, 윈도우에서 만들어준 SLIST_ENTRY 를 사용한다. 실제 코드를 보자.

typedef struct DECLSPEC_ALIGN(16) _SLIST_ENTRY {
    struct _SLIST_ENTRY *Next;
} SLIST_ENTRY, *PSLIST_ENTRY;

#pragma warning(pop)

#else

typedef struct _SINGLE_LIST_ENTRY SLIST_ENTRY, *PSLIST_ENTRY;

#endif // _WIN64

#if defined(_AMD64_)

typedef union DECLSPEC_ALIGN(16) _SLIST_HEADER {
    struct {  // original struct
        ULONGLONG Alignment;
        ULONGLONG Region;
    } DUMMYSTRUCTNAME;
    struct {  // x64 16-byte header
        ULONGLONG Depth:16;
        ULONGLONG Sequence:48;
        ULONGLONG Reserved:4;
        ULONGLONG NextEntry:60; // last 4 bits are always 0's
    } HeaderX64;
} SLIST_HEADER, *PSLIST_HEADER;

이전 글에서 직접 만든 버전과 놀랍도록(?) 유사한 것을 알 수 있다! 사실 그 이유는, 저번 글에서 마소의 구현을 그대로 흉내낸 것이기 때문이다 🤣 실제로, 윈도우 버전도 DECLSPEC_ALIGN(16) 을 통해 메모리를 16 바이트 정렬시킴을 확인할 수 있다.

어쨋든, 이제 메모리풀에서 윈도우 버전의 SLIST_HEADER 를 사용해 보자. PSLIST_HEADER 는, SLIST_HEADER 의 포인터 타입을 PSLIST_HEADER 로 쓰겠다는 뜻이다. 코드를 살펴보자.

 

MemoryPool.h

enum
{
	SLIST_ALIGNMENT = 16
};

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

DECLSPEC_ALIGN(SLIST_ALIGNMENT)
struct MemoryHeader : public SLIST_ENTRY
{
	// [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
------------------*/

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

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

private:
	SLIST_HEADER	_header;
	int32			_allocSize = 0;
	atomic<int32>	_allocCount = 0;
};

이제 MemoryHeader 는 SLIST_ENTRY 를 상속한다. ALIGN 을 해 주는 것 말고는, 헤더에서 고칠 건 딱히 더 없다.

 

MemoryPool.cpp

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

MemoryPool::MemoryPool(int32 allocSize) : _allocSize(allocSize)
{
	::InitializeSListHead(&_header);
}

MemoryPool::~MemoryPool()
{
	while (MemoryHeader* memory = static_cast<MemoryHeader*>(::InterlockedPopEntrySList(&_header)))
		::_aligned_free(memory);
}

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

	::InterlockedPushEntrySList(&_header, static_cast<PSLIST_ENTRY>(ptr));

	_allocCount.fetch_sub(1);
}

MemoryHeader* MemoryPool::Pop()
{
	MemoryHeader* memory = static_cast<MemoryHeader*>(::InterlockedPopEntrySList(&_header));

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

	_allocCount.fetch_add(1);

	return memory;
}

일단 소멸자를 보자. 소멸자에서는, InterlockedPopEntrySList 를 통해 헤더가 valid 한지를 체크하면서, free 를 해 준다.

InterlockedPopEntrySList ... 같은 함수는, 마소에서 이미 정의된 함수를 그냥 갖다 쓰는 것이다 ㅋㅋ

WINBASEAPI
VOID
WINAPI
InitializeSListHead(
    _Out_ PSLIST_HEADER ListHead
    );

WINBASEAPI
PSLIST_ENTRY
WINAPI
InterlockedPopEntrySList(
    _Inout_ PSLIST_HEADER ListHead
    );

WINBASEAPI
PSLIST_ENTRY
WINAPI
InterlockedPushEntrySList(
    _Inout_ PSLIST_HEADER ListHead,
    _Inout_ __drv_aliasesMem PSLIST_ENTRY ListEntry
    );

또한 free 대신 _aligned_free 를 사용하는데, 이는 우리가 메모리를 사용할때 16 바이트로 정렬할 것을 명시했으므로, 이에 맞게 해제도 정렬 규칙에 따른다고 이해하면 된다.

Push 와 Pop도.. 마찬가지로 API 교체 및 캐스팅만 제대로 해 주면 된다 😊

 

이제 실제로 메모리를 할당/해제하는 곳에서, 아주 약간만 수정을 해 주면 된다.

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

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

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

보면 header 를 할당하는 부분에서 _aligned_malloc 을 사용하는 것, 그리고 인자로 SLIST_ALIGNMENT 를 사용하는 것을 알 수 있다. SLIST_ALIGNMENT 는 16 으로 우리가 define 해 놓았으므로... 그냥, 정렬을 하며 malloc 을 해 준다고 이해하면 된다.

 

이제 Main 함수를 보면, 다음과 같이 간단히 사용할 수 있을 것이다 :

class Knight
{
public:
	int32 _hp = rand() % 1000;
};

int main()
{
	for (int32 i = 0; i < 5; i++)
	{
		GThreadManager->Launch([]()
			{
				while (true)
				{
					Knight* knight = xnew<Knight>();

					cout << knight->_hp << endl;

					this_thread::sleep_for(10ms);

					xdelete(knight);
				}
			});
	}

	GThreadManager->Join();
}

이제 우리가 사용하려는 클래스가 우리가 만든 Custom SListEntry 클래스를 상속받도록 만들지 않아도 된다 😎

Comments