40ms Go GC stop-the-world pauses caused by swap
swap이 유발한 40ms Go GC stop-the-world pause — 프로덕션에서 swap을 켜기 전에 알아둘 것
메모리 스파이크 대응을 위해 프로덕션에서 swap을 사용하던 저자가 Go 런타임의 GC 메타데이터 페이지가 swap-out되면서 stop-the-world pause가 중앙값 51µs에서 최악 40ms까지 폭증하는 현상을 실측했습니다. bpf 스크립트로 확인한 결과 40ms 중 39ms가 228건의 major page fault에서 발생했으며, 이는 GC가 heap 밖의 bookkeeping 영역을 읽는 동안 world가 멈춘 상태에서 swap-in을 기다렸기 때문입니다.
- 주제
본문
저자는 프로덕션에서 메모리 스파이크를 흡수하기 위해 swap을 사용하기로 했다가 발생할 뻔한 장애 모드를 공유합니다. 실험 환경은 하나의 cgroup 안에 두 프로세스를 둔 구성인데, 하나는 io.ReadAll과 proto.Unmarshal로 blob과 graph struct를 만드는 Go 프로세스(Go allocator가 이를 scan으로 표시)이고, 다른 하나는 대부분 조용한 HTTP 서버입니다. 저자는 처음에 커널의 page eviction이 per-process가 아니라 per-cgroup으로 이루어지므로 두 프로세스의 페이지가 골고루 evict되어 kernel과 GC 사이의 swap-in/swap-out 문제가 심각하지 않을 것이라 예상했지만, 이 가정은 틀렸습니다.
무엇이 문제였나
Go의 GC는 heap이 아니라 heap 밖의 별도 영역에 있는 GC 메타데이터를 stop-the-world pause 동안 읽습니다. 이 페이지들은 해제되지 않고 재사용되지만 GC 주기 사이에는 접근 빈도가 낮습니다. 커널은 age 기반으로 가장 오랫동안 접근되지 않은 페이지를 swap으로 보내기 때문에, 정작 GC가 world를 멈추고 이 페이지를 읽으려는 순간 major page fault가 발생합니다. 그러면 커널은 PTE를 읽고, do_swap_page를 호출하고, 새 frame을 찾아 cgroup에 charge하고, bio를 submit해 디스크를 기다린 뒤 페이지를 다시 메모리에 올리는 전 과정을 — world가 멈춘 채로 — 수행하게 됩니다.
저자는 kernel 6.8에서 MGLRU가 활성화된 Hetzner 머신으로 모의 실험을 했습니다. 중앙값 pause는 약 51µs였지만 메타데이터가 NVMe의 swap에 올라간 경우 최악의 pause는 40ms였습니다. 40ms는 중앙값의 약 800배이며, 실험 중 메모리 스파이크 한 번당 2~3회씩 발생했고 30분 동안 이런 pause가 총 312회 관측됐습니다.
40ms의 행방 — bpf로 계측
저자는 world가 멈춘 동안 발생한 page fault를 세는 bpf 스크립트를 작성했습니다. 최악의 케이스에서 pause는 39,902µs였고, 그중 228건의 fault에 39,013µs가 소모됐습니다. 즉 40ms 중 39ms가 page fault였습니다. addr2line으로 확인한 fault 지점은 모두 GC의 bookkeeping 코드였습니다: runtime.(*spanSet).reset, runtime.finishsweep_m, runtime.gcStart.func2, runtime.nextMarkBitArenaEpoch 등으로, sweep termination 단계에서 spanSet과 mark bit arena 관련 메타데이터를 읽다가 fault가 난 것입니다.
40ms가 짧아 보일 수 있지만 이것은 stop-the-world pause입니다. Go 용어로 모든 P가 멈추기 때문에, 예컨대 I/O를 기다리던 goroutine이 있으면 pause 동안 I/O가 완료되어도 처리할 실행 주체가 없습니다. Go의 GC는 sweep termination과 mark termination 두 지점에서 world를 멈추므로 이 리스크는 GC 주기마다 반복됩니다.
메타데이터 외의 또 다른 비용
저자는 추가로 관측한 현상도 언급합니다. 보통 3~5ms 걸리던 511KiB 메시지 하나의 구축이 NVMe에서는 105ms, Hetzner의 network volume에서는 903ms까지 늘어났습니다. 메시지당으로 보면 메타데이터 pause보다 더 큰 비용이지만, 이 비용은 해당 할당을 수행하는 goroutine만 부담하므로 적어도 국지적(localized)이며 메타데이터 pause처럼 전역적이지는 않다고 설명합니다. 다만 이 시간이 정확히 어디에 쓰이는지는 확인하지 못했다고 덧붙였습니다.
결론적으로 저자는 Chris Down의 견해처럼 swap 자체가 악은 아니라고 동의하면서도, GC와는 잘 맞지 않았고 자신의 프로덕션 환경처럼 할당이 많은 경우라면 주의가 필요하다고 정리합니다. 또한 9월 14일 업데이트에서 Go 1.26의 Green Tea GC가 메타데이터를 읽는 방식을 바꿨는지 측정해 봤으나 영향은 미미했다고 밝혔습니다. 실험에 쓰인 plot, mock allocator, bpf 스크립트, python 스크립트는 GitHub 저장소(frnsimoes/go-gc-swap-cost)에 공개되어 있습니다.
Reddit 반응
- @radozok — 이전 버전의 글을 읽은 기억이 있다며, 저자가 이전 글을 지우고 다시 올린 것이냐고 묻습니다.
- @renozyx — GC가 OS와 협력해 이런 문제를 피하는 방식에 대한 연구 논문을 기억한다며, 결국 실용화되지 못해 아쉽다는 반응을 보였습니다.
원문: frn.sh / 번역·요약: Trawling