![]()
free-threaded에서 `super()`가 느려질 수 있다: CPython의 TYPE_LOCK 경합 패치가 말해주는 것
free-threaded로 돌려보면 이상한 순간이 있다. 코드가 바뀐 것도 아닌데, 스레드만 늘렸더니 처리량이 꺾인다.
처음엔 나도 습관적으로 “내 코드가 느리냐”부터 의심한다. 그런데 어느 시점부터는 질문이 바뀐다. “락이 시끄럽냐?”
free-threaded에서 진짜 무서운 건, 내 코드가 느린 것도 아니고 파이썬이 느린 것도 아닌데 내부 경로의 잠금 경합 때문에 속도가 꺾이는 순간이다. 여러 스레드가 동시에 뛰면서, 생각지도 못한 경로가 공유자원이 된다.
최근 CPython 3.14 백포트 PR 하나가 그 감각을 잘 보여준다. super() lookup 경로에서 TYPE_LOCK 경합을 줄이는 수정이 들어갔다(참고자료). super()는 우리가 매일 쓰는 문법인데, free-threaded에선 이 ‘매일 쓰는’ 경로가 병목이 될 수 있다는 뜻이다.
super()는 문법이 아니라 조회 경로다
super()는 코드에서 한 줄로 보인다. 하지만 런타임 입장에선 한 줄이 아니다.
- 어떤 클래스의 MRO를 타고
- 어떤 타입/디스크립터 규칙을 적용하고
- 어떤 캐시/락 아래에서
- 속성(메서드)을 찾아 반환한다
싱글스레드에서는 이 경로가 “그냥 빠르다”로 지나간다. 그런데 멀티스레드에서는 이야기가 달라진다. 경로 중에 락이 있으면, 그 락은 공유자원이 된다.
실무에서 super()가 많이 등장하는 패턴은 흔하다. 믹스인으로 공통 로직을 쌓고, 베이스 클래스에서 프레임워크 동작을 제공하고, 그 위에 도메인 클래스를 얹는다.
아래는 그런 패턴을 의도적으로 단순화한 예시다. free-threaded가 아니더라도, 멀티스레드에서 동시에 super()가 호출되는 그림을 만들 수 있다.
import threading
class Base:
def handle(self, x):
return x + 1
class MixinA:
def handle(self, x):
return super().handle(x) * 2
class MixinB:
def handle(self, x):
return super().handle(x) + 3
class App(MixinA, MixinB, Base):
pass
obj = App()
def worker(n):
s = 0
for i in range(n):
s += obj.handle(i)
return s
threads = [threading.Thread(target=worker, args=(200_000,)) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
이 코드는 병목을 “만드는” 코드다. 하지만 실무에서는 이렇게 노골적으로 쓰지 않아도 비슷한 일이 생긴다. 요청 처리 경로에 믹스인이 얹혀 있고, 인증/로깅/트레이싱이 레이어로 쌓여 있고, 그 레이어가 모두 super()를 탄다.
free-threaded에서는 이런 레이어가 병렬로 실행된다. 그러면 super() 조회 경로의 lock contention이 실제 비용이 된다.
PR이 바꾼 포인트를 코드 ‘모양’으로 이해하기
이번 PR을 “super()가 빨라졌다”로만 읽으면 실무에 남는 게 없다. 중요한 건 어떤 종류의 경합이 줄어들었는지다.
PR 제목 그대로, TYPE_LOCK 경합을 피하는 방향이다(참고자료).
정확한 C 구현 디테일을 다 외울 필요는 없다. 다만 디버깅 관점에서 이해해야 하는 건 이런 모양이다.
- 기존 경로는 ‘조회할 때마다’ 타입 쪽 락을 잡는 부분이 있었다
- 여러 스레드가 같은 타입 경로를 동시에 지나가면 그 락이 병목이 된다
- 패치는 그 락을 잡는 빈도/범위를 줄여, 경합 구간을 짧게 만든다
의사코드(실제 구현 아님)로 “락을 잡는 구간이 줄어든다”는 모양만 그리면 이런 느낌이다.
lock(TYPE_LOCK)
result = super_lookup_through_mro_and_descriptors(...)
unlock(TYPE_LOCK)
if needs_lock:
lock(TYPE_LOCK)
result = super_lookup(...)
if needs_lock:
unlock(TYPE_LOCK)
이건 “락을 없애자”가 아니다. free-threaded에서 락은 필요하다. 중요한 건 락을 ‘언제’ 잡는지다.
이 PR이 던지는 메시지는 결국 이거다.
free-threaded에선 “코드가 복잡해서”가 아니라, 런타임 경로에서 한 번 잡히는 락 때문에 전체가 꺾이는 순간이 나온다.
실무에서 얻는 힌트: 멀티스레드 성능 이슈를 재현/측정하는 방식
free-threaded 성능 디버깅을 할 때 제일 위험한 습관은, 싱글스레드 기준으로만 “빠르다/느리다”를 판단하는 것이다.
싱글스레드에서는 잘 안 보이는 병목이, 스레드 수가 늘면서 갑자기 튀어나온다. super() 같은 조회 경로는 대표적인 ‘튀어나오는’ 병목이다.
그래서 나는 멀티스레드 성능을 볼 때 측정을 이렇게 바꾼다.
- 스레드를 늘렸을 때 선형으로 느려지는가, 어느 순간부터 꺾이는가
- 같은 작업을 더 많은 스레드로 했는데, 총 시간이 줄지 않고 늘어나는가
- CPU는 바쁜데 처리량이 안 늘면 락 경합을 의심한다
측정은 길 필요 없다. 같은 작업을 두고 스레드 수를 1→2→4→8로 늘렸을 때, 처리량이 어디서 꺾이는지부터 본다.
import time
start = time.perf_counter()
#(run the threaded workload)
end = time.perf_counter()
print(f"elapsed={end-start:.3f}s")
이 수치 하나로 원인을 확정할 수는 없다. 하지만 “코드가 느리다”에서 “락이 시끄럽다”로 질문이 바뀐다. 질문이 바뀌면 디버깅이 바뀐다.
그리고 질문이 바뀌면, 코드 리뷰도 바뀐다. free-threaded를 전제로 한 코드에서는 “이 함수가 느리냐”보다 “이 경로가 공유 락을 많이 잡지 않나”를 먼저 의심하게 된다. super() 같은 조회 경로는 특히 그렇다. 내가 작성한 파이썬 코드가 아니라, 런타임의 조회 규칙이 스레드 전체의 속도를 결정할 수 있으니까.
그리고 이런 패치가 백포트로 들어온다는 사실도 실무에선 의미가 있다. 3.14에서만 좋아지는 게 아니라, ‘이 경로는 free-threaded에서 병목이 될 수 있다’는 공식 인식이 생긴다는 뜻이니까.
결론: free-threaded 시대의 성능 디버깅은 락 관점이다
super()는 개발자에게는 문법이지만, 런타임에게는 조회 경로다. 조회 경로에는 캐시가 있고, 타입 규칙이 있고, 락이 있다.
free-threaded에서는 이런 락들이 갑자기 전면으로 올라온다. 그래서 성능 이슈를 볼 때도 관점이 바뀌어야 한다.
이런 얘기를 하면 “그럼 super()를 쓰지 말라는 건가요?”라는 질문이 나온다. 내 대답은 반대다. super()를 쓰는 게 문제라기보다, super()가 들어있는 경로를 우리가 너무 ‘공짜’로 믿어왔다는 게 문제다. 병목이 생기면 이제 더 노골적으로 드러난다.
- 내 코드가 느린지 먼저 보되,
- 그 다음에는 런타임 경로가 어떤 락을 잡는지 의심하고,
- 패치/백포트가 무엇을 줄였는지(빈도/범위)로 이해한다
이렇게 보면 CPython PR 하나가 단지 “속도 개선”이 아니라, 디버깅 관점을 바꾸는 힌트가 된다.
그리고 이런 패치를 볼 때 나는 항상 한 가지를 더 확인한다. “이게 내 코드에 바로 영향을 주는가?”가 아니라, “내가 그 경로를 얼마나 자주 밟는가?”다. 믹스인이 많은 코드베이스, 프레임워크 콜백이 촘촘한 서비스, 혹은 메서드 해석이 잦은 요청 경로라면 super()의 조회 경로는 생각보다 자주 밟힌다. 빈도가 높으면 작은 경합도 커진다. 그래서 이런 PR은 ‘기능’보다 ‘부하’에서 체감된다.
참고자료
- CPython PR (3.14 backport): Avoid contention on TYPE_LOCK in super() lookups
- https://github.com/python/cpython/pull/145804
- PR diff
- https://github.com/python/cpython/pull/145804.diff
- Merge commit
- https://github.com/python/cpython/commit/6d28aaf24d8e6406944cf96995e7b34b4b625eea
- Patched file context
- https://github.com/python/cpython/blob/6d28aaf24d8e6406944cf96995e7b34b4b625eea/Objects/typeobject.c
댓글
댓글 쓰기