메모리 모델과 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 왜 "보이지 않을" 수 있는가 직관적으로는 "한쪽이 썼으니 나중에 읽으면 보이겠지" 싶다. 그렇지 않은 이유는 셋이다.
- 컴파일러가 재배치한다. 단일 고루틴 관점에서 결과가 같으면 순서를 바꿔도 된다.
- CPU가 재배치한다. 저장 버퍼, 캐시 일관성 프로토콜, 투기적 실행.
- 값을 레지스터에 들고 있는다. 루프 안의 변수는 메모리에 매번 쓰지 않는다.
동기화 연산은 컴파일러와 CPU 양쪽에 **"여기서 순서를 지켜라"**는 장벽을 세운다. 그게 뮤텍스와 채널이 하는 일의 절반이다. 나머지 절반이 상호 배제다. :::
경합이 있어도 잘 도는 코드
// 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:51이 wg.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 writes를 recover로 잡으려 한다
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.Once나 main에서의 명시적 초기화 순서가 그 역할을 한다.
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이다.- 오버헤드는 메모리 5
10배, 시간 220배. CI에서는 항상 켜고 벤치마크에서는 끈다. -race는 경합이 없다는 증명이 아니다. 실행된 경로만 본다.- 맵 동시 쓰기는 런타임이
fatal error로 죽인다.recover불가. - 실전 경합의 단골: 공유 슬라이스
append, 클로저가 캡처한 변수, 잠금 없는bool필드, 인터페이스·슬라이스 대입.
연습문제
-
06-race-demo의racyCounter에서n++를n = n + 1로 바꾸면 경합이 사라지는가?-race로 확인해 보자. 그다음perWork를 1로 줄여(고루틴당 한 번씩만 증가) 일반 실행 결과가 100이 나오는지 보고,-race는 여전히 잡는지 확인하자. 이 실험이 "증상 없음 ≠ 경합 없음"을 어떻게 보여 주는가? -
racyCounter의n을atomic.Int64로 바꾸고-race로 돌려 보자. 조용해지는가? 그다음n은 원자적으로 두되 추가로 공유 슬라이스에append하는 코드를 넣어 보자. 리포트에 어떤 주소와 어떤 함수가 찍히는가? -
-race를 켠 상태와 끈 상태에서05-mutex예제의 실행 시간을time명령으로 비교해 보자. 배수가 얼마나 되는가? (기계마다 다르다.) 그 배수를 보고 "CI에서는 켜고 벤치마크에서는 끈다"는 규칙이 왜 나오는지 정리해 보자. -
map[int]int를 여러 고루틴이 동시에 읽기만 하는 프로그램을 짜고-race로 돌려 보자. 경합이 잡히는가? 그다음 읽는 도중에 쓰기 고루틴을 하나만 추가하면 어떻게 되는가?