기본 콘텐츠로 건너뛰기

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

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

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

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