Proven C Book←↑→

58 메모리를 다스리는 법 — 수명, 소유, 그리고 다른 언어의 수집기

먼저 알아야 할 것

46장 동적 메모리 · 소유 — 누가 돌려줄 책임을 지는가
57장 메모리 사고 도감 · 수명을 어긴 사고들

돌아보기

57장의 마지막 표에서 「막는 설계」 열은 대부분 누가, 언제 돌려주는가에 대한 답이었다. 그 질문에 답하는 방법은 몇 가지나 있는가?

답. 크게 넷이다. 사람이 정한다(C 의 free), 세어서 정한다(참조 계수), 찾아서 정한다(추적하는 가비지 컬렉터), 그리고 컴파일러가 증명한다(Rust 의 소유). 넷은 같은 질문에 대한 다른 답이고, 저마다 치르는 비용이 다르다. C 는 첫째를 고른 언어지만, 나머지 셋의 생각은 C 안에서도 자료 구조로 흉내 낼 수 있다.

이 장의 필요성과 맥락

앞 장이 사고를 잡는 법이었다면 이 장은 사고가 생기지 않게 하는 법이다. 다른 언어의 가비지 컬렉터까지 다루는 까닭은 둘이다. C 프로그래머는 결국 그런 언어와 맞물리는 코드(확장 모듈, 라이브러리, 런타임)를 쓰게 되고 — 그리고 「C 에는 수집기가 없다」를 정확히 알려면 수집기가 무엇을 해 주고 무엇을 받아 가는지를 알아야 한다.

이 장이 끝나면

메모리 관리를 「누가, 언제 돌려주는가」를 정하는 일로 정의하고, 네 가지 답을 차례로 본다. 먼저 C 에서 수명을 다스리는 요령(소유자 하나, 한 출구, 수명이 같은 것끼리 묶기)과, 댕글링을 검출하는 자료 구조(세대 번호를 단 핸들)와 점검용 할당자. 이어 참조 계수와 그 약점(순환), 추적 수집(표시-쓸기·복사·세대별)과 그 비용(멈춤, 메모리 여유, 종료자). 끝으로 컴파일러가 소유를 추적하는 길과, C 에 수집기를 붙일 때 생기는 일.

이 장에서 답할 질문

  1. 그렇다면 C 로 오래 도는 프로그램을 쓸 때는 무엇을 골라야 하는가?

58.1 메모리 관리란 무엇인가 — 누가, 언제#

메모리 관리는 빌린 기억을 누가, 언제 돌려줄지를 정하는 일이다. 빌리는 일은 어느 언어에서나 쉽다. 어려운 것은 돌려주는 시점이다. 너무 이르면 해제 후 사용이 되고, 너무 늦거나 끝내 안 하면 누수가 된다 (57장). 그 시점을 정하는 방법이 언어마다 다르고, 그것이 언어의 성격을 크게 가른다.

방법누가 정하는가언제 돌려주는가대표
수동프로그래머free 를 부른 때C
참조 계수실행 중의 계수마지막 참조가 사라진 그 순간CPython, Swift, Rust 의 Rc
추적 수집실행 중의 수집기수집기가 돌다가 「닿을 수 없다」를 알아낸 때Java, Go, JavaScript, C#
소유 추적컴파일러소유자가 유효범위를 벗어난 때 — 컴파일할 때 정해진다Rust, C++ 의 RAII

표 58.1 — 「누가, 언제 돌려주는가」에 대한 네 가지 답

C 는 첫 줄이다. 언어가 돌려줄 때를 정해 주지 않으므로, 정하는 규칙을 프로그래머가 세워야 한다. 다음 절이 그 규칙들이다.

58.2 C 에서 수명을 다스리는 요령#

58.2.1 소유자는 하나 — 이름과 주석으로#

46장의 규칙이 출발점이다. 블록마다 돌려줄 책임을 진 소유자를 하나 정한다. C 에는 그것을 적는 문법이 없으므로 이름과 주석으로 적는다. 흔히 쓰는 약속이 있다.

셋째가 특히 중요하다. 구조체를 해제하는 함수는 소유한 멤버만 돌려주어야 하고, 가리키기만 하는 멤버를 돌려주면 이중 해제가 된다.

58.2.2 빌림과 돌려줌의 짝을 맞추고, 정리는 한 출구로#

자원을 여러 개 빌리는 함수에서 누수는 대개 중간에 실패한 경로에서 생긴다. C 의 오래된 관용구는 정리를 함수 끝 한 곳에 모으고 실패하면 그리로 뛰는 것이다.

int load(const char *path)
{
    int rc = -1;
    FILE *f = nullptr;
    char *buf = nullptr;

    f = fopen(path, "rb");
    if (f == nullptr) goto out;
    buf = malloc(4096);
    if (buf == nullptr) goto out;
    /* ... 일 ... */
    rc = 0;
out:
    free(buf);                      /* free(nullptr) 는 아무 일도 하지 않는다 */
    if (f != nullptr) fclose(f);
    return rc;
}

모든 포인터를 처음에 nullptr 로 두는 것이 이 모양의 열쇠다. 어느 단계에서 실패하든 정리 구역은 같은 코드로 「빌린 것만」 돌려준다. goto 를 꺼리는 규약도 있지만, 이 용도만큼은 리눅스 커널을 비롯해 C 로 쓴 큰 프로젝트들이 널리 쓴다(42장).

58.2.3 수명이 같은 것끼리 묶기#

블록마다 수명을 따로 관리하는 대신, 같이 죽는 것들을 한 덩어리로 빌리는 방법이 있다. 요청 하나를 처리하는 동안 쓰는 기억, 한 프레임을 그리는 동안 쓰는 기억, 파일 하나를 파싱하는 동안 쓰는 기억은 모두 함께 끝난다. 그런 것들을 아레나(arena)에서 빌리면 낱낱의 free 가 사라지고, 끝에 아레나 하나만 돌려주면 된다(99장). 누수와 이중 해제가 구조적으로 줄어든다 — 돌려줄 것이 하나뿐이기 때문이다. 대신 아레나가 살아 있는 동안에는 안의 블록 하나만 먼저 돌려줄 수 없다. 할당자를 인자로 넘기는 설계(104장)는 아레나를 쓸지 낱낱의 malloc 을 쓸지의 선택을 함수를 부르는 쪽이 하게 만든다.

58.2.4 해제 뒤 nullptr 는 왜 반쪽인가#

free(p); p = nullptr; 은 좋은 습관이다. 그 변수로 다시 해제하면 free(nullptr) 가 되어 아무 일도 없고, 그 변수로 읽으면 널 역참조로 그 자리에서 멈출 가능성이 크다. 그러나 막는 것은 그 변수 하나다. 같은 주소를 복사해 둔 다른 변수, 구조체 멤버, 목록의 원소는 그대로 댕글링으로 남는다. 별칭이 여럿인 객체의 수명을 지키려면 다른 장치가 필요하다 — 다음 절의 핸들이 그 하나다.

58.3 댕글링을 검출하는 자료 구조 — 세대 번호를 단 핸들#

포인터의 문제는 객체가 죽었는지 물어볼 수 없다는 것이다. 포인터는 주소만 가지고, 그 주소에 지금 무엇이 있는지는 모른다. 그래서 객체를 포인터 대신 핸들로 가리키는 설계가 있다. 객체들을 칸이 정해진 풀에 두고, 핸들은 (칸 번호, 세대)의 쌍으로 만든다. 칸을 돌려받을 때마다 그 칸의 세대를 하나 올린다.

examples/memory-management/handles.c

/* 세대 번호를 단 핸들 --- 댕글링 포인터를 *검출할 수 있는* 참조.
 * 포인터 대신 (칸 번호, 세대)를 건넨다. 칸을 돌려받을 때마다 그 칸의 세대를 올리므로,
 * 옛 핸들은 세대가 맞지 않아 「이미 죽은 객체」로 판정된다. 칸은 다시 쓰여도 된다. */
#include <stdint.h>
#include <stdio.h>

enum { SLOTS = 4 };

typedef struct { uint32_t index, gen; } handle;

typedef struct {
    double   value[SLOTS];
    uint32_t gen[SLOTS];        /* 칸마다의 세대 */
    bool     used[SLOTS];
} pool;

static bool pool_new(pool *p, double v, handle *out)
{
    for (uint32_t i = 0; i < SLOTS; i++) {
        if (!p->used[i]) {
            p->used[i] = true;
            p->value[i] = v;
            *out = (handle){ i, p->gen[i] };
            return true;
        }
    }
    return false;               /* 빈칸이 없다 */
}

static void pool_free(pool *p, handle h)
{
    if (h.index < SLOTS && p->used[h.index] && p->gen[h.index] == h.gen) {
        p->used[h.index] = false;
        p->gen[h.index]++;      /* ★ 이 칸을 가리키던 모든 핸들이 이 순간 낡는다 */
    }
}

/* 살아 있는 객체면 그 자리를, 아니면 NULL 을 돌려준다 --- 쓰기 전에 *물어볼 수* 있다. */
static double *pool_get(pool *p, handle h)
{
    if (h.index >= SLOTS || !p->used[h.index] || p->gen[h.index] != h.gen)
        return NULL;
    return &p->value[h.index];
}

static void show(pool *p, const char *name, handle h)
{
    double *v = pool_get(p, h);
    if (v) printf("%-6s (slot %u, gen %u) -> %.1f\n", name, (unsigned)h.index, (unsigned)h.gen, *v);
    else   printf("%-6s (slot %u, gen %u) -> stale handle, object is gone\n",
                  name, (unsigned)h.index, (unsigned)h.gen);
}

int main(void)
{
    pool p = {0};
    handle a, b;
    if (!pool_new(&p, 1.5, &a)) return 1;
    show(&p, "a", a);

    pool_free(&p, a);               /* a 가 가리키던 객체를 돌려준다 */
    if (!pool_new(&p, 9.0, &b)) return 1;  /* 같은 칸이 다시 쓰인다 */
    show(&p, "b", b);
    show(&p, "a", a);               /* 포인터였다면 b 의 값을 읽었을 자리 */
    return 0;
}

실행 결과

a      (slot 0, gen 0) -> 1.5
b      (slot 0, gen 1) -> 9.0
a      (slot 0, gen 0) -> stale handle, object is gone

a 를 돌려준 뒤 같은 칸 0 에 b 가 들어왔다. 포인터였다면 a 는 이제 b 의 값을 읽었을 것이다 — 해제 후 사용이 아무 소리 없이 남의 객체를 읽는 경우다. 핸들은 칸의 세대(1)와 자기 세대(0)가 다르므로 「이미 죽은 객체」로 판정된다. 칸은 다시 쓰이면서도, 옛 참조는 되살아나지 않는다.

이 모양을 흔히 세대 인덱스(generational index)라 하고, 그런 풀을 슬롯 맵(slot map)이라 부른다. 얻는 것은 셋이다. 댕글링을 검출할 수 있고, 객체가 연속된 배열에 모여 캐시에 유리하며(12장), 핸들은 정수라서 파일에 쓰거나 다른 프로세스에 넘겨도 뜻을 잃지 않는다. 치르는 비용은 접근마다 한 번의 비교와, 세대 번호가 한 바퀴 돌아 옛 핸들과 다시 같아지는 일이다 — 32비트 세대라면 한 칸을 40억 번 넘게 다시 써야 그날이 온다.

58.4 빌린 것을 장부에 적기 — 독 칠하기와 격리#

ASan 이 하는 일의 일부는 malloc 과 free 를 감싼 작은 할당자로도 흉내 낼 수 있다. 빌린 블록마다 장부에 적고, 돌려받은 블록은 곧바로 풀지 않고 격리해 둔 채 독 값을 칠한다.

examples/memory-management/leakcheck.c

/* 작은 점검용 할당자 --- 빌린 블록마다 장부에 적는다.
 * 돌려받은 블록은 곧바로 풀지 않고 *격리*해 둔다(독 0xDD 를 칠해서). 그래서 두 번째
 * 해제가 와도 그 주소는 아직 살아 있는 객체를 가리키고, 장부와 견줘 볼 수 있다.
 * 진짜로 풀어 버렸다면 그 포인터 값을 쓰는 것부터가 계약 밖이다(C23 6.2.4p2). */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

enum { MAX_BLOCKS = 16 };

typedef struct {
    unsigned char *p;
    size_t size;
    const char *where;
    bool released;          /* 돌려받았으나 격리 중 */
} entry;
static entry book[MAX_BLOCKS];

static void *chk_malloc(size_t size, const char *where)
{
    for (size_t i = 0; i < MAX_BLOCKS; i++) {
        if (book[i].p == NULL) {
            unsigned char *p = malloc(size);
            if (p == NULL) return NULL;
            book[i] = (entry){ p, size, where, false };
            return p;
        }
    }
    return NULL;                        /* 장부가 찼다 */
}

static void chk_free(void *ptr, const char *where)
{
    if (ptr == NULL) return;
    unsigned char *p = ptr;
    for (size_t i = 0; i < MAX_BLOCKS; i++) {
        entry *e = &book[i];
        if (e->p == NULL) continue;
        if (p == e->p) {
            if (e->released) {
                printf("  %s: double free of the block from %s\n", where, e->where);
            } else {
                memset(e->p, 0xDD, e->size);    /* 독 칠하기 */
                e->released = true;             /* 격리: 아직 free 하지 않는다 */
            }
            return;
        }
        /* 「이 블록 안인가」는 포인터의 < 로 묻지 않는다 --- 서로 다른 객체의 포인터를
           크기로 견주는 것은 계약 밖이다(6.5.9). 정수로 바꾼 주소로 묻는다(구현 정의). */
        uintptr_t a = (uintptr_t)p, lo = (uintptr_t)e->p;
        if (a > lo && a - lo < e->size) {
            printf("  %s: free of an address inside the block from %s (offset %ju)\n",
                   where, e->where, (uintmax_t)(a - lo));
            return;
        }
    }
    printf("  %s: free of a pointer this allocator never gave out\n", where);
}

/* 끝에서: 돌려받지 못한 것은 누수로 알리고, 격리한 것까지 모두 실제로 푼다. */
static void chk_finish(void)
{
    for (size_t i = 0; i < MAX_BLOCKS; i++) {
        if (book[i].p == NULL) continue;
        if (!book[i].released)
            printf("  leak: %zu bytes from %s\n", book[i].size, book[i].where);
        free(book[i].p);
        book[i] = (entry){0};
    }
}

int main(void)
{
    char *name = chk_malloc(16, "line A");
    char *tmp  = chk_malloc(64, "line B");
    int  *nums = chk_malloc(4 * sizeof *nums, "line C");
    if (!name || !tmp || !nums) return 1;

    strcpy(name, "ok");
    chk_free(tmp, "line D");
    chk_free(tmp, "line E");            /* 두 번째 해제 */
    chk_free(name + 1, "line F");       /* 블록의 가운데 */
    chk_free(name, "line G");
    printf("  first byte of the released block from line B: 0x%02X\n",
           (unsigned)(unsigned char)tmp[0]);    /* 격리 중이라 읽어도 계약 안이다 */

    puts("at exit:");
    chk_finish();                       /* nums 는 돌려주지 않았다 */
    return 0;
}

실행 결과

  line E: double free of the block from line B
  line F: free of an address inside the block from line A (offset 1)
  first byte of the released block from line B: 0xDD
at exit:
  leak: 16 bytes from line C

세 가지가 잡혔다. 두 번째 해제(line E), 블록의 가운데를 돌려준 것(line F), 그리고 끝까지 돌려주지 않은 블록(line C, 누수). 격리한 블록의 첫 바이트는 0xDD 다 — 해제 후 사용이 있었다면 이런 값이 나와서 눈에 띈다.

★ 격리가 이 할당자의 핵심이다. 돌려받은 블록을 곧바로 free 했다면, 두 번째 해제 때 그 포인터를 장부와 견주는 일부터가 계약 밖이다(57장의 포인터 무효화). 블록을 살려 두는 동안에는 그 주소가 아직 살아 있는 객체를 가리키므로, 견주는 것도 읽는 것도 계약 안이다. ASan 이 해제 후 사용을 잡는 방법도 같다 — 돌려받은 블록을 한동안 격리해 다른 요청에 내주지 않는다. 그래서 격리 구역이 다 차서 블록이 재사용된 뒤의 해제 후 사용은 ASan 도 놓칠 수 있다.

58.5 참조 계수 — 세어서 돌려주기#

참조 계수(reference counting)는 객체마다 「나를 가리키는 참조가 몇인가」를 세어 두고, 계수가 0 이 되는 순간 돌려주는 방법이다. 참조를 하나 만들 때 계수를 올리고(retain), 놓을 때 내린다(release). 마지막 참조가 사라지는 그 순간 돌려주므로 돌려주는 때가 예측 가능하고, 수집기처럼 멈춰 서서 일할 필요가 없다.

CPython 은 모든 객체에 참조 계수를 둔다. Swift 는 컴파일러가 retain·release 를 자동으로 넣어 주는 ARC(Automatic Reference Counting)를 쓴다. Rust 는 소유로 충분하지 않을 때 쓰는 Rc(한 스레드)와 Arc(여러 스레드)를 둔다. C 로 쓴 큰 라이브러리들도 객체에 계수를 붙여 이 방법을 손으로 쓴다(68장의 객체 모형이 그 예다).

치르는 비용은 둘이다. 첫째, 참조를 복사할 때마다 계수를 고쳐야 한다. 여러 스레드가 같은 객체를 나눠 쓰면 그 고침이 원자적 연산(95장)이 되어야 하고, 원자적 연산은 보통의 덧셈보다 비싸다. 둘째, 더 근본적인 약점이 있다 — 서로를 가리키는 객체들, 곧 순환이다.

examples/memory-management/refcount.c

/* 참조 계수 --- 「나를 가리키는 것이 몇인가」를 세어, 0 이 되는 순간 돌려준다.
 * 그리고 그 약점: 서로 가리키는 둘(순환)은 계수가 끝내 0 이 되지 않는다.
 * 한쪽을 *약한 참조*(계수를 올리지 않는 참조)로 바꾸면 순환이 풀린다. */
#include <stdio.h>
#include <stdlib.h>

typedef struct node node;
struct node {
    const char *name;
    int   refs;
    node *strong;       /* 계수를 올리는 참조 */
    node *weak;         /* 계수를 올리지 않는 참조 --- 소유하지 않는다 */
};

static int live;        /* 아직 돌려주지 않은 노드 수 */

static node *node_new(const char *name)
{
    node *n = calloc(1, sizeof *n);
    if (n == NULL) exit(1);
    n->name = name;
    n->refs = 1;        /* 만든 쪽이 첫 참조를 쥔다 */
    live++;
    return n;
}

static node *retain(node *n) { if (n) n->refs++; return n; }

static void release(node *n)
{
    if (n == NULL || --n->refs > 0) return;
    printf("  free %s\n", n->name);
    release(n->strong);             /* 내가 쥔 강한 참조를 놓는다 */
    free(n);
    live--;
}

int main(void)
{
    puts("cycle of two strong references:");
    node *a = node_new("a"), *b = node_new("b");
    a->strong = retain(b);
    b->strong = retain(a);          /* a -> b -> a */
    release(a);
    release(b);                     /* 바깥 참조를 다 놓았다 */
    printf("  still alive: %d (a.refs %d, b.refs %d) --- leaked\n", live, a->refs, b->refs);

    /* 시연이 끝났으니 손으로 끊어 정리한다 --- 수집기가 없으면 이 일은 사람 몫이다. */
    a->strong = NULL; b->strong = NULL;
    free(a); free(b); live -= 2;

    puts("same shape, back edge made weak:");
    node *c = node_new("c"), *d = node_new("d");
    c->strong = retain(d);
    d->weak = c;                    /* d -> c 는 계수를 올리지 않는다 */
    release(c);
    release(d);
    printf("  still alive: %d\n", live);
    return 0;
}

실행 결과

cycle of two strong references:
  still alive: 2 (a.refs 1, b.refs 1) --- leaked
same shape, back edge made weak:
  free c
  free d
  still alive: 0

a 와 b 가 서로를 강하게 가리키면, 바깥 참조를 모두 놓아도 각자의 계수는 상대가 쥔 1 에서 멈춘다. 둘은 아무도 닿을 수 없는데 끝내 돌려지지 않는다 — 순환(cycle)은 참조 계수가 거두지 못하는 누수다. 해법은 한쪽 방향을 약한 참조(weak reference), 곧 계수를 올리지 않는 참조로 만드는 것이다. 부모가 자식을 강하게, 자식이 부모를 약하게 가리키는 식이다. 둘째 시연에서 d 가 c 를 약하게 가리키자 둘 다 제때 돌려졌다.

약한 참조에는 따라오는 문제가 있다. 가리키던 객체가 먼저 사라지면 약한 참조는 댕글링이 된다. 그래서 실제 시스템의 약한 참조는 「아직 살아 있는가」를 물을 수 있게 만든다. Swift 의 weak 는 객체가 사라지면 저절로 nil 이 되고, Rust 의 Weak 는 쓰기 전에 upgrade 로 살아 있는지 확인한다. 위 시연의 weak 멤버는 그런 장치 없이 다시 읽지 않는 것으로 버텼을 뿐이다 — 살아 있는지 묻는 일은 앞에서 본 세대 번호를 단 핸들이 할 수 있다.

CPython 은 순환을 위해 따로 수집기를 하나 더 둔다. 참조 계수가 기본이고, 그것이 못 거두는 순환을 순환 수집기가 보충하는 구조다(파이썬 문서의 표현이 「supplements」다). 다음 절의 추적 수집이 바로 그 수집기다.

58.6 추적 수집 — 닿을 수 있는가#

추적 수집(tracing garbage collection)은 「누가 나를 가리키는가」를 세지 않고, 「뿌리에서 닿을 수 있는가」를 찾아본다. 뿌리(root)는 프로그램이 지금 바로 쥐고 있는 것(지역 변수, 전역 변수, 레지스터)이고, 뿌리에서 포인터를 따라 닿을 수 있는 객체는 살아 있고 나머지는 쓰레기다. 이 일을 하는 런타임의 부품이 가비지 컬렉터(garbage collector), 줄여서 수집기다. 「앞으로 다시 쓰일 것인가」는 알 수 없으므로, 수집기는 그것을 「닿을 수 있는가」로 어림한다. 닿을 수 있는데 다시 쓰이지 않는 것은 수집기도 거두지 못한다 — 이것이 뒤에서 볼 「수집기가 있어도 새는 누수」다.

58.6.1 표시-쓸기#

가장 단순한 수집기는 두 단계다. 표시(mark) 단계에서 뿌리부터 닿는 객체에 모두 표시를 하고, 쓸기(sweep) 단계에서 할당한 모든 객체를 훑으며 표시가 없는 것을 돌려준다.

examples/memory-management/marksweep.c

/* 장난감 표시-쓸기 수집기 --- 「뿌리에서 닿을 수 있는가」로 살고 죽음을 가른다.
 * 참조 계수가 못 거둔 순환도, 뿌리에서 끊기면 거둔다. 실제 수집기와 달리 뿌리를
 * 손으로 등록한다 --- C 에는 「어디가 포인터인가」를 알려 주는 장부가 없기 때문이다. */
#include <stdio.h>
#include <stdlib.h>

typedef struct obj obj;
struct obj {
    char  name;
    obj  *ref[2];       /* 다른 객체를 가리키는 칸 둘 */
    bool  marked;
    int   survived;     /* 수집을 몇 번 살아남았나 */
    obj  *next_alloc;   /* 수집기가 아는 모든 객체의 목록 */
};

static obj *all;                /* 할당한 모든 객체 */
static obj *roots[4];           /* 뿌리: 프로그램이 지금 쥐고 있는 것 */

static obj *gc_new(char name)
{
    obj *o = calloc(1, sizeof *o);
    if (o == NULL) exit(1);
    o->name = name;
    o->next_alloc = all;
    all = o;
    return o;
}

static void mark(obj *o)
{
    obj *stack[64];             /* 재귀 대신 명시적 스택 --- 깊은 그래프에서 호출 스택을 넘치지 않게 */
    size_t top = 0;
    if (o) stack[top++] = o;
    while (top > 0) {
        obj *x = stack[--top];
        if (x->marked) continue;        /* 순환이어도 한 번만 표시한다 */
        x->marked = true;
        for (int i = 0; i < 2; i++)
            if (x->ref[i] && !x->ref[i]->marked && top < sizeof stack / sizeof stack[0])
                stack[top++] = x->ref[i];
    }
}

static void collect(void)
{
    for (size_t i = 0; i < sizeof roots / sizeof roots[0]; i++)
        mark(roots[i]);                 /* ① 표시: 뿌리에서 닿는 것 */

    printf("  freed:");
    for (obj **p = &all; *p != NULL; ) { /* ② 쓸기: 표시가 없는 것을 돌려준다 */
        obj *o = *p;
        if (!o->marked) {
            *p = o->next_alloc;
            printf(" %c", o->name);
            free(o);
        } else {
            o->marked = false;          /* 다음 수집을 위해 지운다 */
            o->survived++;
            p = &o->next_alloc;
        }
    }
    printf("\n  alive:");
    for (obj *o = all; o != NULL; o = o->next_alloc)
        printf(" %c(survived %d)", o->name, o->survived);
    printf("\n");
}

int main(void)
{
    obj *a = gc_new('a'), *b = gc_new('b'), *c = gc_new('c');
    a->ref[0] = b;
    b->ref[0] = c;
    c->ref[0] = b;                      /* b <-> c 순환 */
    roots[0] = a;
    (void)gc_new('t');                  /* 아무도 쥐지 않는 임시 객체 */

    puts("collection 1 (root: a):");
    collect();

    a->ref[0] = NULL;                   /* a 에서 순환으로 가는 길을 끊는다 */
    (void)gc_new('u');
    puts("collection 2 (a no longer points at the cycle):");
    collect();

    roots[0] = NULL;                    /* 뿌리를 비우고 마지막 수집 */
    puts("collection 3 (no roots):");
    collect();
    return 0;
}

실행 결과

collection 1 (root: a):
  freed: t
  alive: c(survived 1) b(survived 1) a(survived 1)
collection 2 (a no longer points at the cycle):
  freed: u c b
  alive: a(survived 2)
collection 3 (no roots):
  freed: a
  alive:

첫 수집에서는 아무도 쥐지 않은 t 만 거둬졌다. b 와 c 는 서로 가리키는 순환이지만 뿌리 a 에서 닿으므로 살았다. 둘째 수집 전에 a 에서 순환으로 가는 길을 끊자, 순환 전체가 거둬졌다 — 참조 계수 시연에서 끝내 남았던 바로 그 모양이다. 표시는 「이미 표시했으면 지나간다」로 순환을 한 번씩만 지나간다.

시연이 손으로 하는 일이 하나 있다. roots 배열에 뿌리를 직접 등록했다. 실제 수집기는 런타임이 스택과 레지스터 어디에 포인터가 있는지를 알고 있다 — 컴파일러가 그 장부를 만들어 주기 때문이다. C 에는 그 장부가 없다. 이 차이는 C 에 수집기를 붙이는 뒤의 절에서 다룰 이야기다.

58.6.2 복사 수집 — 옮기면서 모은다#

표시-쓸기는 객체를 그 자리에 둔다. 그래서 오래 돌면 살아 있는 객체 사이사이에 빈자리가 생긴다(단편화, 99장). 복사 수집(copying collection)은 기억을 두 구역으로 나누고, 수집할 때 살아 있는 객체만 다른 구역으로 옮겨 붙인다. 남은 구역은 통째로 빈자리가 된다. 단편화가 사라지고 할당은 포인터를 한 칸 미는 것만큼 싸진다. 대신 객체의 주소가 바뀌므로, 그 객체를 가리키던 모든 포인터를 고쳐야 한다 — 수집기가 모든 포인터의 자리를 알아야 할 수 있는 일이다.

58.6.3 세대별 수집 — 대부분의 객체는 일찍 죽는다#

실제 프로그램을 재어 보면 되풀이되는 관찰이 있다. 대부분의 객체는 만들어지고 얼마 안 가 죽고, 오래 산 객체는 계속 오래 산다. 이것을 세대 가설(generational hypothesis)이라 부른다. 세대별 수집(generational collection)은 이 관찰에 기댄다. 새 객체는 어린 세대에 두고 자주, 싸게 수집한다. 몇 번의 수집을 살아남은 객체는 늙은 세대로 올리고 드물게 수집한다. 매번 기억 전체를 훑지 않아도 되므로 수집 한 번이 짧아진다. 표시-쓸기 시연이 찍은 「survived」 수가 이 승급의 재료다 — 여러 번 살아남은 a 는 늙은 세대로 갈 후보다.

치르는 비용은 세대를 건너는 포인터다. 늙은 객체가 어린 객체를 가리키면, 어린 세대만 수집할 때 그 포인터를 뿌리처럼 보아야 한다. 그래서 수집기는 늙은 객체에 포인터를 써 넣는 순간마다 그것을 기록해 두는 쓰기 장벽(write barrier)을 두고, 기록을 기억 집합(remembered set)에 모은다. 포인터 쓰기마다 몇 개의 명령이 더 붙는 셈이다. CPython 의 순환 수집기도 3.13 까지는 객체를 세 세대로 나누어 다뤘다(3.14 의 증분 수집기는 세대가 둘이다).

58.6.4 증분 수집과 동시 수집#

수집기가 일하는 동안 프로그램을 멈추면(stop-the-world) 응답이 끊긴다. 증분 수집(incremental)은 그 일을 잘게 나누어 프로그램 사이사이에 끼워 넣고, 동시 수집(concurrent)은 다른 스레드에서 프로그램과 함께 돈다. Go 의 수집기는 표시-쓸기 방식이고, 일의 대부분을 프로그램과 동시에 하며, 객체를 옮기지 않는다.1 함께 돈다는 것은 수집기가 표시하는 사이에 프로그램이 포인터를 바꿀 수 있다는 뜻이라, 여기서도 쓰기 장벽이 필요하다.

58.7 수집기의 비용#

수집기는 사람에게서 「언제 돌려주는가」를 빼앗아 간다. 그 대가로 받아 가는 것이 있다.

비용무엇인가누구에게 크게 걸리나
멈춤과 지연수집이 도는 동안 응답이 늦어진다 — 동시 수집도 0 으로 만들지는 못한다지연이 중요한 서버, 게임, 오디오
메모리 여유다음 수집까지 쓰레기가 쌓일 자리가 필요하다기억이 작은 기계, 컨테이너
종료자의 불확실성객체가 언제 거둬질지 모르므로, 거둘 때 파일을 닫는 코드는 언제 돌지 모른다파일·소켓·잠금 같은 자원
수집기가 있어도 새는 누수닿을 수 있지만 다시 쓰지 않는 것 — 계속 자라는 캐시, 떼지 않은 이벤트 처리기오래 도는 모든 프로그램
참조 계수의 순환수집기 없이 계수만 쓰면 순환은 끝내 남는다Swift, Rc 를 쓰는 Rust

표 58.2 — 자동 메모리 관리가 받아 가는 것

둘째 줄은 수치로 볼 수 있다. Go 의 기본 설정(GOGC=100)은 살아 있는 힙(과 뿌리)만큼을 더 할당하면 다음 수집을 시작한다 — 살아 있는 객체가 8 MiB 라면 그만큼의 쓰레기가 더 쌓일 자리를 둔다는 뜻이다. GOGC 를 두 배(200)로 올리면 메모리 여유도 두 배가 되고 수집에 드는 CPU 는 대략 절반이 된다. 수집기의 비용은 시간과 공간 가운데 어디에 낼지를 고르는 일이다.

실제 사례. 2분마다 솟는 지연 — Discord 의 Read States(2020)

메신저 Discord 에는 사용자마다 「어느 채널의 어느 메시지까지 읽었는가」를 기억하는 서비스(Read States)가 있었다. Go 로 쓴 이 서비스는 대부분의 시간 동안 빨랐는데, 2분쯤마다 지연과 CPU 사용량이 크게 솟았다. 원인은 Go 의 수집기였다. 당시 Go 는 힙이 자라지 않아도 적어도 2분마다 한 번 수집을 강제했고, 이 서비스는 거대한 LRU 캐시를 기억에 들고 있었다. 수집기는 그 캐시 전체를 훑어야 했다 — 캐시 안의 객체는 대부분 쓰레기가 아니었는데도.

팀은 같은 서비스를 Rust 로 다시 썼다. Rust 에는 수집기가 없고, 캐시에서 빠지는 항목은 그 순간 돌려진다. 다시 쓴 판에서는 지연이 솟지 않았고, 팀은 캐시를 800만 항목으로 늘렸다.2

이 사례가 말하는 것은 「Go 가 나쁘다」가 아니다. 수집기는 오래 사는 큰 자료를 거듭 훑는 비용을 치르고, 세대별 수집이 그 비용을 줄이려는 장치이며, 지연이 제품인 서비스에서는 그 비용이 눈에 보인다는 것이다.

58.8 컴파일러가 소유를 추적하면#

「누가, 언제 돌려주는가」의 넷째 답은 실행 중에 세거나 찾지 않고, 컴파일할 때 돌려줄 자리를 정하는 것이다.

C++ 의 RAII(Resource Acquisition Is Initialization)는 자원을 객체의 수명에 묶는다. 객체가 유효범위를 벗어나면 소멸자가 자원을 돌려주고, 그 자리는 컴파일러가 안다. 앞의 goto out 정리 구역을 컴파일러가 대신 써 주는 셈이다. Rust 는 여기서 한 걸음 더 간다. 값마다 소유자가 하나이고, 소유자가 유효범위를 벗어나면 값이 돌려지며, 남에게 넘기면(move) 옛 이름은 더 쓸 수 없다. 빌려준 참조는 빌려준 값보다 오래 살 수 없다는 것을 컴파일러가 검사한다. 앞 장의 「수명을 어긴 사고」 가운데 해제 후 사용과 이중 해제가, 안전한 Rust 코드에서는 컴파일 오류가 된다. 주석과 이름으로만 적던 46장의 소유 규칙이 이제 타입 체계로 올라간 것이다.

C 에도 이 방향의 시도가 있다. GCC 와 Clang 은 변수가 유효범위를 벗어날 때 함수를 부르게 하는 확장 속성 __attribute__((cleanup(f))) 를 둔다. 표준 쪽에서는 블록이 끝날 때 할 일을 미리 적어 두는 defer 를 기술 명세(ISO/IEC TS 25755)로 만드는 작업이 2025년에 시작되었다 — 표준 본문이 아니라 그 옆의 확장 문서이고, 이 책을 쓰는 지금 C23 에는 없다. 둘 다 소유를 증명하지는 않는다. 정리할 자리를 컴파일러가 대신 기억할 뿐이다.

58.9 C 에 수집기를 붙이면#

C 프로그램에도 수집기를 붙일 수 있다. 가장 널리 알려진 것이 Boehm–Demers–Weiser 수집기다. malloc 을 GC_malloc 으로 바꾸고 free 를 지우면 대부분의 C 프로그램이 그대로 돈다고 소개된다.3

C 에는 「어디가 포인터인가」의 장부가 없으므로, 이 수집기는 보수적(conservative)이다. 스택과 전역과 힙의 워드 가운데 포인터처럼 보이는 값은 모두 포인터로 친다. 우연히 주소처럼 보이는 정수 하나가 큰 객체를 살려 둘 수 있다 — 덜 거두는 쪽으로 틀리는 것이다. 반대 방향의 사고도 있다. 그 수집기의 문서가 직접 경고하는 것은 포인터를 숨기는 프로그램이다. 포인터를 파일에 썼다가 다시 읽는 것, 정렬이 맞지 않는 자리로 memcpy 하는 것, 포인터를 정수로 바꿨다가 되돌리는 것이 그 예다. 수집기가 그 포인터를 못 보면, 아직 쓰이는 객체를 쓰레기로 거둔다.

그 목록은 38장의 프로버넌스 이야기와 그대로 겹친다. 포인터를 정수로 바꾸고, 정수에 비트를 섞고, 다시 포인터로 만드는 기법(4장의 하위 비트 태그)은 수집기의 눈에서도, 출처를 추적하는 컴파일러의 눈에서도 포인터가 사라지는 자리다. C 가 수집기를 기본으로 갖기 어려운 까닭이 이것이다 — 언어가 포인터를 정수처럼 다루는 자유를 주는 한, 「모든 포인터를 찾는다」는 수집기의 전제가 서지 않는다.

문. 그렇다면 C 로 오래 도는 프로그램을 쓸 때는 무엇을 골라야 하는가?

답. 한 가지만 고를 필요는 없다. 대부분의 큰 C 프로그램은 섞어 쓴다. 수명이 뚜렷한 것(요청 하나, 프레임 하나)은 아레나로 묶고, 여러 곳에서 나눠 쓰며 언제 끝날지 모르는 것은 참조 계수를 붙이고, 오래 살면서 자주 가리켜지는 것은 핸들로 가리킨다. 그리고 시험 빌드에는 언제나 ASan 을 켠다(57장). 중요한 것은 객체마다 어느 규칙을 따르는지가 적혀 있는 것이다 — 규칙이 섞이는 것은 괜찮지만, 한 객체에 두 규칙이 걸리는 순간 이중 해제가 생긴다.

흔한 오해. “가비지 컬렉터가 있는 언어에서는 누수가 없다”

수집기가 거두는 것은 닿을 수 없는 객체다. 닿을 수 있는데 다시 쓰이지 않는 객체(지우지 않는 캐시, 떼어 내지 않은 이벤트 처리기, 전역 목록에 쌓이기만 하는 항목)는 수집기의 눈에 살아 있다. 이런 누수는 C 의 누수보다 찾기가 더 어렵기도 하다. 포인터를 잃어버린 것이 아니라 쥐고 있는 것이라, 도구가 「돌려주지 않았다」고 알릴 근거가 없기 때문이다. 수집기는 「돌려주는 때」를 정해 주지만, 「더는 필요 없다」를 판단하는 일은 여전히 프로그래머의 몫이다.

58.10 제9부를 닫으며#

제9부 「깊은 구석들」이 끝났다 — 연산자를 계약으로 읽는 법(52장), 비트를 실제로 부리는 법(53장), 근사를 다루는 법(54장), 실패를 다루는 법(55장), 계약 밖의 세계를 아는 법(56장), 그리고 그 세계에서 가장 자주 일어나는 메모리 사고를 가르고 다스리는 법(57–58장). 일곱 장의 공통 주제는 하나다: C는 프로그래머에게 많은 것을 맡기는 언어이고, 맡겨진 것을 아는 사람이 안전한 코드를 쓴다.

남은 부는 다섯이다. 지금까지의 프로그램은 파일 하나짜리였다 — 이제 여러 파일로 자라고(60장), 전처리와 번역의 층을 정면으로 보고(65장), C 로 객체 지향을 흉내 내는 법을 따져 보고(70장), 표준 라이브러리의 지형을 익히고(76장), proven을 정면으로 다루며(제13부), 모던 C의 관행으로 책을 닫는다(115장).

다음 부는 프로그램을 구성하는 이야기다. 그 첫 장은 지금까지 여섯 줄짜리 관례로만 써 온 자리 — main 자신이다. 세 가지 형태와, 돌려주는 값이 어디로 가는지를 본다.

주

  1. go.dev, 「A Guide to the Go Garbage Collector」. ↩
  2. Jesse Howarth, 「Why Discord is switching from Go to Rust」, Discord 블로그, 2020-02-04. ↩
  3. H. Boehm, 「A garbage collector for C and C++」(hboehm.info/gc). ↩