본문 바로가기

프로그래밍/Modern C++

[C++] 메모리 풀(Memory Pool)

 

 

 

메모리 풀(Memory Pool) 개요

메모리 풀(Memory Pool)은 고정 된 크기의 블록을 할당하여 malloc 이나 C++의 new 연산자와 유사한 메모리 동적 할당을 가능하게 해준다. malloc 이나 new 연산자 같은 기능들은 다양한 블록사이즈 때문에 단편화를 유발시키고 파편화된 메모리들은 퍼포먼스 때문에 실시간 시스템에서 사용할 수 없게 된다. 이러한 방법을 해결하는 방법은 memory pool이라고 불리는 동일한 사이즈의 메모리 블록들을 미리 할당해 놓는 것이다. 그러면 응용 프로그램은 실행 시간에 핸들에 의해서 표현되는 블록들을 할당하고, 접근하고, 해제할 수 있다. [1]

 

메모리 풀은 고정 메모리풀 과 가변 메모리 풀 두 가지가 있다.

 

이 중 고정 메모리 풀의 특징은 다음과 같다. [2]

  • 고정된 크기의 블록을 갖는 메모리 관리자는 반한된 메모리를 효율적으로 재사용할 수 있다. 똑같은 크기를 요청하기 때문에 파편화가 발생하지 않는다.
  • 고정된 크기의 메모리 블록은 갖는 메모리 관리자는 비교적 쉽게 구현할 수 있고 함수는 Inline 할 수 있다.
  • 고정된 크기의 블록을 갖는 메모리 관리자는 캐시 효율이 좋다. 마지막으로 해제한 노드가 다음에 할당할 노드일 수 있다.

 

이 처럼 고정 메모리 풀은 같은 크기를 할당하기 때문에 내부 단편화가 발생하지 않고 미리 할당받은 공간에 있는 메모리를 할당 및 해제하기 때문에 외부단편화도 발생하지 않는다.

 

프로젝트

기본적으로 Memory Pool뿐 아니라 Object Pool Thread Pool의 성능을 측정할 수 있는 간단한 프로젝트이다.

 

 

위와 같이 사전에 정의한 define의 주석을 해제하면 해당 pool의 테스트를 진행할 수 있고 밑에 상수값을 바꿔 테스트 횟수나 풀 사이즈를 조절할 수 있다. 여기서는 메모리 풀의 성능만 측정한다.

 

성능 측정

먼저 메모리 풀의 사용여부에 따른 성능을 측정해보자 여기에서는 단순하게 할당 및 초기화를 반복만 하기 때문에 실제로 프로젝트에 적용했을 때와 퍼포먼스 차이가 발생할 수 있다.

 

테스트는 함수를 실행하기전 과 실행 후의 시간을 측정해서 비교하는 방식으로 진행한다.

 

위 코드는 메모리 풀을 사용해서 테스트 하는 코드로 기본적으로 MAX_LOOP_NUMBER까지 반복문을 돌아서 측정을한다. 여기에서는 Allocate() 함수에 인자에 원하는 메모리 크기를 매개변수에 넣으면 IntPtr 포인터가 메모리를 받아온다.

그 훙 Deallocate()함수에 해당 포인터를 넘겨주면 freePtr에 해당 메모리를 저장해 관리한다.

 

 

 

위 코드와 동일하게 루프를 돈다. 다른점은 malloc()함수를 사용해서 메모리를 할당받고 free()함수를 이용해 메모리를 해제한다.

 

메모리 풀을 사용한 코드가 시간이 더 빠른것을 확인할 수 있다.

 

 

Pointer 캐스팅

이 코드에서 조금 생소한 문법이 있다. 바로 1차원 Pointer를 2차원 Pointer로 캐스팅 하는 부분이다.

 

이러한 캐스팅 방법은 본적이 없는거 같아서 구글에 검색해봤지만 결국 찾지못해서 혼자 책과 지식을 동원해서 내가 내놓은 작동방식은 다음과 같다.

 

 

먼저 p가 가리키는 메모리의 도식화는 다음과 같다. p는 MemoryHandle이 가리키고 있는 메모리의 시작주소를 가리키고 있는 상태이다.

 

그 다음 문제의 캐스팅을 진행하면 다음과 같이 작동한다고 볼 수 있다.

 

 

size의 크기가 만약 4라면 p+size 만큼 뒤에 있는 주소를 *p가 가리키는 형식으로 해서 가장 앞의 메모리가 다음 블럭 메모리의 앞 주소를 가리키는 메모리간의 연결리스트를 만들 수 있게 된다.

 

 

정말 이렇게 작동하는지 한번 주소값을 확인해보자 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 
#include<iostream>
 
char memory[10]{ 1,2,3,4,5,6,7,8,9,10 };
char* memoryHanlde = memory;
 
void* Allocate() {
    int size4 };
    char* p = reinterpret_cast<char*>(memoryHanlde);
 
 
    std::cout << "Memory의 주소값: " << (int)&memory << "\n";
    std::cout << "Memory+size(4)의 주소값: " << (int)&memory[4<< "\n";
    std::cout << "p가 가리키는 주소값: " << (int)p << "\n";
 
    std::cout << "========캐스팅==========\n";
    auto p1 = reinterpret_cast<char**>(p);
    std::cout << "p1이 가리키는 포인터의 주소값: " << (int)&(*p1) << "\n";
    *p1 = p + size;
    std::cout << "p1이 가리키는 포인터의 값(다음 메모리 주소) " << (int)*p1 << "\n";
    return memoryHanlde;
}
 
int main() {
    Allocate();
 
}

 

다음과 같이 2차원 포인터로 캐스팅한 P1의 1차원 포인터의 주소값은 메모리 주소와 일치하고 그 다음 *p1=p+size를 실행했을 때 가리키는 메모리는 다음 size크기 뒤에 메모리인것을 확인할 수 있다.

 

 

후기

메모리 풀을 구현해봤다. 예전에 메모리 풀을 구현해본 경험이 있는대 그 때 내가 구현했던 코드는 그 당시에는 메모리 풀이라고 생각했지만 공부 후에는 조금 부족한 점이 있다는것을 알게되었다.

 

요즘은 malloc()이나 new()를 사용해서 생기는 메모리 단편화를 신경쓸 정도는 아니라고 하지만 new()나 malloc()같은 함수는 시스템콜을 하기 때문에 발생하는 오버헤드도 있기 때문에 사용하면 좋다고 본다.

 

여기에서는 메모리 풀만 설명했지만 Git에는 오브젝트 풀(Object Pool)과 아직 구현하지 않은 스레드 풀(Thread Pool)의 내용도 같이 있다. 3개를 테스트 하는 코드를 한 솔루션에 넣다보니 코드가 좀 깨끗하지 못한거 같다. 다른 풀은 여기서 설명하지 않았고 스레드 풀은 구현하면 새로 또 작성할 예정이다.

 

이번에 제네릭하게 구현한 이 메모리 풀을 리뉴얼 하는 포트폴리오 프로젝트에 적용해 보는것이 최종목표이다.

 

출처 및 레퍼런스

Git: https://github.com/SnowFleur/2020-Pool-Patterns

 

[1] [Wiki] https://ko.wikipedia.org/wiki/%EB%A9%94%EB%AA%A8%EB%A6%AC_%ED%92%80

[2] [커트 건서로스][C++ 최적화]: 한빛미디어