[About Python] – “CPython 의 Garbage Collection 은 어떻게 동작하는가?”

[About Python] – “CPython 의 Garbage Collection 은 어떻게 동작하는가?”

4월 3, 2024

해당 포스트는 Python 인터프리터 구현체 중 하나인 CPython 3+ 을 기준으로 작성되었습니다.
Jython, PyPy 와 같은 다른 인터프리터 구현체는 다르게 구현되어 있을 수 있습니다.

블로그 저자 객체가

Garbage Collection 이란 무엇인가?

프로그래머는 코드를 작성합니다. 그리고 그 코드는 컴퓨터의 연산 장치, 메모리와 같은 자원을 사용하죠. 프로그래머로서, 컴퓨터의 자원을 적절하게 이용하는 코드를 작성하는 것은 필수 소양 중 하나입니다. C 와 같은 언어가 아니라 Python 에서 메모리의 할당과 해제를 직접 다루는 것은 생소하실지도 모르겠습니다. PythonGarbage Collection 을 내장하고 있기 때문입니다. 공식 문서에서 발췌해 온 아래의 GC 에 대한 설명을 살펴봅시다.

garbage collection

The process of freeing memory when it is not used anymore. Python performs garbage collection via reference counting and a cyclic garbage collector that is able to detect and break reference cycles. The garbage collector can be controlled using the gc module.

https://docs.python.org/3/glossary.html#term-garbage-collection

Garbage Collection 이란, 더 이상 사용되지 않는 메모리를 해제하기 위한 프로세스를 의미합니다. 그리고 언급했듯이 Python 에서는 더 이상 사용되지 않는 메모리를 해제할 수 있는 Garbage Collection 을 알아서 수행해 주죠.

이번 포스트에서는 Python 이 메모리를 자동으로 관리하기 위해 수행하는 마법은 무엇인지, 그 마법은 어떻게 동작하는지, 그 마법의 허점은 무엇인지에 대해 알아보고자 합니다.

Python 에서 모든 것은 객체이다

먼저 Python 에서 메모리가 어떻게 사용되고 관리되는지 이해하기 위해서는, Python 에서 모든 것은 객체이다 라는 사실을 받아들여야 합니다.

다르게 말하면, Python 에서 모든 변수는 값을 담기 위한 공간이 아니라, 인스턴스화된 객체의 값을 가리키는 포인터다 라는 것입니다.

my_name = "goddessana"

위의 코드가 실행되면 어떤 일이 벌어질까요? 아마 아래와 같은 생각을 가지셨을지도 모르겠습니다.

  1. my_name 을 위한 메모리 영역을 할당하고..
  2. 그 메모리 영역에 문자열을 저장하겠네..!

그렇다면 아래의 코드를 추가한다면 어떤 상황이 또 벌어질까요?

my_name = "goddessana"
your_name = "goddessana"
  1. 두 번째 줄에서, your_name 을 위한 메모리 영역을 또 할당한 다음,
  2. 마찬가지로 문자열을 저장하겠구만!

그러면 실제 그렇게 동작하는지, 메모리 주소를 출력해 볼까요?

my_name = "goddessana"
your_name = "goddessana"

print("my_name 의 메모리 주소: ", hex(id(my_name)))
print("your_name 의 메모리 주소:", hex(id(your_name)))


# my_name 의 메모리 주소:  0x7fb56c7821b0
# your_name 의 메모리 주소: 0x7fb56c7821b0

왜죠? 결과는 같습니다. 분명히 다른 두 개의 변수를 선언했는데, 메모리 주소가 같다니요.

위와 같은 착각은 Python 에서 모든 것은 객체이다 를 받아들이지 못했기 때문에 발생한 것입니다. 아마도 C 에 대한 경험이 있는 프로그래머들은, 아래처럼 메모리의 주소가 다를 것이라고 예측했을 것입니다.

#include <stdio.h>

int main()
{
    char my_name[10] = "goddessana";
    char your_name[10] = "goddessana";
    
    printf("my_name 의 메모리 주소: %p\n", &my_name);
    printf("your_name 의 메모리 주소: %p\n", &your_name);
    
    return 0;
}

// my_name 의 메모리 주소: 0x7ffd6b7e0774
// your_name 의 메모리 주소: 0x7ffd6b7e077e

..그리고 다시 문제의 코드로 돌아와 봅시다.

my_name = "goddessana"
your_name = "goddessana"

print("my_name 의 메모리 주소: ", hex(id(my_name)))
print("your_name 의 메모리 주소:", hex(id(your_name)))


# my_name 의 메모리 주소:  0x7fb56c7821b0
# your_name 의 메모리 주소: 0x7fb56c7821b0

사실은, 위의 코드가 실행될 때에는 아래의 동작이 수행됩니다.

  1. Python"goddessana" 라는 str 객체를 하나 만들어둡니다.
  2. my_name, your_name 은 해당 "goddessana" 라는 객체의 주소를 참조합니다.

이는 실제로 CPython 이 만들어내는 바이트코드를 살펴보면 더 쉽게 확인해볼 수 있습니다.

bytecode

Python source code is compiled into bytecode, the internal representation of a Python program in the CPython interpreter. The bytecode is also cached in .pyc files so that executing the same file is faster the second time (recompilation from source to bytecode can be avoided). This “intermediate language” is said to run on a virtual machine that executes the machine code corresponding to each bytecode. Do note that bytecodes are not expected to work between different Python virtual machines, nor to be stable between Python releases.

파이썬 소스 코드는 CPython 인터프리터에서 파이썬 프로그램의 내부 표현인 바이트코드로 컴파일됩니다. 바이트코드는 또한 .pyc 파일에 캐시되므로 동일한 파일을 두 번째로 실행할 때 더 빠릅니다(소스에서 바이트코드로의 재컴파일을 피할 수 있음). 이 “중간 언어”는 각 바이트코드에 해당하는 머신 코드를 실행하는 가상 머신에서 실행된다고 합니다. 바이트코드는 서로 다른 파이썬 가상 머신 간에 작동하거나 파이썬 릴리스 간에 안정적일 것으로 예상되지는 않습니다.

https://docs.python.org/3/glossary.html#term-bytecode
import dis

def main():
    my_name = "goddessana"
    your_name = "goddessana"

dis.dis(main)

그러면, 결과는 아래와 같죠.

 # 줄번호      # 
  4           0 LOAD_CONST               1 ('goddessana')
              2 STORE_FAST               0 (my_name)

  5           4 LOAD_CONST               1 ('goddessana')
              6 STORE_FAST               1 (your_name)
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE


** Process exited - Return Code: 0 **
Press Enter to exit terminal

각각의 명령어는 어떤 것을 지시하는 걸까요?

  1. 2 LOAD_CONST 1 ('goddessana'): 여기서 LOAD_CONST 1은 상수 풀에서 인덱스 1에 위치한 상수를 로드합니다. 이 경우, 인덱스 1에 위치한 상수는 문자열 "goddessana"입니다. 따라서, 이 명령은 "goddessana" 문자열 객체를 스택에 푸시합니다.
  2. 4 STORE_FAST 0 (my_name): STORE_FAST 명령어는 스택의 상단에 있는 값을 지역 변수에 저장합니다. 여기서 0 (my_name)은 지역 변수 테이블에서 my_name 변수의 인덱스를 나타냅니다. 즉, 스택 상단의 객체(여기서는 "goddessana")를 my_name 변수에 저장합니다.
  3. 마찬가지로, 4 LOAD_CONST 1 ('goddessana') 을 살펴보면, 상수 풀에서 인덱스 1에 위치한 상수 를 스택에 푸시하고, 6 STORE_FAST 1 (your_name) 에서 스택의 상단 값인 goddessana 문자열을 지역 변수인 your_name 에 저장하는 것을 확인할 수 있네요.
  4. 결과적으로, my_name, your_name 은 같은 문자열 객체를 참조하게 됩니다.

길고 길게, 여기까지 왔습니다. 위의 과정을 이해하셨다면, 아래의 파트를 읽기 훨씬 더 수월하실 겁니다.

Python 의 메모리 관리

자, 우리는 python 에서 만들어지는 모든 것, 문자열, 숫자, 부동소수점, 개발자가 정의한 클래스, 심지어는 함수, 클래스 자체 또한 객체라는 것을 알았습니다. 그리고 그 객체는 python 인터프리터에 의해 만들어지고, 어딘가에 변수처럼 할당한다는 것은 실제로 메모리 공간을 확보한 다음 그곳에 저장하는 것이 아니라 만들어진 객체의 주소를 참조하는 것이라는 것도 배웠죠.

그렇다면, 메모리는 언제 할당되고 언제 해제되어야 할까요?

아마도 가장 간단한 대답은 “아무도 그것을 사용하지 않을 때!” 일 겁니다. 당연하죠? 코드에서 더 이상 쓰이지도 않는 객체를 굳이 메모리 안에 유지할 필요가 있나요? Python 은 그 아이디어를 구현합니다.

How does Python manage memory?

The details of Python memory management depend on the implementation. The standard implementation of Python, CPython, uses reference counting to detect inaccessible objects, and another mechanism to collect reference cycles, periodically executing a cycle detection algorithm which looks for inaccessible cycles and deletes the objects involved.

Python 메모리 관리의 세부 사항은 구현에 따라 다릅니다. Python의 표준 구현인 CPython은 접근할 수 없는 객체를 감지하기 위해 참조 카운팅을 사용하고, 순환 참조를 수집하기 위해 주기적으로 사이클 검출 알고리즘을 실행하여 접근할 수 없는 순환을 찾고 관련된 객체를 삭제합니다.

https://docs.python.org/3/c-api/memory.html

위의 문서를 요약해 보면 아래와 같죠?

  1. Python 메모리 관리는 인터프리터의 구현(CPython, Jython, PyPy 등) 에 따라 다르다.
  2. CPython 은 접근할 수 없는 객체를 감지하기 위해서 해당 객체가 얼마나 참조되었는지를 카운트한다.
  3. CPython 은 순환 참조를 탐지하기 위해서, 사이클 검출 알고리즘을 실행해 관련 객체를 사용한다.

자, 이건 대체 무슨 말들일까요? 하나하나 파 봅시다. 이 포스트는 CPython 을 대상으로 쓰여지고 있으므로, 2번과 3번에 대해서 살펴보면 되겠습니다.

CPython 은 접근할 수 없는 객체를 감지하기 위해서 해당 객체가 얼마나 참조되었는지를 카운트한다.

위의 말 그대로입니다. 더 이상 참조되지 않는 객체는 즉시 메모리에서 해제되죠. python 에서는, sys.getrefcount() 를 통해서 해당 객체가 얼마나 참조되었는지를 확인해볼 수 있습니다.

import sys


class MyClass:
    def __init__(self):
        pass

my_object_1 = MyClass()
print(sys.getrefcount(my_object_1))  # 2

지금까지 글을 이해했다면, 위의 코드가 어떻게 동작하는지 설명할 수 있겠죠?

  1. CPython 인터프리터는 MyClass 의 객체를 만듭니다.
  2. 그리고, my_object_1 가 위의 과정에서 만든 MyClass 의 객체를 참조하게끔 합니다.
  3. 그래서 참조 카운트를 출력해 보면, 7번째 줄에서의 참조 하나, 8번째 줄에서의 참조가 더해져 총 2를 출력하게 됩니다.
import sys


class MyClass:
    def __init__(self):
        pass

my_object_1 = MyClass()
my_object_2 = my_object_1

print(sys.getrefcount(my_object_1)) # 3

my_object_2 = my_object_1 줄이 추가되면, 해당 객체를 또 한 번 참조하게 되므로 3이 되겠죠?

그렇다면 참조를 끊는 방법은 무엇일까요? 아래처럼 del 키워드를 사용하면 참조를 끊을 수 있습니다. 참조 횟수가 하나 줄어든 것을 확인할 수 있죠?

import sys


class MyClass:
    def __init__(self):
        pass

my_object_1 = MyClass()
my_object_2 = my_object_1

del my_object_2

print(sys.getrefcount(my_object_1)) # 2

그렇기 때문에, 참조가 모두 사라진다면 Python 은 자동으로 메모리를 해제할 겁니다. deleted 가 출력되고, end 가 나중에 출력되겠죠?

class MyClass:
    def __init__(self):
        pass

    def __del__(self):
        print("deleted")


my_object_1 = MyClass()

# Garbage collection will delete the object, because its count is 0
del my_object_1  
print("end")


# deleted
# end

좋아요, Python 이 메모리를 관리하는 가장 첫 번째 매커니즘, 접근할 수 없는 객체를 감지하기 위해서 해당 객체가 얼마나 참조되었는지를 카운트한다. 에 대해서 이해했습니다.

CPython 은 순환 참조를 탐지하기 위해서, 사이클 검출 알고리즘을 실행해 관련 객체를 사용한다.

하지만, 단순히 참조 횟수를 계산하는 것에는 문제가 있습니다. 바로 순환 참조라는 상황 때문입니다. 아래의 코드를 확인해 봅시다:

class MyClass:
    def __init__(self, name, parent=None):
        self.name = name
        self.parent = parent
        self.children = set()

    def __del__(self):
        print(f"Deleting {self.name}")


my_object_1 = MyClass(name="object_1")
my_object_2 = MyClass(name="object_2", parent=my_object_1)

my_object_1.children.add(my_object_2)

del my_object_1
del my_object_2


print("end")
  1. my_object_1MyClass(name="object_1")객체를 참조합니다.
  2. my_object_2MyClass(name="object_2", parent=my_object_1) 객체를 참조하는데, 이 곳에서 parent 를 지정하는 코드에 의해 MyClass(name="object_1") 에 대한 참조 카운트는 1이 증가됩니다.
  3. my_object_1.children.add(my_object_2)my_object_2 에 대한 참조 카운트를 1 증가시킵니다.
  4. del my_object_1: my_object_1 에 대한 참조를 끊습니다. 하지만, 2번의 과정에서 작성된 코드 parent=my_object_1 때문에 object1 에 대한 참조 수는 남아있기 때문에 메모리에서 해제되지 않습니다.
  5. del my_object_2: my_object_2 에 대한 참조를 끊습니다. 하지만, my_object_1.children.add(my_object_2) 때문에 object2 에 대한 참조 수는 남아있게 되고 메모리에서 해제되지 않습니다.

결국, 실행 결과는 아래와 같이 되어버리죠.

end
Deleting object_1
Deleting object_2

__del__ 메서드는 객체가 메모리에서 해제될 때 호출되는데, 프로그램의 모든 과정이 끝나고 나서 (end 가 호출되기 전) 메모리에서 해제되었다는 것은 Python 에서 참조 카운트 방식의 허점을 나타내는 것입니다. 만들어진 모든 객체가 필요없는 순간인 end 가 호출된 이후에도 두 객체가 메모리에서 살아있다는 것이니까요.

이를 직관적으로 확인해볼까요?

import gc


class MyClass:
    def __init__(self, name, parent=None):
        self.name = name
        self.parent = parent
        self.children = set()

    def __del__(self):
        print(f"Deleting {self.name}")


my_object_1 = MyClass(name="object_1")
my_object_2 = MyClass(name="object_2", parent=my_object_1)

my_object_1.children.add(my_object_2)

del my_object_1
del my_object_2

remaining_objects = gc.get_objects()
for obj in remaining_objects:
    if isinstance(obj, MyClass):
        print(f"Remaining object: {obj.name}")

print("end")


# Remaining object: object_1
# Remaining object: object_2
# end
# Deleting object_2
# Deleting object_1

Pythongc 라는 모듈을 통해서 가비지 컬렉터를 제어할 수 있는 인터페이스를 제공합니다. get_objects 는 메모리에서 제거될 대상을 수집하는 메서드인데, 위처럼 호출을 해 보면 del 이후에도 여전히 객체가 살아있었고, 가비지 컬렉터에 의해서 수집되었다는 것을 확인할 수 있죠? gccollect() 를 통해서 가비지 컬렉터를 수동으로 실행하는 인터페이스를 제공합니다.

import gc


class MyClass:
    def __init__(self, name, parent=None):
        self.name = name
        self.parent = parent
        self.children = set()

    def __del__(self):
        print(f"Deleting {self.name}")


my_object_1 = MyClass(name="object_1")
my_object_2 = MyClass(name="object_2", parent=my_object_1)

my_object_1.children.add(my_object_2)

del my_object_1
del my_object_2

gc.collect()

print("end")

# Deleting object_1
# Deleting object_2
# end

성공적으로 end 가 호출되기 전 메모리에서 가비지 수집이 완료되고 제거되었습니다. 그 말은 즉슨 Python 은 어떤 메커니즘으로, 순환 참조로 메모리에서 살아 있는 객체들을 추적하고 – 순환 참조를 탐지한 다음 제거해준다는 의미입니다.

Python 의 가비지 컬렉터가 어떻게 동작하는지는 아래에 문서화되어 있습니다.

https://devguide.python.org/internals/garbage-collector/index.html#identifying-reference-cycles

대략적으로, Python 의 가비지 컬렉터는 아래와 같이 동작합니다:

  1. 참조 순환 탐지: GC는 먼저 모든 컨테이너 객체들을 후보군으로 설정하고, 이들 중에서 외부에서 직접 접근 가능한 객체들을 식별합니다.
  2. 참조 카운트 조정: 각 객체는 가비지 컬렉션을 지원할 경우 알고리즘 시작 시 그 객체의 참조 카운트를 나타내는 추가적인 참조 카운트 필드(gc_ref)를 가집니다. GC는 컨테이너 객체를 순회하며, 각 컨테이너가 참조하는 다른 객체의 gc_ref를 하나씩 감소시킵니다.
  3. 도달 가능성 재평가: 모든 객체를 검사한 후, 외부에서 직접 접근 가능한 객체들만이 gc_ref > 0을 유지합니다. gc_ref == 0인 객체가 반드시 도달할 수 없는 객체는 아니며, 도달 가능한 다른 객체에 의해 여전히 참조될 수 있습니다.
  4. 정리: 마지막으로, GC는 컨테이너 객체들을 다시 스캔하며 gc_ref == 0인 객체를 “잠정적으로 도달할 수 없는” 것으로 표시하고 이를 처리합니다. 이 과정을 통해 실제로 도달할 수 없는 객체들이 식별되고, 가비지 컬렉터에 의해 회수됩니다.

요약하자면..

  1. 프로그램 내의 모든 객체들을 확인하며, 어떤 객체들이 서로 참조하고 있는지를 파악한다.
  2. 참조 순환이 발생한 객체들 중에서, 실제로 프로그램의 다른 부분에서는 더 이상 사용되지 않는 객체들을 식별합니다.

정도가 되겠어요.

마무리하며

개인적으로 이번 포스트를 작성하며 생각보다 Python 이 여러 가지를 해 준다는 것과 어쩌면 내가 작성한 코드 중 어딘가에도 위에서 언급한 순환 참조가 있지 않을까 하는 불안함을 느꼈습니다. 또, 가비지 컬렉터가 존재하는 다른 프로그래밍 언어는 어떻게 구현되어 있을지, 인터프리터나 컴파일러는 어떻게 그것을 최적화하는지에 대해서도 호기심을 가지게 되는 계기가 되었습니다.

PythonGarbage Collector 는 위에서 설명한 대로 동작하지만, 내부적으로 최적화를 위해 여러 방법들을 사용합니다. 아마 다음 [About Python] 시리즈의 글을 쓰게 된다면, 그것에 대한 내용이 아닐까 하네요 :)

읽기 쉬운 글을 지향하고, 최대한 그렇게 쓰려고 노력하고 있습니다. 이해가 되지 않거나, 설명이 틀린 부분들은 알려주신다면 적절히 수정하도록 하겠습니다. 긴 글 읽어주셔서 감사합니다!

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.