Go 경쟁 상태 탐지기(Data Race Detector)로 동시성 버그 잡기
Go 경쟁 상태 탐지기(Data Race Detector)로 동시성 버그 잡기
고루틴으로 동시성을 다루다 보면 꼭 마주치는 골칫거리가 **데이터 경쟁(data race)**이에요. 두 고루틴이 같은 변수에 동시에 접근하는데 그중 하나라도 **쓰기(write)**라면 경쟁이 생기고, 프로그램이 크래시하거나 메모리가 손상될 수 있어요. 여기서 무서운 점은 멀티코어에서 실제로 일어나기 전까지는 눈에 잘 안 띈다는 거예요. 그래서 Go는 내장 데이터 경쟁 탐지기를 제공해서, 이런 버그를 실행 중에 잡아내도록 도와줘요.
이 페이지는 공식 "Data Race Detector" 문서를 따라가며, -race 플래그로 경쟁을 어떻게 발견하는지, 보고서를 어떻게 읽는지, 그리고 흔한 경쟁 패턴 몇 가지를 살펴볼게요.
데이터 경쟁이란
데이터 경쟁은 동시 시스템에서 가장 흔하고 디버깅하기 어려운 버그 중 하나예요. 두 고루틴이 같은 변수에 동시에 접근하는데, 그중 적어도 하나가 쓰기인 경우에 경쟁이 발생해요 (자세한 정의는 The Go Memory Model 참고).
다음은 크래시와 메모리 손상을 일으킬 수 있는 경쟁의 예예요. 두 고루틴이 m 맵에 동시에 쓰기 때문에 충돌해요.
func main() {
c := make(chan bool)
m := make(map[string]string)
go func() {
m["1"] = "a" // First conflicting access.
c <- true
}()
m["2"] = "b" // Second conflicting access.
<-c
for k, v := range m {
fmt.Println(k, v)
}
}
사용법: -race 플래그
이런 버그를 진단하기 위해 Go는 내장 데이터 경쟁 탐지기를 갖고 있어요. go 명령에 -race 플래그를 붙이면 돼요.
$ go test -race mypkg // to test the package
$ go run -race mysrc.go // to run the source file
$ go build -race mycmd // to build the command
$ go install -race mypkg // to install the package
탐지기가 경쟁을 찾으면 보고서를 출력해요. 보고서에는 충돌하는 접근들의 스택 트레이스와, 관련된 고루틴이 어디서 만들어졌는지의 스택이 담겨 유용해요.
옵션과 테스트 제외
GORACE 환경 변수로 탐지기 옵션을 설정할 수 있어요. log_path(기본 stderr)는 보고서를 파일로 내보내게 하고, exitcode(기본 66)는 경쟁 감지 후 종료 상태를, history_size(기본 1)는 고루틴별 접근 이력을 늘려 "failed to restore the stack" 오류를 피하게 해줘요. halt_on_error로 첫 경쟁 보고 후 종료 여부를 정할 수도 있어요.
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race
-race로 빌드하면 go 명령이 추가 빌드 태그 race를 정의해요. 그 태그로 경쟁 탐지 실행 중 특정 코드·테스트를 제외할 수 있어요.
사용 방법
먼저 go test -race로 테스트를 돌려보는 것부터 시작해요. 탐지기는 런타임에 실제 일어난 경쟁만 찾으므로, 실행되지 않은 코드 경로의 경쟁은 못 찾아요. 테스트 커버리지가 부족하면, -race로 빌드한 바이너리를 실제적인 워크로드에서 돌려 더 많은 경쟁을 발견할 수 있어요.
흔한 데이터 경쟁 패턴
루프 카운터 경쟁
아래 코드에서 함수 리터럴 안의 i는 루프가 쓰는 변수와 같아서, 고루틴 안의 읽기가 루프 증가와 경쟁해요. (이 프로그램은 보통 01234가 아니라 55555를 출력해요.)
func main() {
var wg sync.WaitGroup
wg.Add(5)
var i int
for i = 0; i < 5; i++ {
go func() {
fmt.Println(i) // Not the 'i' you are looking for.
wg.Done()
}()
}
wg.Wait()
}
변수의 복사본을 만들어 고치면 돼요.
우연히 공유된 변수
여러 goroutine에서 같은 변수를 쓰다 보면 우연히 공유되는 경우가 있어요. ParallelWrite가 res 채널로 에러를 전달하는데, 고루틴 안에서 실수로 바깥 스코프의 err를 공유하는 전형적인 예가 있어요. 고루틴 안에서 :=로 새 변수를 만들어 고칠 수 있어요.
_, err := f1.Write(data) // new err for the goroutine
보호되지 않은 전역 변수
여러 고루틴에서 호출되면 service 맵에 경쟁이 생기는 코드가 있어요. 같은 맵에 대한 동시 읽기·쓰기는 안전하지 않아요. 접근을 동기화해야 해요.
동기화되지 않은 send와 close
다음 예에서 send와 close는 happens-before 관계를 쓸 수 없어서 동기화되지 않고 동시에 일어나요. Go 메모리 모델에 따르면 채널에 대한 send는 그 채널에서 대응하는 receive가 완료되기 전에 일어나요. 그래서 send와 close를 동기화하려면, close 전에 send가 끝났음을 보장하는 receive 연산을 넣어요.
c := make(chan struct{}) // or buffered channel
go func() { c <- struct{}{} }()
<-c
close(c)
요구 사항과 런타임 오버헤드
탐지기는 cgo가 활성화되어 있고, non-Darwin 시스템에서는 C 컴파일러가 설치되어 있어야 해요. 지원 아키텍처는 linux/amd64·linux/arm64·darwin/amd64·darwin/arm64·windows/amd64 등이에요. Windows에서는 Go 1.21부터 mingw-w64 런타임 8 이상을 포함한 C 컴파일러가 필요해요.
경쟁 탐지 비용은 프로그램마다 다르지만, 전형적으로 메모리 사용이 510배, 실행 시간이 220배 늘 수 있어요. 탐지기는 defer·recover 문당 8바이트를 추가 할당하고, 이것은 그 고루틴이 끝날 때까지 회수되지 않아요. 그래서 오래 사는 고루틴이 defer·recover를 계속 부르면 메모리가 무한정 자랄 수 있어요.
더 알아보기 (Learn more)
- Data Race Detector (원문)
- The Go Memory Model: 경쟁 판정의 근거가 되는 happens-before 정의
- go test 명령어 문서:
-race등 테스트 플래그