/ c++

new / delete overriding

Comments

본 포스트는 Effective C++ item 50,51를 참고하여 작성함.

new & delete 는 언제 overriding 할 필요가 있는가?


1. 잘못된 힙 사용을 탐지

  • new 할때 주소 목록을 다 적어두면, leak 이나 double free 찾아내기 쉬움
  • over run(할당된 메모리블록을 초과하여 더 뒤에다가 쓰는거), under run(할당된 메모리 주소가 시작하기도 전에 앞에다가 쓰는 것) 이 발생했는지 알수 있음
  • operator new를 오버로딩하여 over run, under run 탐지용 byte pattern(signature)을 적어두면 됨

2. 효율 향상

  • 기본적인 new, delete 연산자의 경우, 지극히 일반적인 쓰임새에 맞추어 설계된 것이기 때문에, 특수한 목적에 따라 사용자가 new, delete를 오버로딩하여 효율성을 향상 시킬 수 있음
  • 일반적인 상황에서 고려해야할 점들을 살펴보면, 실행기간이 짧지 않은 프로그램에서도(ex 웹서버) 잘 돌아가야 하며, 1초안에 끝나는 프로그램에서도 별 문제 없어야 하고, 힙 단편화에 대한 대처방안도 필요 하고 등등

3. 동적 할당 메모리의 통계 정보를 수집

  • 할당된 메모리 분포는 어떻게 보이는지, 메모리가 할당되고 해제되는 순서가 FIFO, LIFO 방식인지. 이런 정보들을 알아 낼 수 있음

4. 할당 및 해제 속력을 높이기 위해

  • 사용자가 만들 프로그램은 single thread로 동작하는데 컴파일러에서 기본으로 제공하는 메모리 관리 루틴이 multi thread에 맞게 만들어져 있다면 스레드 안정성을 무시한 할당자를 직접만들어서 속력을 높일 수 있음

5. 기본 메모리 관리자의 공간 오버헤드를 줄이기 위해

  • 메모리 많이 먹는 경우도 있음. 작게만 쓸꺼면 작게작게 할당해서 쓰도록 하면 좋음

6. 적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해

  • x86 아키텍쳐에서는 double이 8바이트로 동작해야 빠른데, 시중에 나온 컴파일러는 아닌 경우도 있다고 함. 8바이트 정렬 맞추도록 만들 수 있음 (이 책이 옛날에 쓰여졋으니 그럴만도.... 지금 죄다 64bit 쓰는데 그럴리 없음!!)

7. 임의의 관계를 맺고 있는 객체들을 한 군데에 나란히 모아놓기 위해

  • 같이 많이 쓰이는 것들을 모아놔야 page fault가 적게 발생

8. 그때 그때 원하는 동작을 수행하기 위해

예시

static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
 
void* operator new(std::size_t size) throw(std::bad_alloc)
{ 
   using namespace std;
   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);
}

위 예제는 앞뒤에 signature를 붙이는 예제임

문제점은 new 처리자 함수 루틴이 없다는 점(뒤에서 곧 설명), byte alignment가 안맞는 점.

Byte alignement
컴퓨터 아키텍쳐마다 특정 타입의 데이터가 특정 종류의 메모리 주소를 시작 주소로 하여 저장될 것을 요구사항으로 두고 있다. 이를테면, 포인터는 4의 배수에 해당하는 주소에 맞추어 저장되어야 하고,(4바이트 단위로 정렬) double의 값은 8의 배수에 해당하는 주소에 맞추어 저장되어야 한다. (8byte 단위로 정렬) 이러한 바이트 정렬 제약을 따르지 않을 경우 프로그램이 실행되다가 하드웨어 예외를 일으킬 수 있다.

대표적인 예로, 인텔 x86같은 경우 어떤 바이트 단위에 맞추더라도 double 값 정렬이 가능한데, 8바이트 단위로 정렬하면 런타임 접근속도가 훨씬 빨라짐.

위의 코드에서도 바이트 정렬 문제는 아주 중요한데, 그 이유는, 모든 operator new 함수는 어떤 데이터 타입에도 바이트 정렬을 적절히 만족하는 포인터를 반환해야 하기 때문(C++ 요구사항)

표준 malloc 함수는 이 요구사항에 맞추어 구현되어있기 때문에, malloc에서 얻은 포인터를 operator new가 바로 반환하는 것은 "안전" 하다.

하지만 위의 코드에서는 그 포인터를 기준으로 "int 크기만큼 뒤로 어긋난 주소를 포인터로 반환" 하게되서(signature 때문에) 안전하다고 할 수 없다.

바이트 정렬과 같은 세세한 문제를 어떻게 다루느냐에 따라 메모리 관리자가 달라지게 된다.

정말 잘 돌아가는 메모리 관리자를 만들기란
개념 상실한 초등학생들을 사람 만드는 것만큼이나 어려우므로,
꼭 만들어 쓸 이유가 없다면 굳이 들이댈 필요가 없다.

new & delete 를 override 할떄 지켜야할 관례는?

앞서서, 언제 new/delete 를 override하는지 알아봤으니, 어떻게 하면 좋을지 알아보자.

1. new

  • 반환값이 제대로 되어있어야함
  • 가용 메모리가 부족한 경우 new 처리가 함수를 호출해야함
  • 크기가 0인 요청에 대한 대비가 있어야함
  • 실수로 기본형태의 new가 가려지지 않도록 해아함 (본 포스트에서는 생략)
void* operator new (std::size_t size) throw(std::bad_alloc)
{
   using namespace std;
 
   if(size == 0){
      size = 1;
   }
 
   while (true) {
      "size 바이트 할당"
      if( 할당 성공 )
         return 할당된 메모리에 대한 포인터;
 
       //할당이 실패했을 경우, 현재의 new 처리자 함수가 어느것으로 설정되어있는지 찾음
       new_hanlder globalHandler = set_new_handler(0);
       set_new_handler(globalHandler);
 
       if(globalHandler) (*globalHandler)();
       else throw std::bad_alloc();
   }
}

설명한 관례들을 지킨 간단한 예시. 코드는 좀 이상함.

  • size가 0 이면 그냥 1로 박아버림. 어쩃든 유효한 값을 내뱉어야 하니까 이렇게 처리했고, 사실 0으로 요청들어올리가 없음.
  • set_new_handler 를 한번 콜하면 현재의 new handler가 반환되고 이를 다시 셋함. 있는거 가져다 쓰려고 쓸데없이 set_new_handler를 두번이나 콜함.
  • 무한루프스러운 놈 ("while(true)")이 있음. 이 무한루프가 끝나려면 꼭 성공을 해야함. 실패하면 다시 handler가 메모리 정리를 해서라도 성공을 해야함. 아니면 포기하고 throw하거나. 무한루프가 없다면 new가 직무유기 할 가능성이 있음.
class Base{
public:
static void *operator new(std::size_t size) throw(std::bad_alloc);
...
};
 
class Derived : public Base
{ ..... };                                  
 
Derived *p = new Derived;            

이렇게, 특정 클래스(Base)의 new 를 만들었는데.... Derived가 그걸 그냥 가져다 써버릴 수가 있음.
이걸 원치 않는다면,
전체 설계를 바꾸지 않고 쓸 수 있는 가장 좋은 해결 방법은 "틀린" 메모리 크기가 들어왔을 때를 시작부분에서 확인한 후에, 표준 operator new를 호출하는 쪽으로 살짝 비껴가게 만드는 것이다.

void * Base::operator new(std::size_t size) throw(std::bad_alloc)
{
  if(size != sizeof(Base))                   
    return ::operator new(size);             

  .....                                       
}

이렇게 하면, new Derived는 size가 sizeof(Base)랑 다르니까 표준 new를 사용하게 할 수 있음.

2. delete

  • nullptr 에 대해 안전하기만 하면 끝!
void operator delete(void *rawMemory) throw()
{
  if (rawMemory == 0) return;      

  ....                             
}

요러면 된당 ㅎㅎ