TL;DR

  • CPython 3.12의 메모리 할당은 객체 크기에 따라 512바이트 이하의 pymalloc과 512바이트 초과의 malloc을 사용하며, 메모리 해제가 곧 운영체제(OS)에 메모리가 반환되는 것을 뜻하지는 않음.
  • pymalloc은 큰 메모리 덩어리인 아레나(arena)를 풀(pool)과 블록(block)으로 나눠 재사용하며, 아레나의 풀이 모두 비어야 해당 메모리를 OS에 반환함.
  • 큰 객체는 시스템 C 라이브러리의 malloc을 사용하며, 객체를 해제해도 메모리를 재사용하기 위해 보유하는 경우가 많음.
  • 객체의 참조 횟수(reference count)가 0이 되면 즉시 객체가 파괴되지만, 순환 참조는 참조 횟수만으로 처리할 수 없어 가비지 컬렉터(garbage collector)가 정리함.
  • OS에 반환되는 메모리를 완전히 제어할 수는 없지만, 참조의 수명을 줄여 큰 객체가 살아 있는 시간을 관리할 수 있음.

메모리 관리에 관심을 갖게 된 계기

  • 오랫동안 Python으로 구현할 때 메모리 관리는 신경 쓸 문제가 아니었고, 직장에서도 여전히 문제를 일으키지는 않지만 작동 원리를 이해하는 데 관심이 생김.
  • 출발점은 「Strong ref, weakref and garbage collector walk into a bar」라는 강연임. 참조 횟수와 gen0, gen1, gen2를 이해한 뒤에도 순서가 뒤바뀐 듯한 느낌이 들어, Python이 메모리를 해제하는 방법에 앞서 메모리를 할당하는 방법부터 살펴봄.
  • 끝이 없을 것 같은 탐구를 계속하는 중이며, 더 깊이 들어가기 전에 이해한 내용을 정리해 기록함.
  • 여기서는 CPython 3.12를 기준으로 학습한 내용을 다룸. 개인적인 정리이므로 모든 내용이 100% 정확하지 않을 수 있음.

Python의 메모리 할당

  • Python은 객체 크기에 따라 두 가지 메모리 할당 방식을 사용함. 512바이트 이하의 작은 객체에는 pymalloc을, 512바이트 초과의 큰 객체에는 malloc(PyMem_RawMalloc)을 사용함.

작은 객체와 `pymalloc`

  • 새 객체를 저장할 공간이 없으면 pymalloc이 OS에 아레나라는 큰 메모리 덩어리를 요청함. 각 아레나는 64개의 풀로 나뉘며, 각 풀은 같은 크기의 블록으로 나뉨.
  • 아레나, 풀, 블록에 대한 자세한 설명은 이 블로그 글에서 확인할 수 있음.
  • 객체는 풀 안의 빈 블록에 저장됨. 해당 페이지에 처음 쓰기가 이뤄질 때 OS 관점에서 메모리가 실제로 사용되기 시작함.
  • 기존 아레나에 해당 크기의 객체를 담을 공간이 없을 때에만 pymalloc이 새 아레나를 요청함.
  • 아레나의 풀이 모두 비어야 OS에 반환됨. 따라서 블록 하나가 해제되어도 OS가 보는 메모리에는 변화가 없으며, pymalloc은 공간을 보유하다가 같은 크기의 다음 객체에 재사용함.
  • 대부분의 할당은 OS에 새 메모리를 요청하지 않고 기존 풀의 빈 블록을 사용하므로 빠름. 반면 살아 있는 객체 하나만 있어도 아레나 전체가 OS로 돌아가지 않을 수 있어 Python 프로세스의 메모리 사용량은 좀처럼 줄지 않음.

큰 객체와 `malloc`

  • 512바이트 초과의 객체는 시스템 C 라이브러리에 포함된 메모리 할당자 malloc을 사용함. 아레나나 풀은 없으며 Python이 바이트 수를 요청하면 malloc이 나머지를 처리함.
  • 객체가 해제되어도 malloc은 보통 메모리를 재사용하려고 보유함. 매우 큰 블록만 OS에 곧바로 반환됨.
  • 이처럼 메모리를 해제해도 OS가 인식하는 메모리 사용량이 항상 줄어드는 것은 아님.

참조 횟수와 객체 해제

  • 메모리 해제에는 참조 횟수와 가비지 컬렉터라는 두 가지 방식이 사용됨.
  • int, 문자열, 리스트, 함수 등을 포함해 Python의 모든 것은 객체임. 변수는 객체를 가리키는 이름임.
  • x = [1, 2, 3]을 실행하면 이름 x가 리스트 객체를 가리키며, 리스트의 참조 횟수는 x가 보유한 참조 하나로 시작함.
  • 이름이 다른 객체를 가리키도록 바뀌거나 del x가 실행되거나 x가 있는 함수가 반환되면 참조 횟수가 하나 줄어듦. 참조 횟수가 0이 되면 객체가 즉시 파괴되고 해당 블록은 풀의 사용 가능한 블록으로 돌아감.
  • 예를 들어 함수 안에서 x가 리스트를 가리키고 y = x를 실행하면 리스트의 참조 횟수는 2가 됨. 이후 y = "foo"로 바꾸면 1이 되고, 함수가 반환되어 x가 사라지면 0이 되어 리스트가 해제됨.

가비지 컬렉터와 순환 참조

  • 가비지 컬렉터는 참조 횟수만으로 해결할 수 없는 순환 참조를 처리함. 두 객체가 서로를 가리키면 참조 횟수가 0에 도달하지 않음.
  • 가비지 컬렉터는 때때로 리스트, 딕셔너리, 클래스 인스턴스와 같은 컨테이너 객체를 살펴보고 프로그램의 나머지 부분에서 더는 도달할 수 없는 객체를 찾아 같은 실행 과정에서 해제함.
  • 새 객체는 gen0에서 시작함. 대부분의 객체가 오래 살아남지 않으므로 이 세대는 자주 확인됨. 가비지 컬렉션에서 살아남은 객체는 더 오래된 세대로 이동하며, 오래된 세대는 덜 자주 확인됨.
  • 예를 들어 리스트 A가 리스트 B를 가리키고 리스트 B가 다시 리스트 A를 가리키면 순환이 생김. 함수가 반환되어 두 리스트에 대한 지역 참조가 사라져도 서로를 가리키는 참조가 남아 각각의 참조 횟수는 1임. 이후 gen0 수집에서 A와 B가 도달 불가능한 객체로 판별되어 함께 해제됨.

참조 수명과 메모리 관리

  • 해제된 메모리 중 무엇이 OS로 돌아가는지는 할당자가 결정하므로 이를 완전히 제어할 수는 없음. 대신 참조가 얼마나 오래 살아 있는지는 조절할 수 있음.
  • 큰 객체를 가리키는 지역 변수는 함수가 반환될 때까지 해당 객체를 살려 둠. 객체를 사용한 뒤에도 함수에서 할 일이 남아 있다면 del로 참조를 지우거나 summarize(load_file())처럼 다음 호출에 바로 전달할 수 있음. 그러면 해당 호출이 반환된 뒤 객체가 해제됨.

Python 내부 동작 탐구

  • Python 내부 구조를 공부하며 즐거움을 느끼고 많은 것을 배움. 메모리 관리를 이해하려고 살펴보다 보면 OS의 메모리 할당 처리, Python의 자유 스레딩(free-threading) 구현 방식, 인터프리터의 코드 실행 방식 등 또 다른 주제로 이어짐.
  • 계속 이어지는 탐구가 즐겁고, 더 일찍 시작하지 않은 점이 아쉬움.