Go 가비지 컬렉터 가이드

Go 가비지 컬렉터 가이드 (A Guide to the Go Garbage Collector)

이 가이드는 Go 가비지 컬렉터(GC)에 대한 통찰을 제공해서, 고급 Go 사용자가 자신의 애플리케이션 비용을 더 잘 이해할 수 있도록 돕는 것을 목표로 해요. 그리고 이 통찰을 활용해 애플리케이션의 리소스 활용을 개선하는 방법에 대한 지침도 함께 다룹니다. 가비지 컬렉션에 대한 사전 지식은 가정하지 않지만, Go 프로그래밍 언어에는 익숙하다고 가정합니다.

출처: Go 공식 문서

Go 언어는 Go 값의 저장을 스스로 책임집니다. 그래서 대부분의 경우 Go 개발자는 이 값들이 어디에, 왜 저장되는지 신경 쓸 필요가 없어요. 하지만 실제로는 이 값들이 컴퓨터 물리 메모리에 저장되어야 하는 경우가 많고, 물리 메모리는 유한한 자원입니다. 유한하기 때문에 메모리는 신중하게 관리되고 재활용되어야 Go 프로그램 실행 중에 메모리가 바닥나는 일을 피할 수 있어요. 필요에 따라 메모리를 할당하고 재활용하는 것이 Go 구현의 몫입니다.

소개 (Introduction)

메모리를 자동으로 재활용하는 것을 달리 부르면 가비지 컬렉션이라고 해요. 높은 수준에서 보면 가비지 컬렉터(줄여서 GC)는 더 이상 필요하지 않은 메모리 부분을 식별해서 애플리케이션을 대신해 메모리를 재활용하는 시스템입니다. Go 표준 툴체인은 모든 애플리케이션에 딸려 나오는 런타임 라이브러리를 제공하며, 이 런타임 라이브러리 안에 가비지 컬렉터가 포함돼 있어요.

이 가이드가 설명하는 가비지 컬렉터의 존재는 Go 명세에서 보장하는 것이 아니라는 점을 알아두세요. 명세가 보장하는 것은 Go 값의 기반 저장소가 언어 자체에 의해 관리된다는 것뿐입니다. 이런 생략은 의도적이며, 완전히 다른 메모리 관리 기법을 쓸 수 있게 해 줍니다.

따라서 이 가이드는 Go 프로그래밍 언어의 특정 구현에 관한 것이고, 다른 구현에는 적용되지 않을 수 있어요. 구체적으로 이 가이드는 표준 툴체인(gc Go 컴파일러와 도구들)에 적용됩니다. Gccgo와 Gollvm은 매우 유사한 GC 구현을 사용하므로 같은 개념 대부분이 적용되지만, 세부 사항은 다를 수 있습니다.

게다가 이 문서는 살아 있는 문서(living document)로, Go의 최신 릴리스를 가장 잘 반영하도록 시간이 지나면서 바뀔 거예요. 현재 이 문서는 Go 1.19 기준의 가비지 컬렉터를 설명합니다.

Go 값이 사는 곳 (Where Go Values Live)

GC에 뛰어들기 전에, 먼저 GC가 관리할 필요가 없는 메모리에 대해 이야기해 볼게요.

예를 들어 지역 변수(local variable)에 저장된 포인터가 아닌 Go 값은 Go GC가 전혀 관리하지 않을 가능성이 큽니다. Go는 대신 그 값이 만들어진 어휘적 범위(lexical scope)에 묶인 메모리를 할당하도록 합니다. 일반적으로 이는 GC에 의존하는 것보다 더 효율적이에요. Go 컴파일러가 그 메모리를 언제 해제할 수 있는지 미리 알 수 있어서, 정리(cleanup)를 수행하는 기계 명령을 생성할 수 있기 때문입니다. 이런 식으로 Go 값에 메모리를 할당하는 것을 보통 "스택 할당(stack allocation)"이라고 부르는데, 그 공간이 고루틴 스택에 저장되기 때문이에요.

Go 컴파일러가 그 수명을 결정할 수 없어서 이런 방식으로 메모리를 할당할 수 없는 Go 값은 힙(heap)으로 탈출(escape)한다고 말합니다. "힙"은 메모리 할당의 종합적인 수용처로 생각할 수 있어요. Go 값이 어딘가에 놓여야 할 때 쓰이는 곳이죠. 힙에 메모리를 할당하는 행위를 보통 "동적 메모리 할당(dynamic memory allocation)"이라고 해요. 컴파일러와 런타임 모두 이 메모리가 어떻게 사용되고 언제 정리될 수 있는지에 대해 거의 가정할 수 없기 때문입니다. 바로 여기서 GC가 등장합니다. GC는 동적 메모리 할당을 구체적으로 식별하고 정리하는 시스템이에요.

Go 값이 힙으로 탈출해야 하는 이유는 여러 가지가 있어요. 한 가지 이유는 그 크기가 동적으로 결정되기 때문이에요. 예를 들어 초기 크기가 상수가 아니라 변수로 결정되는 슬라이스의 백킹 배열(backing array)을 생각해 보세요. 힙으로의 탈출은 전이적(transitive)이어야 한다는 점도 주목하세요. 이미 탈출한다고 결정된 다른 Go 값에 어떤 Go 값의 참조가 쓰이면, 그 값도 반드시 탈출해야 합니다.

Go 값이 탈출하는지 여부는 값이 사용되는 문맥과 Go 컴파일러의 탈출 분석(escape analysis) 알고리즘에 달려 있어요. 값이 정확히 언제 탈출하는지 하나하나 열거하려는 시도는 취약하고 어려울 거예요. 그 알고리즘 자체가 꽤 정교하고 Go 릴리스마다 바뀌거든요. 어떤 값이 탈출하고 어떤 값이 탈출하지 않는지 식별하는 방법에 대한 자세한 내용은 힙 할당 제거 섹션을 참고하세요.

추적 가비지 컬렉션 (Tracing Garbage Collection)

가비지 컬렉션은 메모리를 자동으로 재활용하는 여러 방법을 가리킬 수 있어요. 예를 들어 참조 카운팅(reference counting)처럼요. 이 문서에서 가비지 컬렉션은 추적 가비지 컬렉션(tracing garbage collection)을 가리키며, 이는 포인터를 전이적으로 따라가면서 사용 중인 — 소위 살아 있는(live) — 객체를 식별합니다.

이 용어들을 더 엄밀하게 정의해 볼게요.

  • 객체(Object) — 하나 이상의 Go 값을 담고 있는 동적 할당 메모리 조각.
  • 포인터(Pointer) — 객체 안의 어떤 값이라도 참조하는 메모리 주소. 자연스럽게 *T 형태의 Go 값을 포함하지만, 내장 Go 값의 일부도 포함해요. 문자열, 슬라이스, 채널, 맵, 인터페이스 값 모두 GC가 추적해야 하는 메모리 주소를 담고 있습니다.

객체와 다른 객체로의 포인터는 함께 객체 그래프(object graph)를 이룹니다. 살아 있는 메모리를 식별하기 위해 GC는 프로그램의 루트(roots)에서 시작해 객체 그래프를 걸어 갑니다. 루트는 프로그램이 확실히 사용 중인 객체를 식별하는 포인터들이에요. 루트의 두 예로는 지역 변수와 전역 변수가 있습니다. 객체 그래프를 걸어 가는 과정을 스캐닝(scanning)이라고 해요. Go 문서에서 볼 수 있는 또 다른 표현은 객체가 "도달 가능(reachable)"한지 여부인데, 이는 그 객체가 스캐닝 과정으로 발견될 수 있다는 뜻일 뿐입니다. 한 가지 예외를 제외하면, 메모리가 도달 불가능(unreachable)해지면 그 상태를 계속 유지한다는 점도 알아두세요.

이 기본 알고리즘은 모든 추적 GC에 공통입니다. 추적 GC들이 달라지는 지점은 메모리가 살아 있다는 것을 발견한 뒤 무엇을 하느냐입니다. Go의 GC는 마크-스윕(mark-sweep) 기법을 사용하는데, 이는 진행 상황을 추적하기 위해 GC가 만나는 값을 살아 있다고 마크(mark)한다는 뜻이에요. 추적이 끝나면 GC는 힙의 모든 메모리를 걸어 다니면서 마크되지 않은 모든 메모리를 할당 가능하게 만듭니다. 이 과정을 스위핑(sweeping)이라고 합니다.

여러분이 익숙할 수도 있는 한 가지 대안 기법은 객체를 실제로 메모리의 새 부분으로 옮기고, 나중에 애플리케이션의 모든 포인터를 갱신하는 데 쓰이는 포워딩 포인터(forwarding pointer)를 남겨 두는 것입니다. 이런 식으로 객체를 옮기는 GC를 이동 GC(moving GC)라고 해요. Go는 비이동 GC(non-moving GC)를 갖고 있습니다.

GC 주기 (The GC cycle)

Go GC는 마크-스윕 GC이므로 크게 두 단계로 동작합니다: 마크(mark) 단계와 스윕(sweep) 단계. 이 말이 자명해 보일 수도 있지만, 중요한 통찰을 담고 있어요. 모든 메모리가 추적될 때까지 메모리를 할당으로 되돌려 놓는 것이 가능하지 않다는 점입니다. 객체를 살아 있게 하는, 아직 스캔되지 않은 포인터가 여전히 있을 수 있기 때문이에요. 결과적으로 스윕 행위는 마크 행위와 완전히 분리되어야 합니다. 게다가 GC 관련 작업이 없을 때는 GC가 전혀 활성화되지 않을 수도 있어요. GC는 스위핑, 꺼짐(off), 마킹 이렇게 세 단계를 GC 주기(GC cycle)라고 하는 것으로 계속 순환합니다. 이 문서에서는 GC 주기가 스위핑으로 시작해서 꺼지고, 그 다음 마킹되는 것으로 생각해 볼게요.

다음 몇몇 섹션은 GC의 비용에 대한 직관을 키우는 데 초점을 맞춰서, 사용자가 자신의 이익을 위해 GC 파라미터를 조정하는 데 도움이 되도록 할 거예요.

비용 이해하기 (Understanding costs)

GC는 본질적으로 더 복잡한 시스템 위에 세워진 복잡한 소프트웨어예요. GC를 이해하고 그 동작을 조정하려다 보면 쉽게 세부사항에 빠질 수 있습니다. 이 섹션은 Go GC의 비용과 조정 파라미터에 대해 추론하기 위한 프레임워크를 제공하려고 해요.

시작하려면 세 가지 간단한 공리를 바탕으로 한 GC 비용 모델을 생각해 보세요.

  • GC는 두 가지 리소스만 포함합니다: 물리 메모리와 CPU 시간.

  • GC의 메모리 비용은 살아 있는 힙 메모리(live heap memory), 마크 단계 전에 할당된 새 힙 메모리(new heap memory), 그리고 메타데이터 공간으로 구성됩니다. 메타데이터는 이전 비용들에 비례하긴 하지만 비교하면 작아요.

    GC 메모리 비용 (사이클 N) = 이전 사이클(N-1)의 live heap + 새 힙(new heap)
    

    살아 있는 힙 메모리는 이전 GC 주기가 살아 있다고 결정한 메모리이고, 새 힙 메모리는 현재 주기에서 할당된 메모리로, 마지막에 살아 있을 수도 있고 아닐 수도 있어요. 어떤 시점에 얼마나 많은 메모리가 살아 있는지는 프로그램의 속성이고, GC가 직접 통제할 수 있는 것이 아닙니다.

  • GC의 CPU 비용은 주기당 고정 비용과, 살아 있는 힙의 크기에 비례해 늘어나는 한계 비용(marginal cost)으로 모델링됩니다.

    GC CPU 시간 (사이클 N) = 주기당 고정 CPU 시간 비용 + 바이트당 평균 CPU 시간 비용 * 사이클 N에서 발견된 live heap
    

    주기당 고정 CPU 시간 비용은 다음 GC 주기를 위한 데이터 구조 초기화처럼 각 주기에 일정한 횟수로 일어나는 일들을 포함합니다. 이 비용은 대개 작고, 완전성을 위해 포함된 것입니다.

    GC의 CPU 비용 대부분은 마킹과 스캐닝인데, 이는 한계 비용에 포착됩니다. 마킹·스캐닝의 평균 비용은 GC 구현에 달려 있지만 프로그램의 동작에도 달려 있어요. 예를 들어 포인터가 더 많다는 것은 GC 작업이 더 많다는 뜻입니다. 왜냐하면 최소한 GC는 프로그램의 모든 포인터를 방문해야 하기 때문이에요. 연결 리스트(linked list)나 트리 같은 구조는 GC가 병렬로 걸어 다니기 더 어려워서 바이트당 평균 비용을 높입니다.

    이 모델은 스위핑 비용을 무시하는데, 스위핑 비용은 죽은 메모리(할당 가능하게 만들어져야 하는)를 포함한 전체 힙 메모리에 비례합니다. Go의 현재 GC 구현에서 스위핑은 마킹·스캐닝보다 훨씬 빨라서, 비교하면 비용이 무시할 만합니다.

이 모델은 단순하지만 효과적이에요. GC의 지배적인 비용을 정확히 분류해 줍니다. 또 이 모델은 가비지 컬렉터의 총 CPU 비용이 주어진 시간대에 발생한 총 GC 주기 수에 달려 있음을 알려 줍니다. 마지막으로 이 모델에는 GC의 근본적인 시간/공간 트레이드오프가 내장돼 있어요.

왜 그런지 보려면 제약이 있지만 유용한 시나리오, 즉 정상 상태(steady state)를 살펴보겠습니다. GC의 관점에서 애플리케이션의 정상 상태는 다음 속성으로 정의돼요:

  • 애플리케이션이 새 메모리를 할당하는 비율(초당 바이트)이 일정하다. 이는 GC 관점에서 애플리케이션의 워크로드가 시간에 따라 대략 동일해 보인다는 뜻이에요. 예를 들어 웹 서비스의 경우, 평균적으로 같은 종류의 요청이 들어오고 각 요청의 평균 수명이 대략 일정하게 유지되는 일정한 요청 비율이 될 거예요.
  • GC의 한계 비용이 일정하다. 이는 객체 크기 분포, 포인터 수, 데이터 구조의 평균 깊이 같은 객체 그래프의 통계가 주기마다 동일하게 유지된다는 뜻이에요.

한 예를 함께 풀어 보겠습니다. 어떤 애플리케이션이 정상 상태로 동작하면서 10 MiB/s를 할당하고, GC는 메모리를 100 MiB/cpu-second 비율로 스캔할 수 있다고 가정해 볼게요(이 값은 가상입니다). 정상 상태는 살아 있는 힙의 크기에 대해 가정하지 않지만, 간단히 이 애플리케이션의 live heap이 항상 10 MiB라고 해 봅시다. 고정 GC 비용이 0이라고도 가정하고요. GC 주기 주기를 가지고 놀아 보겠습니다.

각 GC 주기가 정확히 1 cpu-second마다 일어난다고 가정해 봅시다. 그러면 각 GC 주기가 끝날 때마다 예시 애플리케이션은 10 MiB의 추가 메모리를 할당해서 총 힙 크기는 20 MiB가 돼요. 그리고 매 GC 주기마다 GC는 10 MiB의 live heap을 스캔하는 데 0.1 cpu-second를 쓰므로, CPU 오버헤드는 10%가 됩니다. GC는 전체 힙이 아니라 live heap만 걸어 다니면 된다는 점을 기억하세요. (참고: 일정한 live heap이 모든 새 할당 메모리가 죽는다는 뜻은 아니에요. GC 실행 후에 새 힙과 기존 힙 메모리의 어떤 조합이 죽고, 결과적으로 매 주기 10 MiB가 살아 있는 것으로 발견된다는 뜻일 뿐입니다.)

이제 각 GC 주기가 덜 자주, 매 2 cpu-second마다 일어난다고 가정해 봅시다. 그러면 정상 상태의 예시 애플리케이션은 그 시간에 20 MiB를 할당하므로 매 GC 주기마다 총 힙 크기가 30 MiB가 돼요. 하지만 매 GC 주기마다 GC는 여전히 10 MiB의 살아 있는 메모리를 스캔하는 데 0.1 cpu-second만 필요합니다. 다시, 얼마나 많은 메모리가 할당되는지와 무관하게 live heap 크기는 같다고 가정하고 있어요. 그렇다면 GC 오버헤드는 10%에서 5%로 줄어들었는데, 그 대가로 사용하는 메모리가 50% 더 늘어난 셈이에요.

이 오버헤드의 변화가 앞서 언급한 근본적인 시간/공간 트레이드오프입니다. 그리고 GC 빈도가 이 트레이드오프의 중심에 있어요. GC를 더 자주 실행하면 메모리를 덜 쓰고, 그 반대도 마찬가지입니다. 그런데 GC는 실제로 얼마나 자주 실행될까요? Go에서 GC를 언제 시작할지 결정하는 것이 사용자가 통제할 수 있는 주요 파라미터예요.

GOGC

높은 수준에서 GOGC는 GC CPU와 메모리 사이의 트레이드오프를 결정합니다.

그것은 각 GC 주기 이후의 목표 힙 크기(target heap size), 즉 다음 주기의 총 힙 크기에 대한 목표 값을 결정함으로써 동작해요. GC의 목표는 총 힙 크기가 목표 힙 크기를 초과하기 전에 컬렉션 주기를 끝내는 것입니다. 총 힙 크기는 이전 주기 끝의 live heap 크기와, 이전 주기 이후 애플리케이션이 할당한 새 힙 메모리의 합으로 정의됩니다. 한편 목표 힙 메모리는 다음과 같이 정의돼요:

목표 힙 메모리 = Live heap + (Live heap + GC roots) * GOGC / 100

예를 들어 live heap 크기가 8 MiB이고, 고루틴 스택이 1 MiB, 전역 변수의 포인터가 1 MiB인 Go 프로그램을 생각해 보세요. 그러면 GOGC 값이 100일 때, 다음 GC가 실행되기 전에 할당될 새 메모리의 양은 10 MiB(10 MiB 작업의 100%)이고 총 힙 풋프린트는 18 MiB가 돼요. GOGC 값이 50이면 50%, 즉 5 MiB이고요. GOGC 값이 200이면 200%, 즉 20 MiB입니다.

참고: GOGC는 Go 1.18부터만 루트 집합을 포함합니다. 이전에는 live heap만 세었어요. 흔히 고루틴 스택의 메모리 양은 아주 작고 live heap 크기가 다른 모든 GC 작업 소스를 압도하지만, 수십만 개의 고루틴을 가진 프로그램의 경우 GC가 잘못된 판단을 내리고 있었습니다.

힙 목표는 GC 빈도를 통제해요. 목표가 클수록 GC는 다른 마크 단계를 시작하기를 더 오래 기다릴 수 있고, 그 반대도 마찬가지입니다. 정확한 공식은 추정에 유용하지만, GOGC를 근본적인 목적의 관점에서 생각하는 것이 최선이에요. 즉 GC CPU와 메모리 트레이드오프에서 한 지점을 고르는 파라미터라는 뜻입니다. 핵심 요점은 GOGC를 두 배로 하면 힙 메모리 오버헤드가 두 배가 되고 GC CPU 비용이 대략 절반이 되며, 그 반대도 성립한다는 것입니다. (왜 그런지 전체 설명을 보려면 부록을 참고하세요.)

참고: 목표 힙 크기는 그저 목표일 뿐이며, GC 주기가 정확히 그 목표에서 끝나지 않을 이유가 여럿 있습니다. 첫째, 충분히 큰 힙 할당은 단순히 그 목표를 초과할 수 있어요. 하지만 다른 이유들이 이 가이드가 지금까지 사용해 온 GC 모델을 넘어서는 GC 구현에 나타납니다. 좀 더 자세한 내용은 지연 시간 섹션을 참고하되, 완전한 세부사항은 추가 리소스에서 찾을 수 있어요.

GOGC는 모든 Go 프로그램이 인식하는 GOGC 환경 변수나, runtime/debug 패키지의 SetGCPercent API를 통해 설정할 수 있습니다.

GOGC=off로 설정하거나 SetGCPercent(-1)을 호출하면(메모리 한도가 적용되지 않는 경우) GC를 완전히 끌 수 있다는 점도 기억하세요. 개념적으로 이 설정은 GOGC를 무한대 값으로 설정하는 것과 동등합니다. GC가 촉발되기 전의 새 메모리 양에 상한이 없기 때문이에요.

지금까지 논의한 모든 것을 더 잘 이해하려면, 앞서 논의한 GC 비용 모델을 바탕으로 만들어진 아래의 대화형 시각화를 시도해 보세요.

이 시각화는 GC가 아닌 작업이 완료하는 데 10초의 CPU 시간이 걸리는 어떤 프로그램의 실행을 묘사합니다. 첫 1초 동안은 정상 상태에 들어가기 전에 어떤 초기화 단계(live heap 늘리기)를 수행해요. 애플리케이션은 총 200 MiB를 할당하고, 한 번에 20 MiB가 살아 있습니다. 완료해야 할 유일한 관련 GC 작업이 live heap에서 온다고 가정하며, (비현실적으로) 애플리케이션이 추가 메모리를 사용하지 않는다고 가정합니다.

슬라이더를 사용해 GOGC 값을 조정해서, 총 지속 시간과 GC 오버헤드 측면에서 애플리케이션이 어떻게 반응하는지 확인해 보세요. 각 GC 주기는 새 힙이 0으로 떨어질 때 끝납니다. 새 힙이 0으로 떨어지는 데 걸린 시간은 사이클 N의 마크 단계와 사이클 N+1의 스윕 단계를 합친 시간입니다. 이 시각화(그리고 이 가이드의 모든 시각화)는 GC가 실행되는 동안 애플리케이션이 일시 정지된다고 가정하므로, GC CPU 비용은 새 힙 메모리가 0으로 떨어지는 데 걸리는 시간으로 완전히 대표됩니다. 이는 단지 시각화를 단순화하기 위한 것이며, 같은 직관이 여전히 적용돼요. X 축은 프로그램의 전체 CPU 시간 지속 시간을 항상 보여주도록 이동합니다. GC가 사용하는 추가 CPU 시간이 전체 지속 시간을 늘린다는 점을 주목하세요.

GC는 항상 어느 정도의 CPU와 최고(peak) 메모리 오버헤드를 발생시킨다는 점을 주목하세요. GOGC가 증가하면 CPU 오버헤드는 감소하지만, 최고 메모리는 live heap 크기에 비례해 증가합니다. GOGC가 감소하면 최고 메모리 요구량은 추가 CPU 오버헤드의 대가로 감소해요.

참고: 그래프는 프로그램을 완료하는 벽시계 시간(wall-clock time)이 아니라 CPU 시간을 표시합니다. 프로그램이 1개의 CPU에서 실행되고 그 리소스를 완전히 활용한다면 이 둘은 동등해요. 실제 프로그램은 다중 코어 시스템에서 실행되고 항상 100% CPU를 활용하지는 않을 가능성이 높습니다. 이런 경우 GC의 벽시간(wall-time) 영향은 더 낮아질 거예요.

참고: Go GC는 최소 총 힙 크기가 4 MiB이므로, GOGC가 설정한 목표가 그 아래로 내려가면 올림 처리됩니다. 시각화가 이 세부사항을 반영합니다.

여기 좀 더 역동적이고 현실적인 또 다른 예시가 있어요. 다시 한 번 애플리케이션은 GC 없이 완료하는 데 10 CPU-초가 걸리지만, 정상 상태 할당 비율이 중간에 극적으로 증가하고 live heap 크기도 첫 단계에서 조금 움직입니다. 이 예시는 live heap 크기가 실제로 변할 때 정상 상태가 어떻게 보일 수 있는지, 그리고 더 높은 할당 비율이 어떻게 더 빈번한 GC 주기로 이어지는지를 보여줍니다.

메모리 한도 (Memory limit)

Go 1.19까지 GOGC는 GC의 동작을 변경하는 데 쓸 수 있는 유일한 파라미터였어요. 트레이드오프를 설정하는 방법으로는 훌륭하게 동작하지만, 사용 가능한 메모리가 유한하다는 점을 고려하지 않습니다. live heap 크기에 일시적인 급증(spike)이 있을 때 무슨 일이 일어나는지 생각해 보세요. GC는 그 live heap 크기에 비례하는 총 힙 크기를 고르기 때문에, 평소에는 더 높은 GOGC 값이 더 나은 트레이드오프를 제공하더라도 최고 live heap 크기를 위해 GOGC를 그런 값으로 구성해야 합니다.

이 일시적 힙 급증 상황을 보여주는 시각화는 아래와 같습니다.

예시 워크로드가 60 MiB가 조금 넘는 메모리가 있는 컨테이너에서 실행된다면, 나머지 GC 주기들이 그 추가 메모리를 활용할 여유가 있음에도 GOGC를 100을 넘게 올릴 수 없어요. 게다가 일부 애플리케이션에서는 이런 일시적 최고점이 드물고 예측하기 어려워서, 드물고 피할 수 없으며 잠재적으로 비용이 큰 메모리 부족(out-of-memory) 상황으로 이어질 수 있어요.

그래서 Go는 1.19 릴리스에서 런타임 메모리 한도를 설정하는 지원을 추가했어요. 메모리 한도는 모든 Go 프로그램이 인식하는 GOMEMLIMIT 환경 변수나, runtime/debug 패키지의 SetMemoryLimit 함수를 통해 설정할 수 있습니다.

이 메모리 한도는 Go 런타임이 사용할 수 있는 총 메모리 양의 최대치를 설정합니다. 포함되는 메모리의 구체적인 집합은 runtime.MemStats의 관점에서 다음 표현식으로 정의됩니다.

Sys - HeapReleased

또는 동등하게 runtime/metrics 패키지의 관점에서,

/memory/classes/total:bytes - /memory/classes/heap/released:bytes

Go GC는 힙 메모리를 얼마나 사용할지에 대해 명시적인 통제권이 있으므로, 이 메모리 한도와 Go 런타임이 사용하는 다른 메모리 양에 기반해 총 힙 크기를 설정합니다.

아래 시각화는 GOGC 섹션의 동일한 단일 단계 정상 상태 워크로드를 묘사하지만, 이번에는 Go 런타임의 추가 10 MiB 오버헤드가 있고 조정 가능한 메모리 한도가 있습니다. GOGC와 메모리 한도를 모두 움직여 보면서 무슨 일이 일어나는지 확인해 보세요.

메모리 한도가 GOGC가 결정하는 최고 메모리(GOGC 100일 때 42 MiB) 아래로 내려가면, GC가 더 자주 실행되어 최고 메모리를 한도 안에 유지한다는 점을 주목하세요.

이전의 일시적 힙 급증 예시로 돌아가서, 메모리 한도를 설정하고 GOGC를 올리면 두 가지 모두의 장점, 즉 메모리 한도 위반 없음과 더 나은 리소스 경제성을 얻을 수 있어요. 아래 대화형 시각화를 시도해 보세요.

특정 GOGC와 메모리 한도 값에서 최고 메모리 사용량이 메모리 한도가 무엇이든 그 곳에서 멈추지만, 프로그램의 나머지 실행은 여전히 GOGC가 설정한 총 힙 크기 규칙을 따른다는 점을 주목하세요.

이 관찰은 또 하나의 흥미로운 세부사항으로 이어집니다. GOGC가 off로 설정돼 있어도 메모리 한도는 여전히 존중된다는 점이에요! 사실 이 특정 구성은 어떤 메모리 한도를 유지하는 데 필요한 최소 GC 빈도를 설정하므로, 리소스 경제성을 극대화한 것을 나타냅니다. 이 경우 프로그램의 전체 실행에서 힙 크기가 메모리 한도를 충족하도록 올라갑니다.

이제 메모리 한도가 분명히 강력한 도구이지만, 메모리 한도의 사용에도 비용이 따르며 GOGC의 유용성을 무효화하지는 않습니다.

live heap이 커져서 총 메모리 사용을 메모리 한도에 가깝게 만들 때 무슨 일이 일어나는지 생각해 보세요. 위의 정상 상태 시각화에서 GOGC를 끈 다음 메모리 한도를 천천히 점점 더 낮춰 보면서 무슨 일이 일어나는지 확인해 보세요. 애플리케이션이 걸리는 총 시간이 무한한 방식으로 늘어나기 시작한다는 점을 주목하세요. GC가 불가능한 메모리 한도를 유지하기 위해 끊임없이 실행되고 있기 때문입니다.

상수 GC 주기로 인해 프로그램이 합리적인 진전을 이루지 못하는 이 상황을 스래싱(thrashing)이라고 해요. 이는 프로그램을 사실상 정지시키기 때문에 특히 위험합니다. 더 나쁜 것은, GOGC로 피하려 했던 바로 그 상황에서도 일어날 수 있다는 점이에요. 충분히 큰 일시적 힙 급증은 프로그램을 무한정 정지시킬 수 있습니다! 일시적 힙 급증 시각화에서 메모리 한도를 줄여 보면서(대략 30 MiB 이하) 최악의 동작이 특히 힙 급증과 함께 시작되는지 주목해 보세요.

많은 경우 무한정 정지(스래싱)는 메모리 부족 상태보다 더 나쁩니다. 메모리 부족은 훨씬 빠른 실패로 이어지는 경향이 있기 때문이에요.

이런 이유로 메모리 한도는 소프트(soft)한 것으로 정의됩니다. Go 런타임은 모든 상황에서 이 메모리 한도를 유지하겠다고 보장하지 않아요. 단지 합리적인 정도의 노력만을 약속할 뿐입니다. 메모리 한도의 이 완화는 스래싱 동작을 피하는 데 중요해요. GC에 빠져나갈 길, 즉 메모리 사용이 한도를 넘게 두어 GC에 너무 많은 시간을 쓰지 않게 하는 길을 주기 때문입니다.

내부적으로 이는 GC가 어떤 시간 창에 걸쳐 사용할 수 있는 CPU 시간의 양에 상한을 설정함으로써 동작해요(아주 짧은 순간적인 CPU 사용 급증에는 약간의 히스테리시스(hysteresis)가 있음). 이 한도는 현재 대략 50%로 설정되어 있으며, 2 * GOMAXPROCS CPU-초 창을 가집니다. GC CPU 시간을 제한하는 결과는 GC의 작업이 지연된다는 것이고, 그 사이 Go 프로그램은 메모리 한도를 넘어서까지 새 힙 메모리를 계속 할당할 수 있어요.

50% GC CPU 한도 뒤의 직관은 여유 메모리가 충분한 프로그램에 대한 최악의 경우 영향에 기반해요. 메모리 한도가 잘못 구성돼서 실수로 너무 낮게 설정된 경우, GC가 CPU 시간의 50% 이상을 가져갈 수 없으므로 프로그램은 기껏해야 2배로 느려집니다.

참고: 이 페이지의 시각화들은 GC CPU 한도를 시뮬레이션하지 않습니다.

권장 사용법 (Suggested uses)

메모리 한도는 강력한 도구이고 Go 런타임은 오용으로 인한 최악의 동작을 완화하는 조치를 취하지만, 여전히 신중하게 사용하는 것이 중요해요. 아래는 메모리 한도가 가장 유용하고 적용 가능한 곳과, 해로울 수 있는 곳에 대한 조언 모음입니다.

  • Do: Go 프로그램의 실행 환경이 전적으로 여러분의 통제 안에 있고, Go 프로그램이 어떤 리소스 집합에 접근할 수 있는 유일한 프로그램일 때(예: 컨테이너 메모리 한도 같은 일종의 메모리 예약) 메모리 한도를 활용하세요. 좋은 예는 고정된 사용 가능 메모리가 있는 컨테이너에 웹 서비스를 배포하는 것입니다. 이 경우 경험 법칙으로, Go 런타임이 알지 못하는 메모리 원천을 감안하기 위해 추가로 5-10%의 헤드룸(headroom)을 남겨 두는 것이 좋아요.
  • Do: 변화하는 조건에 적응하기 위해 메모리 한도를 실시간으로 조정해도 좋습니다. 좋은 예는 C 라이브러리가 일시적으로 훨씬 더 많은 메모리를 사용해야 하는 cgo 프로그램입니다.
  • Don't: Go 프로그램이 제한된 메모리 일부를 다른 프로그램과 공유할 수 있고, 그 프로그램들이 일반적으로 Go 프로그램과 분리되어 있다면, 메모리 한도와 함께 GOGC를 off로 설정하지 마세요. 대신 메모리 한도는 유지하되(바람직하지 않은 일시적 동작을 억제하는 데 도움이 될 수 있으므로), GOGC는 평균적인 경우에 대해 더 작고 합리적인 값으로 설정하세요. 공동 테넌트(co-tenant) 프로그램을 위해 메모리를 "예약"하려고 하고 싶을 수도 있지만, 프로그램들이 완전히 동기화되어 있지 않는 한(예: Go 프로그램이 어떤 하위 프로세스를 호출하고 그 호출자가 실행되는 동안 블록한다면) 결과는 덜 신뢰할 수 있어요. 불가피하게 두 프로그램 모두 더 많은 메모리가 필요할 테니까요. Go 프로그램이 필요하지 않을 때 더 적은 메모리를 사용하게 두는 것이 전반적으로 더 신뢰할 수 있는 결과를 만들어 냅니다. 이 조언은 한 머신에서 실행되는 컨테이너들의 메모리 한도 합이 그 머신의 실제 물리 메모리를 초과할 수 있는 오버커밋(overcommit) 상황에도 적용돼요.
  • Don't: 통제하지 않는 실행 환경에 배포할 때 메모리 한도를 사용하지 마세요. 특히 프로그램의 메모리 사용이 입력에 비례할 때는 더욱 그렇습니다. 좋은 예는 CLI 도구나 데스크톱 애플리케이션입니다. 어떤 종류의 입력이 주어질지, 시스템에서 얼마나 많은 메모리를 사용할 수 있을지 불분명한데 메모리 한도를 프로그램에 넣어 두면 혼란스러운 크래시와 나쁜 성능으로 이어질 수 있어요. 게다가 고급 최종 사용자는 원한다면 언제든 메모리 한도를 설정할 수 있습니다.
  • Don't: 프로그램이 이미 환경의 메모리 한도에 가까울 때 메모리 부족을 피하려고 메모리 한도를 설정하지 마세요. 이는 메모리 부족 위험을 심각한 애플리케이션 속도 저하 위험으로 사실상 대체하는 것이며, Go가 스래싱을 완화하려고 노력하더라도 종종 유리한 교환이 아닙니다. 그런 경우에는 환경의 메모리 한도를 늘리거나(그다음 잠재적으로 메모리 한도를 설정하거나) GOGC를 낮추는(스래싱 완화보다 훨씬 깔끔한 트레이드오프를 제공) 것이 훨씬 더 효과적입니다.

지연 시간 (Latency)

이 문서의 시각화들은 GC가 실행되는 동안 애플리케이션이 일시 정지된 것으로 모델링했습니다. 실제로 GC 구현 중 그런 식으로 동작하는 것들이 있으며, 이를 "stop-the-world" GC라고 해요.

하지만 Go GC는 완전히 stop-the-world가 아니고, 대부분의 작업을 애플리케이션과 동시에 수행합니다. 이는 주로 애플리케이션의 지연 시간(latency)을 줄이기 위해서예요. 구체적으로는 단일 계산 단위(예: 웹 요청)의 종단 간 지속 시간을 줄이기 위해서입니다. 지금까지 이 문서는 주로 애플리케이션의 처리량(throughput)을 고려했습니다(예: 초당 처리되는 웹 요청 수). GC 주기 섹션의 각 예시가 실행 중인 프로그램의 총 CPU 지속 시간에 초점을 맞췄다는 점을 기억하세요. 하지만 그런 지속 시간은 예를 들어 웹 서비스에게는 훨씬 덜 의미가 있어요. 처리량이 웹 서비스에 여전히 중요하지만(즉 초당 쿼리 수), 종종 각 개별 요청의 지연 시간이 훨씬 더 중요합니다.

지연 시간 측면에서 stop-the-world GC는 마크와 스윕 단계를 모두 실행하는 데 상당한 시간이 필요할 수 있으며, 그 동안 애플리케이션(웹 서비스의 맥락에서 진행 중인 어떤 요청도)은 더 이상 진전을 이루지 못합니다. 대신 Go GC는 어떤 전역 애플리케이션 일시 정지의 길이도 힙 크기에 비례하게 만들지 않도록 하며, 핵심 추적 알고리즘은 애플리케이션이 활발히 실행되는 동안 수행됩니다. (일시 정지는 알고리즘적으로 GOMAXPROCS에 더 강하게 비례하지만, 가장 흔하게는 실행 중인 고루틴을 멈추는 데 걸리는 시간이 지배합니다.) 동시에 수집하는 것도 비용이 없지는 않습니다. 실제로는 동등한 stop-the-world 가비지 컬렉터보다 낮은 처리량을 가진 설계로 이어지는 경우가 많아요. 하지만 더 낮은 지연 시간이 본질적으로 더 낮은 처리량을 의미하지는 않으며, Go 가비지 컬렉터의 성능은 지연 시간과 처리량 양쪽에서 시간이 지나며 꾸준히 개선돼 왔다는 점이 중요합니다.

Go의 현재 GC가 동시적이라는 점은 지금까지 이 문서에서 논의한 어떤 것도 무효화하지 않습니다. 어떤 진술도 이 설계 선택에 의존하지 않았거든요. GC 빈도는 여전히 처리량을 위해 GC가 CPU 시간과 메모리를 맞바꾸는 주요 방식이며, 사실 지연 시간에서도 이 역할을 수행합니다. GC 비용의 대부분이 마크 단계가 활성화된 동안 발생하기 때문이에요.

그럼 핵심 요점은, GC 빈도를 줄이는 것이 지연 시간 개선으로도 이어질 수 있다는 것입니다. 이는 GOGC나 메모리 한도를 높이는 등 조정 파라미터의 수정으로 GC 빈도를 줄이는 것에도 적용되고, 최적화 가이드에서 설명하는 최적화에도 적용됩니다.

하지만 지연 시간은 처리량보다 이해하기 더 복잡한 경우가 많아요. 그것은 프로그램의 순간순간의 실행 결과이지 비용의 집계가 아니기 때문입니다. 결과적으로 지연 시간과 GC 빈도 사이의 연결은 덜 직접적입니다. 더 깊이 파고들고 싶은 사람들을 위해 가능한 지연 시간 원천 목록을 아래에 제시해요.

  • GC가 마크와 스윕 단계 사이를 전환할 때의 짧은 stop-the-world 일시 정지,
  • 마크 단계에서 GC가 CPU 리소스의 25%를 차지하기 때문에 발생하는 스케줄링 지연,
  • 높은 할당 비율에 대응해 사용자 고루틴이 GC를 보조(assist)하는 것,
  • GC가 마크 단계에 있을 때 포인터 쓰기가 추가 작업을 요구하는 것,
  • 루트를 스캔하기 위해 실행 중인 고루틴을 일시 중단해야 하는 것

이 지연 시간 원천들은 추가 작업이 필요한 포인터 쓰기를 제외하면 실행 트레이스에서 볼 수 있습니다.

파이널라이저, 클린업, 약한 포인터 (Finalizers, cleanups, and weak pointers)

가비지 컬렉션은 유한한 메모리만으로 무한한 메모리의 환상을 제공합니다. 메모리는 할당되지만 명시적으로 해제되지 않아서, 기본적인 수동 메모리 관리보다 더 단순한 API와 동시 알고리즘을 가능하게 해요. (수동으로 메모리를 관리하는 일부 언어는 객체가 해제되도록 "스마트 포인터"나 컴파일 타임 소유권 추적 같은 대안 접근을 사용하지만, 이런 기능들은 그 언어들의 API 설계 관례에 깊이 내장돼 있습니다.)

살아 있는 객체 — 전역 변수나 어떤 고루틴의 계산에서 도달 가능한 객체 — 만이 프로그램의 동작에 영향을 줄 수 있어요. 객체가 도달 불가능("죽은")해진 이후의 어떤 시점에도 GC가 그 객체를 안전하게 재활용할 수 있습니다. 이는 오늘날 Go가 사용하는 추적 설계 같은 다양한 GC 설계를 가능하게 해요. 객체의 죽음은 언어 수준에서 관찰 가능한 사건이 아닙니다.

하지만 Go의 런타임 라이브러리는 그 환상을 깨뜨리는 세 가지 기능을 제공합니다: 클린업(cleanups), 약한 포인터(weak pointers), 파이널라이저(finalizers). 이 각 기능은 객체의 죽음을 관찰하고 반응할 방법을 제공하며, 파이널라이저의 경우에는 심지어 그것을 되돌리는 방법까지 제공해요. 이는 물론 Go 프로그램을 복잡하게 만들고 GC 구현에 추가적인 부담을 줍니다. 그럼에도 이런 기능들이 존재하는 이유는 다양한 상황에서 유용하기 때문이며, Go 프로그램은 항상 이들을 사용해 이득을 얻고 있습니다.

각 기능의 세부사항은 해당 패키지 문서(runtime.AddCleanup, weak.Pointer, runtime.SetFinalizer)를 참고하세요. 아래는 이 기능들을 사용하기 위한 일반적인 조언, 각 기능에서 겪을 수 있는 흔한 문제의 개요, 그리고 이 기능들의 사용을 테스트하기 위한 조언입니다.

일반적인 조언 (General advice)

  • 유닛 테스트를 작성하세요. 클린업, 약한 포인터, 파이널라이저의 정확한 시점은 예측하기 어려울 수 있고, 여러 번 연속 실행한 후에도 모든 것이 잘 동작한다고 스스로를 설득하기 쉽습니다. 하지만 미묘한 실수를 저지르기도 쉬워요. 테스트를 작성하는 것은 까다로울 수 있지만, 사용하기가 그렇게 미묘하므로 테스트는 어느 때보다 더 중요합니다.
  • 일반적인 Go 코드에서 이 기능들을 직접 사용하는 것을 피하세요. 이것들은 미묘한 제약과 동작을 가진 저수준 기능이에요. 예를 들어 클린업이나 파이널라이저가 프로그램 종료 시에, 아니면 아예 실행된다는 보장도 없습니다. 그 API 문서의 긴 주석은 경고로 보아야 해요. 대부분의 Go 코드는 이 기능들을 직접 사용해서 이득을 얻지 않고, 간접적으로만 얻습니다.
  • 이 메커니즘들의 사용을 패키지 안에 캡슐화하세요. 가능한 한 이 메커니즘들의 사용이 패키지의 공개 API로 새어 나가게 두지 마세요. 사용자가 오용하기 어렵거나 불가능하게 하는 인터페이스를 제공하세요. 예를 들어 사용자에게 C로 할당된 메모리를 해제하기 위해 클린업을 설정하라고 요청하는 대신, 래퍼 패키지를 작성해서 그 세부사항을 안에 숨기세요.
  • 파이널라이저, 클린업, 약한 포인터가 있는 객체에 대한 접근을, 그것을 만들고 적용한 패키지로 제한하세요. 이는 앞의 사항과 관련되지만, 이 기능들을 더 오류에 덜 취약한 방식으로 사용하기 위한 매우 강력한 패턴이므로 명시적으로 짚고 넘어갈 가치가 있어요. 예를 들어 unique 패키지는 내부에서 약한 포인터를 사용하지만, 약하게 가리켜지는 객체들을 완전히 캡슐화합니다. 그 값들은 애플리케이션의 나머지 부분이 절대 변경할 수 없고, Value 메서드를 통해서만 복사될 수 있어서, 패키지 사용자에게 무한 메모리의 환상을 보존합니다.
  • 가능하면 메모리가 아닌 리소스를 결정론적으로(deterministically) 정리하는 것을 선호하고, 파이널라이저와 클린업은 그 대비책으로 쓰세요. 클린업과 파이널라이저는 C에서처럼 외부에서 할당된 메모리나 mmap 매핑에 대한 참조 같은 메모리 리소스에 잘 맞아요. C의 malloc로 할당된 메모리는 결국 C의 free로 해제되어야 합니다. C 메모리의 래퍼 객체에 붙은, free를 호출하는 파이널라이저는 가비지 컬렉션의 결과로 C 메모리가 결국 회수되도록 보장하는 합리적인 방법입니다. 하지만 파일 디스크립터 같은 메모리가 아닌 리소스는 시스템 한도에 종속되는 경향이 있고, Go 런타임은 일반적으로 그런 한도를 알지 못해요. 게다가 주어진 Go 프로그램에서 가비지 컬렉터의 시점은 대개 패키지 작성자가 거의 통제할 수 없는 것입니다(예: GC가 얼마나 자주 실행되는지는 GOGC에 의해 통제되며, 운영자가 실제로 다양한 값으로 설정할 수 있어요). 이 두 사실이 결합되어 클린업과 파이널라이저는 메모리가 아닌 리소스를 해제하는 유일한 메커니즘으로 쓰기에는 부적합해요. 어떤 메모리가 아닌 리소스를 감싸는 API를 노출하는 패키지 작성자라면, 파이널라이저나 클린업을 통해 가비지 컬렉터에 의존하기보다는 리소스를 결정론적으로 해제하는 명시적 API(Close 메서드나 이와 유사한 것)를 제공하는 것을 고려하세요. 대신 클린업과 파이널라이저를 프로그래머 실수에 대한 최선 노력(best-effort) 처리기로 사용하는 것을 선호하세요. os.File이 하듯 리소스를 어차피 정리하거나, 결정론적으로 정리하지 못한 실패를 사용자에게 보고하는 방식으로요.
  • 파이널라이저보다 클린업을 선호하세요. 역사적으로 파이널라이저는 Go 코드와 C 코드 사이의 인터페이스를 단순화하고 메모리가 아닌 리소스를 정리하기 위해 추가됐어요. 의도된 용도는 C 메모리나 다른 메모리가 아닌 리소스를 소유한 래퍼 객체에 적용해서, Go 코드가 사용을 마치면 리소스가 해제되도록 하는 것이었죠. 이런 이유들이 파이널라이저가 좁게 범위가 한정된 이유, 어떤 주어진 객체가 파이널라이저를 하나만 가질 수 있는 이유, 그리고 그 파이널라이저가 객체의 첫 바이트에만 붙어야 하는 이유를 적어도 부분적으로 설명합니다. 이 제한은 이미 일부 사용 사례를 억제해요. 예를 들어 전달받은 객체에 대한 어떤 정보를 내부적으로 캐시하고 싶은 패키지는 객체가 사라지면 그 정보를 정리할 수 없습니다. 하지만 그것보다 더 나쁜 것은 파이널라이저가 객체를 부활(resurrect)시키기 때문에, 그 파이널라이저 함수에 객체를 넘겨줄 수 있도록(그리고 그 너머에도 계속 살게 할 수 있도록) 비효율적이고 오류에 취약하다는 점이에요. 이 단순한 사실은 객체가 참조 순환의 일부라면 절대 해제될 수 없고, 객체를 뒷받침하는 메모리는 적어도 다음 가비지 컬렉션 주기까지는 재사용될 수 없다는 것을 의미합니다. 파이널라이저는 객체를 부활시키기 때문에 클린업보다 더 잘 정의된 실행 순서를 가집니다. 따라서 파이널라이저는 복잡한 파괴 순서 요구가 있는 구조를 정리하는 데 여전히 (드물지만) 잠재적으로 유용합니다. 하지만 Go 1.24 이후의 다른 모든 용도에서는 클린업을 사용할 것을 권장합니다. 클린업은 파이널라이저보다 더 유연하고, 오류에 덜 취약하며, 더 효율적이기 때문이에요.

흔한 클린업 문제 (Common cleanup issues)

  • 클린업이 붙은 객체는 클린업 함수에서 도달 가능하면 안 됩니다(예: 캡처된 지역 변수를 통해). 이렇게 하면 객체가 회수되지 않고 클린업이 절대 실행되지 않아요.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
	syscall.Close(f.fd) // Mistake: We reference f, so this cleanup won't run!
}, f.fd)
  • 클린업이 붙은 객체는 클린업 함수의 인자에서 도달 가능하면 안 됩니다. 이렇게 하면 객체가 회수되지 않고 클린업이 절대 실행되지 않아요.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
	syscall.Close(f.fd)
}, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
  • 파이널라이저는 잘 정의된 실행 순서를 가지지만, 클린업은 그렇지 않습니다. 클린업은 서로 동시에 실행될 수도 있어요.
  • 오래 실행되는 클린업은 다른 클린업의 실행을 막지 않도록 고루틴을 만들어야 합니다.
  • runtime.GC는 도달 불가능한 객체에 대한 클린업이 실행될 때까지 기다리지 않고, 모두 큐에 들어갈 때까지만 기다립니다.

흔한 약한 포인터 문제 (Common weak pointer issues)

  • 약한 포인터는 예상치 못한 시점에 Value 메서드에서 nil을 반환하기 시작할 수 있어요. 항상 nil 검사로 Value 호출을 방어하고 백업 계획을 가지세요.
  • 약한 포인터가 맵 키로 사용될 때, 그것은 맵 값의 도달 가능성에 영향을 주지 않아요. 따라서 약한 포인터 맵 키가 맵 값에서도 도달 가능한 객체를 가리킨다면, 그 객체는 여전히 도달 가능한 것으로 간주됩니다.

흔한 파이널라이저 문제 (Common finalizer issues)

  • 파이널라이저가 붙은 객체는 어떤 경로로도 자기 자신에게서 도달 가능하면 안 됩니다(다시 말해 참조 순환 안에 있으면 안 돼요). 이렇게 하면 객체가 회수되지 않고 파이널라이저가 절대 실행되지 않아요.
f := new(myCycle)
f.self = f // Mistake: f is reachable from f, so this finalizer would never run.
runtime.SetFinalizer(f, func(f *myCycle) {
	...
})
  • 파이널라이저가 붙은 객체는 파이널라이저 함수에서 도달 가능하면 안 됩니다(예: 캡처된 지역 변수를 통해). 이렇게 하면 객체가 회수되지 않고 파이널라이저가 절대 실행되지 않아요.
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
	syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run!
})
  • 파이널라이저가 붙은 객체들의 참조 체인(예: 연결 리스트에서)은 그 체인의 객체 수만큼의 GC 주기를 적어도 거쳐야 모두 정리됩니다. 파이널라이저를 얕게(shallow) 유지하세요!
// Mistake: reclaiming this linked list will take at least 10 GC cycles.
node := new(linkedListNode)
for range 10 {
	tmp := new(linkedListNode)
	tmp.next = node
	node = tmp
	runtime.SetFinalizer(node, func(node *linkedListNode) {
		...
	})
}
  • 패키지 경계에서 반환하는 객체에 파이널라이저를 배치하지 마세요. 이렇게 하면 패키지 사용자가 runtime.SetFinalizer를 호출해 여러분이 반환한 객체의 파이널라이저를 변경할 수 있게 되는데, 이는 패키지 사용자가 결국 의존하게 될 예상치 못한 동작일 수 있어요.
  • 오래 실행되는 파이널라이저는 다른 파이널라이저의 실행을 막지 않도록 새 고루틴을 만들어야 합니다.
  • runtime.GC는 도달 불가능한 객체에 대한 파이널라이저가 실행될 때까지 기다리지 않고, 모두 큐에 들어갈 때까지만 기다립니다.

객체 죽음 테스트하기 (Testing object death)

이 기능들을 사용할 때, 그것들을 사용하는 코드에 대한 테스트를 작성하는 것이 까다로울 수 있어요. 이 기능들을 사용하는 코드에 대한 견고한 테스트를 작성하기 위한 팁이 여기 있습니다.

  • 그러한 테스트를 다른 테스트와 병렬로 실행하지 마세요. 결정성을 최대한 높이고 어떤 시점의 세상 상태를 잘 파악하는 데 큰 도움이 됩니다.
  • 테스트에 들어갈 때 runtime.GC를 사용해 기준(baseline)을 세우세요. 약한 포인터가 nil이 되도록 강제하고, 클린업과 파이널라이저가 실행되도록 큐에 넣기 위해 runtime.GC를 사용하세요.
  • runtime.GC는 클린업과 파이널라이저가 실행되기를 기다리지 않고, 단지 큐에만 넣습니다. 가능한 가장 견고한 테스트를 작성하려면 테스트에서 클린업이나 파이널라이저에 블록할 방법을 주입하세요(예: 테스트에서 클린업/파이널라이저에 선택적 채널을 넘기고, 실행을 마치면 채널에 쓰기). 이것이 너무 어렵거나 불가능하다면, 특정한 클린업 후 상태가 참이 될 때까지 회전(spin)하는 것이 대안입니다. 예를 들어 os 테스트들은 파일이 도달 불가능해진 뒤 닫혔는지 확인하는 루프에서 runtime.Gosched를 호출해요.
  • 파이널라이저 사용 테스트를 작성할 때, 파이널라이저를 사용하는 객체 체인이 있다면 모든 파이널라이저가 실행되도록 테스트가 만들 수 있는 가장 깊은 체인의 길이만큼의 runtime.GC 호출이 적어도 필요합니다.
  • 경쟁 모드(race mode)에서 테스트해서 동시 클린업 사이, 그리고 클린업·파이널라이저 코드와 나머지 코드베이스 사이의 경쟁을 발견하세요.

추가 리소스 (Additional resources)

위에 제시된 정보는 정확하지만, Go GC 설계의 비용과 트레이드오프를 완전히 이해하기에는 세부사항이 부족해요. 더 많은 정보는 다음 추가 리소스를 참고하세요.

  • The GC Handbook — 가비지 컬렉터 설계에 대한 훌륭한 일반 리소스이자 참고서.
  • TCMalloc — Go 메모리 할당자가 기반으로 하는 C/C++ 메모리 할당자 TCMalloc의 설계 문서.
  • Go 1.5 GC announcement — Go 1.5 동시 GC를 발표한 블로그 글로, 알고리즘을 더 자세히 설명합니다.
  • Getting to Go — 2018년까지 Go GC 설계의 진화에 대한 심층 프레젠테이션.
  • Go 1.5 concurrent GC pacing — 동시 마크 단계를 언제 시작할지 결정하는 설계 문서.
  • Smarter scavenging — Go 런타임이 운영체제에 메모리를 반환하는 방식을 개정하는 설계 문서.
  • Scalable page allocator — Go 런타임이 운영체제에서 얻는 메모리를 관리하는 방식을 개정하는 설계 문서.
  • GC pacer redesign (Go 1.18) — 동시 마크 단계를 언제 시작할지 결정하는 알고리즘을 개정하는 설계 문서.
  • Soft memory limit (Go 1.19) — 소프트 메모리 한도에 대한 설계 문서.

가상 메모리에 관한 메모 (A note about virtual memory)

이 가이드는 주로 GC의 물리 메모리 사용에 초점을 맞췄지만, 그것이 정확히 무엇을 의미하는지, 그리고 가상 메모리(대개 top 같은 프로그램에서 "VSS"로 표시되는)와 어떻게 비교되는지는 자주 나오는 질문이에요.

물리 메모리는 대부분의 컴퓨터에서 실제 물리 RAM 칩에 들어 있는 메모리입니다. 가상 메모리는 운영체제가 프로그램들을 서로 격리하기 위해 제공하는 물리 메모리 위의 추상화에요. 그리고 프로그램이 어떤 물리 주소에도 매핑되지 않는 가상 주소 공간을 예약하는 것도 일반적으로 허용됩니다.

가상 메모리는 운영체제가 유지하는 매핑일 뿐이므로, 물리 메모리에 매핑되지 않는 큰 가상 메모리 예약을 만드는 것은 대개 매우 저렴해요.

Go 런타임은 일반적으로 몇 가지 방식으로 가상 메모리 비용에 대한 이 관점에 의존합니다.

  • Go 런타임은 매핑한 가상 메모리를 절대 삭제하지 않습니다. 대신 어떤 가상 메모리 범위와 연관된 물리 메모리 리소스를 명시적으로 해제하기 위해 대부분의 운영체제가 제공하는 특수 연산을 사용합니다. 이 기법은 메모리 한도를 관리하고 Go 런타임이 더 이상 필요하지 않은 메모리를 운영체제에 반환하는 데 명시적으로 사용됩니다. Go 런타임은 또한 더 이상 필요하지 않은 메모리를 백그라운드에서 지속적으로 해제합니다. 자세한 내용은 추가 리소스를 참고하세요.
  • 32비트 플랫폼에서 Go 런타임은 단편화 문제를 제한하기 위해 힙을 위해 128 MiB에서 512 MiB 사이의 주소 공간을 미리 예약합니다.
  • Go 런타임은 여러 내부 데이터 구조의 구현에서 큰 가상 메모리 주소 공간 예약을 사용합니다. 64비트 플랫폼에서 이들은 대개 최소 가상 메모리 풋프린트가 약 700 MiB입니다. 32비트 플랫폼에서는 그 풋프린트가 무시할 만해요.

결과적으로 top의 "VSS" 같은 가상 메모리 지표는 Go 프로그램의 메모리 풋프린트를 이해하는 데 대개 그다지 유용하지 않습니다. 대신 "RSS"와 같은 측정에 집중하세요. 이들은 물리 메모리 사용을 더 직접적으로 반영합니다.

최적화 가이드 (Optimization guide)

비용 식별하기 (Identifying costs)

Go 애플리케이션이 GC와 상호작용하는 방식을 최적화하려고 시도하기 전에, 먼저 GC가 주요 비용이라는 것을 식별하는 것이 중요해요.

Go 생태계는 비용을 식별하고 Go 애플리케이션을 최적화하기 위한 여러 도구를 제공합니다. 이 도구들에 대한 간략한 개요는 진단 가이드를 참고하세요. 여기서는 이 도구들 중 일부와, GC 영향과 동작을 이해하기 위해 적용할 합리적인 순서에 초점을 맞출게요.

  • CPU 프로파일 (CPU profiles)CPU 프로파일링으로 시작하는 것이 좋아요. CPU 프로파일링은 CPU 시간이 어디에 쓰이는지의 개요를 제공하지만, 훈련되지 않은 눈에는 특정 애플리케이션에서 GC가 수행하는 역할의 규모를 식별하기 어려울 수 있어요. 다행히 GC가 어떻게 들어맞는지 이해하는 것은 대부분 runtime 패키지의 다양한 함수가 무엇을 의미하는지 아는 것으로 귀결됩니다. 아래는 CPU 프로파일을 해석하기 위한 유용한 함수 부분집합이에요. 아래 나열된 함수들은 리프(leaf) 함수가 아니므로 pprof 도구가 top 명령으로 기본 제공하는 것에는 나타나지 않을 수 있다는 점을 주목하세요. 대신 top -cum 명령을 사용하거나 이 함수들에 대해 직접 list 명령을 사용하고 누적 퍼센트 열에 집중하세요.
    • runtime.gcBgMarkWorker: 백그라운드 마크 워커 고루틴의 진입점. 여기에 쓰인 시간은 GC 빈도와 객체 그래프의 복잡성·크기에 따라 확장됩니다. 애플리케이션이 마킹·스캐닝에 쓰는 시간의 기준을 나타내요. 이 고루틴들 안에서 runtime.gcDrainMarkWorkerDedicated, runtime.gcDrainMarkWorkerFractional, runtime.gcDrainMarkWorkerIdle에 대한 호출을 찾을 수 있는데, 이들이 워커 유형을 나타냅니다. 크게 유휴한 Go 애플리케이션에서 Go GC는 작업을 더 빨리 끝내기 위해 추가(유휴) CPU 리소스를 사용하는데, 이는 runtime.gcDrainMarkWorkerIdle 심볼로 표시됩니다. 결과적으로 여기서의 시간은 CPU 샘플의 큰 비율을 차지할 수 있으며, Go GC는 그것을 무료로 간주합니다. 애플리케이션이 더 활발해지면 유휴 워커의 CPU 시간은 줄어들어요. 이것이 일어날 수 있는 한 가지 흔한 이유는 애플리케이션이 전적으로 하나의 고루틴에서 실행되는데 GOMAXPROCS가 1보다 큰 경우입니다.
    • runtime.mallocgc: 힙 메모리용 메모리 할당자의 진입점. 여기에 누적된 시간이 많이(>15%) 쓰였다면 대개 많은 메모리가 할당되고 있다는 뜻입니다.
    • runtime.gcAssistAlloc: 고루틴이 스캐닝과 마킹에서 GC를 돕기 위해 시간 일부를 양보하려고 들어가는 함수. 여기에 누적된 시간이 많이(>5%) 쓰였다면 애플리케이션이 얼마나 빨리 할당하는지에 있어 GC를 앞지르고 있을 가능성이 높아요. 이는 GC에서 특히 높은 영향도를 나타내며, 애플리케이션이 마킹·스캐닝에 쓰는 시간을 나타내기도 합니다. 이는 runtime.mallocgc 호출 트리에 포함되므로 그것도 부풀린다는 점을 주목하세요.
  • 실행 트레이스 (Execution traces) — CPU 프로파일은 시간이 총체적으로 어디에 쓰이는지 식별하는 데 훌륭하지만, 더 미묘하거나, 드물거나, 특히 지연 시간과 관련된 성능 비용을 나타내는 데는 덜 유용해요. 반면 실행 트레이스는 Go 프로그램 실행의 짧은 창에 대한 풍부하고 깊은 시야를 제공합니다. Go GC와 관련된 다양한 이벤트를 포함하고 특정 실행 경로를 직접 관찰할 수 있으며, 애플리케이션이 Go GC와 어떻게 상호작용할 수 있는지도 볼 수 있어요. 추적된 모든 GC 이벤트는 트레이스 뷰어에서 편리하게 그렇게 표시됩니다. 실행 트레이스를 시작하는 방법은 runtime/trace 패키지 문서를 참고하세요.
  • GC 트레이스 (GC traces) — 다른 모든 것이 실패하면, Go GC는 GC 동작에 대한 훨씬 더 깊은 통찰을 제공하는 몇 가지 특정 트레이스를 제공합니다. 이 트레이스들은 항상 STDERR로 직접 출력되며, GC 주기마다 한 줄씩이고, 모든 Go 프로그램이 인식하는 GODEBUG 환경 변수로 구성됩니다. 이들은 주로 Go GC 자체를 디버깅하는 데 유용한데, GC 구현의 특정 사항에 대한 어느 정도의 친숙함을 요구하지만, 그럼에도 GC 동작을 더 잘 이해하는 데 가끔 유용할 수 있습니다. 핵심 GC 트레이스는 GODEBUG=gctrace=1을 설정해서 활성화됩니다. 이 트레이스가 만드는 출력은 runtime 패키지 문서의 환경 변수 섹션에 문서화되어 있어요. "pacer 트레이스"라고 불리는 보조 GC 트레이스는 더 깊은 통찰을 제공하며 GODEBUG=gcpacertrace=1로 활성화됩니다. 이 출력을 해석하려면 GC의 "pacer"(참고: 추가 리소스)에 대한 이해가 필요하며, 이는 이 가이드의 범위를 벗어납니다.

힙 할당 제거하기 (Eliminating heap allocations)

GC 비용을 줄이는 한 가지 방법은 애초에 GC가 관리해야 할 값의 수를 줄이는 것입니다. 아래에 설명하는 기법들은 가장 큰 성능 개선을 만들어 낼 수 있어요. GOGC 섹션이 보여줬듯이 Go 프로그램의 할당 비율은 GC 빈도의 주요 요인이고, GC 빈도는 이 가이드가 사용하는 핵심 비용 지표이기 때문입니다.

힙 프로파일링 (Heap profiling)

GC가 상당한 비용의 원천임을 식별한 다음, 힙 할당을 제거하는 다음 단계는 대부분의 할당이 어디서 오는지 알아내는 것입니다. 이 목적에는 메모리 프로파일(정확히는 힙 메모리 프로파일)이 매우 유용해요. 시작하는 방법은 문서를 확인하세요.

메모리 프로파일은 프로그램에서 힙 할당이 어디서 오는지, 할당된 지점의 스택 트레이스로 식별해 설명합니다. 각 메모리 프로파일은 메모리를 네 가지 방식으로 나눌 수 있어요.

  • inuse_objects — 살아 있는 객체의 수를 나눕니다.
  • inuse_space — 살아 있는 객체를 사용하는 메모리 크기(바이트)로 나눕니다.
  • alloc_objects — Go 프로그램이 실행을 시작한 이후 할당된 객체의 수를 나눕니다.
  • alloc_space — Go 프로그램이 실행을 시작한 이후 할당된 총 메모리 양을 나눕니다.

힙 메모리의 이 다양한 보기 사이를 전환하는 것은 pprof 도구의 -sample_index 플래그로 하거나, 도구를 대화형으로 사용할 때 sample_index 옵션으로 할 수 있어요.

참고: 메모리 프로파일은 기본적으로 힙 객체의 부분집합만 샘플링하므로 모든 개별 힙 할당에 대한 정보를 담지 않습니다. 하지만 이는 핫스팟을 찾기에 충분해요. 샘플링 비율을 바꾸려면 runtime.MemProfileRate를 참고하세요.

GC 비용을 줄이는 목적에서는 alloc_space가 보통 가장 유용한 보기입니다. 할당 비율에 직접 대응하기 때문이에요. 이 보기는 가장 많은 이득을 줄 할당 핫스팟을 나타낼 거예요.

탈출 분석 (Escape analysis)

힙 프로파일의 도움으로 후보 힙 할당 지점을 식별했다면, 그것들을 어떻게 제거할까요? 핵심은 Go 컴파일러의 탈출 분석을 활용해서 Go 컴파일러가 이 메모리를 위한 대안적이고 더 효율적인 저장소(예: 고루틴 스택)를 찾게 하는 것입니다. 다행히 Go 컴파일러는 왜 Go 값을 힙으로 탈출시키기로 결정했는지 설명할 수 있어요. 그 지식을 가지면, 분석의 결과를 바꾸기 위해 소스 코드를 재구성하는 문제가 됩니다(이것이 종종 가장 어려운 부분이지만, 이 가이드의 범위를 벗어납니다).

Go 컴파일러의 탈출 분석에서 정보를 접근하는 방법으로, 가장 간단한 방법은 Go 컴파일러가 지원하는 디버그 플래그를 통해 어떤 패키지에 적용했거나 적용하지 않은 모든 최적화를 텍스트 형식으로 설명하게 하는 것입니다. 여기에는 값이 탈출하는지 여부가 포함돼요. 다음 명령을 시도해 보세요. 여기서 [package]는 어떤 Go 패키지 경로입니다.

$ go build -gcflags=-m=3 [package]

이 정보는 LSP 지원 편집기에서 오버레이로 시각화될 수도 있는데, 코드 액션(code action)으로 노출됩니다.

예를 들어 VS Code에서 "Source Action... > Show compiler optimization details" 명령을 호출해서 현재 패키지에 대한 진단을 활성화하세요. ("Go: Toggle compiler optimization details" 명령을 실행할 수도 있어요.)

어떤 주석을 표시할지 제어하려면 이 구성 설정을 사용하세요:

마지막으로 Go 컴파일러는 이 정보를 기계가 읽을 수 있는(JSON) 형식으로 제공하며, 추가 사용자 정의 도구를 만드는 데 쓸 수 있어요. 자세한 내용은 Go 소스 코드의 문서를 참고하세요.

구현 특정 최적화 (Implementation-specific optimizations)

Go GC는 살아 있는 메모리의 인구 통계에 민감합니다. 객체와 포인터의 복잡한 그래프가 병렬 처리를 제한하고 GC에 더 많은 작업을 만들기 때문이에요. 결과적으로 GC에는 특정한 흔한 구조를 위한 몇 가지 최적화가 있습니다. 성능 최적화에 가장 직접적으로 유용한 것들을 아래에 나열해요.

참고: 아래 최적화를 적용하면 의도를 흐리게 해 코드의 가독성을 떨어뜨릴 수 있고, Go 릴리스에 걸쳐 유지되지 않을 수도 있습니다. 이 최적화는 가장 중요한 곳에만 적용하는 것을 선호하세요. 그런 곳은 비용 식별하기 섹션에 나열된 도구들로 식별할 수 있어요.

  • 포인터가 없는 값은 다른 값과 분리됩니다. 결과적으로 포인터를 엄격히 필요로 하지 않는 데이터 구조에서 포인터를 제거하는 것이 유리할 수 있습니다. 이는 GC가 프로그램에 가하는 캐시 압박을 줄이기 때문이에요. 따라서 포인터 값에 대한 인덱스에 의존하는 데이터 구조는 타입이 덜 잘 정의되지만 더 잘 수행될 수 있어요. 이것은 객체 그래프가 복잡하고 GC가 마킹·스캐닝에 많은 시간을 쓰는 것이 분명할 때만 할 가치가 있습니다.
  • GC는 값의 마지막 포인터에서 값의 스캔을 중단합니다. 결과적으로 struct 타입 값에서 포인터 필드를 값의 시작 부분에 그룹화하는 것이 유리할 수 있어요. 이것은 애플리케이션이 마킹·스캐닝에 많은 시간을 쓰는 것이 분명할 때만 할 가치가 있습니다. (이론적으로 컴파일러가 자동으로 할 수 있지만 아직 구현되지 않았고, struct 필드는 소스 코드에 쓰인 대로 배열됩니다.)

게다가 GC는 보는 거의 모든 포인터와 상호작용해야 하므로, 예를 들어 포인터 대신 슬라이스에 대한 인덱스를 사용하는 것이 GC 비용을 줄이는 데 도움이 될 수 있어요.

Linux 투명 대형 페이지 (Transparent huge pages, THP)

프로그램이 메모리에 접근할 때 CPU는 사용하는 가상 메모리 주소를, 접근하려던 데이터를 가리키는 물리 메모리 주소로 변환해야 합니다. 이를 위해 CPU는 운영체제가 관리하는 가상에서 물리 메모리로의 매핑을 나타내는 데이터 구조인 "페이지 테이블(page table)"을 참조합니다. 페이지 테이블의 각 항목은 페이지(page)라고 불리는 분할할 수 없는 물리 메모리 블록을 나타내며, 그래서 이름이 그렇게 붙었어요.

투명 대형 페이지(THP)는 연속된 가상 메모리 영역을 뒷받침하는 물리 메모리 페이지를 대형 페이지(huge page)라고 불리는 더 큰 메모리 블록으로 투명하게 교체하는 Linux 기능입니다. 더 큰 블록을 사용하면 같은 메모리 영역을 나타내는 데 필요한 페이지 테이블 항목이 더 적어서 페이지 테이블 조회 시간이 개선됩니다. 하지만 더 큰 블록은 시스템이 대형 페이지의 작은 부분만 사용한다면 더 많은 낭비를 의미해요.

Go 프로그램을 프로덕션에서 실행할 때 Linux에서 투명 대형 페이지를 활성화하면 추가 메모리 사용을 대가로 처리량과 지연 시간을 개선할 수 있어요. 힙이 작은 애플리케이션은 THP에서 이득을 얻지 못하는 경향이 있고 상당한 추가 메모리(최대 50%)를 사용하게 될 수 있습니다. 하지만 힙이 큰 애플리케이션(1 GiB 이상)은 그다지 많은 추가 메모리 오버헤드 없이(1-2% 이하) 꽤 많은 이득(처리량 최대 10%)을 얻는 경향이 있어요. 어느 경우든 THP 설정을 인지하는 것이 도움이 될 수 있고, 실험은 항상 권장됩니다.

Linux 환경에서 /sys/kernel/mm/transparent_hugepage/enabled를 수정해 투명 대형 페이지를 활성화하거나 비활성화할 수 있어요. 자세한 내용은 공식 Linux 관리자 가이드를 참고하세요. Linux 프로덕션 환경에서 투명 대형 페이지를 활성화하기로 선택했다면, Go 프로그램에 대해 다음 추가 설정을 권장합니다.

  • /sys/kernel/mm/transparent_hugepage/defragdefer 또는 defer+madvise로 설정하세요. 이 설정은 Linux 커널이 일반 페이지를 대형 페이지로 얼마나 적극적으로 합치(coalesce)는지 제어합니다. defer는 커널에게 대형 페이지를 지연적이고 백그라운드에서 합치라고 말해요. 더 공격적인 설정은 메모리가 제한된 시스템에서 정지를 유발할 수 있고 종종 애플리케이션 지연 시간을 해칠 수 있습니다. defer+madvisedefer와 같지만, 대형 페이지를 명시적으로 요청하고 성능에 필요한 시스템의 다른 애플리케이션에게 더 친화적이에요.
  • /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none0으로 설정하세요. 이 설정은 Linux 커널 데몬이 대형 페이지를 할당하려 할 때 추가로 할당할 수 있는 페이지 수를 제어합니다. 기본 설정은 최대한 공격적이며, 종종 Go 런타임이 OS에 메모리를 반환하기 위해 하는 작업을 되돌릴 수 있어요. Go 1.21 이전에는 Go 런타임이 기본 설정의 부정적 효과를 완화하려 했지만 CPU 비용이 따랐습니다. Go 1.21+와 Linux 6.2+에서 Go 런타임은 더 이상 대형 페이지 상태를 변경하지 않습니다. Go 1.21.1 이상으로 업그레이드했을 때 메모리 사용 증가를 겪는다면 이 설정을 적용해 보세요. 문제를 해결할 가능성이 높아요. 추가 해결 방법으로는 Prctl 함수PR_SET_THP_DISABLE과 함께 호출해 프로세스 수준에서 대형 페이지를 비활성화하거나, GODEBUG=disablethp=1(Go 1.21.6과 Go 1.22에 추가될 예정)을 설정해 힙 메모리에 대해 대형 페이지를 비활성화할 수 있어요. GODEBUG 설정은 향후 릴리스에서 제거될 수 있다는 점을 주의하세요.

부록 (Appendix)

GOGC에 대한 추가 메모 (Additional notes on GOGC)

GOGC 섹션은 GOGC를 두 배로 하면 힙 메모리 오버헤드를 두 배로 하고 GC CPU 비용을 절반으로 한다고 주장했습니다. 왜 그런지 수학적으로 분해해 보겠습니다.

첫째, 힙 목표는 총 힙 크기에 대한 목표를 설정합니다. 하지만 이 목표는 주로 새 힙 메모리에 영향을 줍니다. live heap은 애플리케이션에 근본적이기 때문이에요.

목표 힙 메모리 = Live heap + (Live heap + GC roots) * GOGC / 100
총 힙 메모리 = Live heap + New heap memory

새 힙 메모리 = (Live heap + GC roots) * GOGC / 100

여기서 GOGC를 두 배로 하면 애플리케이션이 각 주기에 할당할 새 힙 메모리의 양도 두 배가 된다는 것을 볼 수 있는데, 이것이 힙 메모리 오버헤드를 포착합니다. Live heap + GC roots가 GC가 스캔해야 하는 메모리 양의 근사라는 점을 주목하세요.

다음으로 GC CPU 비용을 살펴볼게요. 총 비용은 주기당 비용에 어떤 시간 기간 T에 걸친 GC 빈도를 곱한 것으로 나눌 수 있어요.

총 GC CPU 비용 = (주기당 GC CPU 비용) * (GC 빈도) * T

주기당 GC CPU 비용은 GC 모델에서 파생할 수 있어요:

주기당 GC CPU 비용 = (Live heap + GC roots) * (바이트당 비용) + 고정 비용

마크·스캔 비용이 지배적이므로 스윕 단계 비용은 여기서 무시됩니다.

정상 상태는 일정한 할당 비율과 일정한 바이트당 비용으로 정의되므로, 정상 상태에서 이 새 힙 메모리로부터 GC 빈도를 파생할 수 있어요:

GC 빈도 = (할당 비율) / (새 힙 메모리) = (할당 비율) / ((Live heap + GC roots) * GOGC / 100)

이것을 합치면 총 비용에 대한 전체 방정식을 얻습니다:

총 GC CPU 비용 = (할당 비율) / ((Live heap + GC roots) * GOGC / 100) * ((Live heap + GC roots) * (바이트당 비용) + 고정 비용) * T

충분히 큰 힙(대부분의 경우를 나타내는)에서 GC 주기의 한계 비용이 고정 비용을 지배합니다. 이는 총 GC CPU 비용 공식의 상당한 단순화를 가능하게 해요.

총 GC CPU 비용 = (할당 비율) / (GOGC / 100) * (바이트당 비용) * T

이 단순화된 공식에서 GOGC를 두 배로 하면 총 GC CPU 비용이 절반이 된다는 것을 볼 수 있어요. (참고: 이 가이드의 시각화들은 고정 비용을 시뮬레이션하므로, 그것들이 보고하는 GC CPU 오버헤드는 GOGC가 두 배가 될 때 정확히 절반이 되지 않습니다.) 게다가 GC CPU 비용은 대부분 할당 비율과 메모리를 스캔하는 바이트당 비용에 의해 결정됩니다. 이 비용들을 구체적으로 줄이는 방법에 대한 자세한 내용은 최적화 가이드를 참고하세요.

참고: live heap의 크기와 GC가 실제로 스캔해야 하는 그 메모리의 양 사이에는 차이가 있습니다. 같은 크기의 live heap이라도 구조가 다르면 다른 CPU 비용이 되지만 같은 메모리 비용이 되어 다른 트레이드오프를 낳습니다. 그래서 힙의 구조가 정상 상태의 정의의 일부인 이유입니다. 힙 목표는 아마도 GC가 스캔해야 하는 메모리의 더 가까운 근사인 스캔 가능한 live heap만 포함해야 하지만, 이는 스캔 가능한 live heap이 아주 작은데 live heap은 그 외적으로는 큰 경우에 퇴화된(degenerate) 동작으로 이어집니다.

더 알아보기 (Learn more)