Go 언어의 GC에 대해

 

안녕하세요, LINE Ads Platform 개발을 담당하고 있는 Okada(@ocadaruma)입니다. 이번 포스팅에서는 개인적으로 쭉 관심있었던 Go 언어의 가비지 컬렉션(Garbage Collection, GC)에 대해 조사한 내용을 소개하겠습니다.

Go 언어

Go 언어는 Google에서 개발한 시스템 프로그래밍 언어입니다. Channel을 통해 동시성(concurrency)을 지원하고 GC를 제공한다는 점이 특징입니다. Google을 비롯한 많은 기업에서 사용하고 있으며, LINE에서도 Go로 개발하는 도구나 서비스가 많이 있습니다.

Go 언어의 GC

Go 언어를 사용하면 low-latency 애플리케이션을 쉽게 개발할 수 있습니다. 하지만 솔직히 GC는 다른 언어의 runtime GC에 비해 단순해 보입니다. 예를 들어 Go 언어(Go 1.10 기준)의 GC는 Concurrent Mark & Sweep(이하 CMS라 함) 컬렉터이고, JVM(Java Virtual Machine) 등에서 수행하는 일반적인 압축(compaction)이나 세대별 GC(Generational GC)를 수행하지 않습니다.

It is a concurrent mark and sweep that uses a write barrier. It is non-generational and non-compacting.
mgc.go 발췌

Java와 Go의 GC를 비교 정리해보면 다음과 같습니다.

JVM (Java8 HotSpot VM) Go
컬렉터 복수(Serial, Parallel, CMS, G1) CMS only
컴팩션 있음 없음
세대별 GC 있음 없음
튜닝 파라미터 컬렉터에 따라 상이하나 복수 존재 GOGC only

이렇게 단순해 보이는 Go 언어 GC가 어떻게 잘 작동하고 있는 건지 궁금하여 조사해 봤습니다.

압축(compaction)

GC는 정적 유형과 동적 유형으로 나뉩니다.

정적 유형

정적 유형의 GC는 GC별로 heap 내 객체를 재배치하지 않습니다. Go 언어가 사용하는 Mark & Sweep GC는 정적 유형입니다. 일반적으로 정적 유형 GC에서 메모리 할당과 해제를 반복하면 heap 단편화가 발생해 할당 성능이 악화되는 문제가 있다고 알려져 있습니다(단, 이는 메모리 할당자를 어떻게 구현하느냐에 따라 달라집니다).

동적 유형

동적 유형의 GC는 GC 수행 시 alive 상태의 객체를 heap 끝으로 재배치해서 heap을 압축합니다. HotSpot VM의 GC 등에서 사용되는 copy GC는 동적 유형입니다.

압축을 실행하면 아래와 같은 이점을 얻을 수 있습니다.

  • 단편화 회피
  • bump allocation을 통해 고속 메모리 할당 구현 가능(객체가 heap 끝에 위치하므로 메모리 신규 할당 시 끝에서 바로 increment)

Go 언어가 ‘압축’을 도입하지 않은 이유

Google의 Rick Hudson 씨가 ISMM 2018 키노트 Getting To Go에서 아래 내용을 발표하였습니다.

Go 언어의 메모리 할당에 대해서는 런타임의 코드 주석에 자세하게 기재되어 있습니다.

This was originally based on tcmalloc, but has diverged quite a bit.
malloc.go 발췌

세대별 GC

이제 세대별 GC에 대해 말씀드리겠습니다. 세대별 GC의 목적은 heap 내 객체를 수명(GC에서 살아 남은 횟수 등으로 나타냄)에 따라 분류하여 GC 효율을 향상시키는 것입니다.

많은 애플리케이션에서 새로 할당된 객체는 대부분 일찍 죽는다는 가설(세대별 가설)이 있습니다. 이 가설에 따라 아래와 같이 전략을 만들면 수명이 긴 객체를 여러 번 스캔할 필요가 없어서 GC 효율이 향상됩니다.

  • 객체 신규 할당 영역에선 GC를 자주 수행(Minor GC)
  • 해당 영역 GC에서 여러 번 살아 남은(가장 오래된) 객체는 승격(promote)시켜 GC 빈도가 낮은(Major GC) 영역으로 이동

Java8 HotSpot VM에서는 모든 컬렉터가 세대별 GC를 제공합니다.

Write barrier

세대별 GC는 GC를 수행하지 않을 때도 애플리케이션에 오버헤드가 발생한다는 단점이 있습니다. 관련하여 Minor GC 수행 예시를 살펴보겠습니다.

root에서 신규 세대를 참조하는 경로만 조사한 뒤 그 중 접근 불가능한 상태인 것을 회수해 버리면, obj2처럼 오래된 객체가 참조하고 있는 신규 세대 객체도 회수돼버립니다. 그렇다고 오래된 객체를 포함하여 heap 전체를 검사해 버리면 세대별 GC를 하는 의미가 없습니다. 그래서 애플리케이션에서 참조를 대입하거나 rewrite할 때, 오래된 세대에서 신규 세대를 참조하는 경우 해당 참조를 별도로 기록하는 처리가 추가됩니다. 이와 같은 참조의 ‘mutate’와 함께 부수적으로 진행해야 하는 처리를 ‘write barrier’라고 부릅니다.

따라서 세대별 GC를 사용하면 write barrier 오버헤드에 비해 얻을 수 있는 이점이 더 클 것으로 기대됩니다.

Go 언어는 왜 세대별 GC를 도입하지 않았는가

앞서 설명한 바와 같이, 세대별 GC에서는 write barrier를 사용해서 세대 간 포인터를 기록해야 합니다. 여기에서 Getting To Go를 다시 확인해 보면, 세대별 GC를 검토는 했지만, write barrier 오버헤드를 허용할 수 없어 도입하지 않았다는 사실을 알 수 있습니다.

The write barrier was fast but it simply wasn’t fast enough

또, Go 언어는 컴파일러의 escape 분석 성능이 우수하고 필요 시 heap에 할당되지 않도록 프로그래머가 제어할 수 있기 때문에, 세대별 가설에서 나오는 수명이 짧은 객체는 heap이 아닌 stack에 할당되곤 합니다(GC를 수행할 필요가 없음). 따라서 세대별 GC로 얻을 수 있는 이점이 일반적인 runtime GC에 비해 적습니다. 실제로 빠른 속도를 자랑하는 Go 언어 라이브러리에는 0-Allocation을 구현한 것도 많습니다. 하지만, 수명이 긴 객체를 GC할 때마다 여러 번 스캔해야 하는 소모적인 작업 자체는 아직 남아 있습니다. 이 부분에 관해서는 Google의 Ian Lance Taylor 씨도 golang-nuts의 토픽인 Why golang garbage-collector not implement Generational and Compact gc?에서 언급한 바 있습니다.

That is a good point. Go’s current GC is clearly doing extra work,
but it’s doing it in parallel with other work, so on a system with
spare CPU capacity Go is making a reasonable choice. But see
this.

이건 개인적인 생각인데요. 앞으로는 세대별 전략이 도입될 가능성이 있을지도 모르겠습니다.

마치며

이번에 Go 언어의 GC를 조사해 보면서, GC가 지금과 같은 구성을 가지게 된 배경과 단점을 극복한 방법을 잘 이해할 수 있었습니다.

Go 언어는 빠르게 진화하고 있습니다. 앞으로 GC의 개선을 비롯한 동향에 주목해야겠습니다(이번 달(2018년 8월)에는 Go 1.11이 릴리스되었습니다).

참고 문헌