[Go] 메모리 관리와 최적화

Go에서 메모리를 관리하는 원리와, 메모리 사용량이나 GC 타임을 최적화하는 방법을 정리해보겠다.




Go의 GC


https://www.pjstar.com/story/news/local/2021/12/06/whats-best-way-leave-tip-peoria-garbage-collectors/8795034002/
Go는 바이너리를 뽑는 컴파일 언어지만 런타임이 존재하며, 런타임에 의해 메모리 할당, 고루틴 등이 자동으로 관리된다.
모든 값이 다 GC 대상이 되는 건 아니고, 힙 할당만 GC 추적 대상이 된다. 스택 변수는 대체로 GC에서 자유롭다.

Go는 Mark & Sweep 기법으로 GC를 수행한다.
자세한 것은 별도 포스트 참조
https://m.blog.naver.com/sssang97/221567636644

GC 빈도는 설정값을 통해 간접적으로 조정할 수 있으나, GC가 도는 시점은 런타임이 알아서 하는 부분이다. 강제로 돌리는 법이 있지만 권장되진 않는다.

GC가 돌때 정리할 사이즈가 크면 Java 등과 마찬가지로 freezing 현상이 발생할 수 있는 문제가 있다. 다 멈추고(stop the world) 정리하기 때문이다. GC로 인한 freezing은 대체로 수백밀리초 미만으로 발생한다.

사이즈가 작을때는 개별 GC로 자잘하게 정리하기 때문에 부하가 비교적 적다.

Go GC에 대한 세부사항들은 아래 문서를 참조하면 좋겠다.
https://tip.golang.org/doc/gc-guide




최적화 1: 메모리 할당 빈도 줄이기

GC 압력을 줄이는 가장 좋은 방법은 코드 수준에서 힙 할당이 적게 일어나게끔 유도하는 것이다.
여기에는 여러가지 방법이 있다.

A. 슬라이스/맵 생성 시 capacity를 지정하기
슬라이스와 맵은 별도 사이즈를 지정하지 않고 생성할 경우, append를 할때마다 사이즈가 자동으로 재할당된다.
이를 막기 위해서는 make로 사이즈를 별도로 지정해주는 것이 좋다.

이런건 메모리 할당뿐만 아니라 실제 성능에도 영향을 준다.

B. 할당 재사용하기
문제가 있는 경우가 아니라면 할당한 변수를 재사용하도록 하는 것도 방법이다.
간단한 경우에는 그냥 다시 써도 되고, sync.Pool을 쓰거나 다른 memory arena 구현을 사용해도 좋다.

C. 과도한 인터페이스 변환 남용 방지
Go의 인터페이스는 기본적으로 fat pointer다. 그래서 인터페이스로 래핑하는 순간 메모리 할당이 발생하는데, 메모리나 CPU적으로나 별로 좋진 못하다.
필요하다면 쓰는게 맞지만 굳이 필요하지 않은 경우에는 남발하지 않는게 좋겠다. 특히 any=interface{} 변환같은거 말이다.

D. 기본 포인터 객체 잘 다루기
평소에는 너무 가볍게 쓰지만 사실 성능 병목의 원인이 될 수 있는 타입이 몇개 있다.
대표적으로 time.Time은 내부적으로 포인터를 저장하며, 문자열과 슬라이스 모두 내부적으로 포인터를 관리한다. 힙에 할당될 수 있다는 소리다.
일반적인 사이즈에서는 신경쓰지 않아도 되지만 사이즈가 커지면 특수한 최적화를 고려해야 한다.




최적화 2: GC 시간 줄이기

아예 GC 스캔과 정리 시간을 줄이는 것도 유효한 최적화 방법론이다.
그러려면 GC의 원리를 대강이나마 알아야 한다.

GC가 시작되면 컬렉터는 스택에서 시작해서 포인터를 따라 파고들며 스캔을 하게 된다.

이 과정에서 포인터가 없다면 그냥 그대로 스캔을 끝내고 돌아가지만, 포인터가 있다면 그걸 큐에 넣고 또 들어가서 보게 된다.
결국 이 포인터를 얼마나 덜 쓰냐에 따라서 GC 효율성이 결정되는 것이다.

A. 포인터 최소화하기
앞서 말했듯 포인터가 적으면 적을수록 GC 스캔 시간이 획기적으로 줄어든다.
논리적으로 꼭 필요한 경우가 아니라면 일반 값 타입을 위주로 사용한다.

B. 포인터 depth 줄이기
포인터를 쓰더라도 그 scan depth가 너무 길지 않도록 하는 것이 좋다.
가령 []*T(포인터의 배열) 대신 *[]T(배열의 포인터)가 가능하다면 그리 하는 것이 바람직하다. 각 배열 요소가 전부 포인터라면 거기에 대해서 다 스캔을 때리게 되기 때문이다.
그리고 구조체에 포인터가 여러 겹으로 중첩돼있다면, 가능한 경우 압축하거나 값 타입으로 바꿔주는 것이 좋다.




최적화 3: GOGC 옵션 조정하기

GOCG는 GC 빈도를 조정할 수 있는 옵션이다.
환경변수에 GOGC라는 필드를 할당하거나, debug.SetGCPercent(N) 함수를 호출해서 설정할 수 있다.

정확히 말하면 GC에 있어서 메모리와 CPU의 균형을 맞추는 것인데, GOGC 값이 유효할 경우에는 값이 클수록 GC를 수행하는 빈도가 낮아진다.

좀더 상세한 예를 들자면, GOGC의 값이 2배가 되면 메모리 오버헤드가 2배로 늘고, CPU 비용은 절반이 된다.
GOGC의 값이 절반이 되면 반대로 메모리 오버헤드가 절반이 되고, CPU 비용이 2배가 된다.

참고로 GOGC의 기본값은 100이다. 메모리를 2배로 쓰고 싶다면 200으로 하면 되는 것이다.

GOGC=off를 날리거나 debug.SetGCPercent(-1)를 실행하면 GC가 아예 꺼진다.
빠르게 돌고 죽어야 하는 일회성 Batch 프로그램의 경우에는 그렇게 해도 된다.

GOGC의 최적값은 환경에 따라 다 다를 수 있다. 그때그때 알맞게 시험해보거나, 이를 위한 보조도구를 활용해볼 수도 있겠다.




흑마술 1: 강제로 GC 날리기

일반적인 경우는 아니지만 임의로 Full GC를 날려서 정리를 해야할 필요가 있을 수도 있다.
그럴 때는 debug.FreeOSMemory 함수를 이용하면 된다.

이건 그다지 추천되는 방법은 아니다.




흑마술 2: 메모리 고정하기

Go에서 모든 변수는 메모리 위치가 보장되지 않는다. 그러니까, 변수의 주소가 달라질 수 있다는 것이다.
이건 일반적인 사용사례에서는 별로 문제가 되지 않는다.

문제가 발생하는 경우는 unsafe한 연동을 할 경우, C와의 상호운용성이 필요할 경우다.
만약 go에서 할당된 변수의 포인터를 C 함수에 보냈는데, 변수 위치가 달라지면 어쩌겠는가?

그럴때는 Pin을 사용해서 Object Pinning을 해주는 것이 좋다.
go 1.21부터 추가되었다.

사용법은 대충 이렇다.

pinner 객체를 만들면, 해당 객체로 Pin 메서드를 호출, 고정할 주소값을 전달해주면 끝이다.
그러면 Unpin을 할때까지 주소가 고정된다.



참조
https://tip.golang.org/doc/gc-guide
https://stackoverflow.com/questions/1821300/why-do-garbage-collectors-freeze-execution
https://ssup2.github.io/theory_analysis/Golang_Garbage_Collection/
https://stackoverflow.com/questions/38469672/increase-heap-size-in-go
https://stackoverflow.com/questions/12098435/can-you-pin-an-object-in-memory-with-go
https://betterprogramming.pub/memory-optimization-and-garbage-collector-management-in-go-71da4612a960
https://medium.com/a-journey-with-go/go-how-does-the-garbage-collector-mark-the-memory-72cfc12c6976
https://blog.gopheracademy.com/advent-2018/avoid-gc-overhead-large-heaps/