![]()
“용량은 그냥 숫자”라고 믿었던 대가: free-threaded에서 list.__sizeof__ 레이스가 생기는 지점
meta_description: free-threaded 빌드에서 list.__sizeof__가 리스트의 capacity 정보를 읽는 순간, 다른 스레드의 리사이즈와 맞물려 데이터 레이스가 날 수 있다. gh-145036(PR #145365)이 어떤 값을 어떻게 읽도록 바꿨는지, 그리고 실무에서 관찰/측정 코드를 어떻게 다뤄야 하는지 정리한다. meta_keywords: free-threaded, CPython, list, __sizeof__, capacity, allocated, data race, atomic, TSAN, thread sanitizer, list_resize, ob_item, memory model, 관찰코드, 측정, 디버깅, 동시성, GIL-less meta_robots: index,follow
리스트의 __sizeof__()는 대개 “큰 의미 없는 관찰”로 취급된다. 로그에 한 번 찍고, 메모리 사용량 대충 추정하고, 병목을 찾아보는 정도. 나도 그렇게 써왔다.
그런데 free-threaded(실험적 GIL-less 방향)로 가면, 이런 관찰 코드도 동시성의 일부가 된다. “안전하게 실패하는 관찰”이 아니라 “관찰 자체가 경쟁에 끼어드는 순간”이 생긴다.
이번 gh-145036 / PR #145365는 그 지점을 건드린다. 요지는 과장하면 간단하다.
- 다른 스레드가 리스트의 내부 버퍼를 키우거나 줄이는 동안(capacity가 바뀌는 동안)
- 한 스레드가
list.__sizeof__()에서 capacity 관련 값을 읽으면 - 그 읽기가 레이스로 잡힐 수 있다
여기서 중요한 건 “이 버전은 안전/위험” 같은 결론이 아니다. PR이 고친 건 특정 경로에서의 읽기 방식이고, 그게 왜 문제였는지를 이해하면 실무에서 얻을 힌트가 생긴다. 특히 TSAN으로 free-threaded를 돌리며 경고를 쫓는 사람에게.
![]()
‘관찰’도 레이스가 될 수 있는 이유: free-threaded에서는 “읽기”가 공짜가 아니다
GIL이 강하게 걸려 있던 세계에서는, 파이썬 레벨에서 __sizeof__() 같은 걸 호출하는 행위가 내부 구조와 동시에 달릴 수 있는 경우가 제한적이었다. free-threaded는 그 전제를 바꾼다.
리스트는 동적으로 커지고 줄어드는 컨테이너다. 구현 관점에서 리스트는 대략 이런 요소를 가진다.
- 현재 길이(len)
- 내부 버퍼(항목 배열)
- 내부 버퍼의 용량(capacity)
이 중 길이와 용량은 “관찰”에 자주 쓰인다. 길이는 len(lst)로 보고, 용량은 직접 접근할 수 없지만 __sizeof__()가 내부적으로 참고한다.
PR이 겨냥하는 문제는 capacity를 읽는 지점이, 다른 스레드의 리사이즈/재할당과 충돌할 수 있다는 점이다. 충돌 자체는 동시성에서 늘 있을 수 있다. 문제는 그 충돌이 “데이터 레이스”로 분류될 수 있는 형태였다는 것.
재현 감각: append/pop과 __sizeof__를 섞으면 무슨 일이 보이나
아래 코드는 ‘취약점 재현’이 아니라, TSAN 같은 도구를 붙였을 때 어떤 류의 경고가 나올 수 있는지 감각을 만드는 용도다. 실행 환경/빌드 옵션/타이밍에 따라 결과는 달라질 수 있다.
import threading
import time
def churn_list(lst, iters=200_000):
#(리스트 크기를 흔들어 capacity 변화를 유도)
for i in range(iters):
lst.append(i)
if i % 3 == 0:
lst.pop()
def observe_size(lst, iters=200_000):
#(관찰: 값을 쓰지 않고 읽기만 한다는 마음으로 호출)
s = 0
for _ in range(iters):
s ^= lst.__sizeof__()
return s
def main():
lst = []
t1 = threading.Thread(target=churn_list, args=(lst,))
t2 = threading.Thread(target=observe_size, args=(lst,))
t1.start(); t2.start()
t1.join(); t2.join()
if __name__ == "__main__":
main()
이 코드가 “문제”라는 말이 아니다. 다만 free-threaded에서 내부 구조가 동시에 바뀔 수 있는 상태라면, 관찰 코드도 그 경쟁의 일부가 된다. 그리고 __sizeof__()는 그 관찰 중에서도 내부 capacity에 닿기 쉬운 지점이다.
PR이 바꾼 포인트(의사코드): capacity를 ‘그냥 읽지 말고’ 읽는 방식
실제 구현은 Objects/listobject.c에 있고, PR diff를 보면 변경의 핵심은 capacity(또는 그에 준하는 값)를 읽는 경로다.
여기서부터는 “실제 코드 그대로”를 적기보다, 모양을 이해하기 위한 의사코드로만 설명하겠다.
(모양) before
- size = base_overhead
- size += allocated * sizeof(pointer)
#(allocated: 리스트 내부 버퍼의 capacity 성격)
- return size
(모양) after
- size = base_overhead
- cap = read_capacity_safely(list)
#(cap을 원자적으로 읽거나, 동시성 친화적인 경로로 얻는다)
- size += cap * sizeof(pointer)
- return size
핵심은 __sizeof__()가 “용량을 계산하는 함수”처럼 보이지만, 실제로는 “내부 상태를 읽는 함수”라는 점이다. free-threaded에서는 내부 상태를 읽는 순간도 동시성 계약이 필요하다.
PR이 겨냥한 건 여기서 발생할 수 있는 데이터 레이스다. 즉, 다른 스레드가 리사이즈 과정에서 capacity 관련 값을 바꾸는 동안, __sizeof__()가 같은 값을 일반적인 방식으로 읽어버리면 TSAN이 레이스로 잡을 수 있는 모양이 된다.
이 변경은 ‘성능 최적화’보다 ‘정합성(데이터 레이스 관점)’ 쪽에 더 가까운 이야기로 읽힌다. __sizeof__()가 핵심 경로는 아니지만, 경고를 덜 만들고 운영을 덜 불안하게 만드는 데 의미가 있다.
unhelpful한 오해 하나: “그러면 __sizeof__를 쓰지 말자”가 아니다
실무에서 __sizeof__()는 꽤 유용하다.
- 리스트/딕셔너리 크기 추정(대략적인 힌트)
- 캐시/버퍼가 커지는 타이밍 감지
- 메모리 회귀 디버깅에서 ‘변화량’ 잡기
문제는 함수의 유용함이 아니라, 호출 위치와 호출 방식이다.
free-threaded에서 관찰 코드를 넣을 때는, “이 관찰이 다른 스레드의 쓰기와 동시에 달릴 수 있는가?”를 한 번 더 생각하게 된다. 특히 측정/로그가 별도 스레드에서 돌아가는 구조라면 더 그렇다.
(선택) 관찰/메트릭 코드를 짤 때 주의할 점 3가지
free-threaded에서 TSAN 경고를 줄이는 목적이든, 단순히 운영 안정성을 위해서든, 아래 세 가지는 꽤 실무적이다.
1) 관찰 스레드는 ‘같은 객체’를 계속 읽지 않게 만든다
리스트 하나를 계속 찍는 대신, 스냅샷(예: 길이만, 혹은 참조 교체)으로 관찰 대상을 바꾸면 충돌 표면이 줄어든다.
2) 관찰은 “값의 정확도”보다 “흐름”을 본다
__sizeof__()가 반환하는 값은 정밀한 메모리 회계가 아니다. 정확한 숫자를 믿고 의사결정을 하기보다, 갑자기 커지는 구간을 찾는 데 쓰는 게 맞다.
3) 경고는 ‘코드 품질’보다 ‘경계면’을 알려준다
TSAN 경고가 나왔다고 해서 “내 코드가 나쁘다”로 끝내기 쉽다. 하지만 이번 PR처럼, 관찰 함수 하나가 내부 상태 읽기와 맞물려 경계면이 되기도 한다. 경고는 그 경계면을 보여준다.
이런 종류의 레이스는 “내 코드가 느리다”와는 별개로, 운영에서 갑자기 터진다. 그래서 성능 최적화보다 먼저, 관찰 코드가 건드리는 경계면부터 점검하는 게 훨씬 싸다.
참고자료
- CPython PR #145365 — gh-145036: Fix data race for list capacity (free-threaded)
- PR diff
- Merge commit 9e0802330caca51fed7fc0c8c1dcce2daf03d8bd
- Objects/listobject.c @ 9e0802330caca51fed7fc0c8c1dcce2daf03d8bd
이미지 크레딧/라이선스
- Laptop Programmcode.jpg — Negative Space / CC0
댓글
댓글 쓰기