자동 메모리 관리

자동 메모리 관리 (Automatic Memory Management)

D는 선택적 자동 메모리 관리를 지원하는 시스템 프로그래밍 언어예요. 메모리를 명시적으로 해제하지 않아도 되며, 가비지 컬렉터(GC)가 쓰이지 않는 메모리를 주기적으로 회수해요. 이 페이지는 GC가 어떻게 동작하는지, 외부 코드와의 연동, 컬렉터 포인터의 제약, 컬렉터 설정 방법을 설명해요.

출처: Automatic Memory Management

본문

D는 선택적 자동 메모리 관리를 지원하는 시스템 프로그래밍 언어예요. 보통 메모리를 명시적으로 해제할 필요는 없어요. 필요할 때 할당만 하면, 자동 메모리 관리자의 컬렉터가 쓰이지 않는 모든 메모리를 사용 가능한 메모리 풀로 주기적으로 반환해요.

D는 자동 메모리 관리가 개입되지 않는 코드를 작성하는 메커니즘도 제공해요. 자세한 내용은 아래에 있어요.

명시적으로 메모리 할당·해제를 관리하는 데 익숙한 프로그래머들은 자동 메모리 관리의 이점과 효과에 회의적일 가능성이 높아요. 자동 메모리 관리를 염두에 두고 작성한 새 프로젝트와, 기존 프로젝트를 자동 메모리 관리로 전환한 경험은 다음을 보여줘요:

자동 메모리 관리를 사용하는 프로그램은 종종 더 빨라요. 이것은 직관에 반하지만, 그 이유는 다음과 같아요:

  • 참조 카운팅은 명시적 메모리 할당 문제를 해결하는 흔한 해법이에요. 할당이 일어날 때마다 증가·감소 연산을 구현하는 코드가 느려지는 원인 중 하나예요. 스마트 포인터 클래스 뒤에 숨겨도 속도에 도움이 되지 않아요. (참조 카운팅 방법은 어차피 일반적인 해법이 아니에요 — 순환 참조는 결코 삭제되지 않기 때문이에요.)
  • 소멸자(destructor)는 객체가 획득한 자원을 해제하는 데 사용돼요. 대부분의 클래스에서 이 자원은 할당된 메모리예요. 자동 메모리 관리에서는 대부분의 소멸자가 비어 있게 되어 완전히 버릴 수 있어요.
  • 그 메모리를 해제하는 모든 소멸자들은 객체가 스택에 할당될 때 상당히 중요해질 수 있어요. 각각에 대해, 예외가 발생하면 각 프레임의 모든 소멸자가 자신이 보유한 메모리를 해제하도록 호출되는 메커니즘을 마련해야 해요. 소멸자가 무의미해지면 예외를 처리하기 위한 특별한 스택 프레임을 설정할 필요가 없고, 코드는 더 빨리 실행돼요.
  • 수집(collection)은 메모리가 빡빡해질 때만 작동해요. 메모리가 여유 있을 때 프로그램은 최고 속도로 실행되며 추적·해제에 시간을 쓰지 않아요.
  • 자동 메모리 관리를 사용하는 프로그램은 메모리 누수 누적으로 인한 점진적 성능 저하를 겪지 않아요.

자동 메모리 관리자의 컬렉터는 쓰이지 않는 메모리를 회수하므로, 오래 실행되는 프로그램이 점점 더 많은 메모리를 소비하다 시스템을 무너뜨릴 수 있는 "메모리 누수"를 겪지 않아요. 자동 메모리 관리 프로그램은 장기적으로 더 안정적이에요.

자동 메모리 관리를 사용하는 프로그램은 찾기 어려운 포인터 버그가 더 적어요. 해제된 메모리에 대한 허상 참조(dangling reference)가 없기 때문이에요. 메모리를 명시적으로 관리하는 코드가 없으므로 그런 코드에 버그도 없어요.

자동 메모리 관리를 사용하는 프로그램은 명시적 해제 코드를 개발·디버그·테스트·유지보수할 필요가 없으므로 더 빨리 개발하고 디버그할 수 있어요.

자동 메모리 관리는 만병통치약이 아니에요. 몇 가지 단점이 있어요:

  • 자동 메모리 관리자가 언제 메모리를 할당하는지 항상 명확하지 않아서 컬렉션을 촉발할 수 있고, 프로그램이 예상치 못하게 멈출 수 있어요.
  • 컬렉션이 완료되는 데 걸리는 시간은 제한되지 않아요. 실제로는 매우 빠르지만, 일반적으로 보장할 수는 없어요.
  • 보통 컬렉션이 진행되는 동안 컬렉터 스레드가 아닌 모든 스레드는 중지되어야 해요.
  • 자동 메모리 관리자는 명시적 해제자가 해제할 메모리 중 일부를 붙잡아 둘 수 있어요.
  • 자동 메모리 관리 프로그램은 컬렉터 구현을 함께 지녀야 해요. 공유 라이브러리일 수는 있지만, 여전히 거기 있는 거예요.

이런 제약들은 컬렉터 힙 외부에서의 할당을 제어하는 D가 제공하는 메커니즘을 포함한 Memory Management 개요의 기법들로 다뤄져요.

현재 런타임 라이브러리를 자동 힙 할당이 없게 만드는 작업이 진행 중이에요. 이는 자동 메모리 관리 인프라를 쓸 수 없는 시나리오에서의 사용을 가능하게 하기 위한 것이에요.

How Automatic Memory Management Works

다음과 같이 동작해요:

  • 현재 메모리를 할당하려는 스레드를 제외한 모든 다른 스레드를 중지시킴.
  • 컬렉션 작업을 위해 현재 스레드를 '하이재킹'함.
  • 할당된 메모리를 가리키는 포인터가 있는 모든 '루트' 메모리 범위를 스캔함.
  • 루트가 가리키는 모든 할당된 메모리를 재귀적으로 스캔해 할당된 메모리를 가리키는 더 많은 포인터를 찾음.
  • 활성 포인터가 없고 소멸자를 실행할 필요가 없는 모든 할당된 메모리를 해제함.
  • 소멸자를 실행해야 하는 도달 불가능한 메모리를 모두 큐에 넣음.
  • 모든 다른 스레드를 재개함.
  • 큐에 넣은 모든 메모리에 대한 소멸자를 실행함.
  • 남은 도달 불가능한 메모리를 해제함.
  • 현재 스레드를 하던 일로 되돌림.

Interfacing Automatic Memory Management Allocated Objects With Foreign Code

컬렉터는 다음에서 루트를 찾아요:

  • 정적 데이터 세그먼트
  • 각 스레드의 스택과 레지스터 내용
  • 각 스레드의 TLS(스레드 로컬 저장소) 영역
  • core.memory.GC.addRoot() 또는 core.memory.GC.addRange()로 추가된 모든 루트

객체에 대한 유일한 포인터가 이 영역들 밖에 보관되어 있다면, 컬렉터는 그것을 놓치고 메모리를 해제할 거예요.

이런 일이 일어나지 않게 하려면 다음 중 하나를 해요:

  • 컬렉터가 포인터를 스캔하는 영역에 객체에 대한 포인터를 유지.
  • core.memory.GC.addRoot() 또는 core.memory.GC.addRange()로 객체에 대한 포인터가 저장된 곳에 루트를 추가.
  • 외부 코드의 저장소 할당자 또는 C 런타임 라이브러리의 malloc/free를 사용해 객체를 재할당·복사.

Pointers and the Automatic Memory Management Collector

D의 포인터는 크게 두 범주로 나눌 수 있어요: 컬렉터 메모리를 가리키는 것과 그렇지 않은 것. 후자의 예는 C의 malloc() 호출로 만들어진 포인터, C 라이브러리 루틴에서 받은 포인터, 정적 데이터에 대한 포인터, 스택의 객체에 대한 포인터 등이에요. 그런 포인터들에 대해서는 C에서 합법적인 것은 무엇이든 할 수 있어요.

그러나 컬렉터 포인터와 참조에는 몇 가지 제약이 있어요. 이 제약들은 사소하지만, 컬렉터 설계에서 최대한의 유연성을 가능하게 하기 위한 것이에요.

정의되지 않은 동작(Undefined Behavior):

  • 포인터를 다른 값과 xor하지 마세요. C에서 쓰이는 xor 포인터 연결 리스트 트릭 같은 것을 말해요.
  • 두 포인터 값을 교환하는 데 xor 트릭을 사용하지 마세요.
  • 캐스트와 다른 트릭으로 포인터를 비-포인터 변수에 저장하지 마세요.
void* p;
...
int x = cast(int)p;   // error: undefined behavior

컬렉터는 할당된 메모리를 가리키는 포인터를 위해 비-포인터 필드를 스캔하지 않아요.

  • 포인터 정렬을 활용해 낮은 비트에 비트 플래그를 저장하지 마세요.
p = cast(void*)(cast(int)p | 1);  // error: undefined behavior
  • 컬렉터의 힙을 가리킬 수 있는 값을 포인터에 저장하지 마세요.
p = cast(void*)12345678;   // error: undefined behavior

복사 컬렉터는 이 값을 바꿀 수 있어요.

  • null 이외의 마법 값을 포인터에 저장하지 마세요.
  • 포인터 값을 디스크에 쓰고 다시 읽어들이지 마세요.
  • 포인터 값으로 해시 함수를 계산하지 마세요. 복사 컬렉터는 객체를 메모리에서 임의로 옮길 수 있어서, 계산된 해시 값을 무효화해요.
  • 포인터의 순서에 의존하지 마세요:
if (p1 < p2)  // error: undefined behavior
    ...

이것도 컬렉터가 객체를 메모리에서 옮길 수 있기 때문이에요.

  • 원래 할당된 수집 객체의 경계 밖을 가리키도록 오프셋을 포인터에 더하거나 빼지 마세요.
char* p = new char[10];
char* q = p + 6; // ok
q = p + 11;      // error: undefined behavior
q = p - 1;       // error: undefined behavior
  • 컬렉터 힙을 가리킬 수 있는 포인터를 정렬을 깨뜨리도록 만들지 마세요. 예를 들어:
struct Foo
{
  align (1):
    byte b;
    char* p;  // misaligned pointer
}

정렬이 깨진 포인터는 그 포인터를 컬렉터 힙을 가리키는 데 절대 사용하지 않고 하드웨어가 지원한다면 쓸 수 있어요.

  • 포인터 값을 복사하는 데 바이트 단위 메모리 복사를 사용하지 마세요. 이는 유효한 포인터가 없는 중간 상태를 만들 수 있고, 컬렉터가 그런 상태에서 스레드를 중지하면 메모리를 손상시킬 수 있어요. 대부분의 memcpy() 구현은 내부 구현이 포인터 크기보다 크거나 같은 정렬 청크로 복사를 하므로 동작하겠지만, C 표준이 그런 구현을 보장하지 않으므로 memcpy()는 극도로 주의해서만 사용하세요.
  • 같은 인스턴스를 가리키는 포인터를 struct 인스턴스 안에 두지 마세요. 문제는 인스턴스가 메모리에서 이동하면 포인터가 원래 있던 곳을 다시 가리키게 되어 재앙적인 결과를 초래할 가능성이 크다는 거예요.

신뢰할 수 있고 할 수 있는 것들:

  • union을 사용해 포인터와 저장 공간을 공유하기:
union U { void* ptr; int value }
  • 객체 내부의 포인터가 존재한다면 컬렉터 객체의 시작 포인터를 유지할 필요는 없어요.
char[] p = new char[10];
char[] q = p[3..6];
// q is enough to hold on to the object, don't need to keep
// p as well.
  • 어쨌든 대부분의 작업에서는 포인터 사용을 피할 수 있어요. D는 참조 객체, 동적 배열, 자동 메모리 관리 같은 대부분의 명시적 포인터 사용을 쓸모없게 만드는 기능들을 제공해요. 포인터는 C API와 성공적으로 연동하고 일부 저수준 작업을 위해 제공돼요.

Working with the Collector

자동 메모리 할당이 모든 메모리 해제 문제를 해결하지는 못해요. 예를 들어 큰 데이터 구조에 대한 포인터가 유지되면, 다시 참조되지 않더라도 컬렉터는 그것을 회수할 수 없어요. 이 문제를 없애려면 객체에 대한 참조나 포인터가 더 필요하지 않을 때 null로 설정하는 것이 좋은 습관이에요.

이 조언은 정적 참조 또는 다른 객체 안에 내장된 참조에만 적용돼요. 스택에 저장된 것은 새 스택 프레임이 어차피 초기화되므로 null로 만들 이유가 거의 없어요.

Object Pinning and a Moving Collector

D는 현재 이동 컬렉터를 사용하지 않지만, 위에 나열된 규칙을 따르면 구현할 수 있어요. 객체를 고정(pin)하는 데 특별한 조치는 필요 없어요. 이동 컬렉터는 모호한 참조가 없고 그 참조들을 갱신할 수 있는 객체만 이동해요. 다른 모든 객체는 자동으로 고정돼요.

Operations That Involve the Collector

일부 코드 섹션은 컬렉터 사용을 피해야 할 수 있어요. 다음 구성은 컬렉터로 메모리를 할당할 수 있어요:

  • NewExpression
  • 배열 추가(Array appending)
  • 배열 연결(Array concatenation)
  • 배열 리터럴(참고: exceptions)
  • 연관 배열 리터럴
  • 연관 배열에서의 모든 삽입 또는 제거
  • 연관 배열에서 키나 값을 추출
  • 외부 스코프의 변수에 접근하는 중첩 함수의 주소를 취함(즉, delegate를 만듦)
  • 외부 스코프의 변수에 접근하는 함수 리터럴
  • 조건을 실패하는 AssertExpression

컬렉터 할당을 만들 구성이 사용되면 컴파일 타임에 오류를 내는 @nogc 함수 속성도 있어요.

Configuring the Collector

버전 2.067부터 컬렉터를 명령줄, 환경 또는 실행 파일에 내장된 옵션으로 구성할 수 있어요.

기본적으로 GC 옵션은 실행할 프로그램의 명령줄에서만 전달할 수 있어요. 예를 들어:

app "--DRT-gcopt=profile:1 minPoolSize:16" arguments to app

사용 가능한 GC 옵션은:

  • disable:0|1 - 시작 시 비활성화
  • profile:0|1 - 프로그램 종료 시 요약과 함께 프로파일링 활성화
  • gc:conservative|precise|manual - 컬렉터 구현 선택 (기본 = conservative)
  • initReserve:N - 예약할 초기 메모리 (MB)
  • minPoolSize:N - 초기 및 최소 풀 크기 (MB)
  • maxPoolSize:N - 최대 풀 크기 (MB)
  • incPoolSize:N - 풀 크기 증가분 (MB)
  • parallel:N - 마킹용 추가 스레드 수
  • heapSizeFactor:N - 사용 메모리 대비 목표 힙 크기 비율
  • cleanup:none|collect|finalize - 종료 시 살아있는 객체를 어떻게 처리할지
    • collect: 컬렉션 실행 (하위 호환을 위한 기본값)
    • none: 아무것도 하지 않음
    • finalize: 모든 살아있는 객체가 무조건 종료됨

또한 --DRT-gcopt=help는 옵션 목록과 현재 설정을 보여줘요.

"--DRT-"로 시작하는 명령줄 옵션은 main을 호출하기 전에 걸러지므로 프로그램은 그것들을 보지 못해요. 그것들은 rt_args를 통해 여전히 사용할 수 있어요.

명령줄을 통한 구성을 비활성화하려면 런타임의 기본값을 사용하기 전에 링커가 집어 올 변수를 선언해요:

extern(C) __gshared bool rt_cmdline_enabled = false;

마찬가지로 DRT_GCOPT 환경 변수를 통한 구성을 활성화하려면 불리언 rt_envvars_enabled를 선언해요:

extern(C) __gshared bool rt_envvars_enabled = true;

rt_options라는 이름의 옵션 배열을 지정해 실행 파일에 기본 구성 속성을 설정할 수 있어요:

extern(C) __gshared string[] rt_options = [ "gcopt=initReserve:100 profile:1" ];

옵션의 평가 순서는 rt_options, 그다음 환경 변수, 그다음 명령줄 인자예요. 즉, 명령줄 인자가 비활성화되지 않았다면 환경이나 실행 파일에 내장된 옵션을 덮어쓸 수 있어요.

Precise Heap Scanning

위 옵션들로 컬렉터로 precise를 선택한다는 것은 타입 정보를 사용해 힙에 할당된 데이터 객체 안의 실제 또는 가능한 포인터·참조를 식별한다는 뜻이에요. 비-포인터 데이터는 "가짜 포인터"로 다른 메모리 참조로 해석되지 않아요. 메모리 슬롯이 포인터와 정수 값 중 무엇을 담을 수 있는지 모호하면 컬렉터는 비관적으로 가정해야 하며, 여전히 스캔돼요(예: union에서).

core.memory의 컬렉터 메모리 함수를 포인터와 비-포인터 데이터가 섞인 데이터에 사용하려면, 할당된 struct·class·type의 TypeInfo를 선택적 매개변수로 전달하세요. 기본 null은 모든 곳에 포인터가 있을 수 있는 메모리로 해석돼요.

struct S { size_t hash; Data* data; }
S* s = cast(S*)GC.malloc(S.sizeof, 0, typeid(S));

주의: 정밀 스캐닝 활성화는 타입 선언에서 조금 더 주의가 필요해요. 예를 들어 struct의 일부로 버퍼를 예약하고 나중에 다른 할당에 대한 참조가 있는 객체 인스턴스를 이 메모리에 심을(emplace) 때, 기본 정수 타입으로 공간을 예약하지 마세요. 그렇게 하면 가비지 컬렉터가 참조를 감지하지 못해요. 대신 이 영역을 보수적으로 스캔할 배열 타입을 사용하세요. void*를 사용하는 것이 보통 최선이며, GC가 스캔하는 포인터에 적절한 정렬도 보장해요.

Precise Scanning of the DATA and TLS segment

Windows 전용: 버전 2.075부터 실행 파일 또는 DLL의 DATA(전역 공유 데이터)와 TLS 세그먼트(스레드 로컬 데이터)를 가비지 컬렉터가 보수적으로가 아니라 정밀하게 스캔하도록 구성할 수 있어요. 이것은 컴파일러가 내보낸 정보를 활용해 이 세그먼트 안의 가능한 변경 가능한 포인터를 식별해요. 초기화 값을 가진 불변 포인터는 미리 할당된 메모리만 가리킬 수 있으므로 스캔에서 제외되기도 해요.

Precise 스캐닝은 D 런타임 옵션 "scanDataSeg"으로 활성화할 수 있어요. 가능한 옵션 값은 "conservative"(기본)와 "precise"예요. 컬렉터 옵션과 마찬가지로 명령줄, 환경 또는 실행 파일에 내장해 지정할 수 있어요. 예를 들어:

extern(C) __gshared string[] rt_options = [ "scanDataSeg=precise" ];

주의: 정밀 스캐닝 활성화는 전역 메모리 타입 지정에서 약간 더 주의가 필요해요. 예를 들어 DATA/TLS 세그먼트에 메모리를 미리 할당하고 나중에 다른 할당에 대한 참조가 있는 객체 인스턴스를 이 메모리에 심을 때, 기본 정수 타입으로 공간을 예약하지 마세요. 그렇게 하면 가비지 컬렉터가 참조를 감지하지 못해요. 대신 이 영역을 보수적으로 스캔할 배열 타입을 사용하세요. void*를 사용하는 것이 보통 최선이며, GC가 스캔하는 포인터에 적절한 정렬도 보장해요.

class Singleton { void[] mem; }
align(__traits(classInstanceAlignment, Singleton))
    void*[(__traits(classInstanceSize, Singleton) - 1) / (void*).sizeof + 1]
    singleton_store;
static this()
{
    emplace!Singleton(singleton_store).mem = allocateMem();
}
Singleton singleton() { return cast(Singleton)singleton_store.ptr; }

그 영역의 정밀 타입 지정을 위해 컴파일러가 클래스 인스턴스를 DATA 세그먼트에 생성하게 할 수도 있어요:

class Singleton { void[] mem; }
shared(Singleton) singleton = new Singleton;
shared static this() { singleton.mem = allocateSharedMem(); }

이것은 TLS 메모리에는 동작하지 않아요.

참고: void 배열.

Parallel marking

기본적으로 컬렉터는 힙을 마킹하는 데 사용 가능한 모든 CPU 코어를 사용해요.

이것은 컬렉션의 마킹 단계 동안 일시 중지되지 않는 스레드가 있는 애플리케이션에 영향을 줄 수 있어요. 마킹에 사용되는 추가 스레드 수는 컬렉터 옵션 parallel로 구성해요. 예를 들어 명령줄에서 --DRT-gcopt=parallel:2를 전달하거나 rt_options로 바이너리에 옵션을 내장해요. 실제로 생성되는 스레드 수는 core.cpuid.threadsPerCPU-1로 제한돼요. 값 0은 병렬 마킹을 완전히 비활성화해요.

Adding your own Collector

컬렉터 구현은 레지스트리에 추가돼요. 이 레지스트리는 바이너리에 링크만 하면 더 많은 구현을 공급할 수 있게 해요. 그러려면 pragma(crt_constructor)를 사용해 D 런타임 초기화 전에 실행되는 함수를 추가해요:

import core.gc.gcinterface, core.gc.registry;
extern (C) pragma(crt_constructor) void registerMyGC()
{
    registerGCFactory("mygc", &createMyGC);
}

GC createMyGC()
{
    __gshared instance = new MyGC;
    instance.initialize();
    return instance;
}

class MyGC : GC { /*...*/ }

[인터페이스를 정의하는 컬렉터 모듈(gc.interface)과 등록(gc.registry)은 현재 공개되지 않으며 버전마다 바뀔 수 있어요. 예제를 컴파일하려면 druntime/src 경로에 대한 import 검색 경로를 추가하세요.]

새 컬렉터는 보통의 구성 옵션으로 선택할 수 있는 사용 가능한 컬렉터 목록에 추가돼요. 예를 들어 rt_options를 바이너리에 내장해:

extern (C) __gshared string[] rt_options = ["gcopt=gc:mygc"];

정적으로 링크된 바이너리의 표준 GC 구현은 함수 extern(C) void* register_default_gcs()를 재정의해 제거할 수 있어요. 사용자 정의 가비지 컬렉터가 등록되지 않았다면 컬렉터 관리 메모리를 할당하려는 모든 시도는 적절한 메시지와 함께 애플리케이션을 종료해요.

References

더 알아보기

  • 메모리 관리의 전반적인 전략은 Memory Management 문서를 참고하세요.
  • GC를 직접 조작하는 루트·범위 추가는 core.memory 문서에서 볼 수 있어요.
  • 가비지 컬렉션의 일반 개념은 Wikipedia의 가비지 컬렉션을 참고하세요.
  • dlang.org의 원문에서 최신 내용을 확인할 수 있어요.