"interpreting" 의 전체 과정과 cpython이 C로 python 메모리 할당과 관리를 어떻게 하는지 큰 그림을 살펴봤다. 할당을 했으면 해제는 어떻게 할까? python이 메모리를 해제하는 방법과 그 때문에 발생하는 GIL에 대해 알아보자.
파이썬을 사용하면 메모리의 할당과 해제를 프로그래머가 직접할 필요가 없다. 오히려 올바르지 않게 직접하면 역효과가 난다.
"객체가 더 이상 사용되지 않으면", 알아서 해당 객체를 메모리에서 해제해야 한다. 그럼 어떤 객체가 "더 이상 사용되지 않는" 객체일까? 이를 위해 파이썬은 레퍼런스 카운팅과 가비지 컬렉션의 두가지 방법을 모두 사용한다.
사실 레퍼런스 카운팅 자체도 GC에 포함시키기도 하지만, 우선 따로 두고 살펴보기로 한다.
객체 자체에서 스스로가 참조되는 횟수를 기록하고 더 이상 참조되지 않아서 참조 횟수가 0이 된다면 객체를 할당 해제 한다.
할당 해제 할때 __del__(self)
함수가 호출 되며 Destructors
라고도 부른다.
모든 객체는 참조 당할 때 레퍼런스 카운터를 증가시키고 참조가 없어질 때 카운터를 감소시킨다. 이 카운터가 0이 되면 객체가 메모리에서 해제한다. 어떤 객체의 레퍼런스 카운트를 보고 싶다면 sys.getrefcount()로 확인할 수 있다.
파이썬 내부에서는 Py_INCREF
매크로를 사용하여 참조 횟수를 증가시키고 Py_DECREF
매크로를 사용하여 참조 횟수를 감소시킨다. 만약 참조 횟수가 0이 된다면 Py_DECREF
매크로는 _Py_Dealloc
함수를 사용하여 메모리에서 객체를 해제한다. 공식 문서 에서 더 살펴볼 수 있다.
ps) "매크로" 란, C언어에서 #define
선행처리 지시문에 인수로 함수의 정의를 전달함으로써, 함수처럼 동작하는 매크로를 만들 수 있다.
li = list()
li.append(li)
...
del li
li
는 리스트 object 이다. 그리고 자기 자신을 append하여 li
에 대한 참조 회수를 +1 한다. 이런 경우엔 li
를 destory해도 카운팅 값이 1로 남아 메모리에서 해제되지 않게 된다. a = Human()
b = Human()
a.friend = b # 이 순간 b 의 래퍼런스 카운팅 2
b.friend = a # 이 순간 a 의 래퍼런스 카운팅 2
del a
del b
특정 class의 instance로 만든 경우도 동일하다. 역시 del 이후 a, b는 각 1 값으로 남아 해제되지 않게 된다.
그래서 우리는 최대한 순환 참조의 형태를 피해서 코드를 짜는게 좋다. 하지만 웹어플리케이션과 같이 무중단으로, 장기러닝되는 프로세스의 경우 순환 참조의 형태가 있을 수 있다. 그래서 등장한 것이 G.C 이다.
공식 홈페이지 c-api GC 에 대한 글을 살펴보자. 그리고 우린 gc 모듈을 통해 제어할 수 있다.
gc 모듈은 사실 오로지 "순환 참조 해결"을 위해 존재한다. 그래서 official docs에서도 Cyclic Garbage Collection
라고 부른다. 그리고 gc 모듈 문서에서는 수거기는 파이썬에서 이미 사용된 참조 횟수 추적을 보충하므로, 프로그램이 참조 순환을 만들지 않는다고 확신한다면 수거기를 비활성화 할 수 있습니다. gc.disable()을 호출하여 자동 수거를 비활성화 할 수 있습니다. 라고 언급하고 있다.
가비지 컬렉터는 내부적으로 generation(세대)
과 threshold(임계값)
로 가비지 컬렉션 주기와 객체를 관리한다. (threshold는 허용 가능한 한계치라는 의미에 더 가깝다)
세대는 0 - 2세대
로 구분 되며, 최근에 생성된 객체들이 0세대에 포함된다. 한 객체는 하나의 세대에만 속할 수 있다. 그리고 0세대일수록 더 자주 GC를 하도록 설계되어 있다. 이는 아래와 같은 이론에 근거한다.
- 링크 : https://www.memorymanagement.org/glossary/g.html#term-generational-hypothesis
Infant mortality or the generational hypothesis is the observation that,
in most cases, young objects are much more likely to die than old objects.
Strictly, the hypothesis is that the probability of death as a function of age
falls faster than exponential decay (inverse hyper-exponential),
but this strict condition is not always required for techniques
such as generational garbage collection to be useful.
get_threshold()
메서드를 사용하면 3개의 원소가 있는 튜플을 얻을 수 있다. 이는 각 (threshold 0, threshold 1, threshold 2)
를 의미한다. >>> import gc
>>> gc.get_threshold()
(700, 10, 10)
n세대에 객체를 할당한 횟수가 threshold n
을 초과하면 가비지 컬렉션이 수행된다는 것을 의미한다.
0세대의 경우 (메모리에 객체가 할당된 횟수) - (해제된 횟수)
가 threshold 0 => 700
을 초과하면 실행된다. 이후 세대부터는 0세대 가비지 컬렉션이 일어난 후, "0세대 객체를 1세대로 이동시킨 후 카운터를 1 증가" 시킨다. 이 값이 threshold 1 => 10
을 초과하면 그때 1세대 가비지 컬렉션이 일어난다. 2세대도 동일하다.
1세대로 갈때 0세대 count는 0으로 비우고 1세대 카운팅을 ++ 한다. 역시 2세대로 갈때 0세대, 1세대 count는 0으로 비우고, 2세대 카운팅을 ++ 한다.
아주 간단하게 보면 0세대 가비지 컬렉션이 객체 생성 700번만에 일어나고 -> 1세대는 7000번만에 -> 2세대는 7만번만에 일어난다는 의미이다.
위에서 살펴본바와 같이 GC존재 목적은 "순환 참조 감지" 이다. 순환 참조는 컨테이너 객체에 의해서만 발생할 수 있음을 알아야 한다. 컨테이너 객체는 다른 객체에 대한 참조를 보유할 수 있다. 그러므로 정수, 문자열은 무시한 채 관심사를 컨테이너 객체에만 집중할 수 있다.
ps) container object는 "데이터의 종류에 무관하게 저장할수 있는 자료형을 저장한 모델"을 의미한다. 예를 들면 문자열(str), 튜플(tuple), 리스트(list), 딕셔너리(dictionary), 집합(set) 등은 "타입에 무관하게 저장이 가능한 컨테이너 객체"들이고 정수, 실수, 복소수 등은 "타입이 고정되어 있는 단일 종류"(Literal)한 자료형이다. 즉 컨테이너 타입을 상속한 객체로서 여러 데이터 객체에 대한 메모리 참조 정보를 담고 있는 객체 이다.
https://github.com/python/cpython/blob/main/Include/internal/pycore_gc.h
/* GC information is stored BEFORE the object structure. */
typedef struct {
// Pointer to next object in the list.
// 0 means the object is not tracked
uintptr_t _gc_next;
// Pointer to previous object in the list.
// Lowest two bits are used for flags documented later.
uintptr_t _gc_prev;
} PyGC_Head;
#define NUM_GENERATIONS 3
struct gc_generation {
PyGC_Head head;
int threshold; /* collection threshold */
int count; /* count of allocations or collections of younger
generations */
};
/* Running stats per generation */
struct gc_generation_stats {
/* total number of collections */
Py_ssize_t collections;
/* total number of collected objects */
Py_ssize_t collected;
/* total number of uncollectable objects (put into gc.garbage) */
Py_ssize_t uncollectable;
};
struct _gc_runtime_state {
/* List of objects that still need to be cleaned up, singly linked
* via their gc headers' gc_prev pointers. */
PyObject *trash_delete_later;
/* Current call-stack depth of tp_dealloc calls. */
int trash_delete_nesting;
/* Is automatic collection enabled? */
int enabled;
int debug;
/* linked lists of container objects */
struct gc_generation generations[NUM_GENERATIONS];
PyGC_Head *generation0;
/* a permanent generation which won't be collected */
struct gc_generation permanent_generation;
struct gc_generation_stats generation_stats[NUM_GENERATIONS];
/* true if we are currently running the collector */
int collecting;
/* list of uncollectable objects */
PyObject *garbage;
/* a list of callbacks to be invoked when collection is performed */
PyObject *callbacks;
/* This is the number of objects that survived the last full
collection. It approximates the number of long lived objects
tracked by the GC.
(by "full collection", we mean a collection of the oldest
generation). */
Py_ssize_t long_lived_total;
/* This is the number of objects that survived all "non-full"
collections, and are awaiting to undergo a full collection for
the first time. */
Py_ssize_t long_lived_pending;
};
위에서 살펴본 작동 방식 대로, 세대별 임계값이 넘어 gc가 시작되면, struct PyGC_Head
에 선언된 더블 링크드 리스트 (_gc_next
, _gc_prev
) 로 모든 컨테이너 객체에 접근한다.
각 객체에 접근하여 "참조하는 다른 컨테이너 객체의 참조 횟수 감소", gc_refs
값을 감소 시킨다. 아직 외부에서 정상적으로 참조되고 있는 컨테이너 객체는 0이 안된다.
gc_refs
가 0이 되면 unreachable (임시 설정, GC_TENTATIVELY_UNREACHABLE
) flag를 설정한다.
하지만 0이라고 해서 무조건 참조가 안되는 것은 아니다, 외부에서 직접 참조되고 있지 않아도, gc_refs
가 0이상인 것들인 객체에 의해 참조되고 있다면 0이어도 도달 가능하기 때문에 gc는 다시 이러한 객체를 찾아 reachable flag를 설정한다.
위 과정이 끝나면 unreachable flag를 가진 객체는 진짜 도달 불가능이 확정이 되고, 메모리에서 진짜 해제가 된다.
GIL issue는 위와 같은 메모리 할당 해제, 레퍼런스 카운팅과 GC 때문에 존재한다. GIL은 2022 PyCon Korea 내용을 많이 발췌했다. [ 아래 등장하는 사진은 모두 PyCon 유튜브 내용입니다 :) ]
위 그림은 2개의 thread가 빨간점이라는 공통된 자원에 접근 했을 때 그림이다. 빨간점 부분에 thread safe한 조취가 이뤄지지 않으면, dead lock 등의 상황이 발생할 수 있다.
왜 위와 같은 상황이 python에서 발생하는가?
위에서 reference counting를 생각해보자. python은 "사용하지 않는 객체를 찾기위해 참조 횟수를 카운팅" 한다. 그리고 그 값은 실제 "변수" 값으로 저장된다. 레퍼런스 카운팅 값 자체가 메모리에 존재한다는 의미다. 그리고 multi thread 환경에서 그 변수는 위 그림과 같이 빨간점의 영역 이다.
실제 수행 (위와 다를 수 있겠지만)을 위에서 아래 방향으로 시간이 흐르는 형태로 작업을 한다고 가정하자. T1에서 변수를 가져오는 작업 이후 context switching 이 일어나 T2의 LOAD와 INC 작업을 했다. 그 이후 다시 T1으로 context switching 나 +1과 저장까지 완료 후, 1이라는 값이 저장되었다.
마지막으로 T2로 context switching 이 되어 1이라는 값에 +1을 한다. T1의 +1에 의해 2가 되어야 하지만, 최종적으로 1이 저장된다. 이 상황이 흔히 볼 수 있는 race-condition이다. 어떻게 핸들링 할 수 있을까? 대표적으로 Mutex 가 존재한다.
/* Take the GIL.
The function saves errno at entry and restores its value at exit.
tstate must be non-NULL. */
static void
take_gil(PyThreadState *tstate)
{
// ... //
/* Check that _PyEval_InitThreads() was called to create the lock */
assert(gil_created(gil));
MUTEX_LOCK(gil->mutex);
// ... //
while (_Py_atomic_load_relaxed(&gil->locked)) {
unsigned long saved_switchnum = gil->switch_number;
unsigned long interval = (gil->interval >= 1 ? gil->interval : 1);
int timed_out = 0;
COND_TIMED_WAIT(gil->cond, gil->mutex, interval, timed_out);
/* If we timed out and no switch occurred in the meantime, it is time
to ask the GIL-holding thread to drop it. */
if (timed_out &&
_Py_atomic_load_relaxed(&gil->locked) &&
gil->switch_number == saved_switchnum)
{
if (tstate_must_exit(tstate)) {
MUTEX_UNLOCK(gil->mutex);
// gh-96387: If the loop requested a drop request in a previous
// iteration, reset the request. Otherwise, drop_gil() can
// block forever waiting for the thread which exited. Drop
// requests made by other threads are also reset: these threads
// may have to request again a drop request (iterate one more
// time).
if (drop_requested) {
RESET_GIL_DROP_REQUEST(interp);
}
PyThread_exit_thread();
}
assert(is_tstate_valid(tstate));
SET_GIL_DROP_REQUEST(interp);
drop_requested = 1;
}
}
// ... //
MUTEX_UNLOCK(gil->mutex);
errno = err;
}
실제로 MUTEX_LOCK(gil->mutex);
을 호출하는 것을 볼 수 있다.
while (_Py_atomic_load_relaxed(&gil->locked))
에서 해당 LOCK이 해제될때까지 기다리는 것도 볼 쉬 있다.
그리고 while이 끝나고 MUTEX_UNLOCK(gil->mutex);
을 하는것을 볼 수있다.
일반적으로 연산이 많이 필요한 로직은 CPU bound, 로컬 파일 시스템 혹은 네트워크 통신이 많은 로직은 I/O bound, 더 정확하게는 OS의 Burst
용어를 알아야 한다.
버스트는 "어떤 특정된 기준(criterion)에 따라 한 단위로서 취급되는 연속된 신호(signal) 또는 데이터의 모임. 어떤 현상이 짧은 시간에 집중적으로 일어나는 현상. 또는 주기억 장치의 내용을 캐시 기억 장치에 블록 단위로 한꺼번에 전송하는 것." 을 의미한다.
CPU burst는 프로세스 내에서 CPU 명령작업이 연속된 작업을 의미하며 IO burst는 로컬 혹은 네트워크등의 I/O wait 작업이 연속되는것을 의미한다.
MultiThread 대신 MultiProcess를 활용한다. 하지만 MultiProcess는 기본적으로 독립적인 메모리 공간, [code(text), data, heap, stack 영역] 을 가지기 때문에 무겁다. 이런 점 때문에 오히려 더 늘어질 수 도 있다.
C-Bindings를 활용한다. cpython은 c로 되어있기 때문에 C언어 코드를 활용할 수 있다. 하지만 완벽하게 GIL을 벗어날 수 없다. 아래 사진을 살펴보자.
그리고 직접 GIL을 해제하는 로직이 필요하다. 코드의 복잡성과 난이도가 올라가고, 엄청나게 극복할 수는 없다.
마지막으로, 해당 세션에서 GIL에 대한 미래를 소개한다. atomic_fetch_add
와 같은 상세한 정보는 꼭 PyCon에서 직접 확인하는 것을 추천한다.
도움이 많이 되었습니다, 감사합니다!