LabHub
开始
学习 学习路径 课程

打造好服务的计算机科学 — 用测量重新学习教科书概念

引用计数、循环回收器与不断增长的那一行

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

한 줄 요약

CPython 은 대부분의 객체를 참조 계수로 그 자리에서 치우고, 참조 계수로는 못 치우는 순환만 세대별 수집기가 가끔 몰아서 치웁니다. 그 '가끔' 이 요청 한가운데 떨어지면 평균은 그대로인데 p99 가 뜁니다. 메모리가 자라는 문제는 '가장 큰 줄' 이 아니라 '가장 많이 늘어난 줄' 에서 찾아야 합니다.

왜 이게 필요했나

'CPU·메모리 누수 판별' 코스는 프로세스 바깥에서 RSS 와 Private_Dirty 의 기울기로 누수를 잡습니다. 그 방법은 "새고 있다" 까지는 알려 주지만 "어느 줄이" 는 알려 주지 않습니다. '힙은 남았는데 서비스가 멈췄다' 코스는 JVM 의 GC 로그로 멈춘 시간을 읽습니다. 파이썬 서비스에도 같은 두 질문이 있습니다. 지연 꼬리의 몇 %가 GC 때문인가, 그리고 자라는 메모리는 소스의 몇 번째 줄에서 오는가. 둘 다 표준 라이브러리만으로 숫자로 답할 수 있습니다.

어떻게 동작하나

참조 계수와 순환 수집기. gc 모듈 문서는 이 수집기가 파이썬이 이미 쓰는 참조 계수를 보충하는 것이라, 순환 참조를 만들지 않는다고 확신하면 꺼도 된다고 적습니다. 부모가 자식 목록을 들고 자식이 부모를 가리키는 트리는 요청이 끝나도 서로를 붙잡아 계수가 0 이 되지 않습니다. 이런 순환만 수집기의 몫입니다. CPython 내부 문서는 수집기가 다른 객체를 담을 수 있는 컨테이너 객체만 추적한다고 설명합니다.

세대와 문턱. 같은 gc 문서는 객체를 세 세대로 나누고, 마지막 수집 이후 할당 수에서 해제 수를 뺀 값이 threshold0 을 넘으면 0세대부터 검사하며, 0세대 검사가 threshold1 번을 넘으면 1세대도 본다고 적습니다. 가장 오래된 세대는 내부 문서가 설명하듯 오래 산 객체 가운데 새로 들어온 몫이 25% 를 넘을 때만 전체 수집을 합니다. 기본 문턱값은 버전마다 다릅니다. 이 실습 이미지(3.12.3)에서 gc.get_threshold() 를 찍으면 (700, 10, 10) 이 나오고, 내부 문서의 최신 판은 기본 빌드의 초기값을 (2000, 10, 10) 으로 적습니다. 그래서 숫자를 외우지 말고 찍어 봐야 합니다. 핵심은 수집이 "시간" 이 아니라 "할당 수" 로 촉발된다는 점입니다. 순환을 만드는 핸들러는 해제 없이 할당만 쌓으니 문턱을 자주 넘습니다.

수집 시간을 재는 법. gc.callbacks 에 함수를 넣으면 수집 직전에 phase "start", 직후에 "stop" 으로 불리고, info 에는 수집한 세대(generation)와 수거한 객체 수(collected)가 들어옵니다. start 와 stop 사이의 시간이 그 수집의 일시정지입니다. 이것을 요청마다 잰 지연과 나란히 놓으면 "p99 가 왜 뛰었나" 에 답이 나옵니다. 재료의 가짜 서비스로 이 파드에서 재 보니 2만 요청 동안 수집이 300여 번(요청의 2% 가량) 돌았고, 순환을 끊은 핸들러와 비교해 p50 은 비슷한데 p99 는 몇 배로 뛰었습니다. 1% 보다 많은 요청이 수집을 떠안으면 그 비용이 그대로 p99 에 올라옵니다. 요청 전체 시간을 GC 시간으로 적으면 이 인과가 사라집니다.

gc.freeze. 문서에 따르면 gc.freeze() 는 지금 추적 중인 모든 객체를 영구 세대로 옮겨 앞으로의 수집에서 무시합니다. 문서가 드는 쓰임은 fork 전입니다 — 부모에서 일찍 gc.disable(), fork 직전에 gc.freeze(), 자식에서 일찍 gc.enable() 하면 자식의 수집이 부모에게서 물려받은 오래된 객체를 건드리지 않아 copy-on-write 로 인한 복사가 줄어듭니다. 시작할 때 만든 큰 정적 데이터가 수집 때마다 다시 훑이는 비용도 함께 빠집니다.

어느 줄이 자라나. tracemalloc 은 메모리 블록이 할당된 위치와 파일·줄별 통계를 주고, 두 스냅숏의 차이를 계산해 누수를 찾게 해 줍니다. Snapshot.compare_to(old, "lineno") 는 줄마다 size_diff(늘어난 바이트)의 절댓값이 큰 순서로 정렬해 돌려줍니다. 스냅숏 하나의 statistics() 맨 위는 '가장 많이 차지한 줄' 이라, 시작할 때 한 번 만든 큰 표가 먼저 나옵니다. 누수는 늘어나는 것이니 요청을 조금 흘려 데운 뒤 찍고, 더 흘린 뒤 다시 찍어 차이를 봅니다. 프레임을 많이 저장할수록 tracemalloc 자신의 메모리와 CPU 부담도 커진다고 문서가 적습니다.

얕은 크기의 함정. sys.getsizeof 는 객체에 직접 딸린 메모리만 세고 그 객체가 가리키는 객체는 세지 않습니다. 사전 하나의 getsizeof 는 키와 값의 크기를 포함하지 않으니, 컨테이너 전체를 알려면 공식 문서가 링크한 재귀 레시피처럼 따라 내려가며 같은 객체를 두 번 세지 않아야 합니다. slots 절은 인스턴스가 기본으로 속성용 사전을 갖는데 변수가 몇 개뿐인 객체에는 낭비라서, __slots__ 로 그 공간을 줄일 수 있다고 적습니다. 그런데 이 파드에서 재 보면 getsizeof 는 오히려 slots 쪽을 더 크게 보여 줍니다. getsizeof 가 재는 것과 실제로 할당되는 것이 다르다는 뜻이고, 그래서 비교는 tracemalloc 으로 객체 10만 개를 만들어 나눈 값으로 합니다.

캐시가 누수가 될 때. 다시 오지 않는 키로 채우는 전역 사전은 한도가 없으면 그냥 누수입니다. 한도를 두고 오래된 것부터 버리거나, 값 쪽 참조를 weakref 로 들어 다른 곳에서 안 쓰면 사라지게 합니다. weakref 문서는 약한 참조만으로는 객체를 살려 두지 못하며 큰 객체를 담는 캐시가 주된 쓰임이라고 적습니다. 다만 list·dict 는 그대로는, int·tuple 은 상속해도 약한 참조를 걸 수 없다고 적으니 무엇을 담는지 먼저 봐야 합니다. 부모 포인터처럼 순환을 만드는 역참조도 weakref 로 바꾸면 순환이 사라집니다.

현장에서 만나는 모습

JVM 쪽은 '힙은 남았는데 서비스가 멈췄다' 코스가 GC 로그와 힙 덤프로, 프로세스 바깥의 누수 기울기는 'CPU·메모리 누수 판별' 코스가 다룹니다. 이벤트 루프 서버라면 GC 일시정지도 루프를 세우는 호출과 똑같이 모든 요청을 함께 늦춥니다 — '요청 하나가 아니라 전부가 느려졌다' 코스의 루프 지연 관측이 여기에도 그대로 쓰입니다.

다음 실습에서 할 것

이 파드의 GC 문턱을 찍고, 순환을 만드는 핸들러가 남긴 쓰레기를 센 뒤 weakref 로 순환을 끊습니다. gc.callbacks 로 수집 시간을 재 p99 와 잇고, gc.freeze 가 전체 수집을 얼마나 줄이는지 봅니다. getsizeof 와 깊은 크기, slots 를 비교한 다음, tracemalloc 으로 새는 줄을 찾아 한도 있는 캐시로 고치고 증가가 사라졌는지 확인합니다.