Go 고루틴 누수와 pprof — 채널·뮤텍스, 그리고 종료 경로
이 글의 순서8개 절
고루틴을 만들기는 쉽다. 어려운 건 누가 데이터를 바꾸고, 누가 일을 끝내고, 언제 함수가 반환해도 되는지 정하는 일이었다. 채널을 잘못 쓰면 고루틴이 영원히 갇히는데, GC는 갇힌 고루틴을 치워 주지 않는다. 100ms 타임아웃을 건 요청 1000건을 보냈더니 고루틴 1000개가 그대로 남았고, goroutine 프로파일이 그 줄 번호를 가리켰다.
채널과 뮤텍스는 푸는 문제가 다르다
“Go니까 채널”이라는 말은 절반만 맞다. 표준 라이브러리도 뮤텍스를 널리 쓴다. 둘을 가르는 기준은 데이터가 움직이느냐다. 채널은 컨베이어 벨트처럼 값을 다음 고루틴에 건네고, 뮤텍스는 문 잠금처럼 같은 자리를 여럿이 한 번에 하나씩 쓰게 한다.
| 상황 | 도구 | 이유 |
|---|---|---|
| 단계별 가공 | 채널 | 값이 단계마다 이동 |
| 완료 알림 | 채널 | close(done) 한 번 |
| 동시 N개 제한 | 버퍼 채널 | 자리를 센다 |
| 공유 map | Mutex |
데이터가 그대로 |
| 카운터 하나 | sync/atomic |
원자 연산 |
표를 곧이곧대로 읽지는 말자. RWMutex는 읽기가 많다고 늘 더 빠르지 않고, sync.Map도 일반 맵의 기본 대체재가 아니다. 보호할 상태와 접근 패턴부터 정하고, 성능이 문제가 되면 실제 부하로 재서 고른다.
넷째 줄의 공유 map을 채널로 짜 보면 무조건 채널이 왜 곤란한지 보인다.
✕캐시를 채널로
type getReq struct { key string reply chan int}
go func() { // 캐시를 소유하는 고루틴 for r := range reqs { r.reply <- cache[r.key] }}()
reply := make(chan int)reqs <- getReq{"k", reply}v := <-reply읽기 하나에 채널 두 개와 고루틴 전환 두 번이 든다.
✓캐시는 제자리에
mu.RLock()v := cache["k"] // 읽기는 여럿이 동시에mu.RUnlock()데이터는 움직이지 않고 접근 순서만 줄 세운다.
왼쪽은 움직이지 않는 데이터를 움직이는 척 시킨 코드다. 캐시를 소유한 고루틴이 죽으면 어떻게 하느냐는 새 문제까지 생긴다. 표준 라이브러리도 net/http 곳곳과 sync.Map 안에서 뮤텍스를 쓴다1.
🧪 직접 해보기
채널로 보낸 슬라이스도 송신자가 고칠 수 있다
이 글의 실행 결과는 모두 이 맥(darwin/arm64)에서 Go 1.24.7로 돌린 값이다. 그림의 채널 칸에 값이 넘어간다고 썼지만, 채널 송신이 보장하는 건 송신 전에 한 쓰기를 받은 쪽이 볼 수 있다는 동기화 관계다. 보낸 뒤 송신자가 그 데이터를 더 만지지 않는다는 건 Go가 강제하지 않는다. 슬라이스를 보내 보면 바로 드러난다.
package main
import "fmt"
func main() { packets := make(chan []byte, 1)
data := []byte("go") packets <- data // 슬라이스를 채널로 보낸다 data[0] = 'n' // 보낸 뒤에도 송신자가 같은 배열에 쓸 수 있다 fmt.Println("공유:", string(<-packets))
copied := append([]byte(nil), data...) packets <- copied // 이번엔 복사본을 보낸다 data[0] = 'x' fmt.Println("복사:", string(<-packets)) fmt.Println("원본:", string(data))}$ go run ./chown공유: no복사: no원본: xo첫 줄에 go 대신 no가 찍혔다. 채널로 보낸 건 슬라이스 헤더이고, 헤더가 가리키는 배열까지 복제되지는 않았다. 슬라이스 편에서 본 “헤더만 복사된다”가 채널 경계에서도 그대로였다. 둘째 줄은 복사본을 보냈으니 이후 수정이 닿지 않았다.
이 실험은 고루틴 하나에서 순서대로 돌아서 데이터 레이스는 아니다. 두 고루틴으로 나눠 한쪽은 그 배열을 고치고 다른 쪽은 읽게 하면 레이스가 된다. 채널 송신이 그 뒤의 공동 수정까지 안전하게 만들어 주지는 않는다.
그래서 실무에서는 셋 중 하나를 정해 둔다. 보낸 뒤 송신자가 다시 손대지 않는 소유권 이전, 필요한 범위만 떼어 보내는 복사, 같은 잠금 아래에서만 만지는 공유다. 소유권 이전을 골랐다면 그건 코드와 API의 약속이고 컴파일러가 지켜 주지 않는다. 원소가 포인터나 맵을 담은 구조체라면 복사할 범위도 그만큼 넓어진다.
close는 송신이 끝났다는 신호다
버퍼 없는 채널의 송신은 받는 쪽이 와야 진행한다. 버퍼가 있으면 빈자리가 있는 동안은 기다리지 않고 넣고, 가득 차면 다시 기다린다. 그래서 용량을 늘리면 대기 공간이 늘 뿐 받는 쪽이 빨라지지는 않는다.
받는 쪽이 느릴 때 보내는 쪽을 기다리게 하는 이 흐름을 역압력(backpressure)이라고 부른다. 다만 일을 보낼 때마다 새 고루틴을 띄워 그 안에서 기다리게 하면, 기다리는 고루틴 수는 계속 늘 수 있다. 제한하려는 게 큐 길이인지, 실행 중인 작업 수인지, 고루틴 수인지부터 나눠 본다.
close(ch)는 버퍼를 지우거나 받는 쪽을 끝내는 명령이 아니다. 이 채널로 더 보내지 않는다는 신호다. 이미 들어간 값은 순서대로 꺼낼 수 있고, 다 꺼낸 뒤에야 원소 타입의 제로값과 false가 나온다. chan int라면 실제로 보낸 0과 구분하려고 v, ok := <-ch로 받는다.
상태 셋(nil, 열림, 닫힘)과 연산 셋(보내기, 받기, 닫기)을 한 판에 놓으면 이렇게 된다.
🧪 직접 해보기
아홉 칸 중 셋이 panic, 둘이 영구 블록이었다
package main
import ( "fmt" "time")
func try(name string, f func()) { defer func() { if r := recover(); r != nil { fmt.Printf("%-17s → panic: %v\n", name, r) } }() f()}
func main() { c := make(chan int, 1) c <- 7 close(c) v, ok := <-c fmt.Println("closed recv →", v, ok) // 버퍼에 남은 값 v, ok = <-c fmt.Println("closed recv again →", v, ok) // zero값, 블록 안 함
try("closed send", func() { c <- 1 }) try("double close", func() { close(c) })
var n chan int // nil 채널 try("nil close", func() { close(n) }) select { case <-n: case <-time.After(200 * time.Millisecond): fmt.Println("nil recv → 200ms 지나도 블록 (영구)") }}$ go run ./chrulesclosed recv → 7 trueclosed recv again → 0 falseclosed send → panic: send on closed channeldouble close → panic: close of closed channelnil close → panic: close of nil channelnil recv → 200ms 지나도 블록 (영구)닫힌 채널에서 처음 받은 값은 버퍼에 남아 있던 7이었고 ok도 true였다. 그다음부터는 0과 false가 블록 없이 바로 나왔다. nil 채널 수신은 200ms가 지나도 풀리지 않았다.
panic 셋은 닫힌 채널에 보내거나, 닫힌 채널을 또 닫거나, nil 채널을 닫을 때 났다. 셋 다 닫는 쪽이 순서를 어긴 결과라서, 닫는 책임은 보내는 쪽에 둔다. 보내는 쪽이 여럿이면 각자 닫을 수 없으니, 모든 송신이 끝난 걸 확인할 수 있는 조정자 하나가 닫는다. 받는 쪽이 임의로 닫으면 남은 송신자의 다음 송신이 panic으로 끝나고, 닫는 순간 보내려고 기다리던 송신자도 panic한다.
채널은 메모리를 풀려고 닫는 자원도 아니다. 받는 쪽에 송신의 끝을 알려야 할 때만 닫는다.
nil 채널의 영구 블록은 일부러 쓰기도 한다. select에서 어떤 case를 잠시 끄고 싶으면 그 채널 변수에 nil을 넣는다. 그 case는 다시 채널을 넣을 때까지 선택되지 않는다.
버퍼 채널로 동시 실행 수를 센다
채널이 잘 맞는 자리가 하나 더 있다. 외부 API를 동시에 10개까지만 부르고 싶다면 버퍼 10짜리 채널 하나로 자리 10개를 센다. 자리를 얻을 때 넣고, 반납할 때 뺀다.
🧪 직접 해보기
작업 100개를 동시에 10개씩 돌리니 510ms 걸렸다
package main
import ( "fmt" "sync" "sync/atomic" "time")
func main() { var running, peak atomic.Int64 // (측정용) 동시 실행 수와 그 최고치 sem := make(chan struct{}, 10) // 자리 10개 var wg sync.WaitGroup start := time.Now() for i := 0; i < 100; i++ { wg.Add(1) sem <- struct{}{} // 자리 획득 — 10개가 차 있으면 여기서 대기 go func() { defer wg.Done() defer func() { <-sem }() // 자리 반납 n := running.Add(1) for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() { } time.Sleep(50 * time.Millisecond) running.Add(-1) }() } wg.Wait() fmt.Printf("작업 100개, 동시 실행 최고치: %d, 총 %v\n", peak.Load(), time.Since(start).Round(10*time.Millisecond))}$ go run ./sem작업 100개, 동시 실행 최고치: 10, 총 510ms최고치가 정확히 10이었다. 50ms짜리 작업이 10개씩 열 번 돌았으니 계산상 500ms이고, 세 번 돌려 세 번 모두 510ms가 나왔다. 뮤텍스로 같은 일을 하려면 카운터와 조건 변수를 직접 짜야 한다.
이 코드가 무엇을 제한하는지는 정확히 알고 쓴다. sem <- struct{}{}를 고루틴 밖에서 하니 실행 수와 고루틴 수가 함께 10으로 묶인다. 자리 얻기를 고루틴 안으로 옮기면 실행은 10개로 유지되지만, 기다리는 고루틴은 입력 수만큼 생긴다. 처음부터 작업자 고루틴을 N개만 만들고 채널로 일을 흘려보내는 작업자 풀도 있다. 그쪽은 고루틴 수가 입력 크기에 비례하지 않는다. 셋 중 무엇을 고를지는 무엇을 제한하고 싶은지로 정한다.
고루틴은 GC가 치우지 않는다
먼저 선을 하나 그어 두자. 서버가 요청을 기다리거나 작업자 풀이 다음 일을 기다리는 건 정상이다. 기대한 수명 안에서 기다리고 필요할 때 멈출 수 있다면, 오래 살아 있다는 이유만으로 누수라고 부르지 않는다.
문제는 이미 끝났어야 할 작업이 진행할 상대도, 끝낼 길도 없이 남는 경우다. 호출자는 결과를 포기했는데 작업자가 ch <- v에서 계속 기다리면 그 고루틴은 요청의 수명을 넘겨 남는다. 그 고루틴의 스택과 스택이 가리키는 객체도 함께 남는다.
종료 경로가 없는 블록이 곧 누수다. 고루틴 스택은 작게 시작하지만2, 요청마다 하나씩 남으면 그만큼 계속 쌓인다.
누수는 데드락과도 다르다. 프로그램 일부만 막혀 있고 다른 고루틴은 요청을 계속 처리하니 all goroutines are asleep - deadlock! 메시지는 나오지 않는다. CPU 프로파일에도 조용하다. 막힌 고루틴은 계산을 하고 있지 않기 때문이다. CPU는 한가한데 메모리만 는다면 이쪽을 먼저 의심한다.
🧪 직접 해보기
요청 1000건에 고루틴 1000개가 남았다
가장 흔한 모양을 재현했다. 300ms 걸리는 백엔드를 고루틴으로 부르고, 호출자는 100ms만 기다린다. 뒤에서 프로파일을 읽으려고 net/http/pprof도 같이 띄웠다.
package main
import ( "context" "fmt" "net/http" _ "net/http/pprof" // /debug/pprof/* 엔드포인트 등록 "runtime" "sync" "time")
func fetch() int { time.Sleep(300 * time.Millisecond); return 42 } // 느린 백엔드
// 누수: 호출자가 포기하면 send에서 영원히 블록func handleLeaky(ctx context.Context) (int, error) { ch := make(chan int) go func() { ch <- fetch() }() // 받는 쪽이 사라지면 여기서 영원히 멈춘다 select { case v := <-ch: return v, nil case <-ctx.Done(): // 100ms에 호출자는 떠난다 return 0, ctx.Err() }}
// 수정: 버퍼 1 — 받는 쪽이 없어도 send가 끝난다func handleFixed(ctx context.Context) (int, error) { ch := make(chan int, 1) go func() { ch <- fetch() }() select { case v := <-ch: return v, nil case <-ctx.Done(): return 0, ctx.Err() }}
// 100ms 타임아웃 요청 1000건을 동시에 보내고, 1초 뒤 고루틴 수를 센다.func load(name string, handle func(context.Context) (int, error)) { before := runtime.NumGoroutine() var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() handle(ctx) }() } wg.Wait() time.Sleep(time.Second) // 느린 백엔드가 다 끝날 시간 fmt.Printf("%-5s 요청 1000건 후 고루틴: %d → %d\n", name, before, runtime.NumGoroutine())}
func main() { go http.ListenAndServe("localhost:6060", nil) // 운영에선 내부망에만 time.Sleep(100 * time.Millisecond) load("leaky", handleLeaky) load("fixed", handleFixed) select {} // 프로파일을 볼 수 있게 계속 띄워 둔다}$ go run ./timeoutleaky 요청 1000건 후 고루틴: 2 → 1002fixed 요청 1000건 후 고루틴: 1002 → 1002첫 줄의 2는 main과 pprof 서버다. 호출자는 100ms에 ctx.Done()으로 빠져나갔지만, 고루틴은 300ms 뒤 ch <- fetch()에서 받아 줄 쪽이 없어 영원히 멈췄다. 초당 100건이면 하루에 860만 개가 쌓인다. 둘째 줄은 앞의 1000개가 남은 채로 시작해서 1002이고, 새로 남은 건 없었다.
고치는 길은 둘이다. handleFixed처럼 버퍼를 1로 주면 받는 쪽이 없어도 송신이 끝나서 블록 자체가 없어진다. 버퍼를 쓸 수 없거나 보낼 값이 여러 개라면, 고루틴 안의 송신도 select로 감싸 ctx.Done() case를 둔다.
버퍼를 키우는 처방에는 조건이 붙는다. 작업 하나가 값 하나만 보내고 계산이 반드시 끝나는 구조라야 용량 1로 대기가 사라진다. 값을 계속 내는 생산자라면 유한한 버퍼도 언젠가 차니, 버퍼가 종료 책임을 대신하지는 못한다.
기다리는 송신자를 깨우려고 결과 채널을 닫아서도 안 된다. 닫힌 채널에 보내면 panic이다. 종료 신호는 결과 채널과 다른 채널로 두고, 그 채널을 한 번만 닫는다.
취소에 우선순위가 있는 것도 아니다. select에서 취소와 작업 수신이 동시에 준비되면 Go는 둘 중 하나를 무작위로 고른다. 긴 작업이라면 받은 직후 ctx.Err()를 한 번 더 보기도 하는데, 그 직후에 취소될 수도 있다. 취소 순간부터 작업이 한 번도 일어나지 않는다는 보장은 어느 쪽도 해 주지 않는다.
WaitGroup을 쓴다면 wg.Add(1)은 고루틴을 시작하기 전에 부르고, defer wg.Done()은 고루틴 첫 줄에 둔다3. 그래야 정상 종료와 취소 종료 모두에서 Done이 불린다.
누수는 모양이 넷이지만 원인은 같다.
코드 리뷰에서 고루틴을 띄우는 줄을 보면 이 고루틴이 어떻게 끝나는지부터 묻는다. 고루틴을 시작한 쪽이 종료를 책임지고, close는 보내는 쪽이 한다.
select에 ctx.Done()이 있는데 왜 안 끝나나
보낼 값을 계산하는 식이 select에 들어서기 전에 먼저 평가되기 때문이다. case out <- compute():라고 쓰면 compute()가 끝나야 select가 어느 case를 고를지 보기 시작한다. 계산을 앞줄로 옮겨도 사정은 같아서, 취소를 알아야 하는 쪽은 계산 자신이다.
select {case out <- compute(): // 취소 case가 있으니 안전해 보인다case <-ctx.Done():}Go 명세는 select에 들어설 때 모든 case의 채널과 보낼 값을 소스 순서대로 한 번씩 평가한다고 정해 둔다. 세 가지를 나란히 돌려 봤다.
🧪 직접 해보기
취소는 100ms인데 둘은 700ms 만에 돌아왔다
package main
import ( "context" "fmt" "time")
// 취소를 전혀 모르는 계산.func compute() int { time.Sleep(700 * time.Millisecond) return 42}
// 취소를 확인하며 진행하는 계산.func computeCtx(ctx context.Context) (int, error) { for i := 0; i < 70; i++ { select { case <-ctx.Done(): return 0, ctx.Err() case <-time.After(10 * time.Millisecond): } } return 42, nil}
// ① 취소 case 가 있지만, select 진입 시 송신 값 표현식이 먼저 평가된다.func inSelect(ctx context.Context, out chan<- int) time.Duration { start := time.Now() select { case out <- compute(): case <-ctx.Done(): } return time.Since(start)}
// ② 계산을 select 밖으로 뺀다.func beforeSelect(ctx context.Context, out chan<- int) time.Duration { start := time.Now() v := compute() select { case out <- v: case <-ctx.Done(): } return time.Since(start)}
// ③ 계산 자체가 취소를 받아들인다.func ctxAware(ctx context.Context, out chan<- int) time.Duration { start := time.Now() v, err := computeCtx(ctx) if err != nil { return time.Since(start) } select { case out <- v: case <-ctx.Done(): } return time.Since(start)}
func run(name string, f func(context.Context, chan<- int) time.Duration) { out := make(chan int) // 받는 사람은 없다 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() d := f(ctx, out) fmt.Printf("%-28s %v 만에 반환\n", name, d.Round(10*time.Millisecond))}
func main() { fmt.Println("취소 시각은 셋 다 100ms") run("① select 안에서 계산", inSelect) run("② 계산을 앞줄로 이동", beforeSelect) run("③ 계산이 취소를 확인", ctxAware)}$ go run ./sel취소 시각은 셋 다 100ms① select 안에서 계산 700ms 만에 반환② 계산을 앞줄로 이동 700ms 만에 반환③ 계산이 취소를 확인 100ms 만에 반환①과 ②가 같았다. 계산을 select 밖으로 빼도 700ms를 그대로 기다렸다. 취소를 알아야 하는 쪽은 select가 아니라 compute 자신이다. ③처럼 계산이 적당한 단위로 취소를 확인해야 100ms에 끝난다.
실무로 옮기면 네트워크 요청은 ctx를 받는 API에 그대로 넘기고, 긴 계산은 반복 단위마다 ctx.Err()를 확인한다. 취소를 지원하지 않는 호출을 새 고루틴으로 감싸 호출자만 먼저 돌려보내는 건 해결이 아니다. 감싼 호출이 계속 남아서, 앞에서 본 누수 모양이 그대로 생긴다.
그래서 코드를 읽을 때는 select가 있는지보다 그 앞과 안에서 어떤 연산이 막힐 수 있는지를 따라간다.
누수 자리는 goroutine 프로파일로 찾는다
서비스 메모리가 서서히 늘고 고루틴 누수가 의심되면 goroutine 프로파일을 시간차를 두고 두 번 떠서 비교한다. 시간이 갈수록 늘기만 하는 스택이 범인이고, 프로파일은 그 고루틴이 어느 줄에서 멈췄는지까지 알려 준다.
앞의 timeout 프로그램은 net/http/pprof를 불러 두었으니 바로 읽을 수 있다. 운영에서는 이 포트를 내부망에만 연다. 경로가 짧게 찍히도록 -trimpath로 빌드했다.
$ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -3goroutine profile: total 10041000 @ 0x100a61da8 0x1009fb628 0x1009fb258 0x100c126e0 0x100a69b64# 0x100c126df main.handleLeaky.func1+0x3f examples/timeout/main.go:181000 @는 같은 스택에 고루틴 1000개가 있다는 뜻이고, 아래 main.go:18이 go func() { ch <- fetch() }() 줄이다. 운영에서는 5~10분 간격으로 두 장을 떠서 늘어난 스택만 본다. 두 번 뜨는 건 HTTP 서버의 연결 처리처럼 정상적으로 기다리는 고루틴과 구분하기 위해서다. 정상 대기는 개수가 일정하고, 누수는 계속 는다.
curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' > a.txtsleep 600curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' > b.txtdiff <(grep -E '^[0-9]+ @' a.txt) <(grep -E '^[0-9]+ @' b.txt)서버 없이 로컬에서도 연습할 수 있다. 수신자 없는 송신을 세 개씩 두 번 만들고, 그때마다 runtime/pprof로 스냅샷을 파일에 남기는 프로그램이다.
package main
import ( "context" "flag" "fmt" "os" "path/filepath" "runtime" "runtime/pprof" "time")
// 수신자가 사라진 송신. 빠져나갈 길이 없다.func leakySend(out chan<- int, ready chan<- struct{}) { ready <- struct{}{} out <- 42}
// 같은 일에 취소 경로와 종료 신호를 더한 형태.func fixedSend(ctx context.Context, out chan<- int, ready chan<- struct{}, done chan<- struct{}) { defer close(done) ready <- struct{}{} select { case out <- 42: case <-ctx.Done(): }}
func snapshot(dir, name string) { os.MkdirAll(dir, 0o755) p := pprof.Lookup("goroutine") f, _ := os.Create(filepath.Join(dir, name+".pprof")) p.WriteTo(f, 0) // 분석 도구용 f.Close() t, _ := os.Create(filepath.Join(dir, name+".txt")) p.WriteTo(t, 2) // 사람이 읽는 스택 덤프 t.Close()}
func main() { mode := flag.String("mode", "leak", "leak 또는 fixed") flag.Parse() dir := filepath.Join("profiles", *mode)
snapshot(dir, "before") fmt.Printf("시작 전 : %d\n", runtime.NumGoroutine())
for round := 1; round <= 2; round++ { if *mode == "leak" { for i := 0; i < 3; i++ { out := make(chan int) ready := make(chan struct{}) go leakySend(out, ready) <-ready } } else { for i := 0; i < 3; i++ { ctx, cancel := context.WithCancel(context.Background()) out := make(chan int) ready := make(chan struct{}) done := make(chan struct{}) go fixedSend(ctx, out, ready, done) <-ready cancel() <-done // 취소 요청이 아니라 종료를 확인한다 } } time.Sleep(30 * time.Millisecond) snapshot(dir, fmt.Sprintf("after-%d", round)) fmt.Printf("%d번째 세 작업 뒤 : %d\n", round, runtime.NumGoroutine()) }}ready는 실험용 시작 신호다. main이 이 신호를 반드시 받으니 신호 송신이 남지는 않는다. 다만 신호를 받았다고 작업자가 이미 다음 줄에서 멈췄다는 보장은 없어서, 스냅샷 전에 30ms를 기다린다. 관측 시점을 늦추는 여유이고 종료를 보장하는 장치는 아니다.
$ go build -trimpath -o leakdemo . && ./leakdemo -mode=leak시작 전 : 11번째 세 작업 뒤 : 42번째 세 작업 뒤 : 7$ ./leakdemo -mode=fixed시작 전 : 11번째 세 작업 뒤 : 12번째 세 작업 뒤 : 1이제 전후 프로파일의 차이만 본다. -base는 앞 프로파일을 빼서 그 뒤에 늘어난 것만 남긴다.
$ go tool pprof -top -nodecount=8 -base profiles/leak/before.pprof profiles/leak/after-2.pprof | grep -v -e '^File' -e '^Time'Type: goroutineShowing nodes accounting for 6, 75.00% of 8 total flat flat% sum% cum cum% 6 75.00% 75.00% 6 75.00% runtime.gopark 0 0% 75.00% 6 75.00% main.leakySend 0 0% 75.00% 6 75.00% runtime.chansend 0 0% 75.00% 6 75.00% runtime.chansend1두 줄만 읽으면 된다. flat은 그 위치 자체에 걸린 샘플이고, cum은 그 함수 아래 호출까지 합친 샘플이다. runtime.gopark의 flat 6은 늘어난 여섯 개가 런타임 대기 지점에 있다는 뜻이고, main.leakySend의 cum 6은 그 여섯 개가 우리 송신 함수를 거친다는 뜻이다. runtime.gopark가 맨 위에 있다고 런타임을 고칠 일은 아니다.
여기서 비교한 건 goroutine 프로파일이라 샘플 하나가 고루틴 하나다. CPU 프로파일처럼 시간을 세지 않는다. 동기화를 기다리느라 쓴 시간을 보려면 블록 프로파일이 따로 있다.
텍스트 덤프는 분석 입력으로 넣지 않는다. 위 프로그램은 debug=0의 .pprof와 debug=2의 .txt를 함께 남긴다. 비교 명령에는 .pprof를 넣고, 텍스트는 대기 상태와 생성 위치를 눈으로 읽는 데 쓴다.
$ grep -c 'main.leakySend' profiles/leak/after-2.txt6$ grep -m1 -B1 -A4 'main.leakySend' profiles/leak/after-2.txtgoroutine 34 [chan send]:main.leakySend(...) leakdemo/main.go:17created by main.main in goroutine 1 leakdemo/main.go:54 +0x464[chan send]가 대기 상태이고, main.go:17이 out <- 42 줄, main.go:54가 고루틴을 띄운 줄이다. 고루틴 ID나 줄 번호는 실행과 편집에 따라 바뀌니 외울 건 아니다. 수정 모드의 덤프에는 main.fixedSend가 한 건도 없었다.
Go 1.27의 goroutineleak 프로파일
Go 1.26에서 실험으로 들어온 goroutineleak 프로파일이 Go 1.27에서 정식 기능이 됐다. GC가 도달 가능성으로 누수를 찾는다. 블록된 고루틴이 기다리는 채널이나 락에 실행 중인 고루틴 어디서도 닿을 수 없으면, 그 고루틴은 영영 깨어날 수 없다고 판정한다. 같은 timeout 프로그램을 Go 1.27.0으로 빌드해 이 엔드포인트도 열어 봤다.
$ curl -s 'localhost:6060/debug/pprof/goroutineleak?debug=1' | head -3goroutineleak profile: total 10011000 @ 0x100a1c6d8 0x1009ad8a4 0x1009ad4b8 0x100bc8010 0x100a236f4# 0x100bc800f main.handleLeaky.func1+0x3f examples/timeout/main.go:18goroutine 프로파일은 정상 대기까지 1004개를 다 보여 줬는데, 이쪽은 누수 1000개를 바로 골라냈다. total의 나머지 1건은 스택이 빈 항목으로 찍혔는데, 왜 그런지는 아직 모르겠다. 이름과 달리 GC가 누수 고루틴을 회수하지는 않고, 찾아서 보여 주기만 한다. 전역 변수나 실행 중인 고루틴의 지역 변수로 닿는 채널에 걸린 누수는 놓칠 수 있다고 릴리스 노트에 적혀 있으니, 두 장 비교도 계속 쓸모가 있다.
어디서부터 볼지는 증상으로 정한다.
| 증상 | 먼저 볼 것 |
|---|---|
| 메모리가 는다 | runtime.NumGoroutine() 추이. 같이 늘면 고루틴 누수, 아니면 힙 쪽 |
| 고루틴이 는다 | 프로파일 두 장의 차이, 늘어나는 스택, 그 줄을 풀어 줄 상대 |
| 한 요청만 안 끝난다 | go test -race -timeout. 시간을 넘기면 고루틴 덤프가 전부 찍힌다 |
고친 뒤에는 종료 신호로 검사한다
누수를 고쳤으면 다시 생기지 않게 테스트를 남긴다. 성공 조건을 짧은 Sleep이나 전체 고루틴 수 일치로 두지 않았다. 둘 다 다른 이유로 흔들린다. 대신 종료 신호 채널이 닫히는 사건으로 판단한다.
🧪 직접 해보기
취소 분기를 지우자 테스트가 2초 만에 실패했다
package main
import ( "context" "testing" "time")
// 수신자가 없을 때: 취소하면 종료 신호가 닫히는가?func TestCancelEndsSender(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) out := make(chan int) ready := make(chan struct{}) done := make(chan struct{})
go fixedSend(ctx, out, ready, done) <-ready cancel()
select { case <-done: case <-time.After(2 * time.Second): // 실패 한계이지 대기 시간이 아니다 t.Fatal("취소 후에도 송신 고루틴이 끝나지 않았다") }}
// 수신자가 있을 때: 값을 받고 나서도 종료 신호가 닫히는가?func TestDeliveryEndsSender(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() out := make(chan int) ready := make(chan struct{}) done := make(chan struct{})
go fixedSend(ctx, out, ready, done) <-ready if v := <-out; v != 42 { t.Fatalf("받은 값=%d, 기대=42", v) } select { case <-done: case <-time.After(2 * time.Second): t.Fatal("전달 후에도 송신 고루틴이 끝나지 않았다") }}$ go test -race -timeout=10s -run EndsSender -v .=== RUN TestCancelEndsSender--- PASS: TestCancelEndsSender (0.00s)=== RUN TestDeliveryEndsSender--- PASS: TestDeliveryEndsSender (0.00s)PASSok leakdemo 1.294s2초 타이머는 테스트가 영원히 멈추지 않게 둔 실패 한계이고, 정상 경로에서는 done이 바로 닫힌다.
검사가 실제로 일을 하는지도 봤다. fixedSend에서 case <-ctx.Done(): 한 줄을 지운 사본을 만들어 다시 돌렸다.
$ go test -race -timeout=10s -run EndsSender .--- FAIL: TestCancelEndsSender (2.00s) main_test.go:23: 취소 후에도 송신 고루틴이 끝나지 않았다FAILFAIL brk 2.390sFAIL종료 경로가 사라지자 테스트가 바로 잡아냈다.
레이스 검사 통과만으로 누수가 없다고 결론 내리지 않았다는 점도 적어 둔다. 종료 신호와 프로파일을 따로 확인했다. 검사마다 알려 주는 범위가 다르다.
| 검사 | 알 수 있는 것 | 알 수 없는 것 |
|---|---|---|
| 프로파일 비교 | 멈춘 스택과 줄 | 상대가 안 오는 이유 |
-race 통과 |
실행한 경로의 레이스 | 모든 실행 순서 |
| 취소 테스트 | 취소 뒤 종료 | 바로 반영됐는지 |
마지막 줄이 실무에서 제일 자주 어긋난다. 계산이 짧고 부작용이 없으면 취소되면 버린다는 계약으로 충분하다. 그런데 이미 저장이나 결제를 실행했다면 취소가 그걸 되돌려 주지 않는다. 재시도, 멱등성, 트랜잭션은 따로 설계한다.
정리
- 건넨 뒤 다시 볼 일이 없으면 채널, 같은 자리를 여럿이 쓰면 뮤텍스를 쓴다. 채널로 슬라이스를 보내도 배열은 공유되니, 소유권 이전·복사·잠금 공유 중 무엇인지 코드에 정해 둔다.
close는 송신의 끝이다. 닫아도 남은 값은 먼저 나오고, panic 셋은 모두 닫는 순서를 어긴 데서 났다. 닫는 건 보내는 쪽이 한 번만 한다.- 버퍼 채널은 자리를 센다. 작업 100개를 10개씩 돌리니 최고치 10, 510ms였다.
- GC는 블록된 고루틴을 치우지 않는다. 종료 경로가 없는 블록이 누수이고, 모양은 넷이다.
select에 취소 case가 있어도 보낼 값 계산이 먼저 끝나야 한다. 취소는 계산 자신이 확인한다.- 찾을 때는 goroutine 프로파일 두 장의 차이를 보고, Go 1.27 이상이면
goroutineleak프로파일도 본다. 프로파일은 어디서 멈췄는지까지 알려 주고, 왜 상대가 안 오는지는 코드를 읽어야 안다.
시리즈를 마치며
네 편을 쓰고 보니 런타임이 대신해 주지 않는 일이 세 가지로 모였다. GOMAXPROCS를 컨테이너의 CPU limit에 맞추는 일, GOMEMLIMIT을 메모리 limit에 맞추는 일, 그리고 고루틴마다 나갈 길을 내 주는 일이다. GOMAXPROCS는 Go 1.25부터 런타임이 CPU quota를 반영하기 시작했지만 limit과 P가 늘 같지는 않았고, GOMEMLIMIT은 Go 1.27에서도 직접 건다. 나갈 길은 Go 1.27의 goroutineleak 프로파일이 일부를 찾아 주게 됐을 뿐, 고루틴이 영원히 기다리는 게 의도인지 버그인지는 여전히 코드가 정한다.
중간의 sync.Pool 편도 같은 자리에 있다. 풀은 할당을 줄여 주지만 반납한 뒤 무엇을 만지면 안 되는지는 알려 주지 않았다. 소유권도 종료도 코드가 지켜야 하는 약속이었다.
참고 자료
- Effective Go · Share by communicating: 채널로 소유권을 넘기는 관용구
- The Go Memory Model · Channel communication: 송신과 수신 사이의 동기화 관계
- The Go Programming Language Specification · Select statements:
select에 들어설 때 채널과 보낼 값을 먼저 평가한다 - The Go Programming Language Specification · Close: 닫힌 채널에 보내기와 다시 닫기는 panic, 받기는 남은 값 뒤 제로값
- runtime/pprof:
goroutine프로파일과debug단계 - net/http/pprof:
/debug/pprof/*엔드포인트 - Go 1.26 Release Notes · Experimental goroutine leak profile: GC 도달 가능성으로 누수를 찾는 원리와 예제
- Go 1.27 Release Notes · Goroutine leak profile: 정식 기능이 됐고, 전역 변수로 닿는 누수는 놓칠 수 있다
- Go 1.25 Release Notes · sync:
WaitGroup.Go - Data Race Detector · How To Use: 실행한 경로에서 일어난 레이스만 찾는다