CPython

CPython 메모리 관리

25G 2023. 1. 25. 01:44

CPU와 메모리는 컴퓨터에서 가장 중요한 부분이다. CPU와 메모리는 모두 혼자서는 동작할 수 없다.

프로그래밍 언어를 설계할때는 메모리를 관리할 방법을 정해야하는데 언어 설계자는 단순한 인터페이스를 원하는지, 이기종 호환성 대응을 원하는지 안정성보다는 성능에 중점을 둘 것인지에 따라 다양한 메모리 관리 방법중 어떤 방식을 사용할지 성택한다.

C메모리 할당

C는 세가지 메모리 관리방식을 제공한다.

  1. 정적 메모리할당: 필요한 메모리는 컴파일 시간에 계산되고 실행파일이 실행될때 할당된다.
  2. 자동 메모리할당: 스코프에 필요한 메모리는 프레임에 진입할 때 콜스택내에 할당되고 프레임이 끝남ㄴ 해제된다.
  3. 동적 메모리 할당: 메모리 할당 API를 호출해 런타임에 메모리를 동적으로 요청하고 할당한다.

정적 메모리 할당

C는 Type safe하게 타입의 크기가 고정되어 있다. 컴파일러는 모든 정적 전역 변수에 필요한 메모리를 계산한 후 필요한 메모리의 양을 애플리케이션에 컴파일해 넣는다.
sizeof()로 int타입이 macos에서는 4바이트(아키텍처와 컴파일러에 따라 크기가 다를수 있다.)
C컴파일러는 sizeof(int) * n Byte만큼의 메모리 할당으로 변환한다. C컴파일러는 시스템 콜을 이용해 메모리를 할당한다. 메모리 할당 시스템 콜은 시스템 메모리 페이지에서 메모리 할당받기 위해 커널에 요청하는 저수준 함수로, 운영 체제에 따라 다르다.

자동 메모리 할당

정정 메모리 할당 방식처럼 자동 메모리할당 방식도 컴파일 시간에 필요한 메모리를 계산한다.

동적 메모리 할당

많은 경우 정적 메모리 할당과 자동 메모리 할당만으로는 필요한 메모리를 다 할당할 수 없다. 예를 들어 사용자 입력에 따라 필요한 메모리가 결정되는 프로그램은 컴파일 시간에 필요한 메모리를 계산할 수 없다.
이런 경우에 메모리를 동적 할당을 하게 됩니다. c메모리 할당 api를 호출하면 메모리를 동적으로 할당할 수 있다. 운영체제는 프로세스가 메모리를 동적으로 할당할 수 있도록 시스템 메모리의 일부를 예약해 두는데 이공간을 heap이라고 한다. 이때 동적으로 할당한 메모리를 제대로 반환하지 않으면 메모리 누수가 발생한다.

파이썬 메모리 관리 시스템의 설계

C기반인 CPython은 C 메모리 할당 방식들의 제약조건을 따라야하는데 파이썬의 언어 설계중 일부는 이러한 제약조건을 지키며 구현하기 쉽지않다.

  1. 파이썬은 동적 타입언어. 변수의 크기를 컴파일 시간에 계산할 수 없다.
  2. 코어 타입의 크기는 대부분 동적이다. 리스트 타입은 크기를 정할 수 없고 딕셔너리는 제한없이 키를 가질수 있고 정수또한 동적이다. 사용자는 이러한 탕비의 크기를 결정할 필요가 없어야한다.
  3. 값의 타입이 달라도 같은 이름을 재사용할수 있다.

C메모리 할당 방식의 제약을 극복하기 위해 파이썬 객체 메모리는 개발자가 직접 할당하는 대신 하나의 통합 API를 통해 자동으로 할당된다. 이러한 메모리 관리 설계로 인해 CPython 표준 라이브러리와 C로 작성된 코어 모듈 전체는 이 통합 API를 사용해야한다.

할당자 도메인

CPython 세가지 동적 메모리 할당자 도메인

  1. 저수준 도메인: 시스템 힙이나 대용량 메모리 또는 비객체 메모리를 할당하는 데 사용한다.
  2. 객체 도메인: 파이썬 객체와 관련된 메모리를 할당하는데 사용한다.
  3. PyMem 도메인: PYMEM_DOMAIN_OBJ와 동일하며 레거시 API용도로 제공된다.

각 도메인은 다음과 같은 통일한 인터페이스를 구현한다.

  1. _Alloc(size_t): size바이트 만큼 메모리를 할당하고 포인터를 반환한다.
  2. _Calloc(size_t nelem, size_t elsize) : nelem개의 elsize크기 요소들을 할당하고 포인터를 반환한다.
  3. _Realloc(void *ptr, size_t new_size: new_size 크기로 메모리를 재할당한다.
  4. _Free(void *ptr):ptr 위치의 메모리를 해제하고 힙으로 반환한다.

메모리 할당자

  1. malloc: 저수준 메모리 도메인을 위한 운영체제 할당자.
  2. pymalloc: PyMem 도메인과 객체 메모리 도메인을 위한 CPython할당자

CPython 메모리 할당자

CPython 메모리 할당자는 시스템 메모리 할당자 위에 구축된 할당자로, 독자적인 할당 알고리즘을 구현한다.

  • 대부분의 메모리 할당 요청은 고정된 크기의 작은 메모리를 요구한다.
  • pymalloc 할당자로는 메모리 블록을 최대 256kb까지 할당할 수 있고 그보다 큰 할당 요청은 시스템 할당자로 처리한다.
  • pymalloc 할당자는 GIL로 시스템의 스레드 안전성체크 기능을 대신한다.

이는 콘서트 좌석이 등급별로 나눠져 있듯이 블록이 정해진 크기로 고정되어 있고 같은 크기의 좌석들로 각 열이 이뤄지는 것 처럼 같은 크기의 블록들로 각 풀이 이뤄진다.
좌석이 입장될때 첫열이 차면 다음열이 사용되듯이 한 풀이 꽉차면 다음 풀이 사용된다.

  1. 알고리즘은 CPython에서 주로 만들어지는 작고 수명이 짧은 객체에 적합하다.
  2. 시스템 스레드 잠금 검사 기능 대신에 GIL을 사용한다.
  3. 힙 할당 대신 메모리 맵을 사용한다.

여기서 핵심은 다음과 같다

  • 메모리 할당은 블록 크기에 맞추어 이뤄진다.
  • 동일한 메모리 풀에는 같은 크기의 블록들이 저장된다.
  • 아레나로 풀들을 묵는다.

블록, 풀, 아레나

아레나는 가장 큰 단위의 메모리그룹입니다.
CPYthon은 아레나를 할당할때 256kb로 할당하고 시스템 페이지 크기에 맞춰 정렬한다. 시스템 페이지 경계는 고정 길이의 연속 메모리 청크다.

아레나

아레나는 시스템 힙에 할당되며 익명 메모리 매핑을 지원하는 시스템에서는 mmap()으로 할당한다. 메모리 매핑은 아레나의 힙 단편화를 줄이는 데 도움이 된다.
시스템 페이지라는 큰 틀안에 아레나가 256kb만큼 블록을 이루어져서 대기하고있다.

아레나에서 풀에 담을 수 있는 블록의 최대 크기는 512 byte다. 32비트 시스템에서는 블록 크기가 8바이트 단위로 증가하기 때문에 64가지 블록 크기를 사용할 수 있다.
풀의 세가지 상태

  1. 포화: 풀의 모든 블록이 할당되었다.
  2. 사용중: 풀이 할당된 상태이고 일부 블록이 사용되었지만 여전히 빈 블록이 있다.
  3. 미사용: 풀은 할당된 상태지만 어떤 블록도 사용되지 않았다.

블록

풀의 메모리는 블록 단위로 할당된다. 블록은 다음과 같은 특징을 지닌다.

  • 풀에서 블록은 고정 크기로 할당, 해제된다.
  • 사용되지 않은 블록은 풀 내부의 freeblock 단일 연결 리스트에 연결된다.
  • 할당 해제된 블록은 freeblock 리스트의 맨앞에 추가된다.
  • 풀이 초기화 될때 첫 두 블록은 freeblock 리스트에 연결된다.
  • 풀이 '사용 중' 상태인 동안에는 할당 가능한 블록이 존재한다.

PyArena 메모리 아레나

PyArena는 컴파일러와 프레임 평가 등 객체 할당 API이외의 부분에서 사용되는 별도의 아레나 할당 API다. PyArena는 아레나 구조체 내에 별도로 할당된 객체 리스트를 유지한다. PyArena로 할당된 메모리를 가비지 컬렉션 대상이 아니다.
파이썬은 메모리 관리를 단순화 하기 위해 다음과 같은 전략을 사용한다.

  1. 참조 카운팅
  2. 가비지 컬렉션

파이썬에서 변수 생성 과정

파이썬에서 변수를 생성하려면 고유한 이름을 가지는 변수에 값을 할당해야한다.
data = ["hi", "python"]

파이썬에서 변수에 값을 할당할 때는 변수 이름이 지역 또는 전역 스코프에 이미 존재하는지 확인한다.
위 변수는 새로운 list객체가 생성되고 그 포인터가 locals()딕셔너리에 저장된다. 이제 data는 참조되고 있다. 리스트 객체에 할당된 메모리는 유효한 잠조가 존재하는 한 해제되지 않아야한다. 참조가 존재하는 상태에서 메모리가 해제되면 my_variable 포인터는 올바르지 않은 메모리 공간을 참조하게 되고 cpython은 충돌하게 된다.

참조 카운트 증가

CPython C소스 코드에서 Py_INCREF() 와 Py_DECREF() 매크로 호출을 자주 발견할 수 있는데 이 매크로는 파이썬 객체의 참조 카운트를 증감시키는 주요 API이다. 어디선가 값에 의존하게 될때마다 참조 카운트는 증가하고, 의존하지 않게되면 참조카운트도 감소한다. 참조 카운트가 0이되면 메모리가 더는 필요하지 않다는 뜻이기 때문에 메모리는 자동으로 해제된다.
참조 증가 매크로는 다음과 같은 작업에서 가장 빈번하게 호출된다.

  • 변수 이름에 할당될때
  • 함수나 메서드 인자로 참조될때
  • 함수에서 반환되거나 산출될때
    Py_INCREF는 단순히 ob_refcnt 값을 1증가시킨다.

    참조 카운트 감소

    변수가 선언된 스코프를 벗어나면 객체에 대한 참조는 감소한다. 파이썬에서 스코프는 보통 함수나 메서드, 콤프리핸션,람다를 뜻한다. 이러한 명시적 스코프 이외에도 함수 호출시 변수 전달 등다양한 암시적 스코프가 존재한다. Py_DCREF()는 참조 카운트가 0이 됐을 경우 메모리를 해제해야 하기 때문에 Py_INCREF()보다는 복잡하다.
    Py_DECREF()는 참조 카운터(ob_refcnt)의 값이 0이 되면 _Py_Dealloc(op)를 통해 객체 파괴자를 호출하고 할당된 모든 메모리를 해제한다.

가비지 컬렉션

CPython은 가비지 컬렉션 알고리즘에 동일한 원리를 적용했다. Cpython 가비지 컬렉터는 더 이상 존재하지 않는 객체가 사용되고 있는 메모리를 할당해제한다. 가비지 컬렉션은 기본적으로 활성화 되어 있고 백그라운드에서 실행된다. 그리고 복잡하기때문에 항상 실행되지 않는다. 가비지 컬렉션이 참조 카운팅만큼 자주 일어난다면 엄청난 CPU자원을 소모할것이다. 그렇기때문에 정해진 만큼 연산이 일어났을때 주기적으로 실행된다.

가비지 컬렉터 설계

참조 카운터의 카운터가 0에 도달하면 객체의 수명이 끝나고 메모리에 해제된다. 리스트나 튜플,딕셔너리, 집합 등의 컨테이너 타입은 순환참조를 일으킬 수있다. 참조 카운터는 순환 참조를 일으킬 수도 있는 객체들을 완벽하게 처리할 수 없다. 컨테이너 타입에 순환 참조를 만드는것은 피해야만 하지만 표준 라이브러리와 코어 모듈에서도 많은 순환참조가 일어난다.
CPython 가비지 컬렉터는 도달할 수 있는 객체들을 찾는 대신 참조 카운터와 특수한 가비지 컬렉션 알고리즘을 사용하여 도달할 수 없는 개체들을 찾는다. Cpython 가비지 컬렉터의 역할은 참조 카운터를 활용하여 특정한 컨테이너 타입에서 순환참조를 찾는 것이다.

가비지 컬렉션 대상

  • 클래스, 메서드, 함수 객체
  • 셀 객체
  • 바이트와 바이트열, 유니코드 문자열
  • 딕셔너리
  • 어트리뷰트의 디스크립터 객체
  • 열거형 객체
  • 예외
  • 프레임객체
  • 리스트, 튜플,네임드 튜플, 집합
  • 메모리 객체
  • 모듈과 이름 공간
  • 타입과 약한 참조 객체
  • 이터레이터와 제너레이터
  • 피클버퍼

부동 소수점, 정수,불, NoneType은 가비지컬렉션 대상이 아니다.

가비지 컬렉션 알고리즘

초기화

진입점 PyGC_Collect()는 다음 5단계 프로세스를 따라서 가비지 컬렉터를 시작하고 정지한다.

  1. 가비지 컬렉션 상태 GCState를 인터프리터로 부터 얻는다.
  2. 가비지 컬렉터 활성화 여부를 확인한다.
  3. 가비지 컬렉터가 이미 실행중인지 확인한다.
  4. 수거 함수 collect()를 수거 상태 콜백과 함께 실행한다.
  5. 가비지 컬렉션이 완료됐다고 표시한다.

수거단계

메인 가비지 컬렉션 함수 collect()는 세 새대중 한 세대 에서만 쓰레기를 수거한다.
가비지 컬렉터는 수거 시마다 PyGC_HEAD타입을 연결한 이중 연결 리스트를 이용한다. GC는 모든 컨테이너 타입을 찾는 대신, 가비지 컬랙션대상인 컨테이너 타입의 추가 해더를 이용해 모든컨테이너 타입을 이중연결 리스트로 연결한다.

셀객체가 삭제될 때는 cell_dealloc()이 호출되는데 이 함수는 다음 세단계를 수행한다.

  1. 파괴자는 _PyObject_GC_UNTRACK()을 호출해 더이상 이 인스턴스를 추적하지 말라고 가비지 컬렉터에 요청한다. 이 인스턴스는 삭제됐기 때문에 다음 수거때 확인할 필요가 없다.
  2. Py_XDECREF는 파괴자들이 참조 카운터 감소를 위해 사용하는 표준 호출이다. 초기화 됐을때 객체의 참조 카운터가 1부터 시작했기 때문에 연산이 필요하다.
  3. PyObject_GC_Del()은 gc_list_remoce()를 호출해 가비지 컬렉션 대상 리스트에서 객체를 제거하고 PyObject_FREE()로 메모리를 해제한다.

객체 해제하기

도달 불가능하다고 결정된 객체들은 다음 단계들을 따라 해제된다.

  1. 객체가 레거시 tp_del 슬록에 파이널라이저를 구현했을 경우 해당 객체는 안전하게 삭제될 수 없고 해제할 수 없다고 표시된다. 이러한 객체들은 gc.grabage리스트에 추가되고 개발자가 수동으로 삭제해야한다.
  2. 객체가 tp_finalize 슬롯에 파이널라이저를 구현했을 경우 객체는 중복 해제를 막기 위해 완료 상태로 표시된다.
  3. 2단계의 객체가 다시 초기화 되어 살아난다면 가비지 컬레거는 수거 사이클을 다시 실행한다.
  4. 모든 해제 대상 객체의 tp_clear 슬롯을 호출하면 해당 슬롯은 참조 카운트 ob_refcnt를 0으로 설정해서 메모리 해제를 트리거한다.