기본 콘텐츠로 건너뛰기

When repr Locks: Why functools Wraps PyDict_Next in a Critical Section

“repr 한 줄”이 락이 되는 순간: free-threaded에서 functools가 PyDict_Next를 critical section으로 감싼 이유 meta_description: free-threaded 환경에서 dict 순회는 ‘그냥 읽기’가 아니라 동시성 계약의 일부가 된다. gh-145446(PR #145487)는 functools 내부에서 PyDict_Next로 kwargs 등을 순회하는 루프를 critical section으로 감싸고, 에러 처리/정리 순서를 바꿔 경계면을 명확히 했다. 어떤 루프가 왜 바뀌었는지, 실무에서 확장 모듈/FFI의 dict 순회를 어떻게 짜야 덜 흔들리는지 정리한다. meta_keywords: functools, free-threaded, critical section, CPython, PyDict_Next, kwargs, partial, repr, dict iteration, error handling, cleanup, lock, GIL-less, 동시성, data race, refcount, C extension, FFI, 안정성 meta_robots: index,follow free-threaded 쪽 변경을 보다 보면, 가끔 “이게 왜 여기서?” 싶은 지점이 나온다. 이번 gh-145446 / PR #145487이 그렇다. 사람들이 보통 위험하다고 생각하는 건 네이티브 확장이나 I/O 같은 데다. 그런데 패치가 들어간 곳은 functools 다. 더 정확히는 kwargs 같은 dict를 순회하는 루프. 이게 재미있는 이유는, 이 경로가 대부분의 서비스에서 ‘겉보기 무해한 곳’으로 쓰이기 때문이다. 로그에서 객체를 찍는 repr 캐시 키를 만들기 위해 kwargs를 정렬/나열하는 코드 에러 메시지에 함수/인자를 조금 더 친절하게 담는 코드 이런 건 기능이 아니라 운영이다. 운영 코드가 동시성과 만나는 순간, “그냥 출력”이 “잠깐의 락”이 된다. 이번 PR은 “큰...
최근 글

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

“용량은 그냥 숫자”라고 믿었던 대가: 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를 돌리며 경...

"NUL Byte" Breaks C: Fixing PyOS_StdioReadline OOB Reads in CPython

"입력 한 글자"가 C를 깨는 순간: PyOS_StdioReadline NUL 바이트 OOB-read 패치를 실무에 연결하기 meta_description: CPython PR 하나가 보여준 건 거창한 기능이 아니라 입력 종단 규약이었다. PyOS_StdioReadline 경로에서 NUL 바이트로 발생할 수 있었던 out-of-bounds read 패치를 계기로, C 경계면 입력 처리와 회귀 테스트를 실무 관점으로 정리한다. meta_keywords: CPython, Python, PyOS_StdioReadline, readline, stdin, NUL byte, null character, OOB read, out of bounds, memory safety, C boundary, REPL, parser, fuzzing, regression test, input handling, buffer, terminator meta_robots: index,follow REPL은 안전하다고 착각하기 쉬운 영역이다. 화면엔 프롬프트가 뜨고, 우리는 키보드로 한 줄을 치고, 인터프리터가 그걸 처리한다. 그래서 마음속에는 이런 전제가 깔린다. “입력은 그냥 문자열이다.” 그런데 C 레벨에서 입력은 “문자열”이기 전에 “버퍼”이고, 버퍼는 “길이”와 “종단(끝)”을 어떻게 해석하느냐로 안전성이 갈린다. 이번에 머지된 CPython PR(gh-140594, PR #140910)은 그걸 아주 짧게 보여준다. PyOS_StdioReadline 경로에서 NUL 바이트(\0)가 들어올 때 out-of-bounds read로 이어질 수 있는 틈을 메웠다(참고자료). 여기서는 영향 범위를 단정하지 않는다. 대신 PR이 고친 “입력 경계면”을 기준으로, 왜 NUL 한 글자가 메모리 안전 문제로 이어질 수 있는지, 그리고 비슷한 경계면을 가진 우리 코드에 어떤 테스트를 추가하면 좋은지에 집중한다. NUL 바이트는 ‘문자’가 아니라 ‘종...

은 공짜가 아니다: CPython이 RETURN_VALUE를 쪼갠 이유(_MAKE_HEAP_SAFE)

`return`은 공짜가 아니다: CPython이 RETURN_VALUE를 쪼갠 이유(_MAKE_HEAP_SAFE) 성능 이슈를 추적하다 보면, 마지막에 이상한 곳으로 떨어질 때가 있다. 쿼리도 최적화했고, 캐시도 붙였고, 알고리즘도 손봤는데, 그래도 처리량이 더 안 올라간다. 프로파일러를 켜면 프레임워크 내부가 보이고, 그 안에서 또 제너레이터가 보이고, 그 안에서 또 yield 와 return 이 보인다. 이때 팀원 한 명이 이렇게 말한다. “마지막 한 줄이 병목일 수도 있겠네요.” return 과 yield 는 파이썬에서 너무 평범해서, 병목 후보로 올려놓기조차 어렵다. 하지만 CPython은 최근 이 “평범한 끝”을 손봤다. RETURN_VALUE/YIELD_VALUE가 _MAKE_HEAP_SAFE uop를 앞에 두는 매크로 확장으로 바뀌었다(참고자료). 이 글의 목적은 “ return 을 줄여라”가 아니다. 오히려 반대다. 우리가 성능을 볼 때 ‘내 코드’만 보지 말고, 인터프리터가 어떤 모양으로 실행되는지도 함께 보라는 얘기다. 특히 tier2/uop 같은 실행 경로가 생긴 이후에는, 이런 변화가 실무에서 체감될 수 있다. yield / return 이 많은 코드가 실제로 생기는 곳 실무에서 yield 가 많은 코드는 흔하다. 스트리밍 응답을 만들 때 배치 작업에서 레코드를 흘려보낼 때 이벤트를 단계별로 처리할 때 그리고 이런 코드는 보통 “프레임워크가 하라는 대로” 쓰다 보면 나온다. 내가 원해서 yield 를 남발한 게 아니라, 인터페이스가 그렇게 생겼다. 예를 들어 아래처럼 단계별로 값을 내보내는 제너레이터는 자연스럽다. def parse_lines(lines): for line in lines: if not line: continue yield line.strip() def pipeline(lines): f...

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

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

디버깅 로그가 서비스를 죽인다: defaultdict.__repr__ 무한 재귀 버그가 보여준 ‘가드의 계약’

디버깅 로그가 서비스를 죽인다: `defaultdict.__repr__` 무한 재귀 버그가 보여준 ‘가드의 계약’ 운영하다 보면 가끔 이해가 안 되는 장애를 만난다. 기능을 바꾼 것도 아닌데 프로세스가 죽고, 원인을 쫓아가 보면 마지막 로그가 defaultdict(...) 같은 문자열 하나로 끝나 있다. “그냥 출력인데요?” repr은 그렇게 순한 기능이 아니다. repr은 ‘출력’이 아니라 실행 경로다. 로그를 남기려고 객체를 문자열로 바꾸는 순간, 그 객체의 코드가 실행된다. 그리고 그 코드가 재귀로 빠지면, 디버깅이 아니라 장애가 된다. 이번 주 CPython에 흥미로운 버그가 하나 정리됐다. collections.defaultdict.__repr__ 이 특정 조건에서 무한 재귀로 빠질 수 있었고, PR이 2026-03-10에 머지되면서 수정됐다(참고자료). 이 글은 CPython 내부를 공부하자는 글이 아니다. 내가 말하고 싶은 건 훨씬 단순하다. 재귀 가드가 있는 API는 enter/leave의 계약을 지키지 않으면, 디버깅이 서비스 안정성을 공격한다 는 것. 문제가 생기는 코드: 최소 재현은 ‘이상한’ 코드가 아니다 defaultdict 를 쓰는 건 흔하다. 없는 키를 조회할 때 기본값을 만들어 주는 게 편하니까. 여기서 트릭은 default_factory 다. 보통은 list 나 int 를 넣지만, 실무에서는 조금 더 복잡한 팩토리를 넣기도 한다. 예를 들어 로깅을 하고 싶어서, 혹은 상태를 캡처하고 싶어서, 팩토리 자체가 뭔가를 품고 있는 객체가 되기도 한다. 아래 코드는 “이상한 코드”처럼 보이지만, 구조 자체는 흔하다. 핵심은 팩토리의 __repr__ 이 자신을 참조하는 구조를 건드리게 되는 순간이다. from collections import defaultdict class Factory: def __init__(self): self.d = None de...

"보안 릴리즈인데 설치 파일이 없다": Python 3.10/3.11/3.12 소스-온리 보안 릴리즈를 실무에서 처리하는 법

"보안 릴리즈인데 설치 파일이 없다": Python 3.10/3.11/3.12 소스-온리 보안 릴리즈를 실무에서 처리하는 법 보안 릴리즈 공지가 떴다. 팀 채널에 링크가 공유되고, 누군가가 한 줄 던진다. “이번에도 올려야죠?” 여기까지는 평소와 같다. 문제가 되는 건 다음 장면이다. 릴리즈 페이지로 들어가서 다운로드 섹션을 훑다가 멈춘다. 설치 파일이 없다. Windows 인스톨러도 없고, macOS 패키지도 없고, “그냥 받아서 설치”할 수 있는 게 없다. 그러면 대화가 이상해진다. “그럼 우리는 못 올리는 건가?” 아니다. 이건 ‘못 올리는’ 게 아니라, 업스트림이 “이번엔 이렇게 배포한다”라고 명확히 말해주는 구간이다. Python 3.10.20 / 3.11.15 / 3.12.13 릴리즈 페이지에는 보안 릴리즈이며(그리고 설치 파일이 없다는 점까지) 같이 적혀 있다(참고자료). 처음 겪으면 당황하지만, 실무에서는 이걸 우리 파이프라인이 어디에 의존하고 있는지 드러나는 신호 로 읽게 된다. 이 글은 CVE 번호를 나열하는 글이 아니다. 우리 팀이 오늘 해야 하는 건 “무서워하기”가 아니라 “어떻게 먹일지”를 결정하는 거다. 소스-온리 릴리즈를 운영에서 처리하는 흐름을, 실제로 막히는 지점에서부터 풀어보겠다. 여기서 말하는 ‘처리’는 거창한 프로젝트가 아니다. 오늘 안에 끝낼 수 있는 쪽으로 잘게 자르면 된다. 지금 서비스가 어떤 파이썬에서 돌고 있는지부터 확인하고, 그 파이썬이 어디서 들어오는지(베이스 이미지인지, 우리가 직접 빌드하는지)를 확인하고, 올렸다가 되돌리는 길이 열려 있는지까지 같이 본다. 이 흐름이 잡히면 “설치 파일이 없다”는 말은 장애가 아니라 공급 방식의 차이 로 내려온다. 본문에는 생 URL을 넣지 않고, 맨 아래 참고자료에만 링크를 둔다. 릴리즈 노트에서 멈추는 포인트: installers 없음은 ‘단계’의 문제다 Python Insider 공지와 Dis...