[Python] Garbage Collection

·

4 min read

Garbage Collection이란?

데이터를 '저장'하기 위해서는 이를 위한 메모리 공간을 할당받아야 한다. 그러나 메모리는 유한한 자원이기에, 그 데이터가 더 이상 필요가 없어지면(앞으로 참조될 일이 '절대' 없으면) 비워주어야 한다.

Garbage Collection은 필요 없어진 데이터(특정 데이터를 위해 할당 되었으나 더 이상 참조될 일이 없는 메모리 공간), 즉 쓰레기(Garbage)를 골라내어 효과적으로 수거(Collect)하는 작업을 뜻한다.

Why Garbage Collector?

예전에는 이러한 메모리 관리를 개발자가 수동으로 하게 했다. 대표적으로 C언어가 그렇다. malloc, free와 같은 함수를 적절히 호출하여 개발자가 수동으로 메모리를 관리하는 것이다.

하지만 당연히 개발자는 당연히 인간이기에 실수를 하고, free 호출하는 것을 깜빡하여 '메모리 누수'가 일어날 수도, 때로는 아직 free를 하면 안 되는 시점에서 free를 호출하는 등의 문제가 일어날 수도 있다.

이것은 개발자가 복잡한 프로그램을 개발하는데에 있어 큰 걸림돌이었고, 이를 '자동'으로 해주는 기능을 만든 것이다.

Python의 Garbage Collector 작동 원리

언어마다 Garbage Collector의 작동 원리 및 구현 방식이 다르다. 이 글에서는 Python의 Garbage Collector만 알아보고자 한다.

Python의 경우 아주 대략적으로 다음과 같이 작동한다:

  1. Unreachable 한 값을

    1. Reference Counting을 통해 확인

    2. 적절한 주기로 순환 참조를 탐지하여 추가적으로 확인

  2. 적절한 타이밍에 처리

Reference counting

어떤 객체가 참조(reference)되고 있는 횟수를 세서(counting) 0이 될 경우 메모리에서 해제하는 방식이다.

import sys

listA = [1, 2, 3]
print(sys.getrefcount(listA)) # 2
listB = listA
print(sys.getrefcount(listA)) # 3
del listA
print(listB) # [1, 2, 3]
del listB # 이제 [1, 2, 3]은 unreachable 하므로 메모리에서 지워짐

일부러 (초심자 기준으로!) 조금 헷갈리는 예제를 들고 오긴 했다. 하나씩 살펴보자.

  1. sys.getrefcount(listA)의 경우 그 값이 1이 되어야할 것 같지만 2가 출력된다. 이는 sys.getrefcount가 호출되는 순간 listA를 참조하게 되므로 그것까지 더해져 2가 된 것이다.

  2. 두번째 getrefcount 호출에는 3을 출력하는 것을 볼 수 있다. listB라는 변수를 통해 [1, 2, 3]을 추가로 참조하기 때문이다.

  3. del listA 를 통해 [1, 2, 3]이라는 객체를 지웠다고 생각하면 크나큰 오산이다. C언어의 free()와는 다르다. listB를 통해 여전히 접근할 수 있는 것을 통해 지워지지 않았다는 것을 확인할 수 있다. 즉, del 키워드는 단순히 특정 변수가 특정 객체를 reference하는 것만 끊어줄 뿐, 메모리를 직접 건드리는 것은 아니다.

  4. 마지막으로 del listB이후로는 논리적으로도 [1, 2, 3]에 접근할 수 없다는 것을 알 수 있을 것이다. 실제로 [1, 2, 3]의 reference count가 0이 되어 메모리에서 지워졌을 것이다.

그럼 다른 예제를 살펴보자.

listA = [] # refcount(listA) = 1
listB = [] # refcount(listB) = 1
listA.append(listB) # refcount(listB) = 2
listB.append(listA) # refcount(listA) = 2
del listA # refcount(listA) = 1
del listB # refcount(listB) = 1
# ...?

listAlistB 모두 더 이상 unreachable함에도 불구하고 각각의 reference count는 1이다. 이러면 영영 해제될 수 없는 것 아닌가...!?

위와 같은 상황을 '순환 참조'라고 부른다. list, tuple, set, dict, class와 같은 컨테이너 객체에 의해서만 발생하는 것으로, 말 그대로 '서로 다른 객체들이 서로를 참조'하는 상황이다. reference counting 만으로는 unreachable 한지 확인이 불가능하다.

순환 참조 탐지 방법

이는 모든 컨테이너 객체들을 돌며 '순환 고리'를 순회하여 확인할 수 있다. 간단한 예시는 다음과 같다.

https://dw3232.tistory.com/71에서 가져온 이미지이다.

  1. 각 객체의 gc_refs 값을 reference count와 같게 설정한다.

  2. 각 객체에서 참조하고 있는 다른 컨테이너 객체가 있다면, 그 참조되는 컨테이너의 gc_refs를 감소시킨다.

  3. 어느 객체의 gc_refs가 0이 되면, 그 객체는 컨테이너 집합 내부에서 자기들끼리 참조하고 있다는 뜻이다.

  4. 따라서 이 객체는 unreachable하다고 할 수 있다. (따라서 이후 메모리에서 해제될 것이다.)

보다싶이 cost가 꽤나 높다는 것을 알 수 있다. 따라서 이러한 순환 참조 탐지를 매 참조마다 행하는 것은 불가능하고, 적당한 주기로 실행해야 한다. 이러한 주기를 generation을 통해 설정한다.

Generational Garbage Collection

The main idea behind this concept is the assumption that most objects have a very short lifespan and can thus be collected shortly after their creation.

https://devguide.python.org/internals/garbage-collector/#optimization-generations

그렇다. 객체의 수명이 대부분 '무척 짧기' 때문에, 객체들을 나이, 즉 세대(generation)에 따라 분류하고 어린 세대 일수록 더 짧은 주기로 GC를 실행한다.

구체적으로는 모든 객체는 0세대에서 시작하여 2세대까지 총 3세대로 분류가 되며, 세대별로 정해진 임계값(threshold)에 따라 GC가 수행된다.

print(gc.get_threshold()) # (700, 10, 10)

(700, 10, 10) 각각 0, 1, 2세대를 위한 threshold인데, 이 때 이 값이 가지는 의미가 각각 조금씩 다르다.

언제 실행할지를 결정하기 위해, 수거기는 마지막 수거 이후의 객체 할당과 할당 해제 수를 추적합니다. 할당 횟수에서 할당 해제 횟수를 뺀 값threshold0를 초과하면 수거가 시작됩니다. 처음에는 0세대만 검사합니다. 0세대를 검사한 후로, 0세대를 threshold1회를 초과하여 검사했으면, 1세대도 검사됩니다.

https://docs.python.org/ko/3/library/gc.html#gc.set_threshold

마치며

더 자세한 원리나 optimization도 살펴볼 가치가 있어보이지만, 당장 궁굼한 점은 해소가 되었으므로 여기서 글을 마치도록 하겠다.

https://devguide.python.org/internals/garbage-collector/를 읽어보면 더 자세한 내용을 살펴볼 수 있다.

https://blog.siner.io/2021/12/26/garbage-collection/ 또한 읽어보면 다른 언어의 GC의 구현도 대략적으로 파악할 수 있다.