배포 후에야 터지는 버그들 — 데이터 레이스와 고루틴 누수
루프 변수 캡처 — Go 1.22 전후로 정답이 다르다
가장 유명한 Go 동시성 버그부터 시작한다. Go 1.21 이하에서는 다음 코드가 대부분의 사람이 기대하는 대로 동작하지 않는다.
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // Go 1.21 이하: 0, 1, 2가 아니라 3, 3, 3이 나올 수 있다
}()
}이유는 Go 1.21까지 for 루프의 i가 반복마다 새로 만들어지는 게 아니라 루프
전체에서 하나의 변수를 재사용했기 때문이다. 클로저는 그 변수 자체를 참조하므로,
고루틴이 실제로 실행되는 시점(루프가 이미 다 끝난 뒤일 수도 있다)에는 i가 최종값인
3이 되어 있다. 정확히 언제 어떤 값이 찍힐지는 스케줄링 타이밍에 달려 있어서 재현이
들쭉날쭉하다는 게 더 고약하다 — 로컬에서는 안 터지다가 부하가 몰리는 프로덕션에서만
증상이 보이는 전형적인 케이스다.
Go 1.22부터는 이 문제가 언어 차원에서 해결됐다. for 루프 변수가 반복마다 새로
선언되는 것으로 스펙이 바뀌었다. 즉 Go 1.22 이상에서는 위 코드가 기대대로 0, 1, 2를
찍는다(순서는 여전히 보장되지 않지만 값 자체는 각 반복의 값이다). go.mod의
go 1.22 이상 지시자가 있어야 이 새 동작이 적용된다는 점은 기억해 둘 만하다 —
오래된 모듈을 그대로 둔 채 툴체인만 올리면 예전 동작이 유지될 수 있다.
// Go 1.21 이하에서 안전하게 쓰려면 파라미터로 값을 복사해서 넘긴다
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}이 챕터의 나머지 버그들은 버전이 올라가도 여전히 유효한, 근본적으로 다른 종류의 문제다.
고루틴 누수 — 아무도 기다리지 않는 고루틴
고루틴이 영원히 끝나지 않는 채널 연산에서 블로킹된 채로 멈춰 있으면, 그 고루틴이 쓰던 스택과 그 고루틴이 참조하던 모든 객체가 GC 대상에서 제외된다. 메모리 릭이다. 가장 흔한 패턴은 이렇다.
func leaky() <-chan int {
ch := make(chan int)
go func() {
result := computeExpensive()
ch <- result // 받는 사람이 없으면 여기서 영원히 블로킹
}()
return ch
}
func main() {
ch := leaky()
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(1 * time.Second):
fmt.Println("타임아웃, 포기")
// 하지만 leaky() 안의 고루틴은 여전히 ch <- result에서 대기 중이다!
}
}타임아웃으로 "포기"했다고 해서 leaky() 안의 고루틴이 사라지는 게 아니다. 그
고루틴은 여전히 ch <- result에서 아무도 받아주기를 기다리고 있다 — 영원히. 이런
누수는 한두 번으로는 티가 안 나지만, 초당 수백 번씩 이 함수가 호출되는 서버라면
좀비 고루틴이 계속 쌓이다가 결국 메모리 부족으로 죽는다. 배포하고 몇 시간, 며칠이
지나서야 증상이 나타나는 이유가 이거다.
해법은 채널에 버퍼를 주거나(받는 사람이 없어도 송신이 막히지 않도록), 송신 쪽도
ctx.Done()을 함께 감시하게 만드는 것이다.
func fixed(ctx context.Context) <-chan int {
ch := make(chan int, 1) // 버퍼 1 — 받는 사람이 없어도 한 번은 보낼 수 있다
go func() {
result := computeExpensive()
select {
case ch <- result:
case <-ctx.Done(): // 호출자가 포기했으면 이 고루틴도 같이 포기
}
}()
return ch
}데이터 레이스 — 채널 없이 값을 공유할 때
데이터 레이스는 두 개 이상의 고루틴이 락 없이 같은 메모리를 동시에 읽고 쓸 때 생긴다. 흔한 오해와 달리, 데이터 레이스는 "가끔 이상한 값이 나오는" 정도로 끝나지 않는다. Go 메모리 모델상 데이터 레이스가 있으면 컴파일러 최적화 결과가 미정의 동작이 될 수 있다 — 변수가 아예 안 보이거나, 절반만 쓰인 값이 읽히거나, 다른 플랫폼에서는 재현조차 안 될 수 있다.
var counter int
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 레이스: 읽기-증가-쓰기가 원자적이지 않다
}()
}
wg.Wait()
fmt.Println(counter) // 1000이 아닐 수 있다
}counter++는 하나의 명령어가 아니라 "읽기 → 1 더하기 → 쓰기" 세 단계다. 두 고루틴이
동시에 같은 값을 읽고 각자 더한 뒤 쓰면, 한쪽의 증가분이 사라진다. 고칠 방법은
간단하다 — sync.Mutex로 감싸거나, 단순 카운터라면 sync/atomic 패키지를 쓴다.
var counter atomic.Int64 // Go 1.19+
counter.Add(1) // 원자적데드락 — 서로가 서로를 기다림
버퍼 없는 채널 두 개를 교차로 주고받게 짜면 데드락이 나기 쉽다.
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1 // ch1에서 받기를 기다림
ch2 <- 1
}()
ch1 <- 1 // 여기서 블로킹... 그런데 위 고루틴도 아직 시작 안 했을 수도
<-ch2
}이 예제는 실행 타이밍에 따라 성공할 수도, fatal error: all goroutines are asleep - deadlock!로 죽을 수도 있다. 다행히 Go 런타임은 모든 고루틴이 동시에 블로킹된
상태를 감지하면 프로그램을 즉시 죽이고 이 메시지를 찍는다 — 조용히 멈춰버리는 대신
확실하게 실패한다는 점에서 그나마 다행이다. 다만 이건 "전체가 멈췄을 때"만 잡힌다.
고루틴 100개 중 2개만 서로 물려서 멈춰 있고 나머지 98개는 잘 돌고 있다면 런타임은
아무 것도 감지하지 못한다 — 그 2개는 영원히 멈춘 채로 프로그램은 계속 살아있는
것처럼 보인다. 다음 챕터에서 이런 부분적 데드락을 어떻게 찾는지 다룬다.