기본 콘텐츠로 건너뛰기

free-threaded에서 가 느려질 수 있다: TYPE_LOCK 경합 패치가 말해주는 것

thumbnail

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

댓글

이 블로그의 인기 게시물

Django에서 트랜잭션 관리하기

Django에서 트랜잭션 관리하기 안녕하세요! 오늘은 Django에서 데이터베이스 트랜잭션을 효과적으로 관리하는 방법에 대해 알아보겠습니다. 1. 트랜잭션의 중요성 트랜잭션은 데이터베이스의 일관성과 무결성을 보장하는 중요한 개념입니다. Django에서는 여러 가지 방법으로 트랜잭션을 관리할 수 있습니다. 1.1 기본 개념 원자성(Atomicity) : 트랜잭션은 모두 실행되거나 모두 실행되지 않아야 합니다. 일관성(Consistency) : 트랜잭션 전후로 데이터베이스의 일관성이 유지되어야 합니다. 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 합니다. 지속성(Durability) : 완료된 트랜잭션의 결과는 영구적으로 저장되어야 합니다. 2. Django의 트랜잭션 관리 2.1 기본 설정 # settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'mydatabase', 'USER': 'myuser', 'PASSWORD': 'mypassword', 'HOST': 'localhost', 'PORT': '5432', 'ATOMIC_REQUESTS': True, # 모든 뷰를 트랜잭션으로 래핑 } } 2.2 데코레이터 사용 from django.db import transaction @transaction.atomic def create_order(user, items): order = Order.objects.create(user=...

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드 안녕하세요! 오늘은 AWS S3와 CloudFront를 사용하여 정적 파일을 효율적으로 서빙하는 방법에 대해 알아보겠습니다. 왜 S3와 CloudFront를 사용할까요? 높은 가용성 : AWS의 글로벌 인프라를 활용 빠른 전송 속도 : CloudFront의 CDN 기능으로 전 세계 사용자에게 빠른 전송 비용 효율성 : 사용한 만큼만 지불 보안 : AWS의 보안 기능 활용 확장성 : 트래픽 증가에 자동 대응 1. S3 버킷 설정 1.1 버킷 생성 및 설정 import boto3 def create_s3_bucket(): s3 = boto3.client('s3') # 버킷 생성 bucket_name = 'your-static-files-bucket' s3.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ 'LocationConstraint': 'ap-northeast-2' } ) # 버킷 정책 설정 bucket_policy = { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObje...

Python에서 asyncio 완전 정복 (await, async, gather 등)

어휴, 요즘 파이썬으로 비동기 프로그래밍 하는 재미에 푹 빠졌어요! 특히 asyncio 는 정말 마법 같더라고요. 처음엔 좀 낯설었는데, 익숙해지니까 속도 향상이 눈에 띄게 느껴져서 완전 반해버렸습니다. 이 글에선 제가 asyncio 를 배우면서 깨달은 점들을 풀어놓을게요. 혹시 비동기 프로그래밍이 뭔지 잘 모르시겠다면, 간단히 말해 여러 작업을 동시에 처리해서 프로그램 속도를 엄청나게 높이는 기술이라고 생각하시면 돼요. 마치 여러 요리사가 동시에 음식을 만들어서 손님에게 빨리 제공하는 것과 비슷하죠! 일단 async 와 await 라는 녀석들이 핵심인데요, async 는 함수 앞에 붙여서 "얘는 비동기 함수야!"라고 선언하는 거예요. 그리고 await 는 다른 비동기 함수가 끝날 때까지 기다리라고 지시하는 역할을 하죠. 예를 들어, 네트워크에서 데이터를 가져오는 함수가 있다면, await 를 사용해서 데이터가 다 가져올 때까지 기다렸다가 다음 작업을 진행할 수 있어요. 그 동안 다른 작업을 처리할 수 있으니, 마치 멀티태스킹을 하는 것처럼 느껴져요. 신기하지 않나요? 그리고 asyncio.gather 는 여러 비동기 함수를 동시에 실행하고 결과를 모아주는 아주 유용한 친구입니다. 제가 웹사이트 여러 개에서 데이터를 동시에 가져와야 할 때 정말 요긴하게 썼어요. 하나씩 순서대로 가져오는 것보다 훨씬 빠르더라고요! 마치 여러 개의 탭을 동시에 열어놓고 작업하는 것과 같다고 생각하시면 될 것 같아요. 실제로 제가 썼던 코드를 보여드릴게요. 세 개의 웹사이트에서 데이터를 가져오는 예제인데요. (아래 코드 삽입) 이 코드를 보시면, fetch_data 함수가 각 웹사이트에서 데이터를 가져오는 역할을 하고, asyncio.gather 가 이 함수들을 동시에 실행하도록 도와주는 것을 볼 수 있을 거예요. asyncio.sleep(2) 는 네트워크 지연을 시뮬레이션하기 위해 넣...