본문으로 건너뛰기

메모리 모델과 race detector

이 챕터에서 다루는 것

7-5까지는 "이렇게 하면 안전하다"를 다뤘다. 이 챕터는 반대다. 안전하지 않은 코드가 정확히 무엇인지, 그리고 그것을 어떻게 찾아내는지를 다룬다.

핵심 도구는 -race 플래그 하나다. 그리고 이 챕터에서 가장 중요한 문장은 이것이다.

경합이 있는데도 잘 도는 코드가 경합이 있어서 바로 터지는 코드보다 위험하다.

데이터 경합의 정의

Go 메모리 모델의 정의는 정확하다.

두 고루틴이 같은 메모리 위치에 동시에 접근하고, 그중 적어도 하나가 쓰기이며, 둘 사이에 순서 관계가 없으면 데이터 경합이다.

조건 셋을 다 만족해야 한다.

  • 같은 위치 — 서로 다른 변수는 상관없다.
  • 적어도 하나는 쓰기 — 여럿이 읽기만 하는 것은 안전하다.
  • 순서 관계가 없다 — 이것이 happens-before다.

happens-before

"동시에"의 반대말이 "순서가 있다"이다. Go 메모리 모델은 어떤 연산 쌍에 순서를 보장하는지 명시한다. 실무에서 쓸 만한 목록은 짧다.

앞선 일나중 일
같은 고루틴 안의 앞줄뒷줄
go 문 실행새 고루틴의 첫 줄
채널 송신그 값의 수신 완료
채널 닫기그 채널에서 제로값 수신
버퍼 채널의 k번째 수신k+C번째 송신 완료 (C는 용량)
mu.Unlock()그다음 mu.Lock()의 반환
once.Do(f)f 완료모든 Do 호출의 반환
wg.Go로 띄운 함수의 종료wg.Wait()의 반환

이 표에 없는 경로로 값을 주고받으면 경합이다. time.Sleep은 표에 없다. "충분히 기다렸으니 보이겠지"는 보장이 아니다.

:::info 왜 "보이지 않을" 수 있는가 직관적으로는 "한쪽이 썼으니 나중에 읽으면 보이겠지" 싶다. 그렇지 않은 이유는 셋이다.

  1. 컴파일러가 재배치한다. 단일 고루틴 관점에서 결과가 같으면 순서를 바꿔도 된다.
  2. CPU가 재배치한다. 저장 버퍼, 캐시 일관성 프로토콜, 투기적 실행.
  3. 값을 레지스터에 들고 있는다. 루프 안의 변수는 메모리에 매번 쓰지 않는다.

동기화 연산은 컴파일러와 CPU 양쪽에 **"여기서 순서를 지켜라"**는 장벽을 세운다. 그게 뮤텍스와 채널이 하는 일의 절반이다. 나머지 절반이 상호 배제다. :::

경합이 있어도 잘 도는 코드

examples/07-concurrency/06-race-demo/main.go
// racyCounter는 아무 동기화 없이 공유 변수를 증가시킨다. 데이터 경합이다.
func racyCounter() int {
n := 0
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for range perWork {
n++ // 읽기 + 더하기 + 쓰기. 원자적이지 않다.
}
})
}
wg.Wait()
return n
}

n++는 한 줄이지만 세 연산이다. 두 고루틴이 같은 값을 읽고 각자 1을 더해 쓰면 증가가 하나 사라진다.

:::note 이 파트에서 유일하게 "틀린" 예제다 examples/07-concurrency/06-race-demo일부러 경합을 남겨 둔 코드다. 파일 맨 위에 그렇게 적어 뒀다. 나머지 예제는 전부 올바른 코드이니 헷갈리지 않도록 한다. :::

cd examples/07-concurrency
go run ./06-race-demo
경합 있는 카운터: 50785 (기대값 100000, 차이 49215)
뮤텍스로 보호한 카운터: 100000 (기대값 100000)

이 숫자는 실행할 때마다 다르다. 다섯 번 돌려서 관찰한 값은 40789, 42192, 50475, 58717, 55114였다. 절반 가까이 사라졌다.

이제 무서운 부분이다.

GOMAXPROCS=1 go run ./06-race-demo
경합 있는 카운터: 100000 (기대값 100000, 차이 0)
뮤텍스로 보호한 카운터: 100000 (기대값 100000)

정확히 맞는다. 코어를 하나로 제한하면 진짜 병렬 실행이 없고, n++ 도중에 선점될 확률이 사실상 0이 된다. 코드는 여전히 틀렸지만 증상이 사라진다.

이것이 데이터 경합이 특히 나쁜 이유다.

  • 개발 머신에서는 잘 돈다. 프로덕션의 다른 코어 수에서 깨진다.
  • 부하가 낮으면 잘 돈다. 트래픽이 몰리면 깨진다.
  • 로그를 하나 추가하면 사라진다(타이밍이 바뀌므로).
  • 재현이 안 되니 디버깅이 불가능하다.

-race

Go는 이 문제에 도구로 답했다. 빌드에 -race를 붙이면 컴파일러가 모든 메모리 접근에 계측 코드를 심고, 런타임이 happens-before 관계를 실제로 추적한다.

go run -race ./06-race-demo
go build -race ./...
go test -race ./... # 파트 8에서 다룬다
==================
WARNING: DATA RACE
Read at 0x00c000116038 by goroutine 9:
main.racyCounter.func1()
.../06-race-demo/main.go:23 +0x38

Previous write at 0x00c000116038 by goroutine 7:
main.racyCounter.func1()
.../06-race-demo/main.go:23 +0x48

Goroutine 9 (running) created at:
sync.(*WaitGroup).Go()
/usr/local/go/src/sync/waitgroup.go:238 +0x6c
main.main()
.../06-race-demo/main.go:51 +0x24

Goroutine 7 (finished) created at:
sync.(*WaitGroup).Go()
/usr/local/go/src/sync/waitgroup.go:238 +0x6c
main.main()
.../06-race-demo/main.go:51 +0x24
==================
경합 있는 카운터: 49337 (기대값 100000, 차이 50663)
뮤텍스로 보호한 카운터: 100000 (기대값 100000)
Found 2 data race(s)
exit status 66

(실제로는 WARNING: DATA RACE 블록이 둘 나왔다. 여기서는 첫 블록만 옮기고 경로도 줄였다. 고루틴 번호와 주소는 실행마다 다르다.)

리포트 읽는 순서

1. 헤더 — 무엇과 무엇이 충돌했나. Read at ... by goroutine 9 / Previous write at ... by goroutine 7. 주소가 같다는 것이 "같은 메모리 위치"라는 증거다. 조합은 read/write, write/write, write/read 세 가지가 나온다. read/read는 절대 나오지 않는다 — 정의상 경합이 아니기 때문이다.

2. 각 접근의 스택 — 어느 줄인가. 둘 다 main.go:23, 즉 n++다. 서로 다른 줄인 경우가 더 흔하고, 그때는 두 줄을 같은 뮤텍스로 묶어야 한다는 뜻이다.

3. created at — 그 고루틴을 누가 띄웠나. main.go:51wg.Go 호출 지점이다. 라이브러리 깊숙한 곳의 경합을 추적할 때 이 부분이 결정적이다.

4. 종료 코드. -race로 경합이 발견되면 프로세스 종료 코드가 66이다. CI에서 이것만 봐도 실패로 처리된다.

:::tip 첫 번째 리포트만 고친다 경합 하나가 여러 리포트를 낳는 경우가 많다. 위에서도 하나의 n++가 리포트 2개를 만들었다. 전부 읽으려 하지 말고 첫 번째를 고치고 다시 돌린다. :::

오버헤드

-race는 공짜가 아니다. 공식 문서 기준으로 메모리 사용량 510배, 실행 시간 220배다. 계측이 모든 메모리 접근에 붙기 때문이다.

그래서 이렇게 쓴다.

어디-race
로컬 개발 중 테스트켠다
CI반드시 켠다
성능 벤치마크끈다 (의미 없는 숫자가 나온다)
프로덕션 빌드끈다
재현 안 되는 프로덕션 버그카나리 한 대에만 켜 보는 것도 방법이다

:::warning 경합이 없다고 증명해 주지는 않는다 race detector는 실제로 실행된 경로에서 실제로 일어난 접근만 본다. 그 코드가 안 돌면 못 잡는다. "-race가 조용하다 = 경합이 없다"가 아니라 "이번 실행에서는 안 걸렸다"이다.

따라서 두 가지가 필요하다. 첫째, 경합이 날 만한 코드를 실제로 돌리는 테스트. 둘째, 여러 번 반복 실행. 파트 8이 go test -race -count=N-cpu 조합으로 흔들어 보는 방법을 다룬다. :::

런타임이 직접 잡는 것

race detector와 별개로, 런타임이 -race 없이도 잡아 죽이는 경우가 있다.

m := map[int]int{}
var wg sync.WaitGroup
for i := range 100 {
wg.Go(func() {
for j := range 100 {
m[i*100+j] = j
}
})
}
wg.Wait()
fatal error: concurrent map writes

goroutine 26 [running]:
internal/runtime/maps.fatal({0x10294b5ae?, 0x0?})
/usr/local/go/src/runtime/panic.go:1181 +0x20
main.main.func1()
/tmp/mapfatal/main.go:14 +0x44
sync.(*WaitGroup).Go.func1()

(스택 덤프는 뒤를 잘랐다.)

fatal error이지 panic이 아니다. recover로 잡을 수 없고 프로세스가 즉시 죽는다. 맵 구현이 자체적으로 동시 쓰기를 감지한다. 동시 읽기·쓰기도 concurrent map read and map write로 잡는다.

이것은 경합을 잡아 주는 서비스가 아니라 자료구조를 지키는 방어다. 감지 못 하고 지나가는 경우도 있고, 그때는 맵이 조용히 망가진다. 맵은 반드시 잠금으로 보호한다.

경합이 나는 곳들

n++ 같은 노골적인 경우 말고, 실전에서 실제로 걸리는 자리들이다.

1. 슬라이스에 append.

go func() { results = append(results, v) }() // 여러 고루틴이 같은 슬라이스에

append는 길이를 읽고 쓰고, 재할당하면 백킹 배열까지 바꾼다. 3-2에서 본 백킹 배열 공유가 동시성과 만나면 값이 사라지거나 겹쳐 쓰인다. 7-1의 "인덱스로 미리 자리를 잡아 두기"가 이 문제의 답이다.

2. 클로저가 캡처한 지역 변수.racyCounter가 그 예다.

3. 구조체 필드 하나만 잠금 없이 읽기.

if s.closed { return } // 다른 고루틴이 s.closed = true를 쓰고 있다

bool 하나라고 안전한 것이 아니다. atomic.Bool을 쓴다.

4. 인터페이스 값이나 슬라이스 헤더 대입. 4-4에서 본 대로 인터페이스 값은 워드 두 개다. 슬라이스는 세 개다. 찢어진 값(타입 포인터는 새것, 데이터 포인터는 헌것)을 읽으면 그 자리에서 크래시한다. atomic.Pointer[T]가 답이다.

5. time.Sleep으로 "충분히 기다렸다"고 가정하기. happens-before 표에 없다.

흔히 하는 실수

1. 경합을 atomic으로 "덮는다"

var count atomic.Int64
var items []string // 이건 여전히 경합

카운터만 원자적으로 만들고 진짜 공유 자료구조는 그대로 두는 경우다. 경합은 변수 단위로 없앤다. -race가 정확히 어느 주소인지 알려 준다.

2. -race가 통과했으니 안전하다고 결론 내린다

실행되지 않은 경로는 검사되지 않는다.

3. 벤치마크를 -race로 잰다

2~20배 느려진 숫자는 아무 의미가 없다.

4. fatal error: concurrent map writesrecover로 잡으려 한다

fatal error는 복구 불가다. 고칠 방법은 잠금뿐이다.

5. 읽기만 하니 안전하다고 생각한다 — 초기화가 끝나기 전에

var cache map[string]int
func init() { go loadCache() } // 이런 걸 하지 않는다 (6-1, 7-1)
func Get(k string) int { return cache[k] } // 로드 중이면 경합

"쓰기가 전부 끝난 뒤 읽기만 한다"는 것 자체를 동기화로 보장해야 한다. sync.Oncemain에서의 명시적 초기화 순서가 그 역할을 한다.

6. 경합을 재현하려고 time.Sleep을 넣는다

타이밍을 흔들어 우연히 재현될 수는 있지만 신뢰할 수 없다. -race는 실제로 경합이 "동시에" 일어나지 않아도 happens-before 관계만 보고 잡는다Sleep 튜닝보다 훨씬 강력한 이유다.

파트 8에서 이어지는 것

이 챕터는 go run -race만 썼다. 실무에서 -race가 진짜 값을 하는 자리는 테스트다. 아직 testing 패키지를 다루지 않았으므로 여기서 멈추고, 파트 8에서 이어 받는다.

  • go test -race-count=N, -cpu=1,4,16으로 흔들어 보기
  • 플래키 테스트의 원인 분류
  • testing/synctest — 가상 시간 안에서 동시성 코드를 결정적으로 테스트하는 표준 도구. time.Sleep 튜닝을 대체한다.

정리

  • 데이터 경합 = 같은 위치 + 최소 한쪽이 쓰기 + happens-before 관계 없음. 셋 다여야 경합이다.
  • happens-before를 만드는 것은 정해져 있다 — 채널, 뮤텍스, Once, WaitGroup, go 문. time.Sleep은 거기 없다.
  • 경합이 있어도 잘 도는 코드가 가장 위험하다. GOMAXPROCS=1에서 정답이 나오는 카운터가 그 증거다. 환경이 바뀌면 깨진다.
  • -race는 happens-before를 실제로 추적한다. 리포트는 헤더 → 각 접근의 스택 → created at 순으로 읽는다. 종료 코드는 66이다.
  • 오버헤드는 메모리 510배, 시간 220배. CI에서는 항상 켜고 벤치마크에서는 끈다.
  • -race는 경합이 없다는 증명이 아니다. 실행된 경로만 본다.
  • 맵 동시 쓰기는 런타임이 fatal error로 죽인다. recover 불가.
  • 실전 경합의 단골: 공유 슬라이스 append, 클로저가 캡처한 변수, 잠금 없는 bool 필드, 인터페이스·슬라이스 대입.

연습문제

  1. 06-race-demoracyCounter에서 n++n = n + 1로 바꾸면 경합이 사라지는가? -race로 확인해 보자. 그다음 perWork를 1로 줄여(고루틴당 한 번씩만 증가) 일반 실행 결과가 100이 나오는지 보고, -race는 여전히 잡는지 확인하자. 이 실험이 "증상 없음 ≠ 경합 없음"을 어떻게 보여 주는가?

  2. racyCounternatomic.Int64로 바꾸고 -race로 돌려 보자. 조용해지는가? 그다음 n은 원자적으로 두되 추가로 공유 슬라이스에 append하는 코드를 넣어 보자. 리포트에 어떤 주소와 어떤 함수가 찍히는가?

  3. -race를 켠 상태와 끈 상태에서 05-mutex 예제의 실행 시간을 time 명령으로 비교해 보자. 배수가 얼마나 되는가? (기계마다 다르다.) 그 배수를 보고 "CI에서는 켜고 벤치마크에서는 끈다"는 규칙이 왜 나오는지 정리해 보자.

  4. map[int]int를 여러 고루틴이 동시에 읽기만 하는 프로그램을 짜고 -race로 돌려 보자. 경합이 잡히는가? 그다음 읽는 도중에 쓰기 고루틴을 하나만 추가하면 어떻게 되는가?