프리 스레딩을 위한 C API 확장 지원
프리 스레딩을 위한 C API 확장 지원 (C API Extension Support for Free Threading)
CPython은 3.13 릴리스부터 **전역 인터프리터 락(GIL, Global Interpreter Lock)**을 끈 상태로 실행하는 구성, 즉 **프리 스레딩(free threading)**을 지원하기 시작했어요. 이 문서는 C API 확장 모듈이 프리 스레딩을 지원하도록 어떻게 적응해야 하는지 설명합니다.
출처: Python 공식 문서
C에서 프리 스레딩 빌드 식별하기
CPython C API는 Py_GIL_DISABLED 매크로를 노출합니다. 프리 스레딩 빌드에서는 1로 정의되고, 일반 빌드에서는 정의되지 않아요. 이 매크로로 프리 스레딩 빌드에서만 실행되는 코드를 켤 수 있습니다:
#ifdef Py_GIL_DISABLED
/* code that only runs in the free-threaded build */
#endif
참고: Windows에서는 이 매크로가 자동으로 정의되지 않아서, 빌드할 때 컴파일러에 직접 지정해야 해요. 현재 실행 중인 인터프리터가 이 매크로를 정의했는지 알아보려면
sysconfig.get_config_var()함수를 쓰면 됩니다.
모듈 초기화 (Module Initialization)
확장 모듈은 GIL을 끈 상태로 실행되는 것을 지원한다고 명시적으로 밝혀야 합니다. 그렇지 않으면 모듈을 임포트할 때 경고가 발생하고 런타임에 GIL이 켜져 버려요. GIL을 끈 상태 실행을 지원한다고 밝히는 방법은, 확장이 다단계(multi-phase) 초기화를 쓰는지 단일 단계(single-phase) 초기화를 쓰는지에 따라 두 가지입니다.
다단계 초기화 (Multi-Phase Initialization)
다단계 초기화(PyModuleDef_Init())를 쓰는 확장은 모듈 정의에 Py_mod_gil 슬롯을 추가해야 해요. 확장이 더 오래된 CPython 버전도 지원한다면, PY_VERSION_HEX 체크로 슬롯을 감싸 주는 게 좋습니다.
static struct PyModuleDef_Slot module_slots[] = {
...
#if PY_VERSION_HEX >= 0x030D0000
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
{0, NULL}
};
static struct PyModuleDef moduledef = {
PyModuleDef_HEAD_INIT,
.m_slots = module_slots,
...
};
단일 단계 초기화 (Single-Phase Initialization)
단일 단계 초기화(PyModule_Create())를 쓰는 확장은 PyUnstable_Module_SetGIL()을 호출해 GIL을 끈 상태 실행을 지원한다고 밝혀야 해요. 이 함수는 프리 스레딩 빌드에서만 정의되므로, 일반 빌드에서의 컴파일 오류를 피하려면 #ifdef Py_GIL_DISABLED로 호출을 감싸야 합니다.
static struct PyModuleDef moduledef = {
PyModuleDef_HEAD_INIT,
...
};
PyMODINIT_FUNC
PyInit_mymodule(void)
{
PyObject *m = PyModule_Create(&moduledef);
if (m == NULL) {
return NULL;
}
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
return m;
}
일반 API 지침 (General API Guidelines)
C API의 대부분은 스레드 안전하지만 예외가 있어요.
- 구조체 필드(Struct Fields): Python C API 객체나 구조체의 필드를 직접 접근하는 것은, 그 필드가 동시에 수정될 수 있다면 스레드 안전하지 않습니다.
- 매크로(Macros):
PyList_GET_ITEM,PyList_SET_ITEM같은 접근자 매크로와,PySequence_Fast()가 돌려준 객체를 쓰는PySequence_Fast_GET_SIZE같은 매크로는 오류 검사나 락을 전혀 수행하지 않아요. 컨테이너 객체가 동시에 수정될 수 있다면 이런 매크로는 스레드 안전하지 않습니다. - 빌린 참조(Borrowed References): 빌린 참조를 돌려주는 C API 함수는, 포함하는 객체가 동시에 수정되면 스레드 안전하지 않을 수 있어요. 자세한 내용은 빌린 참조 절을 보세요.
컨테이너 스레드 안전성 (Container Thread Safety)
PyListObject, PyDictObject, PySetObject 같은 컨테이너는 프리 스레딩 빌드에서 내부 락을 수행합니다. 예를 들어 PyList_Append()는 항목을 추가하기 전에 리스트를 잠궈요.
PyDict_Next
주목할 만한 예외가 하나 있는데 PyDict_Next()는 사전을 잠그지 않습니다. 사전이 동시에 수정될 수 있다면, 사전을 순회하는 동안 Py_BEGIN_CRITICAL_SECTION으로 보호해야 해요:
Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
...
}
Py_END_CRITICAL_SECTION();
빌린 참조 (Borrowed References)
일부 C API 함수는 **빌린 참조(borrowed reference)**를 돌려줍니다. 이런 API는 포함하는 객체가 동시에 수정되면 스레드 안전하지 않아요. 예를 들어 리스트가 동시에 수정될 수 있다면 PyList_GetItem()을 쓰는 건 안전하지 않습니다.
다음 표는 빌린 참조 API 몇 가지와, 강한 참조(strong reference)를 돌려주는 대체 API를 정리한 것이에요:
| 빌린 참조 API (Borrowed reference API) | 강한 참조 API (Strong reference API) |
|---|---|
PyList_GetItem() |
PyList_GetItemRef() |
PyList_GET_ITEM() |
PyList_GetItemRef() |
PyDict_GetItem() |
PyDict_GetItemRef() |
PyDict_GetItemWithError() |
PyDict_GetItemRef() |
PyDict_GetItemString() |
PyDict_GetItemStringRef() |
PyDict_SetDefault() |
PyDict_SetDefaultRef() |
PyDict_Next() |
없음 (PyDict_Next 절 참고) |
PyWeakref_GetObject() |
PyWeakref_GetRef() |
PyWeakref_GET_OBJECT() |
PyWeakref_GetRef() |
PyImport_AddModule() |
PyImport_AddModuleRef() |
PyCell_GET() |
PyCell_Get() |
빌린 참조를 돌려주는 모든 API가 문제가 되는 건 아니에요. 예를 들어 PyTuple_GetItem()은 튜플이 불변(immutable)이므로 안전합니다. 또 위 API의 모든 사용처가 문제되는 것도 아니에요. 예를 들어 PyDict_GetItem()은 함수 호출에서 키워드 인자 사전을 파싱할 때 자주 쓰이는데, 그런 키워드 인자 사전은 사실상 사적(private)이라(다른 스레드가 접근할 수 없음) 그 맥락에서 빌린 참조를 쓰는 것은 안전합니다.
이 함수들 중 일부는 Python 3.13에서 추가됐어요. pythoncapi-compat 패키지를 쓰면 더 오래된 Python 버전에서도 이 함수들의 구현을 제공받을 수 있습니다.
메모리 할당 API (Memory Allocation APIs)
Python의 메모리 관리 C API는 세 가지 서로 다른 할당 도메인(allocation domain), 즉 "raw", "mem", "object"로 함수를 제공합니다. 스레드 안전성을 위해 프리 스레딩 빌드는 Python 객체만 object 도메인으로 할당해야 하고, 모든 Python 객체가 그 도메인으로 할당되어야 합니다. 이전 Python 버전에서는 이것이 그저 모범 사례(best practice)에 그쳤지만, 이제는 강제 요구 사항으로 바뀐 점이 달라요.
참고: 확장에서
PyObject_Malloc()사용처를 찾아보고, 할당된 메모리가 Python 객체에 쓰이는지 확인하세요. 버퍼를 할당할 때는PyObject_Malloc()대신PyMem_Malloc()을 쓰세요.
스레드 상태와 GIL API (Thread State and GIL APIs)
Python은 스레드 상태와 GIL을 관리하는 함수·매크로 집합을 제공합니다. 예를 들어:
PyGILState_Ensure()와PyGILState_Release()PyEval_SaveThread()와PyEval_RestoreThread()Py_BEGIN_ALLOW_THREADS와Py_END_ALLOW_THREADS
이 함수들은 GIL이 꺼진 프리 스레딩 빌드에서도 스레드 상태를 관리하는 데 그대로 사용해야 해요. 예를 들어 Python 바깥에서 스레드를 만들었다면, Python API를 호출하기 전에 반드시 PyGILState_Ensure()를 호출해 그 스레드가 유효한 Python 스레드 상태를 갖게 해야 합니다.
또 I/O나 락 획득 같은 블로킹 작업 주변에서는 PyEval_SaveThread()나 Py_BEGIN_ALLOW_THREADS를 계속 호출해서, 다른 스레드들이 순환 가비지 컬렉터(cyclic garbage collector)를 실행할 수 있게 해 주어야 해요.
내부 확장 상태 보호하기 (Protecting Internal Extension State)
여러분의 확장에는 이전에 GIL이 보호해 주던 내부 상태가 있을 수 있어요. 이 상태를 보호하려면 락을 추가해야 할 수 있습니다. 접근 방식은 확장마다 다르겠지만, 몇 가지 흔한 패턴은 이래요:
- 캐시(Caches): 전역 캐시는 공유 상태의 흔한 원천입니다. 캐시를 보호할 락을 쓰거나, 캐시가 성능에 필수적이지 않다면 프리 스레딩 빌드에서 캐시를 끄는 것을 고려해 보세요.
- 전역 상태(Global State): 전역 상태는 락으로 보호하거나 스레드 로컬 저장소로 옮겨야 할 수 있어요. C11과 C++11은 스레드 로컬 저장소를 위해
thread_local또는_Thread_local을 제공합니다.
크리티컬 섹션 (Critical Sections)
프리 스레딩 빌드에서 CPython은 원래 GIL이 보호해 주던 데이터를 보호하기 위해 **크리티컬 섹션(critical section)**이라는 메커니즘을 제공합니다. 확장 작성자가 내부 크리티컬 섹션 구현과 직접 상호작용하지는 않더라도, 특정 C API 함수를 쓰거나 프리 스레딩 빌드에서 공유 상태를 관리할 때는 그 동작을 이해하는 게 중요해요.
크리티컬 섹션이란?
개념적으로 크리티컬 섹션은 단순한 뮤텍스 위에 쌓인 교착(deadlock) 회피 계층으로 동작합니다. 각 스레드는 활성 크리티컬 섹션들의 스택을 유지해요. 스레드가 크리티컬 섹션과 연관된 락을 획득해야 할 때(예: PyDict_SetItem() 같은 스레드 안전 C API 함수를 호출하며 암시적으로, 또는 매크로를 써서 명시적으로) 그 기반이 되는 뮤텍스를 획득하려고 시도합니다.
크리티컬 섹션 사용하기
크리티컬 섹션을 사용하는 주요 API는 다음과 같아요:
Py_BEGIN_CRITICAL_SECTION과Py_END_CRITICAL_SECTION— 단일 객체를 잠굴 때Py_BEGIN_CRITICAL_SECTION2와Py_END_CRITICAL_SECTION2— 두 객체를 동시에 잠굴 때
이 매크로들은 새 지역 스코프를 만들기 때문에, 반드시 짝을 이루어 써야 하고 같은 C 스코프 안에 있어야 합니다. 또 프리 스레딩이 아닌 빌드에서는 no-op이므로, 두 빌드 타입을 모두 지원해야 하는 코드에 안전하게 추가할 수 있어요.
크리티컬 섹션의 흔한 용법은 객체의 내부 속성에 접근하는 동안 그 객체를 잠그는 것입니다. 예를 들어 확장 타입에 내부 count 필드가 있다면, 그 필드를 읽거나 쓸 때 크리티컬 섹션을 쓸 수 있어요:
// read the count, returns new reference to internal count value
PyObject *result;
Py_BEGIN_CRITICAL_SECTION(obj);
result = Py_NewRef(obj->count);
Py_END_CRITICAL_SECTION();
return result;
// write the count, consumes reference from new_count
Py_BEGIN_CRITICAL_SECTION(obj);
obj->count = new_count;
Py_END_CRITICAL_SECTION();
크리티컬 섹션이 동작하는 방식
전통적인 락과 달리, 크리티컬 섹션은 지속되는 전체 시간 동안 배타적 접근을 보장하지 않아요. 스레드가 크리티컬 섹션을 보유한 채 블로킹해야 한다면(예: 다른 락을 획득하거나 I/O 수행), 크리티컬 섹션은 일시적으로 중단됩니다. 모든 락이 풀리고, 블로킹 작업이 끝나면 다시 재개되죠.
이 동작은 스레드가 블로킹 호출을 할 때 GIL에서 일어나는 일과 비슷합니다. 핵심 차이점은 이래요:
- 크리티컬 섹션은 전역이 아니라 **객체 단위(per-object)**로 동작합니다
- 크리티컬 섹션은 각 스레드 안에서 스택 규율(stack discipline)을 따릅니다 ("begin"/"end" 매크로가 짝을 이루고 같은 스코프 안에 있어야 하므로 이를 강제합니다)
- 크리티컬 섹션은 블로킹 가능한 작업 주변에서 락을 자동으로 풀고 다시 획득합니다
교착 회피 (Deadlock Avoidance)
크리티컬 섹션은 두 가지 방식으로 교착을 피하는 데 도움을 줍니다:
- 스레드가 다른 스레드가 이미 보유한 락을 획득하려 하면, 먼저 자신의 활성 크리티컬 섹션을 모두 중단해 그들의 락을 일시적으로 풀어줍니다
- 블로킹 작업이 끝나면, 가장 위쪽(top-most) 크리티컬 섹션만 먼저 다시 획득합니다
즉, 중첩된 크리티컬 섹션으로 여러 객체를 한 번에 잠그는 것은 믿을 수 없어요. 안쪽 크리티컬 섹션이 바깥쪽 것을 중단시킬 수 있기 때문입니다. 대신 두 객체를 동시에 잠글 때는 Py_BEGIN_CRITICAL_SECTION2를 쓰세요.
위에서 설명한 락은 PyMutex 기반 락뿐이라는 점에 유의하세요. 크리티컬 섹션 구현은 POSIX 뮤텍스 같은 다른 잠금 메커니즘에 대해 알지도, 영향을 주지도 않습니다. 또 어떤 PyMutex에서 블로킹하면 크리티컬 섹션이 중단되지만, 풀리는 것은 크리티컬 섹션에 속한 뮤텍스뿐이라는 점도 기억하세요. 크리티컬 섹션 없이 PyMutex를 쓰면 그것은 풀리지 않으므로 같은 교착 회피 혜택을 받지 못합니다.
중요 고려 사항
- 크리티컬 섹션은 락을 일시적으로 풀 수 있으므로, 다른 스레드가 보호된 데이터를 수정하게 할 수 있어요. 블로킹할 수 있는 작업 이후의 데이터 상태에 대해 어떤 가정을 하지 않도록 주의하세요.
- 락이 일시적으로 풀리(중단되)므로, 크리티컬 섹션에 들어갔다고 해서 섹션 동안 보호된 자원에 대한 배타적 접근이 보장되지는 않아요. 크리티컬 섹션 안의 코드가 블로킹하는 다른 함수를 호출하면(예: 다른 락 획득, 블로킹 I/O), 그 스레드가 크리티컬 섹션을 통해 보유한 모든 락이 풀립니다. 이는 블로킹 호출 중에 GIL이 풀릴 수 있는 것과 비슷해요.
- 어떤 순간에도 보유가 보장되는 건 가장 최근에 들어간(가장 위쪽) 크리티컬 섹션과 연관된 락뿐입니다. 바깥쪽, 중첩된 크리티컬 섹션의 락은 중단됐을 수 있어요.
- 이 API들로 최대 두 개의 객체를 동시에 잠글 수 있어요. 더 많은 객체를 잠가야 한다면 코드를 재구성해야 합니다.
- 같은 객체를 두 번 잠그려 해도 크리티컬 섹션이 교착에 빠지지는 않지만, 이 용도에는 전용 재진입(reentrant) 락보다 덜 효율적입니다.
Py_BEGIN_CRITICAL_SECTION2를 쓸 때 객체의 순서는 정확성에 영향을 주지 않지만(구현이 교착 회피를 처리함), 항상 일관된 순서로 객체를 잠그는 게 좋은 습관입니다.- 크리티컬 섹션 매크로는 주로, 위에서 설명한 교착 시나리오에 취약한 내부 CPython 연산에 관여할 수 있는 Python 객체에 대한 접근을 보호하기 위한 것임을 기억하세요. 순수하게 내부 확장 상태를 보호하려면 표준 뮤텍스나 다른 동기화 기본 요소가 더 적절할 수 있어요.
프리 스레딩 빌드용 확장 빌드하기 (Building Extensions for the Free-Threaded Build)
C API 확장은 프리 스레딩 빌드를 위해 특별히 빌드되어야 합니다. 그 wheel, 공유 라이브러리, 바이너리는 t 접미사로 표시됩니다.
- pypa/manylinux는
t접미사를 써서 프리 스레딩 빌드를 지원합니다. 예:python3.14t - pypa/cibuildwheel은 Python 3.14 이상의 프리 스레딩 빌드용 wheel 빌드를 지원합니다
Limited C API와 Stable ABI
프리 스레딩 빌드는 현재 Limited C API나 stable ABI를 지원하지 않습니다. setuptools로 확장을 빌드하면서 현재 py_limited_api=True를 쓰고 있다면, py_limited_api=not sysconfig.get_config_var("Py_GIL_DISABLED")를 사용해서 프리 스레딩 빌드로 빌드할 때 limited API를 선택 해제할 수 있어요.
참고: 프리 스레딩 빌드용으로 별도의 wheel을 빌드해야 합니다. 현재 stable ABI를 쓰고 있다면, 프리 스레딩이 아닌 여러 Python 버전을 위해 하나의 wheel을 계속 빌드할 수 있어요.
Windows
공식 Windows 설치 프로그램의 제약 때문에, 소스에서 확장을 빌드할 때 Py_GIL_DISABLED=1을 수동으로 정의해 줘야 합니다.
더 알아보기 (Learn more)
- Porting Extension Modules to Support Free-Threading — 커뮤니티가 관리하는 확장 작성자용 포팅 가이드
- pythoncapi-compat — 구형 Python 버전용 C API 함수 구현 제공
- Python 공식 문서: C API Extension Support for Free Threading