KoreanFoodie's Study
[C++ 게임 서버] 2-6. 메모리 풀 #1 본문
[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바이트 크기의 메모리 풀을 사용하면서, 큐에 메모리를 반납/대여하게 될 것이다 😂
'Game Dev > Game Server' 카테고리의 다른 글
[C++ 게임 서버] 2-8. 메모리 풀 #3 (0) | 2023.09.14 |
---|---|
[C++ 게임 서버] 2-7. 메모리 풀 #2 (0) | 2023.09.13 |
[C++ 게임 서버] 2-5. STL Allocator (0) | 2023.08.25 |
[C++ 게임 서버] 2-4. StompAllocator (0) | 2023.08.24 |
[C++ 게임 서버] 2-3. Allocator (0) | 2023.08.23 |