스토리텔링 개발자

[Effective C++] 50. operator new / delete는 언제 커스텀해야 할까? 본문

개발/Effective C++

[Effective C++] 50. operator new / delete는 언제 커스텀해야 할까?

김디트 2024. 7. 24. 11:14
728x90

항목 50 : new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해두자

 

 

 

operator new / delete를 커스텀하는 이유
  1. 잘못된 힙 사용 탐지를 위해
    • new 한 메모리에 대해 delete를 잊으면 메모리 누수가 발생한다.
    • new 한 메모리에 두번 이상 delete 하면 미정의 동작을 한다.
      • 할당된 메모리 주소의 목록을 operator new가 유지하고 delete에서 제거하게 만들면 실수를 방지할 수 있을 것이다.
    • 데이터 오버런(overrun, 할당된 메모리 블록 끝을 넘어 뒤에 기록), 데이터 언더런(underrun, 할당된 메모리 블록 시작을 넘어 앞에 기록)이 발생할 수 있다.
      • 메모리를 약간 더 할당해서 오버런 / 언더런 탐지용 바이트 패턴을 구현할 수 있다.
  2. 효율을 향상시키기 위해
    • 컴파일러가 기본적으로 제공하는 operator new / delete 함수는 지극히 대중적이고 온건지향 스타일의 전략을 취한다.
      • 실행 기간이 짧지 않은 프로그램(웹서버)에서 잘 돌아가야 한다.
      • 1초 안에 끝나는 프로그램에서도 문제가 없어야 한다.
      • 큰 블록만 할당하든, 작은 블록만 할당하든, 크고 작은 블록을 섞어서 할당하든 무난하게 처리해야 한다.
      • 수명이 매우 길거나 짧은 객체를 아주 많이 할당했다가, 해제했다가 하는 작업까지도 소화해야 한다.
      • 힙 단편화(fragmentation) 대처 방안도 필요하다.
    • 그러므로 개발자가 자신의 프로그램이 동적 메모리를 어떻게 사용하는지 제대로 이해한다면, 만들어 쓰는 편이 우수한 성능을 낼 확률이 높다.
  3. 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
    • 커스텀 new / delete를 무작정 작성하기 전에 정보를 수집하는 것이 좋을 것이다.
    • 모으면 좋은 정보들
      1. 할당된 메모리 블록의 크기 분포
      2. 메모리 블록의 사용 기간 분포
      3. 메모리가 할당, 해제되는 순서가 FIFO(선입선출)인지, LIFO(후입선출)인지, 순서가 없는지
      4. 시간 경과에 따라 메모리 사용 패턴이 바뀌는지
        • 즉, 각 실행 단계마다 프로그램이 보이는 메모리 할당/해제 패턴이 확연한 차이를 보이는지
      5. 한번에 실제로 쓰이는 동적 할당 메모리의 최대량(최고수위선(high water mark))은 어떤지

 

 

 

예제를 통해 알아보는 커스텀 new / delete로 정보 수집하기
  • 버퍼 오버런, 언더런을 탐지하는 커스텀 new / delete
static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;

void* operator new(std::size_t size) throw(std::bad_alloc)
{
    using namespace std;
    
    // 경계표지 2개를 앞뒤에 붙일 수 있을 만큼만 메모리 크기를 늘린다.
    size_t realSize = size + 2 * sizeof(int);
    
    // 메모리를 얻어낸다.
    void* pMem = malloc(realSize);
    if(!pMem) throw bad_alloc();
    
    // 메모리 블록의 시작 및 끝부분에 경계표지를 기록한다.
    *(static_cast<int*>(pMem)) = signature;
    *(reinterpret_cast<int*>(static_cast<Byte*>(pMem) + realSize - sizeof(int))) = signature;
    
    // 앞쪽 경계표지 바로 다음의 메모리를 가리키는 포인터를 반환한다.
    return static_cast<Byte*>(pMem) + sizeof(int);
}
  • 하지만 몇 가지 틀린 점이 있다. (항목 51 참조)
    • 대개 operator new 함수를 만들 때 통상적으로 쓰이는 관례를 지키지 않은 점이 문제이다.
  • 가장 큰 문제점은, 바이트 정렬(alignment)

 

 

 

바이트 정렬(byte alignment) 문제
  • 컴퓨터는 통상적으로 아키텍처(architecture)적으로 특정 타입의 데이터가 특정 종류의 메모리 주소를 시작 주소로 하여 저장될 것을 요구사항으로 두고 있다.
    • 포인터는 4의 배수에 해당하는 주소에 맞추어 저장되어야 한다.(즉, 4바이트 단위로 정렬되어야 한다.)
    • double 값은 8의 배수에 해당하는 주소에 맞추어 저장되어야 한다.(즉, 8바이트 단위로 정렬되어야 한다.)
  • 어떤 아키텍처의 경우 이 바이트 정렬 제약을 따르지 않으면 프로그램이 하드웨어 예외를 일으킬 수도 있다.
  • 또 어떤 아키텍처는 느슨한 제약일지라도, 바이트 정렬을 만족했을 때 더 나은 성능을 보일 때가 있다.
    • 인텔 x86 아키텍처
  • 위의 코드 역시 바이트 정렬에서 안전하지 않다는 문제점이 있다.
    • 모든 operator new 함수는 어떤 데이터 타입에도 바이트 정렬을 적절히 만족하는 포인터를 반환해야 한다는 것이 C++의 요구사항이기 때문이다.
    • 표준 malloc 함수는 이 요구사항에 맞추어 구현되어 있다.
    • 하지만 위의 커스텀 operator new는 malloc 함수가 준 포인터를 기준으로 int 크기만큼 뒤로 어긋난 주소를 포인터로 반환한다.(안전하지 않다!)
  • 사실 이런 식으로 직접 만들 필요조차 없다.
    • 시중의 컴파일러 중엔 메모리 관리 함수에 디버깅 및 로딩 기능을 넣어 놓고 필요에 따라 전환할 수 있도록 마련된 것들도 있다.
    • 메모리 관리 함수만을 전문적으로 다루는 상업용 제품들도 출시되어 있다.
    • 오픈 소스 쪽에도 메모리 관리자 패키지가 많이 공개되어 있다.
    • 부스트의 풀(Pool) 라이브러리가 대표적이다.(항목 55 참조)

 

 

 

다시 한 번 정리하는 커스텀 operator new / delete가 필요한 상황
  1. 잘못된 힙 사용을 탐지하기 위해
  2. 동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
  3. 할당 및 해제 속력을 높이기 위해
    • 기본 제공 범용 할당자는 사용자 정의 버전보다 꽤 느린 경우가 적지 않다.
      • 특히 사용자 정의 버전이 특정 타입의 객체에 맞추어 설계되어 있다면 더욱 그렇다.
      • 부스트 Pool 라이브러리에서 제공하는 할당자처럼 고정된 크기의 객체만 만들어 주는 할당자의 전형적인 응용 예가 바로 클래스 전용(class-specific) 할당자이다.
    • 기본 제공 메모리 관리 루틴이 다중 쓰레드에 맞춰져 있다면?
      • 스레드 안정성이 없는 할당자를 직접 만들 수도 있을 것이다.
  4. 기본 메모리 관리자의 공간 오버헤드를 줄이기 위해
    • 범용 메모리 관리자는 사용자 정의 버전과 비교해서 느리고 메모리도 많이 잡아먹는 사례가 허다하다.
      • 할당된 각각의 메모리 블록을 전체적으로 지우는 부담이 꽤 되기 때문이다.
      • 크기가 작은 객체에 대해 튜닝된 할당자를 사용하면 오버헤드를 실질적으로 제거할 수 있다.
  5. 적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해
    • x86 아키텍처는 double이 8바이트 단위로 정렬되어 있을 때, 읽기 쓰기 속도가 가장 빠르다.
      • 시중에 나와 있는 컴파일러 중에는 기본적으로 제공하는 operator new 함수가 double에 대한 동적 할당 시에 8바이트 정렬을 보장하지 않는 것들이 있다는 슬픈 소식이 나돌고 있다...
      • 이 경우 8바이트 정렬을 보장하는 커스텀 operator new를 만들 수도 있을 것이다.
  6. 임의의 관계를 맺고 있는 객체들을 한군데에 나란히 모아놓기 위해
    • 한 프로그램에서 특정 자료구조 몇 개가 대개 한 번에 동시에 쓰이고 있다는 사실을 이미 알고 있고..
      • 이들에 대해 페이지 부재(page fault) 발생 횟수를 최소화하고 싶을 경우
    • 해당 자료구조를 담을 별도의 힙을 생성해서 가능한 한 적은 페이지를 차지하도록 하면 효과를 볼 수 있다.
    • 메모리 군집화는 위치지정(placement) new / delete로 쉽게 구현이 가능하다. (항목 52 참조)
  7. 그때 그때 원하는 동작을 수행하도록 하기 위해
    • 컴파일러가 주는 버전이 하지 못하는 일을 operator new / delete가 해주었으면 할 때가 있다.
      • 예시 1) 메모리 할당 해제를 공유 메모리에다가 하고 싶은데 공유 메모리를 조작하는 건 C API로밖에 할 수 없을 때
        • 위치지정(placement) new / delete로 처리할 수 있다.(항목 52 참조)
      • 예시 2) 응용 프로그램 데이터의 보안 강화를 위해, 해제한 메모리 블럭에 0을 덮어쓰는 사용자 정의 operator delete를 만드는 경우
728x90
Comments