스왑으로 발생한 Go 가비지 컬렉터의 40ms 정지
요약
Go 언어의 가비지 컬렉터(GC)가 스왑된 메모리 영역에 접근할 때 심각한 성능 저하를 겪는 현상을 분석했습니다. 특히 메타데이터가 NVMe로 스왑될 경우, 최대 40ms에 달하는 긴 정지 시간(STW)이 발생하여 애플리케이션의 지연 시간을 크게 증가시킬 수 있습니다.
핵심 포인트
- GC와 스왑 조합은 성능 저하를 유발할 수 있음 (최대 40ms STW).
- 페이지 폴트 처리 시간이 주요 원인이며, GC 내부 관리 작업에서 발생함.
- 운영체제 차원에서 메모리 접근 및 스왑 주기를 인지하는 기능 개선이 필요함.
- GC 외에도 참조되는 핵심 메모 영역을 다루는 모든 시스템이 취약할 수 있음.
이때 모든 P가 정지하므로, I/O를 기다리던 고루틴은 I/O가 완료돼도 정지 중에는 이를 처리할 수 없음
측정 결과와 확인되지 않은 비용
커널 6.8, MGLRU 활성화 상태의 Hetzner 서버에서 모의 실험을 수행함
30분 동안 STW 정지 312회를 관측했으며, 중앙값은 약 51µs였음
메타데이터가 NVMe로 스왑된 경우 최악의 정지는 40ms로, 중앙값의 약 800배에 달함
테스트에서 이러한 지연은 메모리 급증 한 번당 두세 차례 발생함
BPF 스크립트로 STW 중 페이지 폴트를 집계한 최악의 사례는 39,902µs 정지, 228회 폴트, 폴트 처리 39,013µs였음
약 40ms 가운데 39ms가 페이지 폴트에 쓰였음
호출 스택은 runtime.(*spanSet).reset, runtime.nextMarkBitArenaEpoch, runtime.finishsweep_m, runtime.gcStart.func2, runtime.systemstack, runtime.gcStart 등 GC 내부 관리 작업을 가리켰음
511KiB 메시지 하나를 생성하는 시간도 평소 3~5ms에서 NVMe 사용 시 105ms, Hetzner 네트워크 볼륨 사용 시 903ms로 증가함
메시지당 지연은 메타데이터로 인한 정지보다 컸지만, 할당을 수행하는 고루틴에만 영향을 줌
이 시간이 정확히 어디에 쓰였는지는 확인하지 못함
스왑 자체가 나쁜 것은 아니라는 점에서는 Chris Down의 스왑 옹호 글과 같은 입장임. 다만 이번 실험에서는 스왑과 GC의 조합이 잘 작동하지 않았고, 대상 프로덕션 환경에서는 GC가 자주 실행됨
9월 14일 추가 측정에서 Go 1.26의 Green Tea GC가 메타데이터 읽기에 미치는 영향은 미미했음
go-gc-swap-cost에 그래프, 모의 할당기, BPF 스크립트, Python 스크립트 등 실험 자료를 공개함
구현과 무관하게, GC가 아직 참조되는 영역을 추적하려면 전체 트리를 순회해야 한다는 사실만으로도 스왑을 실용적으로 쓰기 어려워짐. 40ms의 전체 실행 중단(STW)이 없더라도 GC가 돌 때마다 스왑의 LRU 캐시가 흐트러지게 됨.
Apple이 프레임워크에서 GC를 버리고 자동 참조 카운팅을 택한 것도 같은 이유라고 봄.
예상 가능한 현상이긴 하지만, “구현과 무관하다”는 말에는 동의하지 않음. GC 구현은 개선할 수 있음. Reddit 논의에 GC가 운영체제와 협력해 이런 문제를 피하는 연구가 소개되어 있음.
해당 논문은 북마크 수집기를 다루며, 이후 어떤 영향을 미쳤는지는 모르지만 실제로 Linux에 구현되기도 했음.
현대 운영체제에는 스왑 아웃된 메모리에 접근했음을 스레드에 알리는 기능이 필요함. 스왑을 인지하는 GC나 대량의 메모리를 다루는 코드를 만드는 것이 불가능한 일은 아님.
더 나아가 스왑의 전체 수명주기를 알려야 한다고 봄. 페이지를 내보내기 전에 GC를 호출해 해당 영역을 정리하면 쓰레기 데이터를 스왑에 기록하지 않아도 됨. 페이지에 우선순위를 지정해 스왑 아웃되지 않도록 하는 기능도 운영체제가 지원해야 함.
참조 카운팅도 GC 알고리즘이며, 이런 현상이 당연히 발생하는 것은 아님. 구현에 크게 좌우되는데도 GC를 만드는 방법이 하나뿐이라고 착각하기 쉬움.
이 분야의 권위 있는 책으로 GC Handbook이 있고, 이 주제를 다룬 널리 알려진 논문도 있음.
글에서 다루는 현상은 GC에만 국한된 문제가 아님. Go GC에는 메타데이터를 읽는 임계 구역이 있음.
드물게 접근하지만 실행에 꼭 필요한 메모리 영역이 있는 프로그램이라면 무엇이든 이런 문제에 취약함.
Hotspot의 G1 같은 영역 기반 수집기를 madvise와 결합하면 스왑에 더 친화적으로 동작할 수 있을 듯함. 한 번에 다루는 작업 집합을 줄이고, 작업 집합을 바꾸기 전에 운영체제에 미리 알리는 방식이 가능함.
지연 시간이 중요하다면 스왑을 비활성화하면 됨. 시스템 전체에서 끄거나 해당 cgroup에서만 꺼도 됨.
완전히 맞는 말은 아님. 어떤 메이저 페이지 폴트 때문에 애플리케이션이 멈추든 지연 시간 측면에서는 마찬가지임. 스왑을 꺼도 데이터 페이지가 쫓겨나는 것만 막을 뿐, 코드 페이지는 여전히 메모리에서 제거될 수 있음.
지연 시간이 중요하다면 스왑을 끄는 대신 mlock()으로 메모리를 고정해야 함. 스왑은 커널이 데이터와 코드 페이지를 동등하게 퇴거 후보로 삼을 수 있게 해 줌.
왜 핵심 구성 요소를 다른 언어로 다시 작성해야 한다고 여겼는지 이해하기 어려움. GC가 문제라면 먼저 쓰레기를 덜 생성하는 방법부터 찾을 것 같음.
스택을 더 잘 활용하거나, 더 과감하게는 수동 메모리 관리를 도입할 수도 있음. 처음 Go를 선택한 이유가 분명 있었을 텐데 그 부분은 전혀 다루지 않음. 그저 새 장난감을 써 보고 싶었던 것처럼 느껴짐.
글은 좋지만 화면에 떠 있는 하단 영역은 무척 거슬림.
Go가 STW가 전혀 없는 실시간 병행 GC(on-the-fly GC) 를 쓰지 않는 이유가 있나요?
검색해서 찾은 on-the-fly-gc는 문서를 보면 중단 시간이 있는 듯함.
GC를 조정할 때는 한쪽의 이득을 위해 다른 쪽을 희생하게 됨. 대표적인 절충 관계가 중단 지연 시간과 처리량임. 지연 시간만 극단적으로 최적화하면 처리량이 나빠지고, 반대도 마찬가지임. Java의 GC 성능이 나쁘다는 평판도 과거 기본 설정이 처리량 위주였던 데 일부 기인함.
Go는 이미 동시 마크-스윕 GC를 사용하며, 애플리케이션 실행 중단 시간이 수십 마이크로초 수준으로 매우 짧음. on-the-fly도 Go가 이미 하는 일의 다른 변형처럼 보임.
시스템이 스왑 공간을 사용하기 시작하면 이미 쾌적한 동작에 대한 기대를 접는 편이라, 개인적으로는 이 “버그”가 크게 중요하지 않음.
Go에서 메모리 비대화 버그는 비교적 쉽게 고칠 수 있지만, 스왑 사용량이 불어나는 현상을 없애는 일은 때로 꽤 험난함. 그래도 불필요하게 늘어난 스왑 사용량은 전부 없앨 가치가 있다고 봄.
스왑을 인지하는 GC를 만들 수 있지 않을까요? 필요한 페이지를 먼저 메모리로 가져온 다음 전체 실행을 중단하는 방식임. 다만 이를 위한 명확한 API가 있는지는 모르겠음.
원하는 API가 madvise와 mincore 아닌가요?
Linux 커널에 API를 추가하면 되고, 이 정도는 꽤 단순한 편일 것임. 다만 여기서 시스템 호출까지 피하려면 조금 더 어려울 수 있음.
에이전트에 하루치 토큰을 주면 작업해서 측정 결과까지 얻을 수 있을 것이라 봄.
계층 간 통합을 충분히 깊게 하면 흥미로운 접근이 많이 가능함.
예를 들어 LRU 페이지 단위 대신 힙 그래프에서 조밀하게 연결된 노드 군집 단위로 스왑 아웃하고, 도달 가능성을 판단할 수 있도록 들어오고 나가는 간선의 요약을 메모리에 유지하면 어떨까요? 그러면 해당 군집이 관련된 GC를 수행할 때도 다시 메모리로 가져올 필요가 없음.
군집 전체가 도달 불가능해지면 회수할 때조차 다시 읽을 필요 없이 스왑 참조만 버리고 해당 공간을 비었다고 처리하면 됨. 가까운 시일 내에 이 정도 통합이 이뤄질 것 같지는 않지만, 생각해 보면 재미있음.
GC 지연 시간 SLO에는 운영체제의 메모리 압박을 포함해야 함. 그렇지 않으면 페이지 폴트가 수집기 문제처럼 보여 엉뚱한 해결책을 택하게 됨.
페이지 폴트 처리 시간은 얼마든지 길어질 수 있지 않나요? 그렇다면 어떻게 의미 있는 SLO를 제시할 수 있나요?
Node, Ruby, Python을 쓸 만한 곳에는 Go를 쓰고, 낮은 지연 시간이 필요한 곳에는 Rust를 씀.
까다로운 부분은 스왑이 단순히 메모리 할당만 느리게 하는 게 아니라는 것임. GC 메타데이터가 스왑 아웃되면 메모리 압박이 전체 실행 중단 지연의 급증으로 이어짐.
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기