Goroutine 수 증가와 적체·누수의 원인 구분
초당 100개 요청이 들어오고 각 요청이 하위 API에 머무는 평균 시간이 0.1초라면, 안정적으로 진행 중인 작업 규모는 대략 100 × 0.1 = 10개입니다. 평균 시간이 2초로 늘면 같은 계산이 100 × 2 = 200개가 되어, 정상 작업이 느려진 것만으로도 goroutine 수가 늘 수 있습니다. 이는 도착률과 체류 시간의 산술 예제일 뿐 무한 과부하에서 안정 상태를 보장하는 공식은 아니므로, 정상 작업이 느려 쌓인 적체와 더 이상 완료할 수 없는 goroutine 누수를 나눠 봐야 합니다.
수신자가 사라졌는데 결과 채널 send에서 영원히 기다리는 goroutine은 종료 경로 문제입니다. 반대로 부하가 끝나면 모두 줄어들어도 피크 메모리·연결이 한도를 넘으면 용량 문제입니다. 누수가 아니라는 이유로 무제한 적체가 안전한 것은 아닙니다.
GOMAXPROCS와 G·M·P 자원 범위
G는 goroutine, M은 OS 스레드, P는 Go 코드를 실행하는 데 필요한 런타임 자원입니다. GOMAXPROCS는 동시에 Go 코드를 실행할 수 있는 P의 수를 조정하므로, 전체 G 수나 syscall·cgo를 수행 중인 M까지 포함한 전체 M 수의 절대 상한은 아닙니다.
네트워크·채널 대기에서 G가 park되어 다른 G를 실행할 수 있어도 대기 중 스택·버퍼·문맥·연결은 남습니다. blocking syscall·cgo는 다른 스케줄링 비용을 가질 수 있습니다. CPU quota를 넘어 값을 올리면 실행 자원이 늘기보다 throttling·경합이 증가할 수 있으므로 Go 버전의 컨테이너 CPU 인식 기본값과 실제 설정을 확인합니다.
현재 스택과 누적 경합의 관측 차이
| 프로파일·지표 | 주로 보는 것 | 주의점 |
|---|---|---|
| goroutine profile | 현재 스택·대기 위치 | 단일 시점만으로 영구 누수 확정 불가 |
| block profile | 설정된 표본의 동기화 차단 | 샘플링·완료된 대기 기록 특성 |
| mutex profile | 경합에 기여한 잠금 경로 | 대기자의 현재 스택과 같은 것 아님 |
| CPU profile·trace | 실행 비용·스케줄·시간 관계 | 관찰 창·오버헤드 |
| 활성·생성·종료 작업 수 | 수명 회복 여부 | 요청 수와 자식 작업 수 구분 |
block profile은 동기화 때문에 멈춰 있던 대기의 표본을, mutex profile은 잠금 경합에 기여한 경로를 모아 봅니다. 둘 다 프로파일 활성화와 샘플링 설정이 필요할 수 있고 완료된 대기의 표본 특성도 있으므로, 빈 프로파일만으로 대기가 없다고 결론 내릴 수 없습니다. mutex profile의 잠금 해제 쪽 귀속을 실제 긴 임계 구역과 코드에서 연결해 원인을 찾고, 같은 부하·관찰 창에서 비교하며 프로파일링 오버헤드도 봅니다.
생성 전 진입 제한과 허가 대기자 적체
모든 요청에 goroutine을 만든 뒤 그 안에서 세마포어를 기다리면 활성 호출은 제한돼도 대기 goroutine은 계속 늘 수 있습니다. 가능하면 생성 전 수락·실행 허가를 제한하고 대기 수·바이트·최대 나이에도 상한을 둡니다. 하위 연결 풀 대기·채널 송신·잠금 대기를 각각 나눕니다.
취소 신호를 보내는 것만으로 goroutine이 강제 종료되지는 않습니다. 모든 대기 경로가 취소를 관찰하고 부모가 실제 자식 반환을 확인해야 합니다. 요청 이후 지속해야 할 작업은 내구 큐·별도 소유자로 분리합니다.
그림을 글로 읽기
- p99·goroutine·메모리 증가
- 대기 스택·종료 추적
- 할당·GC CPU·scan 추적
- 진입 상한·종료 경로 수정
- 할당·보유·구조 비용 수정
- p99·goroutine·메모리 증가 → 대기 스택·종료 추적: 어디서 기다리나
- p99·goroutine·메모리 증가 → 할당·GC CPU·scan 추적: 무엇에 CPU를 쓰나
- 대기 스택·종료 추적 → 진입 상한·종료 경로 수정: 적체·누수 구분
- 할당·GC CPU·scan 추적 → 할당·보유·구조 비용 수정: 원인 경로 확인
Alloc·Inuse와 누적 할당·생존 보유량
alloc_space는 누적 할당 바이트, alloc_objects는 누적 할당 객체 수를 보는 데 유용합니다. inuse_space·inuse_objects는 살아 있는 것으로 기록된 표본의 보유량을 봅니다. Go heap profile은 GC 시점과 표본 수집 특성의 영향을 받으므로 매 순간 완벽히 최신인 전체 heap census라고 보지 않습니다.
누적 alloc이 크고 inuse가 작으면 임시 객체가 많이 생겼다가 사라졌을 수 있습니다. 같은 관찰 창의 차이·요청당 할당량을 보아야 장수 프로세스의 누적값을 다른 실행과 공정하게 비교할 수 있습니다. inuse가 커지면 캐시·대기 작업·큰 배열의 작은 slice 같은 보유 코드를 조사합니다. profile의 할당 stack만으로 모든 root 참조 경로가 자동 제공되는 것은 아닙니다.
RSS에는 Go heap뿐 아니라 stack·런타임·native·allocator 여유 등이 포함됩니다. Go가 객체를 회수했다고 OS에 즉시 페이지를 반환하는 것도 아니므로 heap 감소와 RSS 감소를 같은 사건으로 보지 않습니다.
GC 정지 시간과 CPU·Assist 비용
Go의 추적 GC는 GC root에서 시작해 포인터를 따라가며 도달 가능한 객체를 표시(mark)합니다. 따라서 같은 100 MiB라도 포인터가 없는 큰 바이트 배열과 작은 포인터 객체가 많은 그래프는 따라가야 할 참조 수가 달라 scan 특성이 다릅니다. 작은 임시 할당이 많으면 생존 heap이 작아도 수집 작업의 비용과 요청이 대신 수행하는 mark assist 비용이 요청 지연에 영향을 줄 수 있으므로, stop-the-world가 짧다는 이유만으로 GC 영향을 배제하지 않습니다.
GOGC를 높이면 대체로 더 큰 heap 목표를 허용하므로 수집 빈도는 줄일 수 있지만 메모리를 더 사용합니다. GOMEMLIMIT는 런타임이 관리하는 메모리의 소프트 한도이므로 프로세스 전체의 절대 상한은 아닙니다. 필요한 생존 데이터 자체가 한도에 가까우면 GC를 반복해도 줄지 않아 CPU만 소비할 수 있으므로, 캐시·동시성·버퍼 상한을 먼저 조정할 수 있어야 합니다.
sync.Pool은 임시 재사용 도구이고 항목이 제거될 수 있으므로 필수 저장소나 고정 용량 풀로 사용하지 않습니다. 재사용률이 낮거나 큰 객체를 오래 보유하면 할당 감소보다 생존 비용이 커질 수 있습니다. 포인터 scan을 줄이겠다며 unsafe 정수 주소에 객체를 숨기지 말고 안전한 인덱스·연속 배열·객체 수 감소를 검토합니다.
동일 부하 대조 실험과 변경 효과
입력·동시성·CPU quota·메모리 한도를 고정하고 하위 API 지연을 조절한 뒤 부하를 멈춥니다. 작업 수와 메모리가 정책상 정상 범위로 돌아오는지 확인합니다. 그다음 불필요한 할당 제거·풀 적용·GOGC 변경을 한 번에 하나씩 비교합니다.
성공 기준은 GC 횟수 하나가 아니라 처리량·p99·오류·GC CPU·live heap·scan·메모리 여유입니다. 이 노트는 진단과 실험 설계이며 실제 Go 서비스의 원인 분석이나 벤치마크 결과를 제시한 것은 아닙니다.
재현 연습에서는 요청 도착률을 100/s로 고정하고 하위 대기를 0.1초에서 2초로 바꾼 뒤, 부하를 멈추고 goroutine 수가 회복되는지 관찰합니다. 예상 결과는 체류 시간 증가만으로도 대략 10개에서 200개 규모의 진행 작업이 생길 수 있지만, 부하 종료 뒤에도 결과 채널이나 취소되지 않는 대기에서 goroutine이 남으면 별도 종료 경로 결함이라는 것입니다. 같은 실행에서 alloc_space와 inuse_space를 나눠 기록해 누적 할당과 생존 보유량을 혼동하지 않습니다.