operator new와 operator delete를 바꾸고 싶은 가장 흔한 이유는 세 가지입니다.
잘못된 힙 사용을 탐지하기 위해
new한 메모리에 delete를 하는 것을 잊어버리면 메모리가 누출되고, 한 번 new한 메모리를 두 번 이상 delete하면 미정의 동작이 발생합니다.
만일 할당된 메모리 주소의 목록을 operator new가 유지해 두고 operator delete가 그 목록으로부터 주소를 하나씩 제거해 주게 만들어져 있다면, 이런 식의 실수는 쉽게 잡아낼 수 있을 것입니다.
또한 프로그래밍을 하다가 이런저런 실수를 하다 보면 데이터 오버런(overrun, 할당된 메모리 블록의 끝을 넘어 뒤에 기록하는 것) 및 언더런(underrun, 할당된 메모리 블록의 시작을 넘어 앞에 기록하는 것)이 발생할 수 있습니다.
이런 경우를 대비하여 사용자 정의 operator new를 활용한다면, 요구된 크기보다 약간 더 메모리를 할당한 후에 사용자가 실제로 사용할 메모리의 앞과 뒤에 오버런/언더런 탐지용 바이트 패턴(일명 "경계표지(signature)")을 적어 이러한 실수를 탐지할 수 있습니다.
operator delete를 활용한다면, 이 사실을 로그로 기록하여 남겨 놓을 수 있습니다.
효율을 향상시키기 위해
컴파일러가 제공하는 기본 버전의 operator new 및 operator delete 함수는 대체적으로는 일반적인 쓰임새에 맞추어 설계된 것입니다.
실행 기간이 짧지 않은 프로그램(예를 들면 웹서버 같은)에서 잘 돌아가야 하며, 1초 안에 끝나는 프로그램에서도 별 문제가 없어야 합니다.
메모리가 어떻게 할당되는 간에, 이렇게 저렇게 계속되는 메모리 할당 요청을 무난하게 처리하고 여러 가지 할당 유형도 소화해야 합니다.
힙 단편화(fragmentation)에 대한 대처방안도 없으면 안 됩니다.
이렇듯 메모리 관리자에 대한 요구사항이 가지각색인 만큼, 자신의 프로그램이 동적 메모리를 어떤 성향으로 사용하는지를 이해하고, 이에 맞게 operator new와 operator delete를 만들어 쓰는 것이 실행 속도가 빠르고 메모리도 적게 차지하는 "우수한 성능"을 낼 확률이 높습니다.
동적 할당 메모리의 실제 사용에 관한 통계 정보를 수집하기 위해
여러분이 만드는 소프트웨어가 동적 메모리를 어떻게 사용하는지에 관한 정보를 수집하는 것이 new 및 delete를 무작정 작성하는 것보다 좋습니다.
할당된 메모리 블록의 크기와 사용 기간이 어떤 분포를 보이는지, 메모리가 할당되고 해제되는 순서는 어떠한지, 시간 경과에 따른 사용 패턴이 바뀌는지, 한 번에 실제로 쓰이는 동적 할당 메모리의 최대량(다른 말로 "최고수 위선(high water mark)")는 어떤지 등 많은 정보들이 있습니다.
operator new와 operator delete를 사용하면 이런 정보를 아주 쉽게 수집할 수 있습니다.
operator new를 직접 만드는 작업의 한 예로, 버퍼 오버런 및 언더런을 탐지하는 쉬운 형태로 만들어 주는 전역 operator new를 보겠습니다. 자잘한 부분에서 틀린 게 좀 많은 코드입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 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); // 경계표지 2개를 앞뒤에 붙일 수 있을 // 만큼만 메모리 크기를 늘립니다. void *pMem = malloc(realSize); // malloc을 호출하여 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); } | cs |
위 코드는 operator new라는 이름이 붙는 함수를 만들 때 통상적으로 쓰이는 관례를 지키지 않고 있습니다. 이를테면, operator new에는 new 처리자 함수를 호출하는 루프가 반드시 들어 있어야 합니다. 하지만 이것보다 좀더 까다로운 문제가 있는데, 바로 바이트 정렬(alignment)입니다.
컴퓨터는 많은 경우에 있어서 아키텍처(architecture)적으로 특정 타입의 데이터가 특정 종류의 메모리 주소를 시작 주소로 하여 저장될 것을 요구사항으로 두고 있습니다.
이를테면, 포인터는 4의 배수에 해당하는 주소에 맞추어 저장되어야(다시 말해 4바이트 단위로 정렬되어야)하거나 double 값은 8의 배수에 해당하는 주소에 맞추어 저장되어야(즉, 8바이트 단위로 정렬되어야) 한다는 것이죠.
이 바이트 정렬 제약을 따르지 않으면 프로그램이 실행되다가 하드웨어 예외를 일으킬 수 있습니다.
이보다는 좀더 느슨한 제약을 두는 아키텍처(예로 인텔 x86 아키텍처)도 있는데, 이런 아키텍처는 바이트 정렬을 만족했을 경우데 더 나은 성능을 제공합니다.
바이트 정렬 문제는 지금 경우에도 아주 중요한데, 왜냐하면 모든 operator new 함수는 어떤 데이터 타입에도 바이트 정렬을 적절히 만족하는 포인터를 반환해야 한다는 것이 C++의 요구사항이기 때문입니다. 표준 malloc 함수는 이 요구사항에 맞추어 구현되어 있기 때문에, malloc에서 얻은 포인터를 operator new가 바로 반환하는 것은 '안전'합니다. 하지만 위의 예시 코드는 operator new 함수에서 malloc에서 나온 포인터를 기준으로 크기만큼 뒤로 어긋난 주소를 포인터로 반환합니다. 이렇게 되는 경우는 안전하다는 보장을 할 수가 없습니다! 이것 때문에 프로그램이 다운될 수도 있고, 실행 속도가 느려질 수도 있습니다.
바이트 정렬 등의 세세한 문제를 어떻게 다루느냐에 따라 메모리 관리자가 달라집니다.
지극히 일반적인 경우만으로 말씀드린다면, 꼭 만들어 쓸 이유가 없다면 굳이 들이댈 필요는 없습니다.
좋은 컴파일러와 좋은 메모리 관리 함수 및 오픈 소스 등을 쓰는 것도 한 방법입니다.
오픈 소스 메모리 할당자 중 추천하는 것은 부스트의 풀(Pool) 라이브러리입니다.
앞서 말씀드린 operator new와 operator delete를 가장 바꾸고 싶은 세 가지 이유 외에도 여러 가지 이유가 있습니다.
할당 및 해제 속력을 높이기 위해
항상은 아니지만 기본으로 제공되는 범용 할당자는 사용자 정의 버전보다 꽤 느린 경우가 적지 않습니다.
특히 사용자 정의 버전이 특정 타입의 객체에 맞추어 설계되어 있으면 더욱 그렇죠.
부스트의 Pool 라이브러리에서 제공하는 할당자처럼 고정된 크기의 객체만 만들어 주는 할당자의 전형적인 응용 예가 바로 클래스 전용(class-specific) 할당자입니다.
단일 스레드에서 스레드 안전성이 없는 할당자를 직접 만들어 쓰면 상당한 속력 이득을 볼 수 있을 것입니다.
기본 메모리 관리자의 공간 오버헤드를 줄이기 위해
범용 메모리 관리자는 사용자 정의 버전과 비교해서 속력이 느린 경우도 많은데다가 메모리도 많이 잡아먹는 사례가 허다합니다.
할당된 각각의 메모리 블록에 대해 전체적으로 지우는 부담이 꽤 되기 때문인데, 크기가 작은 객체에 대해 튜닝된 할당자를 사용하면 이러한 오버헤드를 실질적으로 제거할 수 있습니다.
적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해
기본적으로 제공되는 operator new 함수가 double에 대한 동적 할당 시에 8바이트 정렬을 보장하지 않는 컴파일러들이 있기 때문에, 기본 제공 operator new 대신에 8바이트 정렬을 보장하는 사용자 정의 버전으로 바꿈으로써 프로그램 수행 성능을 확 끌어올릴 수 있습니다.
임의의 관계를 맺고 있는 객체들을 한 군데에 나란히 모아 놓기 위해
한 프로그램에서 특정 자료구조 몇 개가 대개 한 번에 동시에 쓰이고 있다는 사실을 여러분이 알고 있고, 앞으로 이들에 대해서는 페이지 부재(page fault) 발생 횟수를 최소화하고 싶을 경우, 해당 자료구조를 담을 별도의 힙을 생성함으로써 이들이 가능한 한 적은 페이지를 차지하도록 하면 상당히 좋은 효과를 볼 수 있겠지요.
이러한 메모리 군집화는 위치지정(placement) new 및 위치지정 delete를 통해 쉽게 구현할 수 있습니다.
그때그때 원하는 동작을 수행하도록 하기 위해
컴파일러가 주는 버전이 하지 못하는 일을 operator new 및 operator delete가 해주었으면 하고 바라는 때가 종종 있게 마련입니다.
메모리 할당과 해제를 공유 메모리에다 하고 싶은데 공유 메모리를 조작하는 일은 C API로밖에 할 수 없을 때가 한 가지 예이죠.
이때 사용자 버전을 만든다면, 기존의 C API에 C++ 옷을 입힐 수가 있습니다.
또 다른 예로는 응용프로그램 데이터의 보안 강화를 위해 해제한 메모리 블록에 0을 덮어 쓰는 사용자 정의 operator delete를 만드는 경우도 생각해 볼 수 있습니다.
개발자가 스스로 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 나름대로 타당한 이유가 있습니다. 여기에는 수행 성능을 향상시키려는 목적, 힙 사용 시의 에러를 디버깅하려는 목적, 힙 사용 정보를 수집하려는 목적 등이 포함됩니다.