Go 언어의 GC

검프·2021년 8월 7일
5
post-thumbnail

Illustration created for “A Journey With Go”, made from the original Go Gopher, created by Renee French.

Go 언어 소개에서 잠시 언급한 Go 언어의 GC에 대해서 공부해보려고 합니다. 스터디에서 발표할 목적으로 작성하기 시작했는데요, 앞으로 더 알게되는 내용이 생길때마다 꾸준히 업데이트 해보려고합니다.


GC란

가비지 콜렉터Garbage Collector^{Garbage\ Collector}, 줄여서 GC라고 부릅니다. 메모리 관리 방법 중 하나로, 프로그램이 동적으로 할당했던 메모리에서 더 이상 사용하지 않게 된 메모리를 찾아서 재사용 가능하도록 회수하는 기능을 이야기합니다.

func test() {
  user := &User{Name: "Gump"}
  user = &User{Name: "IU"}     // <1> Gump는 어떻게 될까요?
}

user에 이름이 Gump인 객체를 할당하고, 다시 user에 이름이 IU인 객체를 생성하여 재할당합니다. 이때 Gump 객체는 어떻게 될까요? (나는 쓰레기야..)

참조를 잃은 객체는 더 이상 사용할 수 없는 객체가 됩니다. 이런 객체를 Garbage 또는 Dangling Object라고 부르는데요, 이런 객체들을 그대로 둘 경우 메모리 누수Memory leak^{Memory\ leak}가 발생하게 됩니다. 이들을 수거하여 사용 가능한 메모리를 확보하는 절차가 바로 가비지 컬렉션입니다.

C, C++같이 GC를 지원하지 않는 언어에서는 일일이 메모리를 해제해 주어야 합니다. 위 예제 같은 간단한 함수라면 문제가 안되지만, 동시성 환경에서 동작하는 큰 프로그램에서는 메모리 관리가 아주 어려운 문제입니다. 창의적으로 문제를 해결하는 데 집중하지 못하고 메모리 관리에 시간을 써야 하는 상황은 생산성 낭비라고 생각합니다. 아주 미묘한 성능 저하도 아쉬운 임베디드 환경이나 실시간 응답성이 중요한 특정 목적의 애플리케이션을 제외하면 GC가 주는 혜택을 포기할 수는 없을 것 같아요.

Go 언어의 GC

runtime 패키지에 mgc.go에 작성되어 있는 Go 언어 GC에 대한 설명입니다.

// The GC runs concurrently with mutator threads, is type accurate (aka precise), allows multiple
// GC thread to run in parallel. It is a concurrent mark and sweep that uses a write barrier. It is
// non-generational and non-compacting. Allocation is done using size segregated per P allocation
// areas to minimize fragmentation while eliminating locks in the common case.

Go 언어의 GC에 대한 핵심적인 설명이라고 할 수 있는데요. 이에 대해서 알아보려고 합니다.

삼색 표시 후 쓸어 담기 알고리즘

Go 언어의 GC는 삼색 표시 후 쓸어 담기 알고리즘Tricolor mark and sweep algorithm^{Tricolor\ mark\ and\ sweep\ algorithm}을 사용합니다. 먼저 삼색 집합에 대해서 알아봐야 합니다.

  • 흰색 집합White set^{White\ set} : 프로그램에서 더 이상 접근할 수 없어서 GC 대상이 되는 객체
  • 검은색 집합Black set^{Black\ set} : 프로그램이 사용하고 있고, 흰색 집합의 객체에 대한 참조가 없는 객체. 흰색 집합의 객체가 검은색 집합의 객체의 참조를 가져도 문제가 되지 않음
  • 회색 집합Grey set^{Grey\ set} : 프로그램이 사용하고 있고, 흰색 집합의 객체를 가리킬 수도 있어서 검사를 진행해야하는 객체

삼색 표시 후 쓸어담기 알고리즘은 아래의 과정으로 진행됩니다.

  1. 가비지 컬렉션은 모든 객체를 흰색으로 칠한 상태에서 시작합니다.
  2. GC가 모든 루트 객체Root object^{Root\ object}를 방문해서 회색으로 칠합니다. 루트 객체란, 스택이나 전역 변수처럼 애플리케이션에서 직접 접근할 수 있는 오브젝트를 이야기합니다.
  3. GC는 회색 집합의 객체를 하나씩 검은색으로 변경하면서 그 객체가 가리키는 희색 집합 객체가 확인되면 해당 객체를 회색으로 칠합니다.
  4. 3번의 작업을 회색 집합에 객체가 없어질 때까지 반복합니다. 이 작업이 끝나면 더 이상 접근할 수 없는 객체만 희색 집합에 남게 됩니다.
  5. 흰색 집합의 객체에 할당된 메모리 공간을 회수하여 메모리를 확보합니다.

이 내용을 그림으로 보면 아래와 같습니다.

그런데 만약 GC 수행 중 뮤테이터 스레드Mutator threads^{Mutator\ threads}가 힙에 객체 참조를 변경한다면 어떻게 될까요? 뮤테이터 스레드란 GC를 수행하는 스레드를 제외한 우리가 작성한 애플리케이션의 스레드를 의미합니다. 이 경우 GC를 수행하는 객체의 정합성을 보장할 수 없어서 문제가 발생할 수 있습니다. 그래서 GC는 안전하고 정확한 GC 수행을 위해서 뮤테이터 스레드를 중지 시킵니다. 뮤테이터 스레드를 중지한 상태를 Stop the worldSTW^{STW}라고 부르며, 가비지 컬렉션 안전점Garbage collection safepoint^{Garbage\ collection\ safe-point}이 만들어 집니다. 또한, 이 것이 애플리케이션 성능 저하의 주요 원인이 됩니다.

GC를 통해서 지연 시간Latency^{Latency}이 발생하는 만큼 성능이 저하됩니다. Go 언어에서는 GC로 인한 지연 시간을 최소한으로 낮추기 위하여 GC를 동시에 수행하는 Concurrent Mark and SweepCMS^{CMS}을 취합니다. CMS는 단순하여 비교적 쉽게 구현할 수 있지만, 메모리 단편화의 영향으로 메모리 할당 속도가 느려지는 단점이 있습니다.

Go 언어의 GC는 채널Channel^{Channel} 변수에 대해서도 동일하게 메모리 회수 작업을 수행합니다. 더 이상 접근할 수 없는 채널이라면 해당 채널이 열려 있는 상태더라도 메모리에서 해제됩니다. 또한, Java 등 다른 언어처럼 Go 언어에서도 직접 GC를 실행할 수 있도록 runtime.GC() 함수를 제공합니다. 하지만 GC 호출에는 대가가 따르는 만큼 runtime.GC() 호출에는 GC에 대한 깊은 이해와 주의가 필요합니다.


비세대별 GC 알고리즘

non-generational

Go 언어의 GC는 세대별 GC를 수행하지 않습니다. 그럼 세대별 GC는 무엇일까요?

세대별 GC는 세대별 가설Generational hypothesis^{Generational\ hypothesis}에 근거한 알고리즘입니다. 세대별 가설이란, 대부분의 경우 새로 생성된 객체가 더 오래된 객체에 비해서 일찍 죽는다는 가설입니다. 임시 변수, 로컬 변수와 같은 경우대 대표적인데요 이들 변수들은 대부분의 경우 짧은 생명 주기를 갖습니다.

이를 뒷받침하고자 많은 연구가 진행되었습니다. 연구에서 객체 생성 후 객체의 생명 주기가 종료될 때까지 걸리는 시간을 추적했을 때 대부분의 객체는 잠깐 사용되고 버려진다는 것이 확인되었습니다.

그림에서 파란색 영역은 객체의 수명에 대한 일반적인 분포를 나타냅니다. x축은 할당된 메모리 크기로 측정한 객체의 수명을 나타내고, y축은 x축 객체의 수명에 대한 할당된 총 바이트 크기를 나타냅니다. 왼쪽에 높게 표현된 영역이 생성 직후 금방 생명주기가 종료되어 회수할 수 있는 객체를 나타냅니다.

위 내용을 바탕으로 아래와 같은 전략을 세울 수 있고 이를 통해 GC의 효율을 향상시킵니다.

  • 힙을 새로 생성된 객체를 저장하는 Young GenerationYG^{YG}과 오래된 객체를 관리하는 Old GenerationOG^{OG}으로 구분하고 YG에서 더 자주 GC를 수행 (Minor GC)
  • YG에서 여러번 살아남은 객체는 OG로 승격Promotion^{Promotion}시키고 OG는 (Major GC)

여기까지 공부해보면 세대별 GC는 이득이 많은 알고리즘인 것 같습니다. 그런데 왜 Go 언어는 세대별 GC를 수행하지 않는 것일까요? 이유는 Write barrier에 대한 오버헤드를 허용할 수 없었기 때문입니다. 아래 그림을 보면서 설명해 보겠습니다.

Minor GC가 수행되면 YG의 객체들만 확인하게 됩니다. 이때 obj2처럼 OG에서 참조 중인 객체가 존재할 수 있습니다. 이 객체가 GC에 의해서 메모리에서 해제된다면 obj4에서 obj2의 참조를 잃게 되어 문제가 발생합니다. 만약 OG의 객체를 확인한다면 문제를 막을 수 있겠지만 세대별 GC의 이점은 사라집니다.

이런 문제를 막기 위해서 세대별 GC에서는 Write barrier를 사용합니다. Write barrier는 OG의 객체가 YG의 객체를 참조할 때 세대 간 참조 사실을 Card table에 기록하는 역할을 하는 메커니즘을 이야기합니다. 이후 Minor GC가 수행되면 Card table만 검사하여 빠르게 GC를 수행할 수 있게 됩니다.

Go 언어에서는 Write barrier를 이용한 세대 간 참조 기록 작업에서 발생하는 오버헤드를 허용할 수 없었다고 설명(The write barrier was fast but it simply wasn’t fast enough) 합니다. 이런 설명에도 세대별 GC가 주는 이득이 더 큰 것이 아닌가 하는 생각이 드실 겁니다.

이런 결정의 배경엔 우수한 Go 언어 컴파일러가 존재합니다. Go 언어는 컴파일 시점에 이스케이프 분석을 통한 최적화를 수행합니다. 컴파일러의 뛰어난 이스케이프 분석 능력 때문에 더 많은 객체가 힙이 아닌 스택에 메모리를 할당하곤 합니다. 이 덕분에 세대별 GC로 얻을 수 있는 성능 개선이 다른 언어의 런타임에 비해서 적다고 합니다.


비압축형 알고리즘

GC는 압축compaction^{compaction} 방식비압축noncompaction^{non-compaction} 방식으로 나눕니다. 아래 그림은 압축 방식을 설명합니다. GC가 수행될 때 alive 상태의 객체를 힙의 끝으로 재배치하여 힙을 압축합니다.

이를 통해 메모리 파편화를 방지하고 새로운 객체 생성 시 힙 끝에 메모리를 할당하므로 빠른 메모리 할당이 가능합니다.

이와 반대로 비압축 방식은 GC 후 힙 메모리의 객체를 재배치하지 않습니다. Go 언어의 Mark & Sweep 알고리즘은 비압축 방식입니다. 일반적으로 비압축 방식에서 메모리 할당과 GC를 반복하면 힙 메모리가 파편화되어 성능이 악화된다고 알려져 있습니다.

그렇다면 Go 언어는 왜 비압축 방식을 선택했을까요? 라인 엔지니어링 블로그에서 이유를 정리해 주었는데요. 2014년에는 압축 방식으로 GC를 개선할 계획이 있었다고 합니다. 하지만 당시 Go 언어 런타임을 C에서 Go 언어로 재작성하는 일이 진행되고 있었기 때문에 일정 문제로 CMS를 적용하였다고 합니다. 하지만 tcmalloc 메모리 할당자를 도입하면서 메모리 단편화와 할당 속도 문제를 해결했다고 합니다. 그럼 tcmalloc은 무엇일까요?

tcmallocThread caching memory allocation^{Thread\ caching\ memory\ allocation}은 구글에서 개발한 메모리 할당 라이브러리입니다. tcmalloc은 메모리를 중앙 힙Central heap  CH^{Central\ heap\ -\ CH}과 각 스레드 별로 스레드 로컬 캐시Thread local cache  TLC^{Thread\ local\ cache\ -\ TLC}라는 영역을 제공하여 관리합니다. TLC가 있기 때문에 메모리 할당 시 불필요한 동기화가 줄어들어 Lock^{Lock}에 대한 비용이 감소합니다.

  • tcmalloc은 glibc 2.3 malloc(ptmalloc2 라이브러리로 사용 가능) 보다 빠름
  • ptmalloc2는 malloc에 300ns가 걸리지만, tcmalloc은 50ns가 걸림
  • tcmalloc은 멀티 스레드 환경에서 메모리 락으로 인한 지연을 줄임
  • 작은 메모리(size <= 32KB) 할당은 TLC에 하며 거의 메모리 락이 없음
  • 큰 메모리(size > 32KB) 할당은 CH에 하며 4KB 크기의 페이지 단위로 나누어 메모리 맵으로 할당하고 스핀락Spinlock^{Spinlock}을 사용하여 컨텍스트 스위칭Context switching^{Context\ switching}을 방지함

tcmalloc은 메모리 단편화 문제 자체를 없애주는 것은 아닙니다. 단지 단편화를 최소화해주어 메모리 단편화 문제 자체를 덜 중요한 문제로 만들어줍니다.

GC 옵션

Java에서는 GC 최적화를 위한 아주 다양한 옵션을 제공하고 있습니다. 몇 가지만 살펴보면,

JVM 옵션의미예시
-Xms, -Xmx힙의 최소, 최대 크기-Xms=1g -Xmx=4g
-XX:NewSizeYG의 시작 크기-XX:NewSize=128m
-XX:MaxNewSizeYG의 최대 크기-XX:MaxNewSize=1g
-XX:NewRatioYG:OG 비율-XX:NewRatio=2 (YG:OG = 1:2)
-XX:SurvivorRatioYG:Survivor space 비율-XX:SurvivorRatio=8 (YG:SS = 8:1)
-XX:CMSInitiatingOccupancyFractionMajor GC를 실행할 OG 임계치 (기본값 75%)-XX:CMSInitiatingOccupancyFraction=80

Java와 달리 Go 언어에서는 GC와 관련하여 GOGC 단 하나의 옵션만을 제공합니다. GOGC에 대한 설명은 runtime 패키지에 설명되어 있습니다. GOGC는 기존 메모리 대비 얼만큼의 메모리가 할당되면 GC가 실행될지에 대한 백분율 값을 설정하는 환경 변수입니다. 기본값은 100입니다.

GOGC=200 go build .

위와 같이 설정하면 기존 메모리 대비 200%의 메모리가 할당되면 GC를 수행하라는 의미입니다. GOGC 수치를 작게하면 더 빈번하게 GC가 발생합니다.

또한 아래와 같이 설정하면 GC를 사용하지 않겠다는 의미입니다.

GOGC=off go build .

또한 debug 패키지를 이용하여 실행 중인 프로그램에서 GC를 수행할 백분율 값을 설정하는 것도 가능하니 참고해보세요.

// A negative percentage disables garbage collection.
func SetGCPercent(percent int) int


참고자료

profile
권구혁

3개의 댓글

comment-user-thumbnail
2022년 1월 23일

안녕하세요 검프님
저또한 Go 를 사용하는개발자입니다
최근들어 진행하는프로젝트가 마치 웹하드처럼 클라우드에 다중사용자가
파일을 업로드 하는 기능을 가지고 있습니다
특히 업로드 파일이 동영상이 많습니다
그런데 여러 유저가 동시에 업로드 하면 메모리가 너무 차오르고
이에 업로드 에러가 발생됩니다
이와같은 문제를 해결하기 위해 검색하다보니 검프님 블로그에 오게되었습니다
이와같은 문제의 해결방법이 무엇인지 알고 싶습니다
유료라도 좋으니 조언을 받고싶습니

2개의 답글