이번엔 Go의 메모리 관리에 대해 정리해보려 합니다
Go가 1.17이 Release되는 현재 시점에서
해당 내용에 관해 국내에서 정리된 문서가 없는 것 같습니다
(몇가지 번역 문서는 존재하는것 같습니다)
이제 Go도 연식이 꽤 생긴 언어이고 관련 문서는 굉장히 많습니다만
버전마다 상세한 내용들이 추가되고, 바뀌고 있습니다
(문서는 대부분 잘 정리되어 있습니다. 양이 무지막지할 뿐)
LINE 블로그의 굉장히 유명한 Go GC 관련 문서가 있습니다
깔끔하게 GC에 대해 정리해준 문서인데 Go 1.10 버전으로 다루고 있으며
현재는 이와는 GC가 꽤 달라졌습니다
저는 1.16을 기준으로 내용을 정리하려 합니다
go의 메모리 관리에 대해서 이해하면 더
go를 효율적으로(성능 측면) 사용할 수 있고
어떤 문제가 발생했을 때 현상을 이해하고
원인을 파악해 해결하기도 수월할 것입니다
2021.07.11 - [Programming/Golang] - Golang Long Time Performance Test - Memory Leak 해결 과정
기존 경험을 통해 위의 포스팅을 했었는데
해당 과정 중에 Go의 내부 구조에 관심을 갖게 되었습니다
워낙 내용이 많은 분야기 때문에 전반적인 이해를 목적으로 진행하겠습니다
먼저 소프트웨어에서의 메모리 관리를 간단히 살펴보고 Go로 넘어가겠습니다
Memory Management(메모리 관리)
소프트웨어에서의 메모리 관리는 다음과 같습니다
- 소프트웨어가 컴퓨터 메모리를 제어하고 조정하는 방법
프로그램이 무거워질수록 메모리 관리는 굉장히 큰 영향을 미칩니다
때문에 GC의 튜닝이 중요해집니다
먼저 소프트웨어 관점에서의 메모리를 살펴보겠습니다
기본적으로 소프트웨어는 기기 상의 OS에서 동작할 때, RAM(Random-Access Memory)에 접근을 합니다
- 실행될 바이너리 코드 LOAD
- 실행될 프로그램이 사용하는 data value, data structure들을 STORE
- 프로그램에 필요한 run-time system LOAD
프로그램은 2가지 영역의 메모리를 사용하고 관리합니다
다들 아시는 Stack과 Heap이죠
Stack(스택)
스택은 static memory allocation(정적 메모리 할당)에 사용하는 영역이며
자료구조 이름대로 LIFO(Last In First Out)을 수행하는 형태입니다
- 스택의 가장 위에 데이터를 읽고 쓰면 되기 때문에 매우 빠름
- 하지만 동시에 유한하고 정적인(static) 크기만 지정(컴파일 타임에 사이즈가 정해짐)
- function의 실행할 데이터는 여러 stack frame들로 저장됨
- 각 frame은 function의 데이터를 저장
- frame은 각 function 스택 안에서 또 스택의 형태로 저장됨
- frame이 pop되는 경우 해당 function의 데이터도 지워짐
- 멀티쓰레드 App은 쓰레드마다 스택을 가질 수 있음
- 스택의 메모리 관리는 OS에 의해 간단하게 수행됨
- 일반적인 데이터(local 변수, 포인터, function frame)는 모두 stack에 저장
- 그래서 제한된 stack size를 초과하면 stack overflow 에러가 발생하는 것
- 너무 많은 재귀 함수 호출 등
- 각 frame은 function의 데이터를 저장
Heap(힙)
힙은 dynamic memory allocation(동적 메모리 할당)에 사용하는 영역이며
프로그램은 힙에서 pointer를 사용해 들여다볼 수 있습니다
- 데이터를 들여다보는 구조이기 때문에 데이터가 많아질수록 스택보다 느림
- 대신 더 많이 저장 가능
- 힙은 App의 쓰레드들이 공유하여 사용
- 힙은 동적인 특성으로 관리하기 까다로우며 이 때문에 언어들은 자동 메모리 관리 솔루션들이 시작됨
- 힙에 저장되는 데이터는 전역 변수, 레퍼런스 타입
- App이 할당된 힙보다 더 많은 메모리를 사용하려 하면 OOM(Out Of Memory) 에러가 발생
- 일반적으로 제한 없이 생성될 수 있음
GarbageCollection(가비지 컬렉션)
하드디스크와 달리 RAM은 사용에 제한이 없습니다
때문에 위에서 본 메모리(특히 힙)가 App에서 과도하게 사용되어
메모리 리소스가 부족해지면 App이 죽거나 전체 시스템에 영향을 줄 수 있습니다
이 때문에 소프트웨어 차원에서의 메모리 관리가 필요하며
수동으로 메모리를 관리하는 언어는 아래와 같은 특징을 가집니다
- C/C++
- 개발자가 직접 메모리 할당/해제를 관리
- 관리가 어렵지만 성능적으로 이점
메모리를 수동으로 정확하게 관리하는 건
어려운 일이기 때문에(Dangling Pointer, Memory Leak 등의 이유)
많은 현대(Modern) 프로그래밍 언어에서는 자동으로 메모리를 관리하는 방법을 제공합니다
- Dagling Pointer : 포인터가 가리키는 object가 해제되어 유효하지 않은 값을 가리킴
- Memory Leak : 개발자의 실수로 해제되지 않는 메모리가 발생하여 점점 더 많은 메모리를 차지
현대 프로그래밍 언어들은 App 개발자들이 서비스 구현에 집중할 수 있도록
메모리를 언어 차원에서 자동으로 관리하려고 노력합니다(생산성 up)
메모리 관리는 다양한 방식이 있지만 Garbage Collection(GC)이 대표적입니다
GC를 사용하는 언어의 장점은 아래와 같습니다
- 보안성 증가
- 높은 OS 이식성
- 간단해지는 코드
물론 overhead라는 단점이 있습니다만
이는 개발자의 생산성 증가와 위의 장점을 토대로
충분히 tradeoff 할 수 있는 사항이기 때문에
초실시간성 보장이 목표가 아니라면 여전히 채택되고 있습니다
그렇다면 Garbage Collection이란?
Garbage Collection은 말 그대로 쓰레기 수집입니다
여기서 뜻하는 쓰레기는 Heap에서 더 이상 사용되지 않는 메모리이며
GC는 해당 메모리를 수집하여 정리하는 작업을 의미합니다
GC는 일반적으로 아래의 과정을 거칩니다
- GC 수행 시간 동안 GC를 수행하는 쓰레드를 제외한 모든 쓰레드가 일시정지
- GC는 참조할 수 없는 객체에 대한 메모리를 해제
- GC가 끝난 뒤 일시정지되었던 쓰레드의 작업들이 재개
여기서 발생하는 App의 일시정지는 'Stop the World'라고 부릅니다
어떠한 GC 알고리즘을 사용하더라도 stop-the-world는 발생하며
GC 튜닝이란 대부분 stop-the-world 시간을 줄이는 것이 목표입니다
GC의 대표적인 두 가지 방식만 간단히 짚겠습니다(Go의 GC는 뒤에서 정리하겠습니다)
- JVM(JAVA/Scala/Groovy/Kotlin), JavaScript, C#, Golang, Ruby, ...
- heap에서 사용되지 않는 메모리를 자동으로 해제
대표적인 GC 방식
- Mark & Sweep GC
- 스택에서 존재하는 object나 변수에서부터
Heap의 참조를 확인하면서 GC를 시작하므로 이들을 GC Root라고함 - 위의 그림처럼 두 가지 단계로 분리
- Mark : GC Root에 해당하는 object나 변수가 참조하는 메모리,
해당 메모리가 또 참조하는 메모리를 chaining하며 따라가 "alive"로 표시 - Sweep : "alive"가 아닌 object들의 할당을 해제하는 단계
- Mark : GC Root에 해당하는 object나 변수가 참조하는 메모리,
- 말그대로 지울 메모리를 표시(Mark)하고 쓸어버리는(Sweep) 방식입니다
- 이는 기본적인 예시이며 다양한 종류의 Mark & Sweep 변형 GC가 존재
- JVM, C#, Go 등에서 사용
- 스택에서 존재하는 object나 변수에서부터
- Reference Couting GC
- 모든 obejct의 참조되는 횟수가 증가하고 감소하는 reference count를 가짐
- count가 0이 된 object는 GC가 수행되어 지워짐
- 순환하는 참조를 다룰 수 없기 때문에 선호되는 방식은 아니며, 추가적인 다른 방안을 같이 사용함
- PHP, Python 등에서 사용
JAVA의 세대별 GC 같은 경우가 궁금하면 잘 정리된 아래 문서를 참고해봅시다
https://d2.naver.com/helloworld/1329
Go의 Memory Management
일반적인 Memory Management를 살펴봤으니
Go의 Memory Management를 살펴보겠습니다
Go에서도 GC를 사용한다 했습니다
하지만 Go는 정적 타입 & 컴파일 언어이며 VM을 사용하지 않기 때문에
Go App의 바이너리는 작은 런타임이 내장됩니다(부트스트래핑 과정은 다음에 포스팅해 보겠습니다)
- Garbage Collection, Scheduling & Concurrency를 수행하는 런타임 내장
Go의 메모리 관리는 아래의 두 가지를 포함하며 Standard Library를 통해 수행됩니다
- 메모리가 필요할 때 자동 할당
- 더 이상 필요하지 않을 때 Garbage Collection
이제부터 Go의 메모리 구조, 메모리 할당, GC를 그림을 통해 자세히 살펴보도록 하겠습니다
1. Go Internal Memory Structure
앞으로 설명할 개념을 알기 위해
먼저 Go Scheduler 내용을 간단하게 살펴보겠습니다
(Go Scheduler도 다음에 추가로 포스팅해보겠습니다)
현재 저의 Mac은 4개의 Physical Core를 갖고 있고
M1 맥북이 아니므로 Intel CPU의 Hyper-Threading으로 Core 당 2배의 Thread가 존재합니다
즉, 병렬적으로 OS Thread를 동작할 수 있는 8개의 Virtual Core가 존재한다고 볼 수 있습니다
실제로 아래와 같이 Go를 실행하면 8의 결과를 얻습니다
package main
import (
"fmt"
"runtime"
)
func main() {
// NumCPU returns the number of logical
// CPUs usable by the current process.
fmt.Println(runtime.NumCPU())
}
우리는 Virtual Core를 이제부터 Logical Processor라고 부를 거고
Logical Processor를 'P(Processor)'라고 하겠습니다
OS Thread는 'M(Machine)'이라고 하겠습니다
Goroutine은 'G'라고 하겠습니다
모든 P는 M에 의해 할당됩니다
(이러한 패턴을 GMP 패턴이라고 합니다)
Go App을 이 Mac에서 동작시키면
8개의 쓰레드를 사용해 App을 수행할 수 있다는 의미입니다
Goroutine들은 OS Thread들의 Context Switching으로 동작합니다
여기까지 정리하고 다시 Memory로 돌아가겠습니다
아래 그림과 같이 일반적인 GC가 수행되면 메모리 단편화(Fragmentation)가 발생합니다
위와 같이 단편화가 발생하면 EMPTY와 꼭 맞는 크기의 메모리가 아니면 새로운 메모리 할당이 어려워집니다
EMPTY보다 작은 메모리를 할당하면 남은만큼 메모리 낭비도 생기겠죠
많은 언어의 Garbage Collection은 이러한 문제를 해결하기 위해
세대별 메모리 구조로 단편화를 줄이고 compaction을 수행합니다
하지만 Go는 TCMalloc과 매우 유사한 구조를 사용해
이미 구조적으로 단편화를 최소화하고, lock을 줄이고 있습니다
따라서, Go GC는 세대별 GC와 compaction을 수행하지 않습니다
[TCMalloc 간단 설명]
/*
TCMalloc : 구글에서 만든 TCMalloc 방식의 메모리 관리
보통 멀티스레드 환경의 서버는 메모리 풀을 사용합니다
필요할 때마다 malloc을 호출해서 메모리를 할당하는 것보다
거대한 메모리 풀에서 필요한 만큼 잘라 쓰는 것이 더 빠르기 때문입니다
하지만 여러 쓰레드가 하나의 메모리 풀에 동시에 접근하면
lock과 대기가 발생하고, 그에 따른 비용과 지연이 발생합니다
그래서 TCMalloc은 Thread 별로 메모리풀을 따로 두고
다른 쓰레드에서 접근해야 하는 메모리는 전역 메모리풀을 두어 관리합니다
즉, Thread Local Cache(Thread memory)와 Central Heap(Page heap)
으로 나눕니다
Thread Local Cache는 32KB 이하의 작은 object를 담당하여 할당하며
메모리가 부족해지면 Central Heap에서 메모리를 얻어와 할당합니다
Central Heap에서는 32KB가 넘어가는 큰 object들을 담당해 할당하며
4KB의 페이지 단위로 나누어 할당하며 사실상 일반적인 메모리 풀과 같습니다
하지만 Thread Local Cache로 인해 메모리 할당 시, lock으로 인한 비용과 지연이 줄어들어
성능이 크게 향상됩니다
Thread memory
- 각 메모리 page는 size-class별로 고정된 list로 나뉨
- 단편화를 줄이기 위해
- 각 쓰레드는 lock 없이 작은 object를 저장하기 위한 cache를 가짐
- <= 32KB
- 이러한 구조는 병렬 프로그램상에서 작은 object를 할당하는데 매우 효과적인 구조
Page heap
- TCMalloc에 의해 관리되는 Heap은 span에 의해 표현되는 page들의 묶음을 사용
- 32KB를 초과하는 object를 할당할 때에만 Page Heap에 직접적으로 할당 수행
작은 object를 할당하기에 메모리가 부족한 경우, 32KB 이상 object처럼 Page Heap으로 할당
만약 Page Heap도 충분하지 않은 경우 OS에 메모리를 더 요청
작은 object의 할당 속도를 높이기 위해 thread-local cache를 사용하며
GC의 속도를 높이기 위해 scan/noscan을 유지합니다
이러한 TCMalloc 구조는 단편화를 방지하여 GC 동안에 compaction을 불필요하게 만듭니다
*/
TCMalloc의 자세한 사항은 아래에서 확인할 수 있습니다
여기까지 정리하고 TCMalloc를 기반으로한 실제 Go Memory 구조를 살펴보겠습니다
각 Go process는 OS에 의해 Virtual Memory가 할당됩니다
Go의 Virtual memory는 아래 그림과 같은 구조를 가집니다
Virtual Memory에 사용되는 실제 메모리 구조는 Resident Set이라 불립니다
Page Heap(mheap)
우리가 아는 그 Heap영역이며 Go의 동적인 데이터를 저장하는 영역입니다
- 컴파일 영역에서 계산되지 못하는 크기의 데이터
- 메모리의 가장 큰 블럭 차지
- GC가 수행되는 영역
Resident Set
- 8B 크기의 mspan으로 나뉘며 모두 하나의 mheap object 의해 모두 관리됨
32KB보다 큰 object는 mheap에 의해 직접 할당되며
이러한 큰 할당 요청은 central lock을 사용함
때문에 오직 P(Logical Processor/Hardware Thread)만 요청 가능
mspan
- mheap에서 page들을 관리하는 가장 기본 구조
- 같은 크기 page들의 묶음
- double-linked list이며 start page의 address, span size class, span의 page 개수를 가짐
- Go는 page들을 위 그림처럼 8B 이상, 32KB 이하 사이즈 별로 67개 클래스의 블럭으로 나눔
- 각 span은 2배 단위로 존재함
- 한쪽은 포인터를 가진 object들 저장(scan class)
- 다른 한쪽은 포인터가 없는 object들 저장(noscan class)
- 이러한 구조는 GC 동안에 참조 탐색이 noscan span에 도달하면
더 이상 'alive' object를 scan할 필요가 없음을 파악하는 장점이 있음
mcentral
- 같은 크기의 span 클래스 별로 span들이 그룹화된 구조
- mcentral마다 두 개의 mspanList를 포함
- empty mspanList
- free page가 없어 할당에 사용할 수 없는 span list
- allocated object나 mcache에 cache된 span들의 double-linked list
- 여기 존재하는 span이 free되면 free page가 생겨 non-empty list로 이동됨
- non-empty mspanList:
- free page가 있어 할당에 사용할 수 있는 span list
- free object의 span들의 double-linked list
- mcentral로부터 새로운 span이 요청되었을 때, non-empty list는 empty list로 이동함
- empty mspanList
- mcentral의 non-empty list에 더이상 mcache를 위해 할당할 수 있는 span이 없는 경우,
mcentral은 아래처럼 mheap으로부터 새로운 span들을 요청하여 mcache에 전달
arena
- Heap 메모리는 할당된 Virtual Memory 이내에서 요청에 따라 증가하고 줄어듦
- 더 많은 메모리가 필요해지면, mheap은 arena라고 불리는 64MB(64-bit arch) 덩어리를 Virtual Memory로부터 당겨옴
- page들은 이 영역에서 span들에게 묶임
mcache
- Logical Processor가 작은 객체들(<=32KB)을 저장하기 위한 Cache Memory
- 즉, P에 붙어있음
- Goroutine들은 아무런 lock없이 mcache로부터 메모리를 얻을 수 있는 구조
- 모든 P의 영역은 분리되어 있고, P는 한 번에 하나의 Goroutine만 다루므로
메모리 접근은 서로 분할되어 있기 때문에 lock이 필요 없음
- 모든 P의 영역은 분리되어 있고, P는 한 번에 하나의 Goroutine만 다루므로
- lock을 사용하지 않고 메모리를 할당할 수 있으므로 더욱 효율적 구조
- 이는 쓰레드의 stack과 비슷하지만 heap의 일부이며 dynamic 데이터에 사용
- mcache는 모든 class 크기 별 mspan의 scan, noscan 타입을 포함
- mcache는 새로운 span들이 필요해지면 mcentral에 요청하여 non-empty list를 얻을 수 있음
Stack
Go는 Stack 영역과 Goroutine마다 하나의 stack이 존재합니다
function frames, static structs, primitive values, pointers들을 포함한 static 데이터가 저장됩니다
P에 할당되는 mcache와는 다릅니다
2. Go Escape Analysis
이쯤에서 위의 메모리 구조가 코드에서 어떻게 동작하는지 확인해보고 가겠습니다
Go는 Stack에 할당하는 것을 선호합니다
가능하면 모두 Stack에 할당하려고 하죠
Go가 다른 Garbage Collector를 가진 언어와의 큰 차이점은
많은 object들이 stack에 직접 할당된다는 것입니다
Go compiler는 어느 데이터가 Stack에 갈지 Heap에 갈지 결정하는
escape analysis라 불리는 과정을 수행합니다
(compile-time에 수명을 알 수 있는지 여부 확인)
Go에서는 아래 명령을 사용해 App을 빌드하면
go build -gc flags '-m'
컴파일 과정에서 수행되는 escape analysis 과정을 콘솔에서 확인 가능합니다
해당 과정을 시각화하여 보겠습니다
main function이 완료되면 main frame이 stack에서 해제되고 heap의 object들을 참조하는 pointer가 없어집니다
(Employee는 고아가 되어 이후 GC에 의해 처리됩니다)
Stack은 Go보단 OS에 의해 자동으로 수행됨, 따라서 Stack은 신경 쓸 필요가 없습니다
하지만 Heap은 그렇지 않으며, 단편화도 발생하며 App의 속도도 느리게 하고 자원을 고갈할 수도 있습니다
이 때문에 Gabage Collection이 존재합니다
3. Go Memory Allocation
Go GC를 살펴보기 전에 Go의 메모리 할당을 먼저 살펴보겠습니다
이제 Go의 메모리 할당을 살펴보겠습니다
Go는 object 크기에 따라 세 가지 방식으로 할당을 결정합니다
아래의 예시는 하나의 Goroutine을 예시로 이루어집니다
실제론 여러 개의 Goroutine에서 병렬적으로 동작할 수 있습니다
(1) Tiny(< 16B)
- mcache의 tiny allocator를 사용해 할당
- 한 블럭당 16B로 이루어짐
(2) Small(16B ~ 32KB)
- G가 동작하는 P의 mcache의 mspan(해당 사이즈 class의)들이 할당
- 만약 mspan의 리스트가 비어있고, allocator는 mcentral에 요청해 mspan으로 사용하기 위한 page들을 가져옵니다
- mheap이 비어있거나 해당 요청만큼 충분히 큰 page가 없는 경우 OS로부터 새로운 그룹의 page들을 할당받습니다
(3) Large( > 32 KB)
- mcache를 사용하지 않고 mheap에 해당 사이즈의 span으로 직접 할당합니다
- mheap이 비어있거나 해당 요청만큼 충분히 큰 page가 없는 경우 OS로부터 새로운 그룹의 page들을 할당받습니다
여기까지가 Go가 메모리를 할당하는 방법입니다
TCMalloc과 마찬가지로 사이즈 별 mspan과 mcache를 이용해
단편화와 Lock에 의한 비용과 지연을 줄일 수 있는 구조입니다
4. Go Garbage Collection(GC)
Go는 Stack에 할당하는 것을 선호합니다
가능하면 모두 Stack에 할당하려고 하죠
Heap에서 사용 후 해제되지 않고 남은 메모리는 메모리 누수로 이어집니다
Go는 이러한 문제 발생을 막기 위해 Garbage Collection(GC)을 수행합니다
Go GC 동작을 단순히 요약하면
Stack에서 더 이상 참조하지 않거나 다른 object에서 더 이상 참조되지 않아
고아가 된 object들의 Memory를 해제합니다
(참조는 포인터로 해당 object를 가리키고 있음을 의미)
Go는 non-generational concurrent tri-color mark and sweep 방식을 사용합니다
방식이 굉장히 긴데 각 단어가 무슨 뜻인지 봅시다!
세대별 GC는 아래의 가설에 기반합니다
- 임시 변수 같은 수명이 짧은 변수는 자주 사용된다
결과적으로 세대별 GC는 최근 할당된 object에 집중합니다
하지만 위에서 봤듯이 compiler 최적화를 허용하는 Go compiler는
object의 수명이 compile-time에 파악되는 경우 Stack에 할당합니다
즉, Go에서는 세대별 GC가 필요하지 않으며,
이 때문에 non-generational한 방식을 사용합니다
Go는 P마다(Goroutine마다) 할당을 수행하기 때문에 lock을 최소화하였고
mutator thread들에 의해 concurrent하게 GC가 수행될 수 있으므로
concurrent란 단어가 붙었습니다
위의 Go Memory Allocation 부분에서 봤듯이
할당은 정해진 size class별로 진행하여
단편화를 최소화하였으며 이러한 특징들 때문에
non-generational, non-compacting한 특징을 갖고 있습니다
mark and sweep은 GC의 방식이며
tri-color는 Go가 Mark & Sweep을 구현하기 위해
세 가지 색(White, Gray, Black)을 사용한 알고리즘입니다
알고리즘 내용을 살펴보겠습니다
Go GC 단계
Heap의 할당이 특정 percentage(GC percentage)에 도달했을 때,
동작을 수행합니다
- Mark Setup(Stop the world)
- GC가 시작되면 'Stop The World'라는 프로세스로 App(모든 Goroutine)의 동작을 중지
stop 동안 모든 P와 Goroutine이 GC safe point에 도달하도록 함- Stop the world : GC 수행을 위해 App(모든 Goroutine)의 동작을 중지
- 데이터 무결성을 보장하기 위해 Write Barrier를 ON하여 Heap의 데이터 무결성을 유지
- GC가 시작되면 'Stop The World'라는 프로세스로 App(모든 Goroutine)의 동작을 중지
- Marking(Concurrent)
- 모든 P와 Goroutine들이 설정된 Write Barrier를 갖게 되면
Stop되었던 Go runtime은 다시 동작을 시작하고
worker들은 Marking을 수행 - write barrier를 갖게 되면 사용 가능한 CPU의 25%를 사용해
worker들로 병렬 Marking을 수행 - Marking이 시작되면 모든 object들은 하얀색, Stack의 GC Root object들은 회색으로 표시
- Stack의 GC Root에서부터 marking을 시작하고, marking 되는 중에
traverse 되는 object는 회색으로 표시 - worke에 의해 marking이 수행되면
mark가 수행되는 P와 Goroutine은 block됨 - marking이 완료되면 해당 P와 Goroutine을 다시 동작
- 이 과정 중에 새롭게 할당되는 object는 바로 검은색으로 표시
- 이 작업 시간이 길어지는 경우 App에 활성화되어 있는 Goroutine의 지원을 받을 수 있음
- Mark Assist라 부름
- 모든 P와 Goroutine들이 설정된 Write Barrier를 갖게 되면
- Mark Termination(Stop the world)
- 모든 Goroutine들의 marking이 완료되면
Goroutine은 짧게 중지되고 write barrier를 OFF - Goroutine은 다시 활성화
- 회색으로 표시된 object들은 검은색이 되기 위해 queueing되어 있으며
여전히 사용 중인 object란 의미 - 모든 회색이 검은색으로 바뀌게 되면 marking이 완료된 것
- 이 작업 동안 GC는 다음 GC 일정을 계산
- 모든 Goroutine들의 marking이 완료되면
- Sweeping(Concurrent)
- collection이 끝나고 할당이 시도되면, sweep 작업이 수행되어 하얀색 object 정리
- 'alive' 표시되지 않은 Heap 영역의 데이터를 청소
- 이 작업은 App이 동작하면서 Background에서 concurrent 하게 수행됩니다
위 과정을 아래와 같은 그림으로 보면
STW(Stop The World)와 Write Barrier 구간을 한눈에 볼 수 있습니다
해당 과정을 메모리 관점의 그림으로 살펴봅니다
GC Root는 Stack에서 접근할 수 있는 변수나 오브젝트를 뜻합니다.
가비지 컬렉션이 수행될 Root라는 뜻이며 GC Root에서 시작해 이 Root가 참조하는 모든 오브젝트, 또 그 오브젝트들이 참조하는 다른 오브젝트들을 탐색해 내려가며 마크(Mark)를 수행합니다.
이것이 바로 GC의 첫 번째 단계인 Mark단계입니다.
여기서부터 참조 경로에 포함되어 있었던 object(회색)을 순서대로 검사합니다
이 과정들을 거쳐 검은색으로 Marking된 부분들은 여전히 사용되고 있는 object와 메모리임을 알 수 있습니다
흰색 메모리들은 Sweep 과정에서 정리될 예정입니다
그림에서 Sweep이 안 나오는 이유는
Go는 Lazy Sweeping과 Background Concurrent Sweeping을 사용하기 때문입니다
App 동작과 Allocation 동안에 Sweeping이 이루어집니다
결과적으로 GC는 각 GC 싸이클의 시작과 끝에 발생하는 STW에 초점을 맞추어야 합니다
GC 과정은 Stop-the-world를 만들어내지만 굉장히 빠른 시간 내에 처리됩니다
하지만 Heap의 크기에 비례하여
STW(Stop The World) latency가 증가함을 유의해야 합니다
Heap이 굉장히 커지게 되면 STW가 길어지는 것이죠
STW를 짧게 유지하기 위해서는 Heap의 사용량을 줄이는 것이 좋겠죠
또한, 포인터 사용을 줄이는 것이 scanning time을 줄입니다
아래 GC의 go 코드를 참고하면 주석 설명과 함께 구현을 볼 수 있습니다!
https://github.com/golang/go/blob/master/src/runtime/mgc.go#L7
5. Go GC Pacing
Go GC는 다음 GC를 언제 trigger할지 결정하기 위해 Pacer를 사용합니다
Pacing 알고리즘은 목표 Heap 크기에 도달하도록 적절한 GC 주기를 찾으려 합니다
해당 알고리즘은 1.5에서 처음 게시되었으며 계속 연구되고 있습니다
위의 GC 과정에서 설명했듯이 GC Mark Termination 단계에서
다음 Heap Trigger 크기를 설정하고 이를 수행합니다
Go의 Default Pacer는 Heap의 크기가 2배가 될 때마다 trigger하려 시도합니다
- "Alive" 메모리를 Mark한 후에 총 Heap의 크기가
현재 "Alive" 크기의 2배가 될 때 GC를 trigger 하기로 결정 - trigger 비율을 설정하는 데 사용하는 변수는 GOGC에서 가져옵니다
Heap의 크기가 목표한 크기와 같아지면
Stop The World를 하고, GC를 수행하는 것이죠
한 가지 오해는 GC가 수행되는 주기를 늦추는 것이
성능을 향상하는 방법이라고 생각하는 것입니다
하지만 실제로 GC의 주기를 길게 늘여도 오히려 처리량이 낮아지는 경우도 있습니다
주기가 늘어날수록 한 번에 처리해야 할 Heap의 크기도 커지며 이는 GC의 성능을 크게 낮출 수 있습니다
때문에 GC 튜닝을 위해 GOGC 값을 변경하려면 신중해야 합니다
물론, GC의 성능을 높이는 가장 훌륭한 방법은 처음부터
작은 Heap으로 필요한 처리량을 달성하는 것입니다
특히, 클라우드 환경에서 실행할 때 Heap 사용을 최소화하는 것이 중요하죠
GC Pacing 알고리즘은 계속 연구되고 있으며 멀지 않은 시기에 바뀔 것으로 보이며
GC 튜닝을 위해서는 개발자들도 계속해서 Follow up 해야 할 내용입니다
현재 알고리즘은 아래 두 가지를 핵심 목표로 설계되고 있습니다
- Effective Heap Size와 Heap Size Goal 간의 간극을 최소화하려 노력합니다
- Effective CPU Utilization과 CPU Utilization Goal 간의 간극을 최소화하려 노력합니다
Go memory 구조와 관리에 대해 살펴봤습니다
물론 실제로는 더 복잡하고 많은 개념과 Feature들이 있으며 버전마다 추가된 디테일이 많이 있습니다만
Go 개발자들이 전반적인 흐름을 파악할 수 있는 수준으로 내용을 정리해봤습니다
다음 포스팅엔 실제 성능 향상을 위해 지킬 수 있는 내용을 정리해보겠습니다
[Reference]
https://deepu.tech/memory-management-in-programming/
https://deepu.tech/memory-management-in-golang/
https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
http://tcpschool.com/c/c_memory_structure
https://engineering.linecorp.com/ko/blog/go-gc/
https://medium.com/safetycultureengineering/an-overview-of-memory-management-in-go-9a72ec7c76a8
http://goog-perftools.sourceforge.net/doc/tcmalloc.html
https://medium.com/eureka-engineering/understanding-allocations-in-go-stack-heap-memory-9a2631b5035d
https://go.dev/blog/ismmkeynote
https://www.timqi.com/2020/06/11/golang-memory-management/
https://developpaper.com/detailed-explanation-of-memory-allocation/
'Programming > Go' 카테고리의 다른 글
Go/Golang Scheduling (8) | 2021.09.02 |
---|---|
Go/Golang Test (2) | 2021.08.26 |
Go/Golang Protobuf Compile Guide (0) | 2021.08.16 |
Go/Golang Configuration (0) | 2021.08.16 |
Go/Golang awesome-go (0) | 2021.08.11 |