[Unity] GC딥다이브
댓글 0
댓글을 작성하려면 로그인이 필요합니다.
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!
이 글은 GC(Garbage Collector)의 정밀 GC와 보수적 GC를 비교합니다. 그리고 Unity가 CoreCLR로 포팅하길 결정하게 된 한 가지 배경에 대해 언급합니다.
일반적으로 배운 GC의 동작(정밀GC)은 크게 세 단계로 나뉜다.
GC는 루트*에서부터 참조그래프를 따라 내려가며 mark를 남긴다. 이는 그 노드가 도달 가능한지의 여부를 나타내는 1비트에 불과하다.
회수한다. 마크가 된 것만 빼고.
회수하고 나면 빈 공간이 생긴다. 객체를 적당히 앞으로 이동해서 압축한다.
처음엔 사람이 직접 할당하고 사람이 직접 해제해야했다(C/Cpp). 그에 반해 C#에서는 아무도 이 객체에 도달할 수 없음을 관찰하여 회수하는 GC가 일반적이다. 닷넷의 CoreCLR은 이 GC를 지원하며, 위에서 언급한 Mark-Sweep-Compact의 과정을 따른다. 다만, 그렇지 않은 GC도 있다.
8바이트씩 끊어서 보자. 왜 하필 8바이트냐면, 64비트 시스템에서 슬롯/포인터의 크기가 8바이트기 때문이다. Managed 메모리의 슬롯과 스택 메모리의 슬롯은 8바이트의 크기로, 그 안에는 0x0002와 같은 꼴의 값이 담긴다.
그런데, 저 것은 주소(ref)인가 값(val)인가?
0x0002 는 그 주소를 가리키는 포인터고왜 이것이 문제가 되냐면, GC가 compact 과정에서 참조를 수정할 때 멀쩡한 값을 수정할 가능성이 있기 때문이다. 만약 0x0002에 있던 객체를 이동했다면 해당 위치를 가리키는 모든 값을 갱신해야하는데, 이것이 실제 그 위치를 가리키는 참조인지 아니면 숫자 2인지 알 수 없다. 숫자였다면 변경되어서는 안 되는 값이 변경된다.
GC가 루트에서부터 참조 그래프를 따라내려가며 마크를 남긴다고 했다. 만약 부모 노드가 힙 객체라면, 그 객체의 각 슬롯이 참조인지 아닌지 알 수 있고, 참조를 따라가면 자식 노드가 나온다. 이는 CoreCLR이 그 객체의 타입 메타데이터를 들고 있기 때문이다.
타입 메타데이터는 참조인지의 여부뿐만이아니라 런타임에 필요한 이런 저런 것에 대한 정보를 담고 있지만, GC가 동작하는 데 필요한 것은 그 여부뿐이다.
좌우지간, 특정 슬롯의 값을 들고있는 어떠한 부모 힙 객체가 있다면 그 슬롯의 값은 참조인지 아닌지 명확히 할 수 있다는 것이다.
그러나 스택(또는 레지스터) 슬롯에 저장된 것에 대해서는 참조인지 값인지 알 수 없다. 힙 슬롯에서 참조되는 슬롯들은 타입 메타데이터를 통해 참조인지 아닌지 확신가능하다고 했다. 한편 스택 슬롯은 그 자신이 담은 값이 참조인지 값인지 알려줄 ‘누군가’가 별도로 필요하다. 그 누군가가 스택 맵(GC info)이며, 이것이 그 루트 정보(root info)를 들고 있다. 스택 맵은 컴파일 시점에 컴파일러가 메소드를 읽고 번역하며 저장한다.
이러한 정밀한 추적으로써 CoreCLR의 GC는 객체를 안전하게 이동시킬 수 있다.
이제 CoreCLR과는 다른 방식의 GC, Boehm에 대해 알아보자. 보수적 GC인 Boehm에는 객체를 이동시키기 위해 참고할 지도가 없다. 따라서 Non-moving이다.
예를 들어, 어느 한 스택 슬롯에서라도 객체 O를 참조하고 있는 것으로 ‘의심’된다면, 그것은 이론상 이동해서는 안 된다. 의심되는 그 것이 실제로 참조라면 그 슬롯을 새로운 주소로 갱신해도 되지만, 그렇지 않았다면 그것은 실수일 것이다. 지도가 없으므로 보수적으로 예측한다. 물론, 어떤 스택 슬롯에서도 객체 O를 참조하지 않는다면, 이론상 그것은 옮겨도 되는 객체일 것이다. 이런 방식의 부분 압축형 보수적 GC(mostly-copiying GC. Bartlett 방식)도 있으나, Boehm은 아니다.
스택맵은 컴파일러가 뱉는다. Boehm은 C/Cpp 로 작성된 라이브러리이고, 그 C/Cpp 환경에서 동작하도록 설계되었다. 단순히 어느 개발자가, 할당한 것을 알아서 해제해줬으면 하는 바램에 GC_malloc과 같은 메소드를 사용했던 것이 시작이었다. 그런데 C/Cpp 컴파일러가 GC를 위해 스택 맵을 제공할까? 그렇지 않다. 애초에 그들은 GC를 필요로하지 않았기 때문이다.
C/cpp환경의 컴파일러는 힙 객체에 타입 정보를 포함한 헤더를 달 필요도 없었다. 한 번 컴파일이 끝나 기계어가 되고 나면 객체의 타입 정보는 쓸데없이 공간만 차지할 뿐이다. 결국 컴파일러로부터 지도를 받지 못하는 Boehm은, 이렇게 컴파일러에 구애받지 않도록 설계되었다.
이것이 Boehm이 Non-moving일 수밖에 없는 근본적인 이유이다. 이 GC는 안타깝게도 컴파일러의 협력을 받지 못한다. 그러나 그것은 또 다른 강점이 있었는데, 컴파일러의 협력을 요구하지 않아 범용적이라는 점이다. 사실 애초에 이것이 Boehm의 정체성이다.
Unity는 내부적으로 cpp를 쓰고, cpp는 포인터로 주소를 직접 참조하곤 한다. C#과 cpp를 연결함에 있어 Boehm과 같은 Non-moving 방식이 더 호환성이 높았기에, 그리고 무엇보다도, 정밀 GC를 도입하려면 스택맵을 뱉는 컴파일러를 새로 만들어야했기에 단순히 링크만 하면 되는 Boehm을 사용했다.
STW, GC Spike, 또는 Hitch라고도 부른다. GC가 Mark and Sweep 과정을 진행 중이라면 객체의 참조가 바뀌어서는 안되므로 Managed 스레드(Managed 힙 참조 그래프를 바꿀 수 있는 스레드)가 멈춘다.
한 번에 Mark하는 대신 나눠서 하면 STW가 덜하다는 아이디어로 동작하는 GC.
소멸자가 있는 객체는 그렇지 않은 객체보다 늦게 죽는다. 다른 객체가 죽을 시간에 소멸자를 호출해야하기 때문이다. 처음 GC가 돌 땐 소멸자 때문에 죽지 못하고, 다음 GC가 돌 때까지 해제되지 않는다.
언제 실행될지 명확히 모르기도 하므로, 그다지 쓰지 않는 것이 좋다. IDisposable+using, 또는 SuppressFinalize로 대신할 수는 있다.
각 객체에 세대 가설 - 객체들은 생성되면 대개 곧 죽는다 - 을 적용하여 GC가 순회할 범위를 줄이는 데 도움이 된다. 여러 세대에 걸쳐 살아남은 객체일 수록 더 긴 주기로 순회한다.
https://unity.com/kr/blog/engine-platform/porting-unity-to-coreclr
유니티는 Mono에서 CoreCLR로의 포팅을 준비하고 있다. 6.7에서 실험적으로 도입되고, 6.8에서 적극 도입될 것이다.
위 블로그의 자세한 내용과 함께 이 글을 읽음으로써 지금 Unity 내부 개발자들이 마주한 문제들에 대해 어렴풋이 감잡을 수 있을 듯하다.