기본 콘텐츠로 건너뛰기

When Observability Races: Fixing list.__sizeof__ Data Races in Free-Threaded CPython

thumbnail

“용량은 그냥 숫자”라고 믿었던 대가: 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처럼, 관찰 함수 하나가 내부 상태 읽기와 맞물려 경계면이 되기도 한다. 경고는 그 경계면을 보여준다.


이런 종류의 레이스는 “내 코드가 느리다”와는 별개로, 운영에서 갑자기 터진다. 그래서 성능 최적화보다 먼저, 관찰 코드가 건드리는 경계면부터 점검하는 게 훨씬 싸다.

참고자료

이미지 크레딧/라이선스

댓글

이 블로그의 인기 게시물

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