Golang GC
Golang은 GC(garbage collector)를 갖고있는 언어입니다. 이 글에서는 Golang의 GC를 자세하게 살펴보겠습니다.
Golang의 GC는 concurrent mark and sweep GC이며, 비세대화(non-generational) & 비압축(non-compacting) GC입니다. 하나하나 풀어나가보겠습니다.
Mark and Sweep
concurrent mark and sweep은 GC 알고리즘으로, mark and sweep 작업을 유저의 코드와 동시에 수행합니다. mark and sweep은 매우 전통적인 GC 알고리즘인데, 우리는 일단 concurrent하지 않은 일반 버전의 mark and sweep을 먼저 알아보겠습니다.
mark and sweep은 mark와 sweep의 2가지 단계로 이루어져있습니다. 단순하게 말하자면 mark하고 sweep하죠. mark는 우리가 청소해야할 영역이 어디인지 확인하고 sweep은 실제로 청소합니다.
mark 과정을 먼저 살펴보겠습니다. mark 단계에서는 사용 중인 모든 메모리 영역에 표시(mark)를 합니다. 메모리(주로 힙)에 존재하는 객체들은 그래프 구조를 형성합니다. 다음은 예시 golang 코드와 메모리 구조입니다.

일반적으로, mutator root라는 특정 위치로부터 접근 가능한 모든 자식 객체들을 탐색하면서 mark하게 됩니다. 이 과정이 전부 끝나면 mark되지 않은 객체들은 root로부터 접근가능하지 않다고 할 수 있습니다. 따라서 mark되지 않은 모든 곳은 새로운 객체를 할당할 수 있는 공간입니다.
sweep 단계에서는 모든 힙을 검사하고, mark되지 않은 메모리를 해제합니다. 이렇게 보면 간단하고 쉬운 알고리즘같습니다. 조금 더 디테일을 살펴보기 위하여 tri-color abstraction을 알아봅시다.
tri-color abstraction은 mark and sweep의 작업 상태를 추상화하는 방법입니다. tri-color라는 말처럼 3가지 색상으로 객체를 마크합니다.
- black
- grey
- white
black은 접근 가능하며 제거되어서는 안 되는 라이브 객체를 표현합니다. 간단하게 이미 mark가 끝난 객체라고 볼 수도 있습니다. grey는 black과 유사하지만 다른데, root로부터 접근은 가능하지만 아직 모든 자식 객체들이 mark되지 않아서 mark단계가 끝나지 않았음을 암시합니다. white는 mark되지 않은 객체를 의미합니다. sweep 단계에서 white객체들을 해제하게 됩니다.
처음에 모든 노드(객체)들은 white 상태로 시작합니다. marking단계에 접어들었을 때 mutator root를 시작으로 marking이 진행됩니다. 노드를 처음 만난다면 grey로 색칠하고, 그 노드의 자식을 모두 식별했다면 black으로 채색합니다.
이런 추상화 방법을 사용하면, mark 단계가 진행 중일 때는 black, grey, white 객체가 모두 존재하게 됩니다. grey 객체가 있다는 것은 아직 grey객체의 자식중에 mark(grey or black)가 되지 않은 것이 존재함을 의미합니다. 그러므로 안전하게 메모리 해제를 진행하려면, sweep 단계로 진입하기 전에는 grey 객체가 없어야 합니다.
tri-color abstraction은 다음의 불변식(invariant)을 만족합니다
1. mark가 끝나면, black 객체에서 white 객체로의 접근이 존재하지 않습니다.
2. mark가 끝나면, grey객체가 없습니다.
tri-color abstraction에 대해 더 자세히 알고 싶다면, 다음 논문을 참조하세요. On-the-Fly Garbage Collection: An Exercise in Cooperation
Concurrently Running Mark and Sweep
전통적인 mark and sweep은 잘못된 메모리 할당을 방지하기 위해 mark와 sweep 과정 동안 일시정지(STW)가 필요합니다. 하지만 STW는 여러가지 문제점을 안고있는데, 예를들어 어떤 요청을 받은 순간 GC가 시작되고 STW가 실행되어 요청을 처리하지 못할 수 있습니다. 요청에 대한 응답시간이 증가하는 것은 물론이며 부작용이 발생할 수 있습니다. 따라서 GC를 concurrent하게 실행하여 GC latency를 줄이는 것이 중요합니다.
보통 GC는 latency와 throuput의 트레이드 오프가 있습니다. latency는 얼마나 빠르게 GC 사이클이 실행되어 STW를 줄이는지이며, throuput은 단위 시간당 메모리를 정리할 수 있는 크기를 말합니다.
Golang의 GC는 최소 지연시간을 달성하기 위해 다양한 노력을 기울였으며, 그 결과가 바로 CMS(Concurrent Mark Sweep)입니다. mark and sweep을 concurrent하게 실행하는 CSM는 상대적으로 단순한 개념으로, 이는 mark와 sweep 과정을 수행하는 쓰레드 또는 고루틴을 생성함으로써 실현됩니다. 하지만 동시성이 복잡한 만큼, 예를 들어 잘못된 객체가 해제되는 등의 문제가 발생할 수 있습니다. 다음의 케이스를 살펴보겠습니다.
black객체는 이미 체크를 전부 진행했기에 다시 확인하지 않습니다. tri-color abstraction의 불변식중의 하나인 “mark가 끝났으면 black객체로 부터 white객체들은 전부 접근이 불가능한 객체입니다.”를 보장하기 때문에 이게 가능하죠. 그러나, 아래 예시에서는 black 객체에 white 객체의 참조가 추가된 것을 확인할 수 있습니다.
// TODO: 이미지 추가
GC에 대한 지식이 없는 mutator 때문에 이러한 문제가 발생하는 것입니다. 불변식이 깨지게 되었기에 sweep단계에서 실제로 사용하고 있는 white 객체를 해제할 수 있습니다. 잘못 해제된 객체는 매우 큰 문제를 유발합니다. 따라서 우리는 몇가지 방법을 적용하고 있습니다.
write barrier라는 방법을 통하여 객체를 생성하는 순간 바로 채색할 수 있습니다. 예를들어, mark phase를 시작하면서 “지금부터 생성되는 객체는 모두 grey이야”라고 설정할 수도 있습니다. 모든 할당은 저 값이 설정되어있다면 black으로 채색된 객체를 반환할 수 있습니다. memory allocator의 도움을 받고있지만, memory allocator와 GC는 많은 연관이 되어있기에 엄청 이상한 의존성은 아닙니다.
to Simplicity
Golang은 초기에 Dijkstra style write barrier만을 사용하고 있었습니다. 이 write barrier는 생성되는 객체들을 grey로 만들어서 tri-color abstraction의 불변성을 보장합니다. 하지만 stack의 경우 write barrier를 적용하면 너무 많은 오버헤드가 생겨서 write barrier를 적용하기 꺼려집니다. Go는 stack을 grey로 유지하는 것으로 이 문제를 해결했었는데, 여전히 문제는 존재했습니다.
mark를 진행하면서 stack이 grey를 계속 생성하기에 mark 단계를 나누어 해결하고 있었습니다. mark phase 1에서 객체들의 scan을 진행한 다음, mark phase 2에서는 STW를 설정하고 grey 객체들을 rescan하게 됩니다. 이는 mark단계가 복잡해질 뿐만 아니라 GC latency에도 좋지 않은 영향을 주고 있습니다.
이 문제를 해결하기 위해, Golang의 compiler/runtime 팀은 하이브리드 방식의 barrier를 사용합니다. Yuasa style deletion barrier를 사용하여, A객체에서 B객체로의 참조를 삭제하는 경우에 B객체를 grey로 만들어서 GC가 놓치지 않도록 합니다. (증명은 생략합니다) 이 방식으로 stack을 black으로 만들어서 STW가 필수적이었던 stack rescan의 필요성을 제거할 수 있습니다.
더욱이, mark phase 1과 mark phase 2를 별도로 유지할 필요성이 없어진 덕분에, 우리는 이를 단일 mark phase로 통합함으로써 mark 단계를 단순화할 수 있었습니다. 이 작업들로 인하여 STW 시간을 줄이면서도 mark 단계를 간결하게 유지할 수 있습니다.
Memory Allocations
GC의 목적은 동적인 메모리를 잘 관리하는 것에 있습니다. 생성되는 객체를 개발자가 직접 관리하지 않아도 되도록 하여 더욱 편한 개발이 가능해집니다. 이렇듯 GC는 메모리 할당/해제와 매우 밀접하게 연관되어있습니다. 어떻게 연관되어있는지 한번 살펴보겠습니다.
주의: 이 글은 GC에 관련된 글로서 메모리에 대한 내용은 개념 설명을 위하여 조금 생략되거나 강조된 내용이 있습니다. 메모리와 관련된 정확한 내용은 추후 별도의 글로 소개하겠습니다.
TCMalloc like allocator
Go의 메모리 할당은 TCMalloc과 비슷한 방식으로 구현되어 있습니다. TCMalloc은 구글에서 개발한 메모리 할당자로, T(Thread)별로 local C(Cache)를 갖고있는 메모리 할당자입니다. TCMalloc은 스레드의 캐시를 사용하여 메모리 지역성을 높이고 성능 증가를 가져옵니다. Go도 이와 거의 유사한 방식을 사용하고 있습니다.

TCMalloc은 작은 객체들을 cache에 할당하고 큰 객체들은 central에 할당하는 방식으로 더욱 효과적인 메모리 할당을 달성합니다. 이 글은 GC와 관련된 글이기에 추가적인 세부사항은 스킵합니다. 관심있으시다면 tcmalloc을 참고해주세요.
Ms Ps Gs, mcache & mcentral
Go의 핵심 개념으로는 M, P, G가 있습니다. 여기서 M은 실제 OS thread, P는 goroutine을 실행하기 위한 자원들을 관리하며, G는 goroutine을 의미합니다.
기본적으로 goroutine은 개별 stack을 갖고있으며 최소 사이즈는 2KB입니다. 이러한 goroutine의 stack은 heap에 저장되며 여러가지 정보들이 goroutine의 stack에 저장됩니다. goroutine의 stack은 최소 사이즈에서 시작해서 더 많은 메모리가 필요하다면 사이즈를 키우게(Grow) 됩니다. 너무 많은 메모리를 점유하는 것을 방지하기 위하여 grown stack의 사용량이 너무 적다면 스택을 줄이게(Shrinking) 됩니다. 이러한 동작을 통하여 stack의 사이즈를 조정하고 stack overflow등의 문제를 보완합니다.
goroutine의 stack이 heap에 저장되면 성능 저하가 있을 수도 있지만 몇가지 장점들을 활용해서 이 부분을 최소화 시킵니다. stack이 heap에 비하여 빠른 이유는 지역성(locality)과 pc를 이용하여 값을 바로 가져올 수 있다는 점이 있고, goroutine의 stack도 위와 비슷하게 지역성을 어느정도 보장합니다.
goroutine의 stack의 경우 로컬 변수, 함수 스택 프레임(parameter, return value, caller’s pc)등이 저장됩니다. 그에비하여 new, make 키워드로 할당되는 객체들이나 escape되는 객체들은 다른 곳에 할당됩니다.
go의 mem allocator는 TCMalloc과 비슷한 형태로, mcache(thread local cache)와 mcentral로 이루어져 있습니다. mcache는 P가 소유하고 있으며, 작은 객체들이 이곳에 할당됩니다. 다음 이미지는 대략적인 메모리 구조를 나타냅니다.

non-copying, non-moving
Go의 GC는 객체를 복사하지 않으며, 객체를 이동시키지도 않습니다. 이는 객체의 포인터가 항상 고정되는 것을 의미하는데, 여러가지 장단점이 존재합니다. 단순하다는 장점이 있지만, 일반적으로는 다음과 같은 문제를 야기합니다. 메모리를 할당하고 해제하는 과정에서 메모리 단편화가 발생할 수 있습니다. 메모리 단편화는 메모리를 할당하는 과정에서 발생하는 비효율로 인하여 실제보다 더 많은 메모리를 사용하거나, 메모리를 할당하지 못하는 문제를 이야기합니다.
이러한 메모리 단편화는 위에서 언급한 문제 이외에도 메모리 접근에 대한 공간적, 시간적 지역성을 떨어뜨리고 이는 메모리 cache miss 혹은 여러가지 성능 제약을 가져옵니다. 또한 잘 정렬되지 않은 메모리에서는 큰 객체를 할당하는 경우 단편화 때문에 할당할 공간이 부족해지는 문제가 발생할 수도 있습니다. 이러한 문제는 특정 애플리케이션에 따라서는 매우 심각할 수 있으며, 시스템의 중요한 부분입니다. 메모리를 과도하게 쓰거나, 메모리 때문에 성능 저하가 있다면 좋지않을 것입니다. Go는 여러가지 방법을 사용하여 이를 보완해왔습니다.
Go는 TCMalloc과 유사하게, 객체의 크기에 따라 메모리를 다르게 할당하는 방식을 적용하여 이를 개선합니다. 객체의 할당은 크게 3가지로 분류할 수 있습니다.
- tiny
- small
- large
tiny 객체들은 매우 작은 크기의 할당으로, mcache에서 tiny 객체들끼리 묶어서 관리합니다. 같이 관리되는 만큼 메모리를 절약할 수 있습니다. small 객체들은 mcache에서 적절한 span(메모리 덩어리)을 찾아서 할당합니다. 이 과정에서 span별로 bitmap을 사용해서 객체 할당 여부, 채색 여부를 관리하게 됩니다.
큰 객체들은 mhaep에서 직접 관리하게 합니다. 이렇듯 비슷한 객체끼리 모여있기에 객체 크기차이가 심한 메모리 단편화는 비교적 조금 발생합니다. 큰 객체들은 mhaep에서 직접 관리하기에 이러한 문제를 직접 다룰 수 있습니다.
Write Barrier
CMS 챕터에서 언급했다시피, mark 과정에서 메모리를 할당하게되면 잘못된 메모리를 해제하는 등의 문제가 생길 수 있기에 write barrier를 사용합니다. write barrier는 memory allocator가 GC에 대한 내용을 알고있기에 달성할 수 있습니다. write barrier는 메모리를 할당하는 과정에서 write barrier가 켜져있다면 객체를 바로 채색합니다.
gcalloc이라는 함수를 통하여 객체를 할당하게 됩니다. 객체 자체에도 객체의 header(첫 몇 bit)에 색을 나타내는 mark bit를 사용하고 있습니다. 이 과정에서 _GCOff상태가 아니라면 black 객체를 생성하게 됩니다. 이는 STW latency를 최소화 하기 위함입니다.
Marking Mechanism
객체 마킹 메커니즘은 특정 객체가 마크되었음을 표시하고, 이 정보를 사용하여 가비지 컬렉션을 수행하는 데 중요한 역할을 합니다. 우리는 아래의 문서를 통하여 어떻게 mark하는지 살펴볼 수 있지만, 간단하게 설명하자면 객체의 시작 몇 비트를 mark bit로 설정하여 객체의 색을 나타냅니다.
ref: https://go.googlesource.com/proposal/+/master/design/12800-sweep-free-alloc.md
Triggering GC
가비지 수집을 아무리 동시에 실행하고 STW가 적다고 하지만, 적절한 시기에 GC를 실행해야하는 것은 중요합니다. 너무 자주 실행하게 되면 latency가 증가하고 메모리 정리를 거의 하지 못할 수도 있습니다. 그렇다고 너무 가끔 실행하게 된다면 메모리 사용량이 너무 많아지고 mark phase가 길어질 수 있습니다. (mark는 lived object에 비례해서 시간이 걸립니다)
기본적으로 특정 시간이 지나면 GC를 트리거합니다. 이 시간은 하드코딩되어있는데, 특정 시간동안 GC가 실행되지 않고 가만히 있는 것을 방지합니다.
메모리 할당 과정에서 fast track을 실행할 수 없는 경우에도 GC의 도움을 받아 메모리를 정리합니다. 메모리를 할당하기 위하여 새로운 메모리를 얻어야 한다면, 일단 메모리는 얻으면서도 GC를 트리거하여 메모리 사용량을 줄입니다. 이렇게 할당 해제된 메모리는 반환하지 않고 free list로 들고있으면서 mcache에서 재사용합니다.
위의 방식은 상호보완적인 면이 있지만, 그게 전부는 아닙니다. Go는 pacer를 구현하여 더욱 적절한 타이밍에 GC를 트리거합니다.
Go는 GC를 조정할 수 있는 파라미터 2개를 노출하여 어느정도
Assist goroutine and CPU Limiter
위를 실행하기 위하여 user goroutine의 assist를 받음
mark goroutine worker들을 사용함
CPU를 너무 많이 사용하는 것을 방지하지 위하여 CPU limiter를 구현해서 쓰고잇음. 대충 이런 비율…
Preemption
Preemption은 특정 실행의 제어권을 넘기는 것을 의미합니다. Go로 작성된 프로그램은 여러 goroutine들이 동시에 실행되며 스케줄링되고, 어느 goroutine이 실행되다가 다른 goroutine이 자원을 선점하기도 합니다. goroutine의 제어권을 넘기는 가장 대표적인 예시는 channel을 통하는 것입니다. 다음과 같이 명시적으로 goroutine의 제어권을 넘겨 다른 goroutine이 실행되게 만들 수 있습니다.
func handoverToOtherGoroutine(c <-chan struct{}) {
// some code here
<-c // handover
// some code here
}
func main() {
c := make(chan struct{}, 0)
go handoverToOtherGoroutine(c) // goroutine A
c <- struct{} // re-excute goroutine A
}
이 다음에서 preemption이 왜 중요하고 제어권을 넘겨주는 다른 방법에는 어떤 것들이 있는지 더 살펴보겠습니다.
STW Implementation using Preemption
Preemption은 STW의 구현에서 필수적입니다. STW를 실행하기 위하여는 GC를 제외한 모든 goroutine(정확히는 mutator)들의 실행을 멈추어야합니다. 이러한 goroutine의 실행을 멈추기 위하여 preemption을 사용하여 스케줄러는 goroutine을 멈추게 됩니다. 가장 간단한 방법으로는 goroutine이 제어권을 넘겨주기까지 기다리는 방식이 있습니다.
Preemption은 GC의 STW 외에도 goroutine 스케줄링에서 중요합니다. 만약 다음 고루틴이 실행되고 있다면 제어권을 넘겨주는 부분이 없기에 goroutine의 실행이 끝나기 전에는 다른 고루틴들이 스케줄링되지 못합니다.
func neverHandoverToOtherGoroutine() {
strings.ToUpper(bigData) // 100GB data
}
많은 프로세스를 가진경우 유저가 생성한 goroutine은 작은 문제로 보일 수 있지만, GC에는 큰 문제가 됩니다. STW의 경우, 모든 mutator goroutine이 멈춰야 합니다. 이로 인해 GC의 STW latency가 증가하게 되는데, 이는 Golang의 low latency GC의 목적에 부합하지 않습니다.
Function Call Preemption
많은 goroutine의 실행은 스케줄러와 GC 모두에게 불편함을 줍니다. 따라서 Go 1.2에서 function call을 할 때 preemption check하는 기능이 추가되었습니다. goroutine이 함수를 호출하는 시점에 추가적인 check를 통하여 제어권을 넘겨주어야하는지 확인합니다.
이정도만 해도 어느정도 큰 문제는 발생하지 않았습니다. 대부분의 goroutine은 함수를 자주 호출하기 때문이죠. 하지만 함수를 호출하지 않는 경우, 특히 tight loop라고 불리는 경우에는 여전히 제어권을 넘겨주지 못하는 문제가 있습니다.
func tightLoopCase(image [][]int) {
for y := range image {
for x := range image[y] {
image[y][x] += 1
}
}
}
위 케이스처럼 함수 호출이 없이 빠르게 loop를 실행하는 경우에는 여전히 제어권을 넘겨주지 못합니다. 문제는 GC에서 특히 명확하게 나타나며, STW 동안 대부분의 goroutine들이 멈춘 반면, tight loop goroutine은 계속 실행됩니다. 이 경우 다른 goroutine들도 tight loop goroutine이 제어권을 넘겨주는 것을 기다려야합니다. GC STW latency도 증가하고, tight loop를 기다리는 동안 다른 고루틴들이 실행되지 못하니 프로그램의 throuput도 떨어지게 됩니다.
특정 코드를 삽입해 이 문제를 해결하는 것도 가능하지만, 이는 사용자 경험을 손상시킵니다. 또한 자기가 소유하지 않은 코드라면 코드를 삽입할 수도 없습니다.
func tightLoopCaseWorkAround(image [][]uint8) {
for y := range image {
for x := range image[y] {
image[y][x] += 1
}
// 다른 goroutine들이 스케줄링될 수 있도록 양보
runtime.Gosched()
}
}
Async Preemption (aka non-cooperative preemption)
이러한 문제는 goroutine이 직접 preempt를 결정하기 때문입니다. 이와 같은 방식을 cooperative preemption이라고 합니다.
Go는 non-cooperative preemption라고 불리는 Async Preemption를 구현하여 scehduler가 goroutine들을 직접 멈추게 됩니다.
scheduler가 직접 preempt하는 경우도 있지만, 시간에 따라서 preempt하는 경우도 있습니다. Go의 경우 10ms가 지나면 다른 goroutine들이 선점될 수 있도록 하고있습니다.
Async Preemption Implementation Using Signals
async preemption의 경우 스케줄러가 preempt를 실행하기 때문에 goroutine들에게 이를 알려주어야합니다. 구현은 OS마다 조금의 차이는 있지만 unix의 경우 signal을 사용해서 구현하고 있습니다. signal handler를 구현하고 특정한 signal을 주고받는 것으로 동작합니다.
preempt를 위한 signal은 sigurg(urgent, Socket is urgent)를 사용합니다. 잘 사용되지 않은 signal code라서 어느정도 괜찮다고 합니다.
하지만 signal을 받는다고해서 모든 곳에서 제어권을 넘겨줄 수 있지는 않습니다. 우리는 이러한 제어권을 넘겨줄 수 있는 부분을 safepoint라고 합니다. async preempt를 구현하는 과정에서 더 많은 safepoint를 마련해야했습니다. 따라서 몇가지 개선사항을 적용하여 safepoint를 늘리며 이런 문제를 해결하였습니다.
to Simplicity
이 문제를 해결하기 위하여 몇가지 대안들이 고려되었습니다. 가장 간단하고 많은 언어에서 채택된 방법은 모든 loop에 preemption코드를 추가하는 것이었습니다. 이러한 방법은 바이너리 사이즈를 키우거나, 성능을 희생해야했습니다. 성능 희생을 줄이기 위한 매우 복잡한 구현이 필요한 방법도 있었지만 단순함이 철학인 언어에는 맞지 않았습니다.
따라서 GC 문제를 단순하게 해결하면서 사용자 경험을 해치지 않는 방식으로 문제를 해결하였습니다. 이와 관련된 내용은 다음 자료를 살펴보시면 더 자세히 알 수 있습니다.
Golang’s Implementation
Golang의 GC 구현을 살펴보는 것으로 가장 좋은 시작지점은 src/runtime/mgc.go입니다. 이 코드를 읽어내려가다보면 ‘gcStart(trigger gcTrigger)’를 확인할 수 있습니다. 이 부분이 GC를 시작하는 부분이며, 내부 구현을 좀 더 자세히 살펴볼 수 있습니다.
함수의 대략적인 구현(동작)은 다음과 같습니다.
1. 가비지 컬렉션 시작 조건 확인
이 단계는 함수 시작 부분에서 acquirem() 함수를 호출한 후에 발생합니다.
2. 가비지 컬렉션 모드 설정
이 단계는 debug.gcstoptheworld 값을 확인하여 가비지 컬렉션의 모드를 결정합니다. Go의 GC는 기본적으로 gcBackgroundMode로 background에서 실행되지만, debug.gcstoptheworld 값에 따라서 STW로도 실행이 가능합니다.
3. 세계를 정지시키고 스윕 단계 종료
“세계를 정지시키는” 작업은 semacquire(&gcsema)와 semacquire(&worldsema)에서 이루어집니다. semapore를 얻은 다음 stopTheWorldWithSema(reason stwReason)을 실행하여 실제로 stop the world를 실행합니다.
STW상태에서 finishsweep_m 함수를 호출하여 sweep 단계를 종료합니다.
4. 가비지 컬렉션 모드에 따라서 스케줄러 조정
뒤에 나올 mark단계는 background에서 동시에 실행될 수 있습니다. 2단계에서 설정한 가비지 컬렉션 모드에 따라서 만약 background mode가 아니라면 유저의 고루틴들이 스케줄링 되는 것을 막습니다.
5. 마킹 단계 시작
마킹 단계는 GC의 phase를 _GCmark로 설정하는 것으로 시작합니다. 몇몇 mark단계 실행을 위한 준비들을 진행합니다.
뒤에서 소개하겠지만, 이 단계 이후부터 생성되는 객체들은 모두 black으로 칠해집니다.
6. STW를 해제하고, mark를 진행합니다.
background worker들이 mutator root로부터 시작하여 greyobject들을 처리하기 시작합니다.
7. 가비지 컬렉션 시작의 완료 후 정리
가비지 컬렉션의 시작 완료와 이후 정리 작업이 이루어집니다. 여기서 유저의 고루틴이 다시 시작되고 필요한 세마포어(semaphore)가 해제됩니다.
이 시점에도 background worker들은 marking을 진행하고 있습니다.
8. worker들이 전부 mark를 실행했고, mark단계를 끝낸다
marking을 진행할 것이 남았다면 다시 check하고 marking을 진행합니다.
전부 marking이 진행되었다면 stopTheWorldWithSema(stwGCMarkTerm)를 통하여 STW를 실행하고, GC의 phase를 _GCmarktermination로 설정합니다. mark가 전부 되었는지 확인하고나서 GC phase를 _GCoff로 변경합니다.
9. Sweep을 진행합니다
sweep goroutine을 ready 상태로 만들고, 직접 sweep을 진행하지는 않습니다. sweep은 매우 빠르게 실행되며, heap에 할당하는 과정에서 application이 sweep을 실행하기에 낮은 우선순위로 sweep goroutine을 설정하고 끝내게 됩니다.
그 다음 GC를 시작하기 전에 sweep이 되었는지 체크하기 때문에, heap이 너무 커지는 문제를 막을 수 있습니다.
가비지 컬렉터에서 자주 쓰이는 primitive / 용어들
Heap
힙은 컴퓨터 메모리에서 프로그램이 필요에 따라 데이터를 저장하고 제거하는 영역입니다. 이 공간은 자유롭게 사용할 수 있으며, 여러분이 변수나 객체를 생성하면 그 정보는 힙에 저장됩니다. 힙에 저장된 데이터는 프로그램이 더 이상 사용하지 않을 때, 가비지 컬렉터에 의해 제거됩니다. Heap(힙)은 연속적인 메모리 워드의 배열, 연속적인 워드의 불연속적인 블록의 집합으로 이루어집니다.
객체
객체는 메모리에서 프로그램이 사용하는 데이터의 집합을 나타냅니다. 객체는 특정 유형의 데이터를 나타내며, 이 데이터는 메모리의 연속된 영역, 즉 힙(heap)에 저장됩니다. 예를 들어, ‘학생’이라는 객체는 이름, 학번, 성적 등의 정보를 포함할 수 있습니다. 이 객체가 생성되면, 해당 정보는 힙 영역에 저장되며, 프로그램은 이 영역에 있는 데이터에 액세스하고 조작할 수 있습니다.
뮤테이터
쉽게 말해 뮤테이터는 프로그램의 일부분으로, 객체를 생성하고 변경하고 삭제하는 역할을 합니다. 여러분이 코딩해서 만드는 대부분의 프로그램 코드는 뮤테이터로 볼 수 있습니다.
뮤테이터 루트
뮤테이터 루트는 프로그램 코드(뮤테이터)가 현재 사용하고 있는, 즉 접근 가능한 모든 객체들의 ‘시작점’을 말합니다. 이는 변수나 함수 등에서 참조되는 모든 객체를 포함합니다. 가비지 컬렉터는 뮤테이터 루트를 통해 어떤 객체가 아직 필요하고 어떤 객체를 제거해도 되는지를 판단합니다.
Stop The World
STW로 자주 줄여서 부르는 STW는 모든 프로그램의 실행이 중지되는 것을 의미합니다.
Golang에서는 Stop된 프로그램을 실행시키는 것을 Start The World라고 부르기도 합니다.
ps.
긴 글을 읽어주셔서 감사합니다. 이 글은 Gophercon Korea 2023 발표에서도 사용되었기에 발표도 많은 관심 부탁드립니다.
개인적으로 현재 구직중입니다. 이 글을 재미있게 읽으셨고 구인중인 회사라면 about을 참고해서 메일 주시면 감사드리겠습니다.