기본 콘텐츠로 건너뛰기

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...

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁 안녕하세요! 오늘은 AWS RDS를 사용하는 Django 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 1. RDS 인스턴스 최적화 1.1 인스턴스 타입 선택 # RDS 인스턴스 크기 조정 import boto3 def resize_rds_instance(): rds = boto3.client('rds') response = rds.modify_db_instance( DBInstanceIdentifier='your-db', DBInstanceClass='db.t3.large', # 워크로드에 맞는 인스턴스 타입 선택 ApplyImmediately=True ) return response['DBInstance'] 1.2 파라미터 그룹 설정 def create_parameter_group(): rds = boto3.client('rds') # PostgreSQL 파라미터 그룹 생성 response = rds.create_db_parameter_group( DBParameterGroupName='django-optimized', DBParameterGroupFamily='postgres13', Description='Optimized parameters for Django applications' ) # 성능 관련 파라미터 설정 parameters = [ { 'ParameterName': 'shared_buffers', 'ParameterValue': '2GB...