CPython 에서 GC는 어떻게 작동할까요?
작년에 영문 블로그에 적었던 글인데 (링크), 복기할 겸 한글로 다시 적습니다.
python 표준에서 gc interface를 정의하고 있기는 하지만, python interpreter마다 내부적인 동작은 상이할 수 있습니다. 이 글에서는 표준인 CPython 에서의 GC에 대한 이야기를 해보겠습니다.
일단 메모리 관리는 OS 레벨에서 시작됩니다. 가상메모리와 페이징 등을 통해 개별 프로세스는 다른 프로세스로부터 독립적인 메모리 공간을 갖습니다. 프로세스 내에서 메모리를 관리하는 방법에는 dynamic memory allocation, automatic variables 두 가지 방법이 있습니다.
dynamic memory allocation은 프로세스에서 직접 메모리를 할당받고 할당 해제하는 방법입니다. 완전한 컨트롤이 있지만, dangling pointer, double free bug, memory leak 등의 우려가 있습니다.
automatic variables는 런타임 환경이 어떤 프로시져(함수) 내에 선언된 변수에 대해 메모리를 할당하고 그 프로시져가 종료되면 다시 메모리를 회수하는 방법입니다. 보통 그 과정에 개입할 수 있지만, 큰 틀에서는 런타임 환경이 관리합니다.
Garbage Collection은 automatic variables 의 한 방법으로, garbage collector 라고 하는 무언가가 더 이상 쓰지 않는 메모리 공간을 회수합니다. 그 방법으로는 세 가지가 있습니다.
Python 2.0 이전에는 reference counting만 사용했다고 합니다. 하지만 위에서 언급한 한계로, tracing을 통해 GC를 보완하게 됩니다. 정확한 명칭은 generational cyclic GC 라고 하는데요.
Generational cyclic GC는 tracing의 리소스 비용 측면을 보완하기 위해, 방금 만들어진 변수는 사라질 가능성이 높지만, 오래 존재한 변수는 사라질 가능성이 낮다는 점에 착안해 변수가 얼마나 오래됐는지 (세대, generation) 에 따라 tracing 하는 빈도를 달리하여 전체 과정을 최적화합니다. 정확하게는 3개의 generation 으로 구분하며, 자세한 내용은 이곳에서 더 읽어보실 수 있습니다.
추가적으로 full GC 횟수를 줄이기 위해 특정 세대에 있는 오브젝트 수가 threshold를 넘어가면 GC가 시작되고, 위 세대의 GC에는 반드시 아래 세대의 GC가 선행되며, 가장 오래된 세대 (generation 2)에 대한 GC(=full GC)는 아래 세대에서 GC를 살아남은 비율이 지난 full-GC에서 살아남은 오브젝트 수의 25% 이상일 때 진행된다는, 묘하게 구체적인 최적화 조건들이 있습니다.
갑자기 궁금해져서 잠깐 찾아봤는데, jvm도 비슷한 형태로 구현되어 있군요. (링크)
java 진영에서는 jvm의 GC 튜닝에 대한 이야기나 weak/soft/phantom을 통한 GC 기능 보조에 대한 이야기를 종종 들어도, python 진영에서 GC에 대한 이야기는 상대적으로 적은 것 같다는 느낌을 받았습니다. 이유는 아마 python GC에 있어서는 특별히 선택지가 없다는 점도 있고, 메모리가 이슈가 되는 경우는 c-extension으로 python 인터프리터 바깥에서 메모리를 관리하는 선택지가 있다는 점도 있을 것 같습니다.
상대적으로 단순해서 쉽게 정리가 되는 것 같습니다. 극한의 퍼포먼스 튜닝이 필요한 예외적인 케이스가 아니라면 이 정도로 충분 해 보입니다.