가비지 컬렉션

가비지 컬렉션 (자동 메모리 관리)

D 언어 공식 사양(D Language Reference)의 "Garbage Collection" 챕터를 한국어로 옮긴 번역 문서예요. 메모리를 어떻게 자동으로 회수하는지, 컬렉터(collector)를 어떻게 다루고 설정하는지, 그리고 외부 코드와 어떻게 연동하는지까지 D 언어의 가비지 컬렉션 전반을 다룹니다. 기술 용어와 코드는 원문 그대로 보존했어요.

출처: https://dlang.org/spec/garbage.html

본문

D는 선택적인(optional) 자동 메모리 관리를 지원하는 시스템 프로그래밍 언어예요. 보통은 메모리를 명시적으로 해제할 필요가 없습니다. 필요할 때마다 그냥 할당하면, 자동 메모리 관리자의 *컬렉터(collector)*가 주기적으로 사용하지 않는 메모리를 전부 사용 가능한 메모리 풀로 되돌려 줘요.

D는 또한 자동 메모리 관리가 개입되지 않는 코드를 작성할 수 있는 메커니즘도 제공합니다. 자세한 내용은 아래에서 설명할게요.

메모리 할당과 해제를 직접 관리하는 데 익숙한 프로그래머라면 자동 메모리 관리의 장점과 효율성을 의심할 가능성이 높아요. 하지만 자동 메모리 관리를 염두에 두고 새로 작성한 프로젝트와, 기존 프로젝트를 자동 메모리 관리로 전환한 경험은 모두 다음을 보여 줍니다.

  • 자동 메모리 관리를 사용하는 프로그램은 종종 더 빠릅니다. 직관에 반하는 결과인데, 이유는 이래요.

    • 명시적 메모리 할당 문제를 푸는 흔한 해법이 참조 카운팅(reference counting)입니다. 할당이 일어날 때마다 증감 연산을 구현하는 코드가 느려지는 한 원인이 되어요. 스마트 포인터 클래스 뒤에 숨긴다고 해서 속도가 빨라지지는 않아요. (참조 카운팅 방식은 어차피 일반적인 해법이 아니에요. 순환 참조는 절대 삭제되지 않으니까요.)
    • 소멸자(destructor)는 객체가 획득한 자원을 해제하는 데 쓰입니다. 대부분의 클래스에서 이 자원은 바로 할당된 메모리예요. 자동 메모리 관리를 쓰면 대부분의 소멸자는 텅 비게 되고 통째로 버릴 수 있게 됩니다.
    • 객체가 스택에 할당될 때, 이렇게 메모리를 해제하는 소멸자들은 상당한 부담이 돼요. 소멸자마다 예외가 발생했을 때 각 프레임의 소멸자들이 보유한 메모리를 빠짐없이 해제하도록 어떤 메커니즘을 마련해야 합니다. 소멸자가 쓸모없어진다면 예외를 처리할 특별한 스택 프레임을 마련할 필요가 없어지고, 코드는 더 빨리 실행돼요.
    • 컬렉션은 메모리가 빠듯해질 때만 발동합니다. 메모리가 넉넉할 때는 프로그램이 최고 속도로 돌면서 메모리를 추적하고 해제하는 데 시간을 전혀 쓰지 않아요.
    • 자동 메모리 관리를 쓰는 프로그램은 메모리 누수가 쌓여 점진적으로 성능이 악화되는 문제를 겪지 않습니다.
  • 자동 메모리 관리자의 컬렉터가 사용하지 않는 메모리를 회수하므로, 오래 실행되는 애플리케이션이 메모리를 점점 더 많이 소모하다가 시스템을 마비시키는 "메모리 누수"를 겪지 않아요. 자동 메모리 관리 프로그램은 장기적으로 더 안정적입니다.

  • 자동 메모리 관리를 쓰는 프로그램은 찾아내기 어려운 포인터 버그가 더 적습니다. 해제된 메모리를 가리키는 댕글링 참조(dangling reference)가 없기 때문이에요. 메모리를 명시적으로 관리하는 코드가 없으니, 그런 코드에 버그가 생길 일도 없죠.

  • 자동 메모리 관리를 쓰는 프로그램은 개발과 디버깅이 더 빠릅니다. 명시적 해제 코드를 개발·디버깅·테스트·유지보수할 필요가 없으니까요.

하지만 자동 메모리 관리가 만병통치약은 아니에요. 몇 가지 단점도 있습니다.

  • 자동 메모리 관리자가 메모리를 할당하는 순간이 항상 명확하지는 않아요. 그 할당이 컬렉션을 촉발할 수도 있으니, 프로그램이 예상치 못하게 멈출 수 있습니다.
  • 컬렉션이 완료되는 데 걸리는 시간은 상한이 없어요. 실제로는 매우 빠르지만, 보통 이를 보장할 수는 없습니다.
  • 보통 컬렉션이 진행되는 동안 컬렉터 스레드를 제외한 모든 스레드가 중지되어야 해요.
  • 자동 메모리 관리자는 명시적 해제 방식이라면 남기지 않을 메모리를 일부 보유할 수 있습니다.
  • 자동 메모리 관리를 쓰는 프로그램은 컬렉터 구현을 함께 지니고 다녀야 해요. 공유 라이브러리로 만들 수는 있지만, 어쨌든 함께 존재하죠.

이런 제약들은 Memory Management 문서에 정리된 기법들로 해결합니다. 여기에는 컬렉터 힙 밖에서 할당을 통제하는 D에서 제공하는 메커니즘도 포함돼요.

현재 런타임 라이브러리를 자동 힙 할당 없이 만드는 작업이 진행 중입니다. 자동 메모리 관리 인프라를 쓸 수 없는 상황에서도 런타임을 사용할 수 있게 하기 위해서요.

자동 메모리 관리가 동작하는 방식

동작 방식은 이래요.

  1. 지금 메모리를 할당하려는 스레드를 제외한 다른 모든 스레드를 멈춥니다.
  2. 현재 스레드를 컬렉션 작업에 '탈취(hijack)'합니다.
  3. 할당된 메모리를 가리키는 포인터를 찾기 위해 모든 '루트(root)' 메모리 영역을 스캔합니다.
  4. 루트가 가리키는 할당 메모리를 재귀적으로 스캔하면서, 할당 메모리로 향하는 더 많은 포인터를 찾습니다.
  5. 활성 포인터가 없고 소멸자를 실행할 필요도 없는 할당 메모리를 전부 해제합니다.
  6. 소멸자를 실행해야 하는 도달 불가능한 메모리를 큐에 넣습니다.
  7. 다른 모든 스레드를 다시 재개합니다.
  8. 큐에 담긴 모든 메모리의 소멸자를 실행합니다.
  9. 남은 도달 불가능한 메모리를 해제합니다.
  10. 현재 스레드를 원래 하던 작업으로 되돌립니다.

자동 메모리 관리로 할당된 객체를 외부 코드와 연동하기

컬렉터는 다음 위치에서 루트를 찾습니다.

  1. 정적 데이터 세그먼트(static data segment)
  2. 각 스레드의 스택과 레지스터 내용
  3. 각 스레드의 TLS(thread-local storage) 영역
  4. core.memory.GC.addRoot() 또는 core.memory.GC.addRange()로 추가된 모든 루트

객체를 가리키는 유일한 포인터가 이 영역 밖에 보관되어 있다면, 컬렉터는 그 포인터를 놓치고 메모리를 해제해 버려요.

이런 일을 피하려면 다음 중 하나를 해야 합니다.

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

포인터와 자동 메모리 관리 컬렉터

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

포인터 정렬(alignment)을 이용해 하위 비트에 비트 플래그를 저장하지 마세요.

p = cast(void*)12345678;   // error: undefined behavior

컬렉터 힙을 가리킬 수 있는 값을 포인터에 저장하지 마세요. 복사 컬렉터(moving/copying collector)는 이 값을 바꿀 수 있거든요.

  • null 외에는 포인터에 마법 값(magic value)을 저장하지 마세요.
  • 포인터 값을 디스크에 썼다가 다시 읽어 들이지 마세요.
  • 포인터 값을 해시 함수 계산에 사용하지 마세요. 복사 컬렉터는 객체를 메모리 안에서 마음대로 옮길 수 있어서, 계산된 해시 값을 무효로 만들어 버려요.
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
}

컬렉터 힙을 가리킬 수 있는 포인터는 정렬을 어기지(misalign) 마세요. 다음과 같은 경우요. 정렬이 어긋난 포인터는, 기반 하드웨어가 지원하고 그리고 그 포인터가 컬렉터 힙을 가리키는 데 전혀 쓰이지 않는다면 사용할 수 있어요.

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

믿을 수 있고 해도 되는 것들:

union U { void* ptr; int value }

union을 사용해 포인터와 저장 공간을 공유하세요.

char[] p = new char[10];
char[] q = p[3..6];
// q를 유지하면 객체를 붙들어 두기에 충분하다,
// p까지 유지할 필요는 없다.

객체 내부를 가리키는 포인터가 있다면 컬렉터 객체의 시작을 가리키는 포인터를 반드시 유지할 필요는 없어요.

대부분의 작업은 어차피 포인터를 쓰지 않고도 피할 수 있습니다. D는 참조 객체(reference object), 동적 배열, 자동 메모리 관리처럼 명시적 포인터 사용을 대부분 불필요하게 만드는 기능을 제공해요. 포인터는 C API와 성공적으로 연동하고, 일부 저수준 작업을 위해 제공됩니다.

컬렉터와 함께 작업하기

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

이 조언은 정적 참조나 다른 객체 안에 포함된 참조에만 해당해요. 스택에 저장된 참조를 null로 만드는 건 별 의미가 없어요. 어차피 새 스택 프레임은 초기화되니까요.

객체 고정(Object Pinning)과 복사 컬렉터

D는 현재 복사 컬렉터를 사용하지 않지만, 위 목록의 규칙을 따르면 복사 컬렉터를 구현할 수 있어요. 객체를 고정(pin)하기 위한 특별한 조치는 필요 없습니다. 복사 컬렉터는 애매모호한 참조가 없고 그 참조를 갱신할 수 있는 객체만 옮겨요. 다른 모든 객체는 자동으로 고정됩니다.

컬렉터가 관여하는 연산

일부 코드 구간은 컬렉터 사용을 피해야 할 수도 있어요. 다음 구성들은 컬렉터로 메모리를 할당할 수 있습니다.

  • NewExpression
  • 배열 추가(Array appending)
  • 배열 연결(Array concatenation)
  • 배열 리터럴(예외는 이쪽을 보세요)
  • 연관 배열 리터럴
  • 연관 배열에서의 삽입이나 제거
  • 연관 배열에서 키 또는 값을 꺼내기
  • 외부 스코프의 변수에 접근하는 중첩 함수의 주소를 취하는 것(즉 델리게이트 만들기)
  • 외부 스코프의 변수에 접근하는 함수 리터럴
  • 조건이 실패하는 AssertExpression

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

컬렉터 설정하기

버전 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 - 마킹(marking)용 추가 스레드 수
  • 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를 선택하면, 타입 정보를 이용해 힙에 할당된 데이터 객체 안의 실제 또는 가능한 포인터·참조를 식별한다는 뜻이에요. 비포인터 데이터는 다른 메모리를 가리키는 "거짓 포인터(false pointer)"로 해석되지 않습니다. 하지만 메모리 슬롯이 포인터와 정수 값을 모두 담을 수 있다면 컬렉터는 비관적 가정을 해야 하므로, 여전히 스캔됩니다(예: 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가 스캔하는 포인터의 정렬도 제대로 보장해 주기 때문이에요.

DATA와 TLS 세그먼트의 정밀 스캔

Windows 전용: 버전 2.075부터 실행 파일이나 DLL의 DATA(전역 공유 데이터)와 TLS 세그먼트(스레드 지역 데이터)를, 가비지 컬렉터가 보수적으로 스캔하는 대신 정밀하게 스캔하도록 설정할 수 있어요. 이는 컴파일러가 출력한 정보를 활용해 이 세그먼트 안의 변경 가능한 포인터를 식별하는 방식이에요. 초기화자가 있는 immutable 포인터도 스캔에서 제외됩니다. 사전 할당된 메모리만 가리킬 수 있으니까요.

정밀 스캔은 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; }
class Singleton { void[] mem; }
shared(Singleton) singleton = new Singleton;
shared static this() { singleton.mem = allocateSharedMem(); }

이 영역을 정밀하게 타입 지정하려면, 컴파일러가 클래스 인스턴스를 DATA 세그먼트에 생성하게 하세요. 다만 이 방식은 TLS 메모리에서는 동작하지 않아요. 참고: void 배열.

병렬 마킹(Parallel marking)

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

이 동작은 컬렉션의 마킹 단계 동안 중단되지 않는 스레드를 가진 애플리케이션에는 영향을 줄 수 있어요. 마킹에 사용할 추가 스레드 수는 컬렉터 옵션 parallel로 설정하면 됩니다. 예를 들어 명령줄에 --DRT-gcopt=parallel:2를 넘기거나, rt_options를 통해 바이너리에 옵션을 포함하면 돼요. 실제로 생성되는 스레드 수는 core.cpuid.threadsPerCPU-1로 제한됩니다. 0 값을 주면 병렬 마킹이 완전히 비활성화돼요.

나만의 컬렉터 추가하기

컬렉터 구현은 레지스트리(registry)에 추가하는 방식으로 등록해요. 이 방식은 바이너리에 구현을 그냥 링크하는 것만으로 더 많은 구현을 공급할 수 있게 해 줍니다. 그러려면 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)

더 알아보기 (Learn more)