본문 바로가기
Programming/Python

Garbage Collector 동작 방식

by DUSTIN KANG 2024. 2. 7.

개발자에게 메모리 관리는 성능을 개선하는 측면에서 중요하게 자리잡혀야할 개념이다. 비효율적인 메모리 관리는 프로그램이나 서버의 속도를 느리지게 할 수도 있다. 다음 코드를 보면 함수 내부에 객체를 생성했지만 리턴하지 않았다. 이 리스트에서 해당 원소를 제거하면 객체는 참조점을 잃게 된다. 물론 가비지 컬렉터(GC)가 회수하겠지만 언제 실행될지 모르며 해결되지 못하는 경우 메모리 누수(memory leak)라는 문제가 발생한다. 메모리 누수는 객체를 더이상 사용하지 않아도 발생한다. 여기서는 사용자의 부주의에 의한 메모리 누수만 언급한다. 

def func(arr):
    a = Foo()
    arr.append(a)

 

우리는 이러한 비효율적인 메모리를 관리하기 위해 효율적인 코드를 작성해야 한다. 파이썬에서는 Python Memory Manager에 의해 메모리 관리가 수행된다. 효율적인 코드 작성과 파이썬 메모리 관리자의 작동 방식을 제대로 이해하고 있어야지 효율적이고 유지보수가 가능한 코드를 작성할 수 있다.

 

파이썬 메모리 관리

파이썬의 메모리 관리는 Python Memory Manager에 의해 수행된다. 그러면 파이썬은 어떻게 객체에게 메모리를 할당하는 것일까?

우선, 메모리 할당(Memory Allocation)에는 정적인 메모리 할당과 동적인 메모리 할당이 있는데 파이썬은 기초적으로 동적 메모리 할당 방식을 사용한다.  동적 메모리 할당은 런타임 시 메모리가 할당되며 힙(heap)이라는 자료구조로 동적 할당을 구현하며 만약 필요 없는 메모리가 있다면 비우고 다시 사용할 수 있게끔한다. 그래서 모든 파이썬의 객체와 자료구조는 python private heap이라는 공간에 관리된다.  파이썬 메모리 관리자는 블록(Block)이라는 메모리 청크를 통해 관리한다. 다음 그림을 보자. 

 

코드에서 8Byte의 메모리 청크가 필요하다고 하면 empty 풀(빈 풀)이 초기화 되어 8Byte의 블록을 저장하게 된다. 블록에도 약간의 차이가 있다. 메모리가 할당되지 않는 부분이 있고 할당은 되었으나 데이터가 포함되지 않는 부분이 있다.  만약 객체가 파손되면 이 공간을 동일한 크기의 새로운 객체로 채우게 된다. 

Memory Management in Python - RealPython

메모리 할당에는 두가지 방식이 존재한다. 정적 메모리 할당과 동적 메모리 할당이 있다.
이 중 정적 메모리 할당은 프로그램 컴파일 시 메모리가 할당되며 고정 크기로 정적 배열을 선언한다. "스택(stack)" 이라는 자료구조로 정적 할당을 구현할 수 있다. 

 

파이썬 메모리를 할당하는 방식에 대해 간단히 짚고 넘어갔다.

이번에는 Python Memory Manager에 내장된 GC에 대해 알아보려고 한다. 가비지 컬렉션을 통해 사용하지 않은 객체를 회수할 수 있다. 이렇게 메모리를 관리하는 기능에는 가비지 컬렉션과 레퍼런스 카운팅이 존재한다. 물론 레퍼런스 카운팅은 가비지 컬렉션의 한 형태이다. 

가비지 컬렉터

레퍼런스 카운팅

레퍼런스 카운팅(Reference Counting)참조 카운터가 0이 되었을 때 메모리에서 해제 되는 방식이다. 다음 예시를 보자.

import sys
a = []
b = a
print(sys.getrefcount(a)) # 3

 

위 코드에서 `[]`를 참조하는 객체는 어떤 것이 있을까? `a`, `b` 그리고 `getrefcount()`이다. 총 3개이다. 만약 이 참조가 0이 되어버리면 효율성을 위해 파이썬에서는 GC가 점유된 메모리를 해제할 것이다. 추가로 첨언하자면, GIL이 필요한 이유도 해당 상황에서 두가지 스레드가 동시에 하나의 값을 줄이거나 생성한다면 Race Condition(경쟁 상태)가 발생하기 때문이다. 

 

만약 두번째 그림에서 z도 참조하는 값이 변경되면 5는 가비지 컬렉터에 의해 사라질 것이다.

 

순환 참조

순환 참조(circular reference)는 참조 횟수가 0은 아니지만 객체에 도달 할 수 없는 상태일 경우를 말한다. 대표적으로 자기 자신을 참조하게 되는 경우이다. 파이썬의 `gc` 모듈은 Cyclic Garbage Collection을 지원하는데 이를 통해 순환참조를 해결할 수 있다. 

 

어떻게 순환 참조를 해결할까라는 질문에 앞서 관련 개념을 이해하고 넘어가야한다. 가비지 컬렉션은 어떤 기준으로 발생하느냐인데 가비지 컬렉터는 세대(generation)과 임계값(threshold)로 객체를 관리한다. 세대가 짧을 수록 Young한 객체(가장 최근에 생긴 객체)이고 세대가 클수록 오래된 객체이다. 통상에서 사용하는 세대랑 헷갈리면 안된다.   가비지 컬렉터는 0세대일 수록 더 자주 가비지 컬렉션하도록 설계되었다.

 

이제 가비지 컬렉터가 어떻게 순환 참조를 해결하는지 알아보자.

순환 참조는 컨테이너 객체(list, dict, set, tuple 등)에 의해서만 발생한다. 그러므로 가비지 컬렉터는 모든 컨테이너 객체를 추적한다. 

import gc

gc.is_tracked(0) # False : 추적하지 않음
gc.is_tracked("a") # False : 추적하지 않음
gc.is_tracked([]) # True : 컨테이너 객체이므로 추적함
gc.is_tracked({"a": 1}) # False : 예외적으로 atomic type을 가진 dict는 추적하지 않음
gc.is_tracked({"a": []}) # True : 모든 컨테이너 객체는 추적함

 

컨테이너 객체가 생성되면 파이썬은 0세대 연결리스트(LinkedList)에 추가하게 된다. 그리고 객체가 참조하는 다른 객체를 확인하고 레퍼런스 카운트를 저장한다.  다음 각 컨테이너 객체를 연결리스트에 추가하게 되면 다음과 같다. 가운데 박스는 레퍼런스 카운트이고 화살표로 다른 객체에 참조되었다는 것을 알려준다.

a = [1]
b = ['a']
c = [a, b]
d = c
e = [3]

 

현재 과정에서 각 컨테이너 객체의 gc_refs가 설정되는데 이는 레퍼런스 카운트와 같다.

 

파이썬의 객체에는 다음과 같은 PyGC_HEAD 구조체를 볼 수 있다.

구조체 안의 `gc_refs` 필드는 레퍼런스 카운트와 같게 설정하고 각 객체들을 순회하면서 다른 컨테이너 객체가 참조하고 있는 객체가 있을 경우 `gc_refs`를 줄인다. 그런후, `gc_refs`가 0이 되었을 경우엔 순환참조가 되었다고 판단하여 메모리를 해제한다. 

/* GC information is stored BEFORE the object structure. */
typedef union _gc_head
{
    struct {
        union _gc_head *gc_next;
        union _gc_head *gc_prev;
        Py_ssize_t gc_refs;
    } gc;
    long double dummy; /* force worst-case alignment */
} PyGC_Head;

 

`gc_refs`가 0인 된 순환 참조 객체를 발견하게 되면 메모리를 해제하고 다음 세대로 할당해 마지막 2세대까지 남아있는 객체들은 프로그램이 멈출 때까지 남아있게 된다. 하지만 가비지 컬렉션이 항상 발생하는 것은 아니다.

 

파이썬이 기본값으로 정한 임계값을 확인해 보면 1번째 임계값(700)과 2~3번째 임계값이 다르다는 것을 볼 수 있다. 

첫번째 값(0세대 임계값)은 메모리에 객체가 할당 후 해체된 횟수를 뺀 값을 말하며 즉, 객체 수가 700이 초과되면 가비지 컬렉션이 실행된다는 뜻이다. 

두번째 값은 0세대에서 가비지 컬렉션이 실행된 횟수를 말한다. 0세대에서 700이 넘어 가비지 컬렉션이 실행되면 1세대로 이전시키고 1세대에서 가비지 컬렉션 횟수를 1을 더하고 0세대 객체 수를 0으로 초기화한다.

이러한 세대별 가비지 컬렉션은 파이썬에서 변경(`gc.set_threshold(0)`)할 수 있다. 가비지 컬렉터의 동작 또한 제어(`gc.disable()`) 할 수 있다.

 

가비지 컬렉션은 성능에 영향을 준다.

파이썬에서는 GIL이라는 정책으로 인해 가비지 컬렉션을 수행하려면 응용 프로그램들을 중지해야한다. 그러므로 객체가 많을 수록 수집하는 시간이 오래걸린다. 그러니 가비지 컬렉션 주기가 짧다면 응용 프로그램을 많이 중지해야하고 길다면 가비지가 많이 쌓이게 될 것이다. 

여러 요인을 조절하면서 성능을 최적화 해야할 것이다.

 

파이썬을 공부하면서 쉽지않은 부분이었던 것 같다. 그래도 추후에 좀 더 깊은 자료들을 읽어 다듬어야 할 것 같다.

 

해당 링크는 인스타그램에서 Django 기반 웹서버를 운영하면서 Garbage Collection을 없애 최적화했던 글을 번역한 블로그 글이다. 아직 본인은 읽어보진 않았지만 한번 쯤 읽어보려고 한다.
인스타그램이 Python Garbage Collection을 없앤 이유(원:instagram Blog) - luavis Dev Story
인스타그램이 Python Garbage Collection을 없앤 그 다음(원:instagram Blog) - luavis Dev Story
원본

☕️ 포스팅이 도움이 되었던 자료

오늘도 저의 포스트를 읽어주셔서 감사합니다.

설명이 부족하거나 이해하기 어렵거나 잘못된 부분이 있으면 부담없이 댓글로 남겨주시면 감사하겠습니다.