이론 정리/Golang

Go의 GC에 대해 (feat. java)

철매존 2022. 10. 31. 01:17
728x90

GC가 일단 뭔데?

C/C++을 배웠던 사람은 알텐데 얘들은 메모리를 사용할 때 필요할 때에 생성하고, 필요가 다하면 해제해 주었다.

이는 메모리의 관리 측면에서 굉장히 좋지만(쓸데없이 메모리를 먹는 애들이 없으니까 퍼포먼스가 뛰어나다) 매번 이를 적절하게 할당하고 해제해야 하기 때문에 아주 귀찮다.

그래서 매번 메모리를 해제하는걸 그냥 해주는 녀석이 생겨났고, 이녀석이 Garbage Collector 즉 GC이다.

STW : Stop The World

GC의 동작동안 다른 쓰레드의 작업은 모두 중단된다.

즉, 가비지 컬렉터가 동작하게 되면 모든 작업이 멈추게 된다는 것이다
그리고 이 STW시간을 줄이는 것이 바로 GC튜닝이다.

java에서의 GC

한국에서 웹개발을 할 때에 가장 보편적으로 사용되고, 보통 GC를 처음 접하게 되는 java에서의 GC특징에 대해 먼저 알아본다.

java에서 GC는 Heap영역에 존재한다.
이 Heap영역은 두 가지 전제 조건을 가지는데,

  • 대부분의 객체는 금방 접근 불가능한 상태(unreachable)가 된다.
  • 오래된 객체에서 새로운 객체로의 참조는 매우 드물게 일어난다.

이다.

이러한 개념을 통해 JVM에서는 Heap영역을 Young Old 두 가지 영역으로 나누었다.
(사족으로 java7까지는 Perm영역이 존재했었다고 한다)

Young영역

  • 새로운 객체가 할당되는 영역이다.
    • 위에서 설명했다시피 대부분의 객체는 금방 용도가 끝난다.
      • 따라서 대부분의 객체는 여기 존재했다가 사라진다.
    • 이 Young 영역에의 GC를 Minor GC라고 한다.

Old영역

  • 오래된 객체가 할당되는 영역이다.
    • 참고로 객체는 본래 Young영역에 생성되는데, 그 중 드물게 오랫동안 쓰이게 되는 친구들이 여기 복사된다.
    • 여기 할당되는 친구들은 오랫동안 살아있기 때문에 Old영역은 Young영역보다 큰 메모리 영역을 가진다.
    • 이 Old영역에의 GC를 Major GC / Full GC라고 한다.

java에서 Garbage Collector의 동작방식

  1. 먼저 GC의 동작동안 모든 쓰레드가 중단된다.(STW)
  2. 이후 스택에 있는 모든 변수 또는 Reachable 객체를 스캔하면서 각각 어떤 객체를 참고하는지 탐색한다. 그리고 사용하고 있는 메모리를 보고(Mark) 사용하지 않는건 제거(Sweep)한다. (Mark and Sweep)

그리고 한가지 중요한 것이, JVM에서는 가비지 컬렉션이 동작한 후 빈 공간이 생기면 다시 allocation을 진행해 준다.(압축)

이런 식으로

1, 2, 3, 4 라는 객체가 있다가 GC이후 2, 4가 사라지면 그것들을 붙이는 느낌으로 생각하면 된다.

일단 이걸 기본으로 깔고 간다고 생각하고 밑을 바라보자.

Minor GC의 동작

이는 Young영역에서 일어나는 동작이다.

그 전에 Young영역에 대해 잠깐 설명하자면, 얘는 1개의 Eden영역2개의 Survivor영역을 가진다.

  • Eden영역
    • 새로 생성된 객체가 할당되는 영역(아직 GC를 겪지 않음)
  • Survivor영역
    • 최소 1번이상의 Garbage컬렉터 동작 이후 살아남은 객체가 존재하는 영역(1번 이상의 GC를 겪음)

이것들을 가지고 동작을 살펴보면

  1. 객체를 새로 생성 시 Eden영역에 생성됨
  2. 여러번 객체를 생성하여 Eden영역이 가득 차게 되면 Minor GC실행
    • 앞으로 사용되지 않는 객체는 그냥 제거함
    • 다른 곳에서 사용하는 경우(Reachable) 일단 살려서 Survivor영역으로 이동
  3. Survivor영역이 가득 차면 여기에 대해서도 GC를 동작시킨다.
    • 앞으로 사용되지 않는 객체는 그냥 제거함
    • 여기서도 계속해서 사용되는 객체가 있으면 또 다른 Survivor 영역으로 이동
  4. 계속해서 객체가 살아남으면 Old영역으로 이동시킨다.
    • 어떻게 계속해서 살아남는지 알 수 있는데?
      • 이 사용 횟수를 알기 위해 MInor GC는 살아남는 객체에게 이 친구가 몇 번 살아남았는지를 알기 위해 age를 Object Header에 기록해 준다.

여기서 중요한 것은 Survivor영역의 경우 하나만 사용되고 있어야 한다는 것이다.
즉, 하나의 Survivor영역에서 계속해서 사용되는 객체들은 모조리 다른 Survivor 영역으로 이동한다는 것이다.

추가적으로 HotSpot JVM에서 Eden영역에의 객체 할당을 빠르게 하는 방법이 있다.

bump the pointer
  • Eden영역에 마지막으로 할당된 객체의 주소를 캐싱
  • 이렇게 하면 새로운 객체를 할당할 때에 마지막 할당 객체의 바로 다음 주소를 사용할 수 있다.
  • 참고로 압축을 진행하면 중간중간 빈 공간이 없게 되니까 이 bump the pointer진행시 무조건 캐싱된 맨 뒤 이후에 객체를 할당해주면 될 것이다.
TLABs(Thread-Local Allocation Buffers)

멀티 쓰레드 환경의 경우 Eden영역에 객체를 할당할 때에 Lock을 걸어 동기화를 해주어야 할 텐데, 이 때 발생 가능한 성능 문제의 해결을 위해 도입된 방법이다.

  • 각각의 Thread마다 Eden영역에 객체를 할당하기 위한 주소를 부여
  • 이를 통해 동기화 작업 없이 빠르게 메모리 할당이 가능해진다.
    • 각각의 Thread는 자신의 주소에만 객체 할당 진행

Major GC의 동작

이거는 Minor랑 비슷하긴 한데, Old영역이 부족해 지면 해주는 친구이다.

다만 위에서 설명하였듯 Old영역은 Young영역보다 상대적으로 매우 크다.
그렇기 때문에 Major GC의 경우 Minor GC보다 훨씬 오랜 시간이 걸리고, 그 시간 동안 STW가 걸려 있으므로 성능 하락에 큰 영향을 미치게 된다.

근데 여기까지 오면 좀 이상한게 있을 것이다.
위에서 분명히 오래된 객체에서 새로운 객체로의 참조는 매우 드물게 일어난다.라고 했는데, 만약에 여기 Old객체에서 Young객체를 참조한다면??

write barrier

만약 Old객체가 Young객체를 참조하는 중에 Minor GC가 일어난다면 어떻게 될까?

  1. Old 영역에 있는 객체가 Young영역의 객체를 참조중
  2. Minor GC수행에서 이 Young객체가 더이상 수행되지 않으면 없앰
  3. Old영역 객체는 없는애를 참조함??

MajorGC와 MinorGC를 나눈 이유는 MajorGC가 더 오래 걸리고, 자주 사용되는 것들은 일단 유지함으로서 성능을 극대화하기 위함이었다.
그렇기에 필연적으로 MajorGC는 MinorGC보다 상대적으로 드물게 일어나며, 위의 문제를 해결하기 위해 Old영역에서의 참조를 확인하는 연산은 해서는 안 될 것이다.

이를 해결하기 위해 GC에서는 Old영역의 객체가 Yount영역의 객체를 참조하는 경우 이 참조를 별도로 기록하는 처리를 추가한다.

이를 Write Barrier이라고 부른다.

java의 GC의 특징 - 성능 향상을 위해 얘들이 더 해줬던 것들

  • java에서는 Young영역과 Old영역으로 나누어 GC를 동작시킨다.(세대별 GC)
    • Young영역에서는 MinorGC가 수행되며 속도가 비교적 빠르다.
    • Old영역에서는 MajorGC가 수행되며 속도가 비교적 매우 느리다.
  • GC동작 이후, 남아있는 친구들을 재배치(압축)
    • 이를 통해 단편화를 회피하고(빈공간 최소화)
    • bump allocation에의 고속 메모리 할당이 구현 가능해진다.

이러한 GC의 성능 하락 요인을 줄이는 GC튜닝이 매우 중요하며, 여러 가지 알고리즘이 java개발 면접에서도 종종 등장한다 한다.

Go에서의 GC

먼저 Go언어는 java처럼 JVM같은 언어별 가상머신을 사용하는 방식이 아니라, 실행파일 안에 가비지 컬렉터를 내장한다.
그래서 일단 가볍다는 장점이 있다.

그리고

go는 JVM에서의 세대별 GC와 압축을 해주지 않는다.

분명히 저게 성능 향상에 쓰였다는데? 사용 외않헷데?

압축

Go는 압축을 사용하지 않는 정적 유형 Mark & Sweep GC

일단 Go에서 압축을 도입하지 않은 이유는 이거 슬쩍 찾아보니까 원래 하려고 했는데 시간 상 제약이 생겨서 안했다...라고 하는 것 같다.

GO에서는 압축 대신에 TCMalloc(Thread Caching Malloc)을 사용한다.

TCMalloc이 뭔데?

하나의 메모리 풀을 사용하는 경우 멀티 쓰레드 환경에서 lock이 걸리게 되면 속도가 저하된다.
그래서 Thread별로 메모리 풀을 만들어 두는 방식인데

  • 자기만 쓰는 메모리는 지역 메모리품
  • 다른 Thread에서 접근해야 하는 메모리는 전역 메모리풀

이렇게 하면 메모리 관리가 용이해진다...

본래 압축을 사용하면 heap단편화가 발생할 수 있다고 하는데, TCMalloc을 통해 단편화와 할당 속도 문제를 해결했다고 한다.

세대별 GC

위의 java에서의 내용을 보면 알 수 있듯, 세대별 GC의 목적은 대부분의 객체는 금방 접근 불가능한 상태(unreachable)가 된다에서 입각하여 효율적인 GC의 사용을 가능케 하려는 것이었다.

다만 문제는 세대별 GC의 경우 write barrier를 도입해서 매번 오버헤드를 넣어주어야 하는데 Go에서는 이걸 용납할 수 없었다고 한다.
추가적으로 Go언어는 세대별GC에서 고민하던 수명의 짧은 객체의 경우 heap이 아니라 stack에 할당하여서(이건 컴파일러의 분석 성능이 우수해서라는데...정확한 방법은 모르겠음) 세대별 GC의 이점이 그렇게 크지는 않다고 한다.

그러면 저걸 안쓰고 어떻게 했을까??

Go에서는 GC중 Tri-Color Marking Algorithm을 사용한다고 한다.(Concurrent Mark Sweep GC)

Tri-Color Marking Algorithm

이름 그대로 3색으로 마킹해서 하는 것으로 흰색, 검은색, 회색으로 객체를 색칠하며 진행한다고 한다.

  • 흰색
    • 더이상 접근하지 않는, GC대상이 되는 객체
  • 검은색
    • 프로그램이 사용하고 있는, 흰색 객체를 참조하지 않는 객체
  • 회색
    • 프로그램이 사용하고 있는, 검사가 아직 진행되지 않은 객체

이걸 활용해서 검사를 진행한다.

진행 방법

  1. 먼저 GC가 모든 객체들을 흰색으로 칠해서 시작한다.
  2. GC가 모든 루트 객체를 방문해서 회색으로 칠한다. - Initial Mark (STW발생!)
    • Root객체란, stack이나 static같은 어플리케이션이 직접 접근할 수 있는 객체를 뜻한다.
  3. 이제 회색 객체들을 하나씩 검은색으로 변경하면서, 이 객체가 가리키는 흰색 객체를 회색으로 칠해준다. - Concurrent Mark (STW없이 이루어짐)
    • 이게 가리키는 객체도 검색 대상이 된것이다.
    • STW없이 이루어지기 때문에 이 때에는 아직 참조 안했는데 어느 순간 흰색 객체를 참조할 수 있다.
      • 이를 Dirty Card라고 부른다.
  4. 위의 3번을 회색 객체가 없어질 때 까지 반복한다. (STW없이 이루어짐)
  5. 3,4는 STW없이 이루어지는데, 이 때 변경된 사항이 있는지 다시 한번 마킹을 진행하며 확정해준다. - Remark (STW발생!)
  6. 흰색 객체들을 다 지워버린다. - Concurrent Sweep (STW없이 이루어짐)

참고로 여기서 STW없이 이루어진다는게 진짜 아예 안멈추는거는 아니고... 100%의 성능이 있을 때에 50%는 GC, 50%는 Thread 작업에 쓴다는 것이다.

Tri-Color Marking Algorithm의 장단점

  • 장점
    • 기존의 Parrallel / Parrallel Old GC에 비교하면 멈추는 기간이 짧다.
      • 위의 진행 방법에서 보았듯 Tri-Color에서는 스레드를 여러번 쪼개서 멈추기 때문에 사용자가 볼 때에 오랫동안 멈춰있지 않는다.
  • 단점
    • 사실 걸리는 시간 자체는 Parrallel / Parrallel Old보다 길다.
      • 그리고 이 긴 시간동안 50%메모리는 GC에 동작하므로 그 기간동안 성능이 좀 낮아지게 된다.
    • 딱봐도 복잡해 보이는데, 그때문에 메모리와 CPU를 많이 잡아먹는다.

마치며...

사실 지금 적어둔 방법 외에도 가비지 컬렉션이 동작하기 위한 Algorithm이 상당히 많다고 한다.
그리고 각각의 Trade-off나 사용 가능 여부가 있다고 하는데 언어의 한가지 내용인 GC만 해도 이렇게 엄청나게 방대한 내용이 있다는 사실에 놀랐다.
이걸 찾으면서 여러 Algorithm이나 사용에 대해 알아봤는데, 언어의 특성 별로 GC알고리즘을 선택하면서 한다는 것에 놀랐고 꽤 오랫동안 공부했는데도 아직 내가 찾아본 것이 기초의 기초만도 못한 일부 수준이라는 것이 슬프면서 신기했다.

그래도.. 기존에 쓰던 java와 Go에서의 GC차이와 관련 내용들에 대해서는 알 수 있어 좋았다 :)

'이론 정리 > Golang' 카테고리의 다른 글

고루틴(goRoutine)  (0) 2023.08.28
Golang의 포인터  (0) 2022.07.24
Golang의 구조체  (0) 2022.07.24
Golang의 배열  (0) 2022.07.24
Golang의 기초  (0) 2022.07.24