개요
메모리풀은 미리 메모리를 준비시키고 이를 재사용하게 함으로서 생성과 반환에 소모되는 시간을 없애는 기술입니다. 가상메모리 시스템에서도 4KB단위로 페이징하는 것이 메모리풀의 성격을 가지고 있다고 볼 수도 있겠습니다.
다양한 버킷 크기를 지원하는 메모리풀의 구현은 안전 장치가 더해질수록 기본 힙 관리자와 성능차이가 줄어들 것이므로, 이 프로젝트에서는 고정 크기의 객체 (오브젝트)에 대한 메모리풀을 도입하는 것으로 하겠습니다.
메모리풀과 프리리스트
메모리풀
메모리풀은 처음 초기화 시 지정된만큼 충분한 메모리 공간을 할당받아 관리를 시작합니다. 이 경우, 준비된 메모리를 할당받아 사용하고 반환하는 것은 성능 향상이 되지만, 만일 메모리풀이 부족할 경우 resize를 할 지, 이 오버헤드가 감당 가능한지 고민해야 하는 문제가 생깁니다.
프리리스트 (Object FreeList)
위에서 논의된 문제에서 나아가, 미리 준비하고 반환된 메모리를 사용하는 메모리풀 대신, 프리리스트를 사용하는 방법 또한 존재합니다. 이는 미리 메모리풀처럼 충분한 메모리를 확보하는 것이 아닌, 반환된 메모리 공간을 재사용하는 방식입니다.
메모리풀과 프리리스트의 두 개념을 혼합하여, 프리리스트 기반이지만 최초 초기화 시 일정량의 메모리 공간을 미리 확보해 놓는 방식으로 구현 또한 가능합니다.
배열로의 구현
만일 배열로 메모리 공간을 관리할 지, 노드 리스트 구조로 관리할 지 고민해 보아야 할 필요가 있습니다.
각자 다른 장단점이 있기에, 이를 살펴보았습니다.
캐시히트 측면
배열로 메모리 공간이 관리되는 경우, 이 메모리 공간은 연속된 메모리 공간이므로, 캐시의 ‘공간지역성’ 측면에서 이점을 가져오게 됩니다. 하나의 캐시 라인 64KB안에서 주변의 다른 오브젝트가 참조되는 경우 최선의 접근시간을 보여줄 수 있고, 그렇지 않더라도 L1, L2 캐시 체계를 거치며 주변 메모리 공간 참조는 캐시 메모리의 이점을 받을 수 있게 됩니다.
또한, 연속된 메모리 공간을 사용할 때, 캐시 라인 측면이 아닌, 캐시 디렉터리 인덱스에서도 이점을 가져가게 됩니다. 현대 캐시 메모리는 N-way 시스템을 사용하여 캐시라인을 여러 디렉터리로 계층적으로 관리하고 있는데, 이 경우 메모리 주소값으로 캐시 디렉터리 인덱스를 찾아내게 됩니다. 연속되지 않은 멀리 떨어진 메모리 주소값이라면, 동일한 캐시 디렉터리 인덱스를 가지게 되어 해당 디렉터리 내에서 선형 캐시 라인 탐색을 요구하고 캐시 라인이 지워졌을 수 있습니다.
하지만, 살펴보아야 할 점은 실제로 메모리 공간 상에서 주변의 메모리 공간을 참조할 일이 자주 있는지입니다. 지금 만드려는 것은 메모리 풀로서, 서버 내의 다양한 위치에서 불규칙적으로 메모리를 가져가고 반납하는 것이 반복됩니다. 이는 내가 메모리 풀에서 연속으로 열 개의 오브젝트 메모리 공간을 받아왔다 하더라도, 연속된 메모리 공간이 아니라는 뜻입니다.
따라서, 캐시히트 측면에서 배열 기반 구조가 보여주는 강점은 의미가 없어집니다.
resize 등 관리 측면
배열의 경우 크기가 고정되어 있기에, 만일 메모리풀이 더욱 큰 메모리 공간을 관리해야 할 경우 resize를 해야 할 상황이 생깁니다.
이 경우 새로운 큰 공간을 잡으며 기존 메모리풀의 오브젝트들을 해제해야 하는데, 혹은 이러한 resize가 주는 성능관리상, 또한 구현상의 단점을 고민해야 할 필요가 있습니다.
노드 리스트 기반 구현
페이지 아웃 측면
배열로 구현한 경우 캐시 측면에서 이점은 있을 수 있지만 실제로 그 이점이 사용될 일은 거의 없지만, 노드 리스트 기반의 구현보다 확실히 강점이 될 수 있는 점은 한가지 더 존재합니다.
노드 연결리스트 기반으로 만들어진 경우, 노드들의 메모리는 가상메모리 상 연속되지 않은 다양한 위치에 산재하게 됩니다. 이는 배열로 구현했을 때보다 확실히 더욱 많은 페이지에 걸쳐있게 된다는 뜻이 되며, 이는 곧 실제로 노드를 참조하고자 하였을 때 swap-out되어있어 추가적인 IO 시간이 필요하게 될 수도 있다는 상황을 야기합니다.
따라서 이를 조금이나마 완화하기 위해 노드 연결리스트를 큐가 아닌 스택 기반으로 만들어볼 수 있겠습니다. 가장 최근에 반환된 메모리일수록 곧 해당 메모리를 가진 페이지가 가장 최근에 사용되었다는 뜻이고, 따라서 재할당 요청 시에 최근에 사용한 페이지들을 참조하게 되기에, 스택 기반으로 만드는 것이 큐 기반의 구현보다 성능을 더욱 향상시킬 수 있습니다.
메모리풀의 안전성
힙 메모리 관리자는 사용자에게 메모리를 할당해줄 때, 해당 메모리를 관리하는 데이터를 내부적으로 관리하고, 이는 실제 할당받은 메모리 앞 뒤에도 바로 존재합니다. 따라서, 만일 할당받은 힙 메모리를 초과하여 사용하는 경우, 해당 메모리를 해제하려고 시도하거나, 새로운 힙 메모리를 할당받으려 시도할 때 힙 메모리 관리자에서 오류를 내게 됩니다.
이러한 방식으로 사용자의 메모리 오사용을 방지할 수는 없지만, 감지할 수는 있습니다. 실시간으로 침범하는 것을 만드는 것은 불가능하지만, 힙 메모리 관리자와 동일하게, 혹은 스택 쿠키 가드와 동일하게 실제 할당한 메모리 앞뒤에 범퍼를 추가하여 이를 검사하는 방식으로 구현 또한 가능합니다.
메모리 앞뒤에 추가하는 범퍼의 값은, 컴파일 시 고정된 상수값이 될 수도 있지만, 다음 문제가 발생할 수 있습니다.
MemoryPool<ObjectA> objectPool1;
MemoryPool<ObjectA> objectPool2;
ObjectA *newObj = objectPool1.Alloc();
objectPool2.Free(newObj);
위 상황에서, 동일한 오브젝트 타입을 다루는 두 개의 다른 메모리풀 인스턴스가 있을 경우, 컴파일 시점에서 할당받아온 메모리풀이 아닌 다른 메모리풀로의 해제 요청에 대해 오류를 감지해내지 못한다는 문제가 있습니다.
이를 위해 메모리풀을 싱글톤 형태로 구현하는 방법 또한 존재하겠지만, 메모리 앞뒤에 안전장치로 추가할 상수값을, 인스턴스마다 고유한 값이 되게끔 함으로서, 할당받아온 메모리풀로의 반환만 허용할 수 있게끔 런타임에서 검사하는 구현 또한 가능합니다.
메모리풀의 오브젝트 생성자/소멸자 호출 여부
메모리풀에서 한 가지 더 생각해보아야 할 점은 오브젝트의 생성자 소멸자 관리입니다.
당연히 오브젝트가 할당되어질 때 생성자가 호출되고, 오브젝트를 메모리풀로 반납할 때 소멸자가 호출되게끔 구현하는 것이 옳을 수도 있지만, 메모리풀을 이용하여 직렬화버퍼를 관리하려는 경우에는 이러한 생성자 소멸자 호출을 한번 더 고민해보아야 합니다.
직렬화버퍼의 생성자에서는 (힙 메모리로 구현된 경우), 해당 직렬화버퍼가 가질 버퍼만큼 메모리를 할당하고, 소멸자에서는 해당 버퍼를 메모리 해제합니다.
이 직렬화버퍼를 메모리풀로 관리한다 하더라도, 직렬화 버퍼 자체의 생성자 소멸자 호출 시 버퍼가 생성되고 소멸되는 오버헤드는 그대로 입니다. 이 경우에는, 메모리풀에 들어가고 나갈 때마다 소멸자나 생성자가 호출되는 것 보다, 실제로 최초 초기화될 경우 생성자가 호출되고, 메모리풀이 소멸할 때 (실제 메모리 해제 시) 소멸자가 호출되는 것이 성능이 월등합니다.
따라서 오브젝트의 생성자 소멸자 호출은 메모리풀 생성 시 옵션으로 받을 수 있게 하였습니다.
메모리풀 (프리리스트) 구현
메모리풀 노드
#pragma pack(1)
struct Node
{
UINT BUFFER_GUARD_FRONT = 0;
T data;
UINT BUFFER_GUARD_END = 0;
Node *next = nullptr;
};
위 경우 T의 자료형을 모르기 때문에 struct Node 또한 몇 바이트의 경계에 서는 자료형일지 알 수 없습니다.
따라서 T의 앞뒤로 만일 구조체 패딩이 위치하게 된다면 버퍼 가드의 의미가 퇴색되게 되므로, pragma pack을 통해 1바이트의 경계에 서도록 강제합니다.
메모리풀 초기화
메모리풀의 생성자에서는 전달받은 옵션에 맞추어 초기 메모리를 확보하게 됩니다.
MemoryPool(BOOL isPlacementNew = true, UINT32 sizeInitialize = 0, UINT32 sizeMax = UINT32_MAX) : _isPlacementNew(isPlacementNew), _sizeInitialize(sizeInitialize), _sizeMax(sizeMax)
{
// 최대 메모리풀 관리 크기를 제한하지 않습니다
if (sizeMax == 0)
{
sizeMax = UINT32_MAX;
}
// 최대 메모리풀 관리 크기보다 초기화 크기가 큰 경우 리턴
if (sizeInitialize > sizeMax)
{
return;
}
// 메모리 앞뒤에 넣을 범퍼 값은 인스턴스마다 고유한, this 포인터 주소값으로 한다
_bufferGuardValue = reinterpret_cast<UINT32>(this);
_freeNode = nullptr;
_countAlloc = 0;
_countFree = 0;
_countPool = 0;
// 초기에 일정한 메모리 공간을 준비시킵니다
if (sizeInitialize > 0)
{
for (int i = 0; i < sizeInitialize; i++)
{
Node *newNode = new Node;
newNode->BUFFER_GUARD_FRONT = _bufferGuardValue;
newNode->BUFFER_GUARD_END = _bufferGuardValue;
if (!isPlacementNew)
{
T *data = new (&(newNode->data)) T;
}
newNode->next = _freeNode;
_freeNode = newNode;
_countPool++;
}
}
}
메모리풀의 소멸자
메모리풀의 소멸자에서는 남아있는 노드들을 메모리 해제합니다.
virtual ~MemoryPool()
{
// 존재하는 모든 노드를 해제합니다
Node *deleteNode = _freeNode;
while(deleteNode != nullptr)
{
Node *nextNode = deleteNode->next;
delete deleteNode;
deleteNode = nextNode;
_countPool--;
}
}
코드 모음
메모리풀의 할당 코드
T *Alloc()
{
Node *returnNode = nullptr;
_countAlloc++;
// 만일 반환되어 들고있던 메모리 공간이 있다면, 해당 메모리 공간을 리턴합니다
if (_freeNode != nullptr)
{
returnNode = _freeNode;
_freeNode = _freeNode->next;
if (_isPlacementNew)
{
// 오브젝트의 생성자는 명시적으로 호출할 수 없기에,
// placement new를 사용해 실제로 해당 메모리 위치에 오브젝트를 초기화합니다
T *data = new (&(returnNode->data)) T;
}
return &returnNode->data;
}
// 반환된 메모리 풀이 비어있다면, 새로 할당하여 리턴합니다.
Node *newNode = new Node;
newNode->BUFFER_GUARD_FRONT = _bufferGuardValue;
newNode->BUFFER_GUARD_END = _bufferGuardValue;
newNode->next = nullptr;
if (_isPlacementNew)
{
T *data = new (&(newNode->data)) T;
}
return &newNode->data;
}
메모리풀로의 반환 코드
BOOL Free(T* ptr)
{
if (ptr == nullptr)
{
return false;
}
Node *ptrNode = reinterpret_cast<Node*>(reinterpret_cast<PCHAR>(ptr) - 4);
if (ptrNode->BUFFER_GUARD_FRONT != _bufferGuardValue ||
ptrNode->BUFFER_GUARD_END != _bufferGuardValue)
{
// STACK GUARD CHECK FAILED
return false;
}
_countFree++;
if (_isPlacementNew)
{
// 오브젝트의 소멸자를 명시적으로 호출합니다
ptrNode->data.~T();
}
if (_countPool < _sizeMax)
{
ptrNode->next = _freeNode;
_freeNode = ptrNode;
_countPool++;
} else
{
free(ptrNode);
}
return true;
}
'연구한 이야기 > 깊게 공부해보기' 카테고리의 다른 글
IOCP 서버 코어 개발하기 (2) | 2023.06.06 |
---|---|
패킷 직렬화버퍼 (0) | 2023.05.02 |
TCP와 링버퍼 (0) | 2023.05.02 |
더 빠른 길찾기 (0) | 2023.05.02 |
레드블랙트리 구현 및 분석 (0) | 2023.05.02 |