파이썬은 메모리를 참조계수와 GC로 관리한다. 그렇다면 참조계수가 무엇일까?
🎆참조계수
- 모든 객체는 참조 당할 때 레퍼런스 카운터를 증가시키고 참조가 없어질 때 카운터를 감소시킨다. 이 카운터가 0이 되면 객체가 메모리에서 해제한다. 그리고 참조계수로 인하여 순환참조 문제가 발생할 수 있다.
a = Foo() # 0x60
b = Foo() # 0xa8
a.x = b # 0x60의 x는 0xa8를 가리킨다.
b.x = a # 0xa8의 x는 0x60를 가리킨다.
// 이 시점에서 0x60의 레퍼런스 카운터는 a와 b.x로 2
// 0xa8의 레퍼런스 카운터는 b와 a.x로 2다.
del a # 0x60은 1로 감소한다. 0xa8은 b와 0x60.x로 2다.
del b # 0xa8도 1로 감소한다.
이 상태에서 0x60.x와 0xa8.x가 서로를 참조하고 있기 때문에 레퍼런스 카운트는 둘 다 1이지만 도달할 수 없는 가비지가 된다. 이를 해결하기 위해서 GC가 있다. 파이썬 공식문서에서 나와있는 설명을 보자.
이 말을 본다면, 결국 GC는 순환참조를 해결하려 나왔다고 해도 과언이 아닌것을 알 수 있다. 그렇다면… 결국 GC도 특정 기준이 있을 것이고, 어떠한 기준으로 순환참조를 감지할까?
generation(세대)과 threshold(임계값)로 가비지 컬렉션 주기와 객체를 관리한다. 세대는 0 세대, 1 세대, 2 세대로 구분되는데 최근에 생성된 객체는 0 세대(young)에 들어가고 오래된 객체일수록 2 세대(old)에 존재한다. 더불어 한 객체는 단 하나의 세대에만 속한다. 가비지 컬렉터는 0 세대일수록 더 자주 가비지 컬렉션을 하도록 설계되었는데 이는 generational hypothesis에 근거한다. 🎆
🎆generation
- generational hypothesis는 대부분의 객체가 금방 사용되고 죽는다는 가설이다. 즉, 대부분의 객체는 짧은 생명주기를 가지며, 오래 살아남는 객체는 일부이며 매우 적다는 것이다. 이 가설에 근거하여 가비지 컬렉션에서는 객체들을 young generation, old generation, permanent generation 등의 세대로 구분하여 관리한다. 보통 young generation은 빈번한 가비지 컬렉션을 수행하며, 대부분의 객체는 짧은 생명주기를 가지기 때문에, 빠르게 수거된다. 오래 살아남는 객체는 old generation으로 이동하여 관리되며, 이를 통해 old generation에서의 가비지 컬렉션 빈도를 줄여서 성능을 향상시킬 수 있다. Generational hypothesis는 대부분의 언어에서 가비지 컬렉션 알고리즘의 기초로 적용되고 있으며, 가비지 컬렉션의 성능을 개선하는 데에 매우 중요한 역할을 한다.
🎆threshold
- 주기는 thershold와 관련이 있다. Threshold는 객체가 old generation으로 이동하는 기준 값으로, 객체가 young generation에서 몇 번의 가비지 컬렉션을 거쳐도 살아남아 있을 경우, 해당 객체는 old generation으로 이동하게 된다. gc.get_threshold()로 확인해 볼 수 있다.
각각threshold 0
,threshold 1
,threshold 2
을 의미하는데 n세대에 객체를 할당한 횟수가threshold n
을 초과하면 가비지 컬렉션이 수행되며 이 값은 변경될 수 있다.
0 세대의 경우 메모리에 객체가 할당된 횟수에서 해제된 횟수를 뺀 값, 즉 객체 수가threshold 0
을 초과하면 실행된다. 다만 그 이후 세대부터는 조금 다른데 0 세대 가비지 컬렉션이 일어난 후 0 세대 객체를 1 세대로 이동시킨 후 카운터를 1 증가시킨다. 이 1 세대 카운터가threshold 1
을 초과하면 그 때 1 세대 가비지 컬렉션이 일어난다. 러프하게 말하자면 0 세대 가비지 컬렉션이 객체 생성 700 번만에 일어난다면 1 세대는 7000 번만에, 2 세대는 7 만번만에 일어난다는 뜻이다.
이렇듯 가비지 컬렉터는 세대와 임계값을 통해 가비지 컬렉션의 주기를 관리한다. 이제 가비지 컬렉터가 어떻게 순환 참조를 발견하는지 알아보기에 앞서 가비지 컬렉션의 실행 과정(라이프 사이클)을 간단하게 알아보자.
- 새로운 객체가 만들어질때 파이썬은 객체를 메모리와 0세대에 할당한다. 만약 0세대의 객체 수가
threshold 0
보다 크면collect_generations()
를 실행한다.- 새로운 객체가 만들어 질 때 파이썬은
_PyObject_GC_Alloc()
을 호출한다. 이 메서드는 객체를 메모리에 할당하고, 가비지 컬렉터의 0세대의 카운터를 증가시킨다. 그 다음 0세대의 객체 수가threshold 0
보다 큰지,gc.enabled
가 true인지,threshold 0
이 0이 아닌지, 가비지 컬렉션 중이 아닌지 확인하고, 모든 조건을 만족하면collect_generations()
를 실행한다.- 사실 가비지 컬렉션은 해당 계속 버전이 업데이트 되기 때문에 함수의 내부 구조와 이름은 버전마다 매번 다르다. 그러나 동작과정에는 현재기준으로는 많은 변화가 없기때문에 전체적인 로직에서는 차이가 없다.
_PyObject_GC_Alloc()
의 경우는 현재_PyObject_GC_New()
가 역할을 대체하고 있으며,collect_generations()
의 경우에는 현재는gc_collect_generations()
가 역할을 대체한다.- gc_collect_generations()의 경우 공식문서에 코드가 있었지만 중간에 주석이 매우 길었기 때문에, 주석을 제외한 필요 부분을 vscode로 옮겨서 포스팅했다. 여러 블로그에서 GC의 실행과정을 함수를 통해 설명하지만, 위와같이 현재 함수들이 많이 바뀌었기 때문에 직접 찾아보았다.
_PyObject_GC_Alloc()
을 대체하는 함수 _PyObject_GC_New()
가비지 컬렉션을 지원하는 객체를 할당하고 초기화하는 역할을 수행
collect_generations()
을 대체하는 함수 gc_collect_generations()
- 세대별 가비지 수집을 수행하는 기능을 담당하는 기능
- 현재 스레드의 가비지 컬렉션 상태를 확인하고, 객체 개수가 임계값을 초과하는 세대부터 가비지 컬렉션을 수행
- 가장 최신 세대의 경우, 만약 장기 생존 객체의 보류 개수가 전체의 1/4보다 작다면 가비지 컬렉션을 건너뛰며,
가비지 컬렉션을 수행한 세대의 수집된 가비지 객체 개수를 반환하는 역할
순환참조 탐지를 위한 더블 링크드 리스트가 있는 파일 변경 objimpl.h → object.h
순환참조 탐지 방법에는 여러 방법이 가능하지만 가장 일반적인 방법은 더블 링크드 리스트를 사용하는 것이다.
더블 링크드 리스트를 사용하여 이렇게 하면 추가적인 메모리할당없이 집합에서 객체의 삭제 추가가 가능하다.
이외에도 다른 함수들이 많이 개선되었으며, 자세한 코드는 파이썬GC 에서 확인할 수 있다.
gc_stats()함수는 gc.get_stats()를 사용하여 gc의 통계를 가져와 출력하는 함수이다.
gc.collect()함수는 gc를 명시적으로 실행하고, 현재 사용하지 않는 객체들은 정리한다.
첫 번째 gc를 유발하지 않은 경우, 가비지 컬렉션 실행 횟수는 8이고, 두 번째의 경우 gc를 유발하게 되면 가비지 컬렉션 실행 횟수는 138이라는 것을 확인할 수 있다. 공통점은 가비지 컬렉션에 의해 정리된 객체의 수는 24이다. 또한 GC Unreachable Objects는 도달할 수 없는 객체의 수를 나타내는데, 여기서는 0으로 표시 되었으며, 도달할 수 없는 객체가 없음을 의미한다.
gc 주기적으로 실행되며, 실행 횟수는 실행 시점에서 생성된 객체의 수, 객체 수명 등에 따라 다를 수 있다. 또한 gc는 메모리를 최적화하기 위해 실행되지만, 실행될 때마다 모든 객체가 정리되지는 않을 수 있다.
간단한 코드로 확인을 했지만 이를 통해서 gc통계를 활용하여 메모리 사용을 모니터링하고 최적화할 수 있게된다.
많은 수의 객체를 동적으로 생성하는 상황에서는 가비지 컬렉션의 오버헤드가 증가할 수 있다. 예를 들어, 반복문을 사용하여 대량의 객체를 생성하는 경우 가비지 컬렉션이 더 많은 객체를 추적하고 정리해야 하므로 오버헤드가 발생한다. 또한 실시간 요구에 따라 지연이 발생하지 않아야 하는 응용 프로그램에서는 가비지 컬렉션에 의한 작은 지연이 문제가 될 수 있으며, 이때는 가비지 컬렉션을 최소화하거나 컨트롤할 수 있는 방법을 고려해야한다. 이러한 문제로 gc는 튜닝, 객체 수명 관리, 메모리 사용 최적화 등을 고려하며 사용되고, 메모리 사용 패턴 및 작업 요구 사항에 맞게 적절한 가비지 컬렉션 알고리즘 및 설정을 선택한다.
문득 궁금해졌다. 메모리 관리를 위해 gc를 사용하는데.. 오버헤드를 발생시킨다? 한번 확인해보기로 했다.
gc.enable() : gc 활성화
psutil.Process().memory_info().rss : gc 메모리 사용량을 측정
garbage_dummy() : 가비지 객체 생성
gc.collect() : gc 실행
timeit.timeit(gc.collect, number=1) : gc 걸린 시간 측정
gc를 사용하면 약간의 메모리 해제가 이루어지고, 실행 시간도 약간 증가함을 확인할 수 있다. 반면, gc 사용하지 않으면 메모리 사용량에 변화가 없으며, 실행 시간도 거의 없음을 알 수 있는데 이는 gc의 유무가 메모리 사용량과 실행 시간에 영향을 미칠 수 있음을 보여준다.
지금까지 gc를 이론적으로만 학습했지만 코드를 직접 확인해보고, 실행시키며, 분석을 해보면서 예전에 학습했던 이론적인 부분이 더 잘 이해가 되었던 시간이었다.