Golang은 메모리 관리를 런타임에서 관리해주는 Garbage Collector를 가지고 있는 언어이다.
이번에는 Golang의 GC는 어떻게 동작하는지 정리해보려고 한다.
프로그래밍 공부를 하면서 C/C++언어를 모두 한번쯤은 공부하며 malloc이나 free같은 함수를 사용하여 메모리 할당 해제 경험을 해봤을 것이다.
메모리를 직접 관리 하는 경우의 장점은 잘! 관리한다면 메모리 사용을 정밀하게 제어하여 메모리를 효율적으로 사용할 수 있다.
하지만 단점도 존재한다. 메모리 관리를 잘못해 Dagling Pointer, Memory Leak같은 일이 발생될 수도 있다.
메모리 관리를 직접해야하는 언어를 사용하는 경우 항상 메모리 관리를 신경쓰며 개발해야한다는 번거로움이 있다.
위 처럼 개발자 들이 직접 메모리를 관리해줘야 하는 복잡성 인해 메모리를 자동으로 관리해주는 GCGarbage Collector가 등장하게 되었다.
GC는 메모리 관리 기법중 하나이며 프로그램에서 동적 메모리를 할당했지만 더 이상 사용하지 않는 메모리를 확인하여 재사용 가능하도록 회수하는 역할을 수행한다.
물론 GC를 사용하면 편리하기는 하지만 오버헤드도 분명히 존재하다. 하지만 편리함에서 오는 개발자들의 생산성 증가의 장점이 있기때문에 현재 많은 언어에서 메모리 관리하는 방법으로 GC를 제공한다.
Golang의 GC는 어떻게 관리되는지 확인해 보자
It is a concurrent mark and sweep that use s a write barrier.
It isnon-generational and non-compacting.- runtime/mgc.go
Golang은 concurrent mark and sweep, non-generational, non-compacting GC 라고 이야기 한다.
무슨말인지 천천히 알아보자...
Golang에서도 메모리 해제 방법으로 Mark and Sweep방법을 사용한다.
가장 기본적인 Mark and Sweep부터 간략하게 정리하고 넘어가 보겠다.
이미지 링크
위 그림을 보면 알겠지만 말 그대로 객체를 mark하고 sweep하는 작업이다.
mark and sweep방식은 2단계로 나눠저 있다.
1. mark
스택에서 힙을 참조하는 있는 root포인터를 찾아 루트 노드부터 체이닝 하며 접근할 수 있는 객체를 모두 마킹 한다.
GC root의 하위 객체들과 연결되어있지 않는 객체들은 마킹되지 않는 모습을 볼 수 있다.
2. sweep
mark단계에서 순회후에 마킹되지 않는 객체들은 더이상 사용하지 않는 객체라고 판단하고 sweep단계에서 모두 삭제한다.
mark and sweep은 GC작업동안 애플리케이션이 객체 상태를 바꾸게되면 문제가 생길 수 있기때문에 STWStop the world상태가 되는데 STW는 말 그대로 세상을 멈추듯이 다른 스레드를 모두 중지 시킨다.
안전성을 위해 STW상태를 만들지만 애플리케이션의 성능에는 좋지 않은 영향을 끼친다.
예를 들어 어떠한 작업을 처리하는도중 GC가 시작되게 되면 STW로 인해 멈추게 되고 STW상태가 끝날 때 까지 딜레이 되게 된다.
tri-color marking은 mark and sweep의 작업 상태를 추상화하는 방법이다.
tri-color라는 말처럼 3가지 색상으로 객체를 마크하여 객체의 상태를 표시한다.
tri-color Mark & Sweep의 동작 과정은 이러하다
1. GC는 모든 객체들을 White로 칠한다.
2. 스택에서 힙을 참조하고 있는 root객체를 찾아 루트 노드부터 grey로칠하고 하위 객체까지 완료되면 black으로 변경한다.
3. 2번은 계속 반복하고 더 이상 접근할 수 없는 white상태의 객체만 남게 된다.
4. white객체는 더 이상 접근할 수 없다고 판단하고 메모리를 해제한다.
Golang은 STW로 인한 GC laytency를 낮추기 CMSConcurrent Mark and Sweep 방식을 선택했다.
mark과 sweep 과정에서 STW상태에 들어가지 않고 애플리케이션과 동시에 작업한다. (아예 없는것은 아니다.!!!)
잘못된 메모리 할당을 방지하기 위해 STW를 한다고 했는데 STW가 없으면 어떻게해???
마킹이 완료된 블랙 객체에 새로운 객체의 참조가 일어나면 새로운 객체는 white상태라 sweep단계에서 해제당할거 아니야??
맞다 Golang의 CMS방식은 STW상태가 아니다.
그렇기 때문에 위같은 동시성 환경에서 발생한 수 있는 문제를 방지하기 위해 Golang은 write barrier를 사용하여 marking단계에서 생성되는 객체는 바로 grey표시를 해버린다.
write barrier는 마킹 단계에서 새로운 데이터가 할당됐을때 정합성을 유지해주는 기능이다.
위에서는 STW상태가 아니라고 말했지만 정확히 말하자면 STW가 없는것은 아니고 Write Barrier가 키거나 끌때 아주 잠시 STW상태가 된다.
위 내용들만 보면 Golang은 STW의 오버헤드도 많이 줄였으니 GC가 애플리케이션에 영향을 별로 안주겠네 라고 생각할 수 있지만 꼭 그렇지만은 않다.
Go의 GC는 GC가 시작되면 Write Barrier가 켜지고 마킹 작업을 시작하게 되는데
가용 가능한 CPU사용률의 30%를 목표로 CPU 리소스를 가져다 사용하게 된다. (GC Worker 25%, assis goroutine 5%)
GC가 CPU의 리소스를 가져다 사용하는 만큼 애플리케이션의 속도 저하의 원인이 된다.
애플리케이션 실행중 특정 트리거로 인해 GC가 수행되면 아래 그림과 같은 해제된 빈공간이 생긴다.
실제로 사용 가능한 공간임에도 불구하고 빈 공간의 크기가 연속되지 않고 작게 분포되어 있어 사용하지 못하는 상황이 생긴다.
메모리를 할당/해제하는 과정에서 파편화된 메모리들이 잘 관리되지 않아 필요한 메모리보다 더 많은 메모리를 사용하게되는 현상을 메모리 단편화Memory Fragmentation라고 부른다.
이러한 현상은 공간이 충분함에도 불구하고 새로운 메모리 공간을 할당받아야 하기때문에 낭비 문제를 야기한다.
많은 언어들은 문제를 해결하기 위해 압축compacting방법을 사용한다.
물건들을 정리하여 빈공간을 만들어 내듯이 살아있는 객체들을 한곳으로 모아 빈공간을 만들어 낸다.
위 메모리를 정리하는 과정에서 애플리케이션이 데이터에 접근하면 참조 문제가 생길 수 있기 때문에 read barrier를 사용한다.
해당 글을 보면 golang은 compaction과 관련된 "read barrier free concurrent copying GC"를 계획했으나 시간상의 문제와 read barriers 오버헤드에 대한 불확실성으로 선택하지 않고 현재의 "read barrier free concurrent GC"가 되었다.
그러면 어떻게 메모리 파편화 문제를 해결했을까???
Golang의 메모리 할당자는 TCMalloc이라는 메모리 할당자와 굉장히 유사하게 모델링 되어있다.
TCMallocThread caching memory allocating은 구글에서 개발한 메모리 할당자로 central heap과 Thread별로 TLCthread local cache라는 영역을 가지고 있다.
TLC에는 주로 32kb이하의 작은객체(small)을 할당하고 그 보다 더 큰 객체(large)들은 Central heap에 할당하여 관리한다.
여기서 주목해야할 점은 스레드별로 TLC를 가지고 있다는 점인데 장점은 빠른 메모리 할당이 가능하다.
왜 빠른 메모리 할당이 가능해???? 라고 물어본다면
위와 같은 구조로 미디어 처리나 대용량 처리를 사용하지 않은경우 센터힙까지 가지않고 스레드 로컬 메모리 영역안에서 처리한다는 뜻이다.
또 TCMalloc은 size-class라는 개념을 가지고 있다.
size-class는 클래스마다 특정 범위의 크기를 묶어서 관리하는 개념으로 보면 된다.
각각의 클래스는 크기에 맞는 메모리 블록으로만 구성되어 있다.
비슷한 크기별로 메모리 블록을 관리함으로써 메모리 단편화를 감소시킨다.
위 TCMalloc의 자세한 내용은 여기에서 확인이 가능하다.
Golang이 메모리를 어떻게 관리하는지 보자.
먼저 G, P, M에 대해서 간략하게 정리해보겠다.
(이 친구들이 뭐하는지 궁금하다면 golang runtime scheduler을 보자)
msapn: 특정 크기의 연속된 메모리 블록을 관리한다.
mcache: TCMalloc의 TLC같은 영역이다. mcache는 각 P(Processor)에 할당된 로컬 메모리 캐시로 32KB이하의 작은 객체들을 주로 관리한다.
주로 32kb이하의 작은 객체들을 관리한다. mcache에 공간이 부족한 경우 mcentral에서 추가로 할당받는다.
mcentral: 특정 사이즈 클래스의 메모리 mspan들을 관리한다. 이는 전체 시스템에서 해당 사이즈 클래스의 메모리 할당을 조정하는 중앙 저장소 역할을 한다.
mheap: 할당된 힙 전체 영역이다.
Golang은 객체의 크기에 따라 할당 프로세스를 결정한다.
Tiny Allocator(size < 16B), Small(16B ~ 32KB)
mcache에서 특정 크기 범위안에 있는 객체들끼리 묶어서 관리 한다.
비슷하게 작은 객체들끼리 관리되기 때문에 메모리 절약에 효과적이다.
Large objects (size > 32KB)
32kb보다 큰 객체들은 mheap에 할당한다.
크게 tiny, small, large3개로 분류했지만 실제로 32kb이하의 사이즈들은 총 70개의 span size class로분류하여 저장한다고 한다.
위처럼 비슷한 크기의 객체들을 모아 관리함으로써 서로 다른 객체들이 불규칙하게 할당될 때 발생하는 빈 공간들을 줄여주고 메모리 효율성을 높이는 방법을 선택했다.
그렇다고 단편화 현상이 아예 없어지는것은 아니고 덜 발생되는 방법이다.
사이즈가 큰 객체를 많이 할당하는 애플리케이션의 경우에는 단편화가 심각해질거라고 생각되는데
golang은 단편화의 문제를 심각하게 보지 않는것인지 내가 모르고 있는 내용들이 있는것인치 찾아보고 추가로 정리해봐야 겠다.
세대별 GC, Generational GC라고 불린다.
대부분의 새로생긴 객체는 오래된 객체보다 더 짧은 시간내에 사용되지 않게 되는 경향이 있다라는 세대 가설Generational hypothesis 을 기반으로 한다.
세대별이라는 말은 힙 영역을 세대별로 나눠 오래 살아남은 객체와 그렇지 않은 객체를 구분 짓은 것을 의미한다.
Young 영역과 Old영역을 분리하여 새로 생긴 객체들에 집중해 Mintor GC를 자주 실행하고 힙 전체 GC를 줄인다.
하지만 Old영역에 있는 객체가 Young영역에 있는 객체를 참조하고 있을 때 문제가 발생한다.
Young영역 Old영역을 나눠서 GC를 수행한다고 했는데 Old영역에 있는 객체가 Young영역에 있는 객체를 참조하게 되면 Young영역 GC수행중 지워저 버릴수가 있다.
그렇다고 힙 영역을 모두 확인하면 세대별 GC를 사용하는 이유가 없어져 버린다.
이런 문제를 막기 위해 write barrier를 사용해 old객체에서 young객체를 참조하는 경우 해당 참조를 별도 기록하는 처리를 한다.
generational GC는 충분히 효율적이라고 생각하는데 Golang은 왜 도입하지 않았을까??
해당 글을 보면 golang은 generational GC도입을 시도했지만 아래와 같은 결론을 낸듯하다.
여기서 말하는 escape analysis는 뭘까??
Compiler는 컴파일 할때 여러 작업을 거친다.
파싱, AST, 최적화와 이스케이프 분석, SSA......
escape analysis에 대해서 알아보자.
Golang은 reference-oriented가 아닌 value-oriented이다. 왜 value-oriented를 선택했을까???
golang 컴파일러는 escape analysis단계에서 객체가 함수 범위 벗어나는지, 참조 전달하는지 지역성(locality) 확인하여 데이터를 자동으로 스택에 할당할 수 있는지 또는 힙에 할당해야 하는지 결정한다.
Stack은 간단하지만 빠른 구조로, 후입선출LIFO 순서로 값에 접근할 수 있도록 한다.
스택은 사용 중인 메모리를 제거해야 하는 시점을 파악하기 위한 추가 오버헤드가 없기 때문에 매우 빠르다. 단순하게 가장 위에있는 데이터를 읽고 쓰다가 함수가 종료되면 같이 메모리 할당 해제한다.
golang은 최대한 스택 영역을 활용하기 위해 값지향 방식을 사용한다. 이 방법을 도와주는 escape analysis도 존재한다.
generational GC는 수명이 짧은 객체를 heap에 할당했다가 빠르게 정리한다는 개념이지만 Golang은 수명이 짧다고 판단되는 객체는 heap이 아닌 stack에 할당하여 GC를 수행할 필요도 없이 스택 영역에서 생겼다가 사라진다.
메모리 할당을 할 때 비용을 1회만 지불하는 것이 아니다. 첫 번째는 당연히 할당할 때 지불하는 것이고, 또 가비지 컬렉션이 실행될 때마다 비용을 지불해야 한다.
- 데미안 그리스키Damian Gryski, 깃허브 페이지 dgryski/go-perfbook95
어차피 알아서 해주는데 내가 알아야해???
맞는 말이긴 하다. 어차피 Runtime에서 알아서 해준다... 멋쓱
하지만 GC수행이 애플리케이션에 어떤 영향을 미치는지는 알아야하지 않을까 생각한다.
위에서 설명했듯이 GC가 수행되면 CPU사용량의 30%나 사용하게 된다. 이는 곧 애플리케이션의 속도와 연결된다.
그러면 GC임계값을 최대한 높여서 적게 수행하도록 만들어야 하나요???
뭔가 그럴듯 하지만 너무 가끔 실행하게 만들면 메모리 사용량이 많아져 mark 단계가 길어질 수 있고 순간적으로 메모리를 많이 사용하는 애플리케이션의 경우 OOMOut of Memory으로 비정상 종료가 될 수 있다.
그러면 뭐 어쩌라는 거임???
애플리케이션의 특성에 따라 적절하게 GC설정을 하고 메모리 최적화 습관을 들이는게 좋지않을까 ...? 생각한다.
나도 모르는 내용은 똑똑한 사람들의 경험으로 대체하도록 하겠다.
아래 글들은 GC 튜닝 경험이나 메모리 최적화 경험들이다.
공부하며 정리한다고 하긴 했는데 빠진 내용들이 있는듯 하다.
번역실수로 잘못된 내용이 있다면 빠르게 수정하도록 하겠습니다.