로또
50. new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자. 본문
기본 제공 new / delete를 바꾸고 싶은 이유
1. 효율을 향상시키기 위해
기본 제공 new / delete는 대체적으로 일반적인 쓰임새에 맞추어 설계되었다. 웹 서버처럼 실행 기간이 긴 프로그램에서도, 굉장히 짧은 프로그램에서도 문제가 발생하지 않아야 한다. 메모리 블록을 크게 할당하든 작은 블록만 할당하든 섞어서 할당하든 간에 메모리 할당 요청을 무난하게 처리해야 하며 오랜 시간 사용될 메모리 블록이나 단시간 사용될 수 많은 메모리 블록을 할당, 해제하는 작업과 같이 여러 가지 유형을 소화할 수 있어야 한다. 또한 힙 단편화에 대한 대처 방안도 갖고 있어야 한다.
이처럼 기본 제공 new / delete는 어느 누구에게나 적당히 무난하게 동작하지만 어느 누구에게도 특별히 칭찬받지 못한다. 개발자가 자신의 프로그램이 동적 메모리를 어떤 성향으로 사용하는지 올바르게 이해하고 있다면 사용자 정의 new / delete를 만들어 쓰는 편이 기본 제공 버전을 썼을 때보다 우수한 성능을 낼 확률이 높다.
2. 잘못된 힙 사용을 탐지하기 위해
new한 메모리에 delete를 잊으면 메모리가 누출되고, 두 번 이상 delete를 실행하게 되면 미정의 동작이 발생한다. 만약 할당된 메모리 주소 목록을 new가 유지해 두고, delete가 해당 목록으로부터 제거한다면 이러한 실수를 쉽게 잡아줄 수 있을 것이다.
여러 실수에 의해 데이터 오버런(overrun, 할당된 메모리 블록의 끝을 넘어 뒤에 기록하는 것)과 언더런(underrun, 할당된 메모리 블록의 시작을 넘어 앞에 기록하는 것)이 발생할 수도 있다. 이에 대비하여 사용자 정의 new를 활용한다면 요구된 크기보다 약간 더 메모리를 할당한 후, 사용자가 실제로 사용할 메모리의 앞뒤에 오버런/언더런 탐지용 바이트 패턴(경계 표지, signature)를 만들 수도 있을 것이다.
3. 동적 할당 메모리의 실제 사용에 관한 정보를 수집하기 위해
메모리 블록의 크기가 어떤 분포를 보이는지, 각각의 사용 기간은 어떤지, 메모리 할당/해제 순서가 FIFO인지 LIFO인지, 실행 단계마다 메모리 할당/해제 패턴이 차이를 보이는지, 한 번에 실제로 쓰이는 동적 할당 메모리의 최대량을 얼마인지 등, 사용자 정의 new / delete를 만든다면 이러한 정보를 쉽게 수집할 수 있을 것이다.
간단한 new 구현
static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
void* operator new(std::size_t size)
{
using namespace std;
// 경계표지(signature) 2개를 위한 실제 size
size_t real_size = size + 2 * sizeof(int);
// malloc을 통한 동적 메모리 할당
void* p_mem = malloc(real_size);
if(!p_mem) throw bad_alloc();
// 메모리 블록의 시작과 끝 부분에 경계표지 기록
*(static_cast<int*>(p_mem)) = signature; // 시작 부분
*(reinterpret_cast<int*>(static_cast<Byte*>(p_mem) + real_size - sizeof(int))) = signature; // 끝 부분
// 시작 부분 경계표지 바로 다음의 메모리 블록 반환
return static_cast<Byte*>(p_mem) + sizeof(int);
}
위 코드는 잘못된 힙 사용을 탐지하기 위해 할당된 메모리 블록의 앞뒤에 경계 표지(signature)를 추가한 코드로 여러 문제를 품고 있다.
new handler를 지정하는 것처럼 operator new를 작성할 때 지켜야 할 관례를 지키지 않고 있지만, 이보다는 바이트 정렬 문제에 집중해보자.
바이트 정렬(Byte Alignment)
컴퓨터는 아키텍처(architecture)적으로 특정 타입의 데이터가 특정 종류의 메모리 주소를 시작 주소로 하여 저장될 것을 요구사항으로 두고 있다. 예를 들어, 포인터는 4의 배수에 해당하는 주소에 맞추어 저장되어야(4Byte 단위로 정렬)하거나 double 값은 8의 배수에 해당되는 주소에 맞춰 저장되어야(8Byte 단위로 정렬) 한다는 것이다.
아키텍처에 따라 이러한 제약은 다를 수 있다. 요구사항을 어겼을 때 HW예외가 발생할 수도 있고, 느슨하게 허용할 수도 있다는 것이다. 예를 들어, Intel x86 아키텍처는 어떤 바이트 단위에 맞추더라도 double 값을 정렬할 수 있지만 8바이트 단위로 정렬할 경우 런타임 접근 속도가 훨씬 빨라진다고 한다.
C++은 "모든 operator new 함수는 어떤 데이터 타입에도 바이트 정렬을 적절히 만족하는 포인터를 반환해야 한다."는 요구사항을 가진다. 표준 malloc 함수는 이를 만족하기에 operator new가 malloc 함수를 통해 얻은 포인터를 바로 반환하는 것은 안전하지만 위에서 작성한 new 함수의 경우에는 malloc 함수를 통해 얻어낸 포인터를 기준으로 int 크기만큼 어긋난 주소를 포인터로 반환하기에 안전하지 못하다. 만약, int의 크기가 4바이트이지만 double이 8바이트 단위로 정렬되어야 하는 컴퓨터에서 사용자가 new를 통해 double을 담을 메모리를 얻어내 실행한다면 바이트 정렬이 완전히 어긋난 포인터를 사용하게 될 것이다. 이 경우 프로그램이 다운되거나 실행 속도가 느려질 수 있다.
바이트 정렬 등 세세한 문제를 다룸에 따라 메모리 관리자의 품질이 크게 달라진다. 하지만 이러한 것을 직접 만드는 것은 굉장히 어렵기 때문에 별다른 이유가 없다면 시중에 나와있는 제품을 사용하거나 오픈 소스를 찾아보도록 하자. 예를 들어, 부스트 풀 라이브러리에서 제공하는 메모리 할당자는 크기가 작은 객체를 많이 할당할 경우에 적절히 튜닝되어 있다고 한다.
기본 제공 new / delete를 바꾸고 싶은 이유 2
4. 할당 및 해제 속력을 높이기 위해
기본 제공 new / delete는 사용자 정의 버전보다 느린 경우가 다반사이다. 특히 사용자 정의 버전이 특정 타입의 객체에 맞추어 설계되어 있을 때 그 정도가 더욱 심한데, 앞서 소개한 부스트 풀 라이브러리에서 제공하는 할당자처럼 고정된 크기의 객체만 만들어주는 할당자의 응용 예시가 바로 클래스 전용(class-specific) 할당자이다.
또한 우리가 만들 프로그램이 단일 스레드로 동작하는데 반해, 컴파일러에서 기본으로 제공하는 메모리 관리 루틴이 다중 스레드에 맞게 만들어져 있다면 스레드 안전성이 없는 할당자를 직접 만듦으로 상당한 속력 이득을 취할 수 있을 것이다.
5. 기본 메모리 관리자의 공간 오버헤드를 줄이기 위해
기본 메모리 관리자는 사용자 정의 버전과 비교하여 속력이 느리고 메모리도 많이 잡아먹는 사례가 허다하다. 할당된 각각의 메모리 블록에 대해 전체적으로 지우는 부담이 크기 때문이다. 부스트 풀 라이브러리처럼 크기가 작은 객체에 대해 튜닝된 할당자를 사용하면 이러한 오버헤드를 실질적으로 제거할 수 있다.
6. 적당히 타협한 기본 할당자의 바이트 정렬 동작을 보장하기 위해
x86 아키텍처에서는 double이 8바이트 단위로 정렬되어 있을 때 읽기/쓰기 속도가 가장 빠르다. 시중에 나와 있는 컴파일러 중에는 new 함수가 double에 대한 동적 할당 시 8바이트 정렬을 보장하지 않는 것들이 있다. 이들을 8바이트 정렬을 보장하는 사용자 정의 버전으로 바꾼다면 프로그램 수행 성능을 향상시킬 수 있을 것이다.
7. 임의의 관계를 맺고 있는 객체들을 한 군데에 나란히 모아 놓기 위해
한 프로그램에서 특정 자료구조 몇 개가 동시에 쓰이고 있고, 이들에 대해 Page fault를 최소화하고 싶을 때 해당 자료구조를 담을 별도의 Heap을 생성하여 이들이 가능한 한 적은 Page를 차지하도록 하면 좋은 효과를 볼 수 있을 것이다. 이러한 메모리 군집화는 위치 지정(placement) new 및 위치 지정 delete를 통해 쉽게 구현할 수 있다.
8. 그때그때 원하는 동작을 수행하도록 하기 위해
기본 버전 new / delete가 하지 못하는 일을 원하는 때가 있다. 메모리 할당과 해제를 공유 메모리에 하고 싶은데, 공유 메모리를 조작하기 위해서는 C API로밖에 할 수 없을 때, 응용프로그램 데이터 보안 강화를 위해 해제한 메모리 블록에 0을 덮어 씌우는 operator delete를 만들고 싶을 때가 그 예시이다. 사용자 정의 버전을 만들어 원하는 동작을 하게끔 만들 수 있다.
이것만은 잊지 말자!
- 개발자가 스스로 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 나름대로 타당한 이유가 있습니다. 여기에는 수행 성능을 향상시키려는 목적, 힙 사용 시 에러를 디버깅하려는 목적, 힙 사용 정보를 수집하려는 목적 등이 포함됩니다.
'책 > Effective C++' 카테고리의 다른 글
| 52. 위치지정 new를 작성한다면 위치지정 delete도 같이 준비하자. (2) | 2024.01.04 |
|---|---|
| 51. new 및 delete를 작성할 때 따라야 할 기존의 관례를 잘 알아 두자 (1) | 2024.01.03 |
| 49. new 처리자의 동작 원리를 제대로 이해하자. (1) | 2023.12.28 |
| 44. 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자 (0) | 2023.12.15 |
| 43. 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자 (0) | 2023.12.14 |