GC の一時停止と漏れる行を数字で見つける
한국어 원문으로 표시합니다.
목표
이 파드의 GC 설정을 찍고, 순환 참조를 만드는 핸들러가 남기는 쓰레기를 센 뒤 weakref 로 순환을 끊습니다. gc.callbacks 로 GC 일시정지를 재 요청 지연의 p99 와 잇고, gc.freeze 의 효과를 봅니다. sys.getsizeof 의 함정과 slots 를 tracemalloc 으로 확인하고, 새는 서비스에서 늘어난 줄(파일:줄) 을 찾아 한도 있는 캐시로 고친 뒤 증가가 사라졌는지 잽니다.
왜 중요한가
CPython 은 대부분의 객체를 참조 계수로 바로 치우지만 순환은 세대별 수집기가 할당 수에 따라 몰아서 치웁니다. 그 수집이 요청의 1% 넘게에 떨어지면 평균은 그대로인데 p99 가 뜁니다. 메모리 문제는 '가장 큰 줄' 이 아니라 '가장 많이 늘어난 줄' 에서 찾아야 하고, getsizeof 는 가리키는 객체를 세지 않아 크기를 작게 말합니다. 채점기는 여러분이 적은 숫자만 보지 않고, 여러분의 함수를 새 인터프리터에서 재료가 아닌 입력에 다시 돌려 기준과 대조합니다.
재료
/opt/fixtures/svccs/memory/ 아래에 있습니다. 읽기만 하세요. 파이썬에서 sys.path.insert(0, "/opt/fixtures/svccs/memory") 뒤 import svc 로 부릅니다.
svc.py 가짜 주문 서비스
CATALOG 불러올 때 한 번 만드는 상품 2만 개(자라지 않는 큰 정적 데이터)
load_requests() requests.jsonl 의 요청 목록
handle_cyclic 요청마다 부모↔자식이 서로 가리키는 트리를 만든다 → {"id", "items", "path"}
render(req) 요청을 문자열로 렌더링
handle_leaky 렌더링 결과를 전역 사전에 넣고 지우지 않는다 → {"id", "bytes"}
requests.jsonl 요청 3,000줄 {"id", "user", "items"} — id 는 다시 오지 않는다
sessions.json 세션 400개 {세션 id: {"user", "roles", "cart", "flags"}}
단계
/root/svccs/memory/gcinfo.json에python(platform.python_version()),threshold(gc.get_threshold()를 목록으로),gc_enabled(gc.isenabled())를 적습니다.svc.load_requests()의 앞 200건을gc.collect()→gc.disable()→svc.handle_cyclic200번 →gc.collect()(돌려준 값이 수거한 도달 불가 객체 수) →gc.enable()순서로 돌려/root/svccs/memory/cycles.json에requests·unreachable·per_request(unreachable ÷ requests, 소수 둘째 자리)를 적습니다./root/svccs/memory/mem.py에handle_acyclic(req)를 만듭니다.svc.handle_cyclic(req)와 같은 값을 돌려주되 순환을 만들지 않습니다(자식 → 부모를weakref로). 채점기는 GC 를 끈 채 여러 번 부르고gc.collect()가 0 인지 봅니다.- 같은 파일에
measure(handler, reqs)를 아래 '측정 규칙' 대로 만듭니다. 요청 목록을[base[i % len(base)] for i in range(20000)](base =svc.load_requests())로 늘려/root/svccs/memory/pause.json에{"cyclic": measure(svc.handle_cyclic, …), "acyclic": measure(mem.handle_acyclic, …)}를 적습니다. svc를 불러온 프로세스에서gc.collect()세 번 중 가장 짧은 시간(ms),gc.freeze()직후의gc.get_freeze_count(), 다시gc.collect()세 번 중 가장 짧은 시간을 재고gc.unfreeze()합니다./root/svccs/memory/freeze.json에collect_ms_before·freeze_count·collect_ms_after로 적습니다.- 같은 파일에
deep_size(obj)(dict 의 키·값과 list·tuple·set·frozenset 의 원소를 따라 내려가며 서로 다른 객체(id 기준)마다sys.getsizeof를 더한 값), 속성 a·b·c·d 네 개를 가진PlainPoint(a, b, c, d)와 같은 속성을__slots__로 둔SlotPoint(a, b, c, d),per_object_bytes(cls, n=100000)(tracemalloc 을 켜고cls(0, 0, 0, 0)을 n 개 목록으로 만든 전후get_traced_memory()[0]차이 ÷ n, 소수 첫째 자리)를 만듭니다.json.load로 읽은sessions.json에 대해/root/svccs/memory/sizes.json에shallow(getsizeof)·deep·plain_getsizeof·slots_getsizeof(각각(0, 0, 0, 0)인스턴스 하나의 getsizeof)·plain_per_object·slots_per_object를 적습니다. svc를 불러오기 전에tracemalloc.start()하고,handle_leaky로 요청 앞 500건을 흘린 뒤 스냅숏, 다음 2,000건(500~2,499번째)을 흘린 뒤 스냅숏을 찍습니다.compare_to(앞 스냅숏, "lineno")의 첫 항목을/root/svccs/memory/leak.json에file(파일 이름만)·line·size_diff_kb(size_diff ÷ 1024, 소수 첫째 자리)·count_diff로 적습니다.- 같은 파일에
handle_fixed(req)를 만듭니다.svc.handle_leaky와 같은 값을 돌려주되 캐시는 최대 1,000개로 제한합니다(넘치면 오래된 것부터 버림). tracemalloc 을 켜고 두 핸들러마다 요청 앞 1,500건으로 데운 뒤 다음 1,500건 동안get_traced_memory()[0]가 늘어난 양(KB, 소수 첫째 자리)을 재어/root/svccs/memory/fix.json에requests_measured(1500)·leaky_growth_kb·fixed_growth_kb·leak_line(7단계의 "파일:줄")을 적습니다.
측정 규칙
시작 전에 gc.collect() 한 번. gc.callbacks 에 콜백을 넣어 phase "start" 에서 시각을 적고
"stop" 에서 그 차이를 일시정지 하나로 기록한다. 요청마다 handler(req) 앞뒤를 perf_counter 로 잰다.
끝나면 콜백을 뺀다(예외가 나도 — try/finally).
돌려줄 것: requests · collections(일시정지 개수) · gc_pause_ms_total · gc_pause_ms_max(소수 셋째 자리)
total_ms(요청 지연의 합, 소수 셋째 자리)
p50_ms · p99_ms(지연을 정렬한 목록의 [int(n×0.5)] · [int(n×0.99)] 번째, 소수 넷째 자리)
참고
- 채점기는
mem.py를 불러옵니다. 결과 JSON 을 만드는 코드는if __name__ == "__main__":아래나 별도 스크립트에 두세요. - GC 와 tracemalloc 의 숫자는 그 프로세스가 무엇을 이미 불러왔는지에 따라 달라집니다. 단계마다 새
python3프로세스에서, 지시한 순서대로 재세요. - 흔한 실수: getsizeof 하나로 컨테이너 전체 크기를 적는 것, 스냅숏 하나의 가장 큰 줄을 범인으로 지목하는 것, 요청 전체 시간을 GC 일시정지로 적는 것, 한도를 너무 크게 잡아 고친 뒤에도 증가가 남는 것.
- 산출물은 세션이 끝나면 사라집니다. 필요하면 따로 보관하세요.
이 파드의 GC 설정을 찍는다
/root/svccs/memory/gcinfo.json 에 python·threshold·gc_enabled 를 이 파드의 python3 로 찍어 적는다.
gc.get_threshold() 는 튜플을 돌려주니 목록으로 바꿔 적습니다. 문턱값은 버전마다 달라서 문서의 숫자가 아니라 이 인터프리터에서 찍은 값이 기준입니다.
참조 계수로는 안 사라지는 쓰레기를 센다
svc.handle_cyclic 을 GC 를 끈 채 200번 부른 뒤 gc.collect() 가 수거한 수를 /root/svccs/memory/cycles.json 에 requests·unreachable·per_request 로 적는다.
순환이 없는 객체는 요청이 끝나는 순간 참조 계수가 0 이 되어 사라지므로, GC 를 꺼 두면 남는 것은 순환뿐입니다. 시작 전에 gc.collect() 로 이전 쓰레기를 먼저 치워야 이 요청들이 남긴 것만 셀 수 있습니다. GC 를 켠 채로 돌리면 중간에 자동 수집이 일부를 가져갑니다.
weakref 로 순환을 끊는다
/root/svccs/memory/mem.py 에 handle_acyclic(req) 를 만든다 — svc.handle_cyclic 과 같은 값, 순환 없음. 채점기는 다른 요청으로 값을 대조하고, GC 를 끈 채 돌린 뒤 gc.collect() 가 0 인지 본다.
부모 → 자식(children 목록)은 강한 참조로 두고, 자식 → 부모만 weakref.ref(parent) 로 들면 순환이 사라집니다. 경로를 거슬러 올라갈 때는 약한 참조를 불러(ref()) 부모를 얻습니다. 루트는 함수가 끝날 때까지 지역 변수가 붙잡고 있으니 중간에 사라지지 않습니다.
GC 일시정지가 p99 를 만든다
mem.py 에 측정 규칙대로 measure(handler, reqs) 를 만들고 2만 요청으로 두 핸들러를 재어 /root/svccs/memory/pause.json 에 cyclic·acyclic 을 적는다. 채점기는 여러분의 measure 를 자기 핸들러(수집 없는 것·gc.collect 를 부르는 것)로 다시 부른다.
일시정지는 콜백의 start 와 stop 사이 시간이지 요청 전체 시간이 아닙니다. 수집은 할당 수로 촉발되므로 순환을 만드는 쪽만 자주 돕니다. 수집이 요청의 1% 넘게에 걸리면 p99 가 그 비용을 떠안습니다.
gc.freeze 로 오래 사는 객체를 빼 둔다
svc 를 불러온 프로세스에서 freeze 전후의 전체 수집 시간과 freeze_count 를 재어 /root/svccs/memory/freeze.json 에 collect_ms_before·freeze_count·collect_ms_after 로 적는다.
freeze 는 '지금' 추적 중인 객체를 영구 세대로 옮깁니다. 그러니 큰 정적 데이터(svc 의 CATALOG)를 만든 뒤에 불러야 효과가 있습니다. 시간은 흔들리니 세 번 중 가장 짧은 값을 씁니다.
getsizeof 는 얕다 — 깊은 크기와 slots
mem.py 에 deep_size·PlainPoint·SlotPoint·per_object_bytes 를 만들고 /root/svccs/memory/sizes.json 을 적는다. 채점기는 공유·순환이 섞인 다른 객체로 deep_size 를 대조한다.
getsizeof 는 사전의 키·값을 세지 않습니다. 따라 내려가되 id 로 이미 센 객체를 건너뛰어야 공유된 문자열을 두 번 세지 않고, 자기 자신을 담은 목록에서도 끝납니다. slots 인스턴스는 getsizeof 로는 더 커 보일 수 있으니 tracemalloc 으로 객체 10만 개를 만든 값을 믿으세요.
가장 큰 줄이 아니라 가장 많이 늘어난 줄
svc 를 불러오기 전에 tracemalloc 을 켜고, handle_leaky 로 500건 데운 뒤와 2,000건 더 흘린 뒤의 스냅숏 차이에서 첫 줄을 /root/svccs/memory/leak.json 에 file·line·size_diff_kb·count_diff 로 적는다.
스냅숏 하나의 statistics 맨 위는 '가장 많이 차지한' 줄이라, 시작할 때 한 번 만든 큰 데이터가 나옵니다. 누수는 시간이 지나며 '늘어나는' 것이니 compare_to 로 두 스냅숏의 차이를 봅니다. filename 은 전체 경로라 os.path.basename 으로 이름만 남깁니다.
한도 있는 캐시로 고치고 증가가 사라졌나 잰다
mem.py 에 캐시를 최대 1,000개로 제한한 handle_fixed(req) 를 만들고, 두 핸들러의 증가량을 재어 /root/svccs/memory/fix.json 에 requests_measured·leaky_growth_kb·fixed_growth_kb·leak_line 을 적는다. 채점기는 새 요청 id 로 여러분의 handle_fixed 를 새 프로세스에서 다시 잰다.
다시 오지 않는 키로 채우는 캐시는 한도가 없으면 누수입니다. OrderedDict 에 넣고 길이가 한도를 넘으면 popitem(last=False) 로 가장 오래된 것을 버리면 됩니다. 데우는 요청 수가 한도보다 적으면 측정 구간에서 캐시가 아직 자라 증가가 남아 보입니다. 채점기는 새 요청 id 로 2,000건 데운 뒤 4,000건을 잽니다.