Go프로그래밍수석 Go 개발자

Go의 런타임이 여분의 고루틴 스택 메모리를 회수하는 메커니즘을 평가하고, 할당 해제를 촉발하는 활용 임계값과 해제된 영역의 최종 운명을 설명하시오.

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

질문의 역사

Go 1.3 이전, 런타임은 함수 호출 경계에서 연결된 청크로 나누는 세그먼트 스택을 사용했습니다. 이 설계는 스택 경계가 자주 넘겨질 때 심각한 "핫 스플릿" 성능 낭비를 초래했습니다. Go 1.3은 이를 연속적인 스택으로 교체하고, 자라날 때 더 크고 단일 연속 영역으로 복사합니다. 그러나 초기 연속 스택 구현은 메모리를 힙으로 다시 반환하지 않았고, 초기화 또는 배치 처리 동안 깊은 호출 스택을 일시적으로 필요로 하는 고루틴에 대해 지속적인 RSS 증가를 초래했습니다. Go 1.5는 가비지 컬렉션 주기 동안 사용되지 않는 스택 메모리를 회수하기 위해 자동 스택 축소 기능을 도입하여 고루틴 스택의 메모리 관리 수명을 완성했습니다.

문제

축소 메커니즘이 없으면, 깊은 재귀에 일시적으로 들어간 고루틴(예: 깊게 중첩된 JSON 문서 처리 또는 복잡한 의존성 트리 탐색)은 유휴 이벤트 루프로 돌아온 후에도 피크 스택 할당을 무기한 유지하게 됩니다. 이로 인해 긴 실행 중인 애플리케이션에서 메모리 부풀음이 발생하며, 특히 고루틴이 높은 스택 작업과 유휴 상태를 번갈아 가는 작업자 풀을 사용하는 경우에 해당합니다. 문제는 스택이 실제로 과소 사용되고 있는지 안전하게 식별하고, 진행 중인 계산, 스택 할당 포인터를 손상시키지 않거나 호출 규칙의 ABI 요구 사항을 위반하지 않으면서 활성 프레임을 더 작은 메모리 영역으로 이동하는 것입니다.

해결책

Go 런타임은 GC 마크 단계에서 루트 세트를 스캔할 때 스택을 축소합니다. 각 고루틴의 스택 사용을 검사하며, 활용된 부분의 최고 수위가 현재 할당된 스택 크기의 4분의 1(25%) 미만으로 떨어지면, 런타임은 현재 스택 크기의 절반 크기의 새로운 스택을 할당합니다(단, 최소 2KB보다 작지 않음). 그런 다음 런타임은 안전한 시점에서 대상 고루틴을 비동기적으로 중단하고, 살아있는 스택 프레임을 새로운 더 작은 영역으로 복사하며, 컴파일러가 생성한 포인터 맵을 사용하여 스택 주소를 참조하는 모든 내포 포인터를 업데이트하고, 오래된 스택 메모리를 런타임의 mheap 할당기로 반환합니다.

실제 상황

우리는 각 고루틴이 잘못된 입력 공격 중에 최대 10,000 수준 깊이의 JSON 페이로드를 파싱하는 고 처리량 로그 처리 서비스를 운영했습니다. 처리 후 이 고루틴들은 새로운 연결을 기다리기 위해 sync.Pool로 돌아갔습니다. 우리는 풀에 있는 고루틴 수에 따라 서비스의 RSS 메모리가 선형적으로 증가하고, 유휴 기간에도 메모리가 전혀 해제되지 않아 200MB의 실제 작업 세트에도 불구하고 4GB 제한이 있는 컨테이너에서 OOM 킬을 발생시킨 것을 관찰했습니다.

우리는 처리된 요청 수가 설정된 수를 초과한 후 풀에 있는 고루틴을 강제로 종료하고 새로운 대체 고루틴을 생성하는 것을 고려했습니다. 이렇게 하면 새로운 고루틴이 최소 2KB 스택으로 시작하기 때문에 스택 메모리가 해제되는 것을 보장할 수 있습니다. 그러나 이 접근 방식은 고루틴의 지속적인 생성 및 파괴로 인한 상당한 CPU 오버헤드를 유발하고, TCP 연결 풀 최적화를 방해하며, 캐시 콜드 시작으로 인한 높은 지연 시간을 초래했습니다.

debug.SetMaxStack를 통해 스택 성장에 대한 하드 제한을 구현하면 심층 재귀 이벤트 중 과도한 할당을 방지할 수 있었습니다. 이는 OOM에 대해 보호했으나, 합법적이지만 깊은 파싱 작업이 runtime: goroutine stack exceeds 1000000000-byte limit로 패닉을 일으키게 했습니다. 이로 인해 고객 데이터가 손실되고 서비스 오류가 발생하여 우리의 신뢰성 SLA를 위반하게 되었으며, 이는 생산 환경에서 용납될 수 없는 일이었습니다.

우리는 매 30초마다 **runtime.GC()**를 호출한 후 **debug.FreeOSMemory()**를 호출하여 스택 스캔 및 축소를 강제로 수행하는 방안을 평가했습니다. 이는 RSS를 성공적으로 줄였지만, 매 호출마다 5-10ms의 정지-세계 중지가 발생하여 API 계층의 p99 지연 요구 사항 <2ms를 위반하고, 강제로 전체 컬렉션을 수행하여 CPU 사용률을 15% 증가시켰습니다.

결국 우리는 Go 1.20+에서 실행하고 GOGC를 튜닝하여 더 빈번한 가비지 컬렉션을 유도하면서 Go의 네이티브 스택 축소 메커니즘에 의존하게 되었습니다(이를 100 대신 50으로 설정함). 이는 수동 개입 없이 스택 축소 발생 기회를 증가시켰습니다. 우리는 또한 경로 추적을 위해 명시적인 힙 할당 스택을 사용하는 반복적 접근 방식을 사용하여 파서의 구조를 재구성함으로써 최대 재귀 깊이를 10,000에서 100으로 줄였습니다. 이 조합을 통해 자연스럽게 축소가 빈번히 발생하여 메모리가 제한되었습니다.

부하 하에서 서비스 RSS는 약 800MB로 안정화되었으며, 이전의 3.8GB 상한선에서 감소했습니다. 고루틴 스택 프로파일은 풀에 있는 작업자의 95%가 요청 간 최소 2KB 스택 크기를 유지하고 있으며, 활성 파싱 중에만 스파이크가 발생함을 보여주었습니다. OOM 킬은 완전히 중단되었고, p99 지연은 수동 GC 중지 및 고루틴 소진을 피했기 때문에 1.5ms 미만으로 유지되었습니다.

후보자들이 자주 놓치는 것

함수가 반환되고 스택 포인터가 감소할 때 스택 축소가 즉시 발생합니까?

아니요, 런타임은 즉각적인 할당 해제를 유도하기 위해 스택 포인터 감소를 실시간으로 모니터링하지 않습니다. 축소는 전적으로 가비지 컬렉션 마크 단계에서 스케줄러가 모든 고루틴 스택을 스캔할 때 수행됩니다. 런타임은 마지막 GC 이후 스택 사용의 최고 수위가 현재 물리적 할당의 25% 미만인지 확인합니다. 이 최고 수위가 25% 미만일 때만 축소 로직이 실행됩니다. 이 지연 평가 방식은 이미 마크를 위해 세상이 중지되어 있는 동안 모든 고루틴의 스택을 복사하는 비용을 분산시키지만, 실제 복사는 개별 고루틴을 중단해야 합니다.

정확한 축소 비율과 최소 크기는 얼마이며, 런타임이 메모리를 OS로 다시 반환합니까?

스택이 축소 자격이 있을 때, 런타임은 현재 스택의 절반 크기로 새로운 스택을 할당합니다. 이 기하급수적 감소는 임계값 위아래로 약간 요동치는 고루틴이 지속적으로 성장하고 축소되는 것을 방지합니다. 새로운 크기는 일반적으로 64비트 시스템의 최소 스택 크기 2KB로 제한됩니다. 이전 스택의 메모리는 운영 체제로 직접 반환되지 않고, 런타임의 mheap에 반환됩니다. 운영 체제는 이 물리적 메모리를 스카벤저가 힙이 유휴 상태에 있고 목표치를 초과했다고 판단하거나 **debug.FreeOSMemory()**가 호출될 때만 회수합니다.

스택 축소 중 고루틴이 중단되며 포인터는 어떻게 업데이트됩니까?

예, 축소는 스택 증가와 유사하게 안전한 시점에서 대상 고루틴을 중단해야 합니다. 런타임은 살아있는 프레임을 새 메모리 위치로 복사하고 스택 할당 변수에 대한 모든 포인터를 업데이트해야 합니다. 컴파일러는 각 프레임에서 무엇이 포인터인지 식별하는 포인터 맵을 생성합니다. 축소 과정에서 런타임은 이러한 맵을 사용하여 내포 포인터가 새로운 스택 주소를 가리키도록 찾아서 조정합니다. 이 작업은 동시성이 없으며, 복사 중에는 고루틴이 실행될 수 없지만 다른 고루틴은 계속 실행됩니다.