23장. 동시성 문제와 뮤텍스
22장에서 고루틴과 채널을 손에 넣었다. 이제 동시에 돌릴 수 있고, 값을 주고받을 수도 있다.
그런데 도구가 강해진 만큼 새로운 종류의 버그도 따라온다. 하나의 데이터를 여러 고루틴이 동시에 만지면 정말로 이상한 일이 벌어진다.
이 장의 목표는 다음과 같다.
- 경쟁 조건(race condition)이 무엇인지 이해하기
- race detector 로 문제를 잡아내는 법 익히기
sync.Mutex/sync.RWMutex로 보호하기- 데드락 같은 락 함정과 안티패턴 알아보기
sync/atomic의 위치 파악하기
긴 장처럼 보이지만 사실 주제는 하나다. “공유 데이터를 안전하게 다루는 가장 기본적인 방법” 이다.
23.1 무엇이 잘못될 수 있는가
먼저 진짜로 망가지는 코드를 보자. 이론은 그 뒤에 따라온다.
카운터 1000개 증가
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("counter =", counter)
}
counter++ 를 1000 번 했으니
당연히 1000 이 찍혀야 한다.
실제로 돌려 보면 결과는,
counter = 873
counter = 962
counter = 1000 // 가끔은 맞기도 한다
counter = 941
매번 다르고, 보통은 1000 보다 작다.
왜 이런 일이 일어나나
counter++ 는 사람 눈엔 한 단계로 보이지만
사실 세 단계다.
counter의 값을 읽는다- 1을 더한다
- 결과를
counter에 쓴다
두 고루틴이 거의 동시에 이 세 단계를 밟으면 다음 같은 시나리오가 가능하다.
| 시각 | 고루틴 A | 고루틴 B | counter |
|---|---|---|---|
| t0 | read 0 | 0 | |
| t1 | read 0 | 0 | |
| t2 | add → 1 | add → 1 | 0 |
| t3 | write 1 | 1 | |
| t4 | write 1 | 1 |
증가 두 번이 일어났는데 결과는 1. 하나가 사라졌다.
경쟁 조건과 데이터 레이스
두 용어가 헷갈리기 쉽다.
- 경쟁 조건 (race condition)
- 실행 순서에 따라 결과가 달라지는 모든 상황
- 동시성 버그의 큰 분류
- 데이터 레이스 (data race)
- 그중에서도 같은 메모리에 대해 한 쪽이라도 쓰기인 두 접근이 동기화 없이 동시에 일어나는 경우
- 메모리 모델 차원에서 정의된 엄격한 개념
위 카운터 예제는 데이터 레이스이자 경쟁 조건이다. Go 언어 명세는 데이터 레이스가 있는 프로그램의 동작을 정의하지 않는다. 즉, 뭐가 나와도 이상하지 않다.
핵심: 단순한
i++도 동시에 하면 우리가 기대하는 동작을 보장하지 않는다.
23.2 race detector
이런 버그를 눈으로 찾기는 끔찍하다. Go 는 다행히 강력한 도구를 기본으로 제공한다.
사용법
-race 플래그 한 줄이면 끝이다.
go run -race main.go
go test -race ./...
go build -race
이 플래그를 붙이면 컴파일러가 코드 곳곳에 메모리 접근 추적 코드를 함께 끼워 넣는다. 실행 중 의심되는 동시 접근을 만나면 보고한다.
보고서 읽기
위 카운터 예제를 go run -race 로 돌려 보면
대략 이런 메시지가 뜬다.
==================
WARNING: DATA RACE
Read at 0x00c0000180a8 by goroutine 8:
main.main.func1()
/path/main.go:14 +0x44
Previous write at 0x00c0000180a8 by goroutine 7:
main.main.func1()
/path/main.go:14 +0x55
Goroutine 8 (running) created at:
main.main()
/path/main.go:12 +0xa4
...
==================
Found 1 data race(s)
읽는 요령은 세 줄이면 충분하다.
- Read at … / Write at … — 어떤 주소에서
- goroutine N — 어느 고루틴이
- at /path/main.go:14 — 어느 줄에서
두 접근이 동기화 없이 부딪쳤다는 신고서다.
비용과 한계
- 실행이 5~10배 느려지고 메모리도 많이 쓴다
- 그래서 운영 배포에선 쓰지 않는다
- 개발/테스트 단계에서 켜는 것이 정석
- CI 에서
go test -race ./...를 도는 것을 강력히 권장
한 번 켜 두면 동시성 버그의 대부분을 잡아 준다. “잘 돌아가는데?” 보다 훨씬 믿을 만한 기준이 된다.
23.3 sync.Mutex
가장 기본적인 보호 도구는 뮤텍스(mutex) 다. 이름은 “mutual exclusion (상호 배제)” 의 줄임말이다.
- 동시에 들어갈 수 있는 자리는 단 하나
- 한 고루틴이 들어가 있으면 다른 고루틴은 문 앞에서 대기
이 단순한 규칙이 데이터 보호의 핵심이다.
기본 사용
import "sync"
var mu sync.Mutex
mu.Lock()
// ... 보호된 영역 (critical section) ...
mu.Unlock()
Lock 과 Unlock 사이가 임계 영역(critical section) 이다.
이 영역 안에서는 한 번에 한 고루틴만 실행된다.
카운터 고치기
23.1 의 카운터를 뮤텍스로 보호해 보자.
package main
import (
"fmt"
"sync"
)
func main() {
var (
counter int
mu sync.Mutex
wg sync.WaitGroup
)
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("counter =", counter)
}
이제 출력은 언제나,
counter = 1000
-race 로 돌려도 아무 경고가 나오지 않는다.
defer mu.Unlock() 관례
Unlock 을 깜빡하면 데드락이 난다.
패닉이 나면 더 심하게 망가진다.
그래서 가능하면 항상 defer 와 함께 쓴다.
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
이 패턴이라면,
- 함수에서 어떤 경로로 나가든
Unlock이 보장된다 - panic 이 나도 stack unwind 중에 호출된다
뮤텍스를 어디에 둘까
관례:
- 보호 대상이 되는 데이터 바로 옆에 둔다
- 구조체에 묶어 두는 게 가장 깔끔하다
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
조회 메서드(Value)도 락이 필요하다.
읽기조차 동기화 없이 동시 접근하면 데이터 레이스다.
이 부분이 처음엔 의외로 자주 빠진다.
뮤텍스는 복사하지 않는다
sync.Mutex 는 값으로 복사하는 순간 망가진다.
복사된 사본은 원본과 별개의 락이라서
같은 데이터를 같이 보호하지 못한다.
go vet 이 이 문제를 잡아 준다.
copylocks: passes lock by value: ...
규칙은 단순하다.
- 뮤텍스를 가진 구조체는 보통 포인터로 다룬다
- 함수 매개변수도 포인터로 받는다
23.4 sync.RWMutex
뮤텍스는 강력하지만 한 가지 아쉬움이 있다. 읽기끼리도 서로 배제한다는 점이다.
| 상황 | 안전한가 | Mutex 는? |
|---|---|---|
| 동시에 읽기 | 안전 | 막는다 (아쉬움) |
| 동시에 쓰기 | 위험 | 막는다 (필요) |
| 읽기 + 쓰기 동시 | 위험 | 막는다 (필요) |
읽기는 본질적으로 안전한데도 막는 건 손해다.
이걸 풀어 주는 도구가 sync.RWMutex 다.
두 종류의 잠금
var mu sync.RWMutex
mu.RLock() // 읽기 락
// ... 읽기만 ...
mu.RUnlock()
mu.Lock() // 쓰기 락
// ... 읽기/쓰기 ...
mu.Unlock()
규칙:
- 읽기 락은 여러 개가 동시에 잡혀 있어도 OK
- 쓰기 락은 단 하나만 가능하며, 잡힌 동안엔 다른 어떤 락도 못 들어온다
예제
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(k string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *SafeMap) Set(k string, v int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[k] = v
}
Get 은 읽기 락,
Set 은 쓰기 락을 잡는다.
읽기 요청이 많이 몰리는 캐시류에 잘 맞는다.
언제 이득인가
- 읽기가 쓰기보다 압도적으로 많을 때
- 임계 영역이 어느 정도 길어서 락 비용 절감 효과가 보일 때
이득이 별로 없는 경우:
- 임계 영역이 매우 짧을 때
RWMutex자체가Mutex보다 무거워 오히려 더 느릴 수 있다
- 쓰기가 자주 일어날 때
- 어차피 쓰기 락이 다 막으므로 이점이 사라진다
망설여지면 그냥
Mutex로 시작하자. 측정 후 정말 병목이면 그때RWMutex로 바꿔도 늦지 않다.
23.5 데드락
락은 만능이 아니다. 잘못 쓰면 새로운 사고를 부른다. 대표적인 것이 데드락(deadlock) 이다.
데드락이란
두 개 이상의 고루틴이 서로가 가진 락을 기다리며 영원히 멈춰 있는 상태.
가장 간단한 예제는 락이 두 개일 때다.
package main
import (
"sync"
"time"
)
func main() {
var a, b sync.Mutex
go func() {
a.Lock()
time.Sleep(10 * time.Millisecond)
b.Lock()
b.Unlock()
a.Unlock()
}()
go func() {
b.Lock()
time.Sleep(10 * time.Millisecond)
a.Lock()
a.Unlock()
b.Unlock()
}()
select {}
}
- 고루틴 1:
a를 잡고 →b를 기다림 - 고루틴 2:
b를 잡고 →a를 기다림
서로가 상대를 기다리며 멈춘다. 운이 나쁘면 영원히 풀리지 않는다.
Go 런타임은 모든 고루틴이 멈췄을 때만 데드락 패닉을 띄운다. 일부만 데드락 상태라면 런타임은 모른다. 그래서 다른 일을 하는 고루틴이 같이 있으면 데드락이 조용히 묻혀 버린다.
락 순서 일관성 규칙
데드락을 막는 가장 쉬운 규칙은 단순하다.
모든 곳에서 동일한 순서로 락을 잡는다.
위 예제라면 모든 고루틴이 “a 먼저, b 나중” 순으로만 잡으면 데드락이 일어나지 않는다.
go func() {
a.Lock()
b.Lock()
// 작업
b.Unlock()
a.Unlock()
}()
go func() {
a.Lock() // 똑같이 a 먼저
b.Lock()
// 작업
b.Unlock()
a.Unlock()
}()
이걸 lock ordering 이라고 부른다. 규칙이 단순하지만 실전에선 의외로 어렵다.
- 락이 늘면 순서 규칙이 복잡해진다
- 여러 모듈을 합치면 서로의 락 순서가 충돌한다
- 라이브러리 코드 안에 락이 숨어 있으면 추적이 어렵다
근본적으로는 락을 적게 가져가는 설계가 답이다. 이게 24장의 주제다.
한 가지 도구: TryLock
Go 1.18 이후 Mutex.TryLock 이 추가됐다.
잡을 수 있으면 잡고, 못 잡으면 즉시 false 반환.
if mu.TryLock() {
defer mu.Unlock()
// 보호된 작업
}
데드락 회피용으로 보이지만, 공식 문서가 “거의 쓸 일이 없다” 고 명시할 만큼 권장되지 않는다. 설계로 풀리지 않을 때의 마지막 수단 정도로만 알아 두자.
23.6 라이브락과 기아
데드락만큼 자주 언급되진 않지만 알아 두면 좋다.
라이브락 (livelock)
데드락이 “둘 다 멈춤” 이라면 라이브락은 “둘 다 계속 움직이지만 일은 못 하는 상태” 다.
- 충돌을 감지하면 양보한다
- 양쪽이 동시에 양보하면 또 충돌
- 또 양보, 또 충돌… 반복
비유: 좁은 복도에서 두 사람이 같은 방향으로 피하면서 계속 마주치는 상황.
기아 (starvation)
특정 고루틴이 자원을 거의 못 잡고 계속 뒤로 밀리는 상태.
- 쓰기 락 요청이 줄을 서 있는데 읽기 요청이 끊이지 않고 들어오면 쓰기 락이 영영 차례를 못 받는 식
RWMutex설계에 따라 쓰기 starvation 이 생길 수 있다
Go 의 sync.Mutex 는 starvation 방지 모드를 내장해
오래 기다린 고루틴이 우선권을 받는다.
하지만 직접 만든 동기화 로직에선 이런 안전망이 없다.
데드락 / 라이브락 / 기아 모두 “공정성 (fairness)” 의 문제다. 락을 직접 설계할 일이 있다면 늘 의심해 봐야 한다.
23.7 락 안티패턴
코드에서 잘 보이는 함정들이다.
(1) 락을 든 채로 채널 송수신
mu.Lock()
ch <- value // 받는 쪽이 없으면 여기서 영원히 막힌다
mu.Unlock()
채널 송수신이 막히는 동안 락이 계속 잡혀 있다. 다른 고루틴은 그 락을 영원히 기다린다. 효과적으로 데드락이다.
규칙:
락을 잡은 상태에서 시간이 얼마나 걸릴지 모르는 작업은 하지 않는다.
여기 해당하는 작업:
- 채널 송수신
- 네트워크 호출
- 파일 I/O
- 다른 락 요청 (lock ordering 주의)
준비를 락 밖에서 해 두고, 락 안에서는 짧게 끝나는 변경만 한다.
(2) 락 범위가 너무 넓다
mu.Lock()
defer mu.Unlock()
data := loadFromDisk() // 느림
parsed := parse(data) // 느림
m[key] = parsed // 이게 보호 대상
보호하고 싶은 건 m[key] = parsed 한 줄이다.
디스크 I/O 가 끝날 때까지 락을 쥐고 있을 이유가 없다.
data := loadFromDisk()
parsed := parse(data)
mu.Lock()
m[key] = parsed
mu.Unlock()
(3) 메서드마다 별도 락
같은 데이터를 보호하는데 메서드마다 다른 락을 쓰면 보호가 안 된다.
type Buggy struct {
mu1, mu2 sync.Mutex
value int
}
func (b *Buggy) Inc() { b.mu1.Lock(); b.value++; b.mu1.Unlock() }
func (b *Buggy) Value() int {
b.mu2.Lock(); defer b.mu2.Unlock()
return b.value
}
Inc 와 Value 가 서로 다른 락을 잡고 있어
경쟁 조건이 그대로 남는다.
원칙:
하나의 데이터 = 하나의 락. 그 데이터를 만지는 모든 곳에서 같은 락을 쓴다.
(4) 락을 가진 채 콜백 호출
mu.Lock()
defer mu.Unlock()
callback(data) // 콜백이 무엇을 할지 모른다
콜백 안에서 같은 락을 다시 잡으면 데드락이다. 콜백이 시간을 끌면 다른 고루틴이 다 막힌다. 호출자가 만든 코드라 무엇을 하는지도 알 수 없다.
가능하면 락 밖에서 콜백을 호출한다.
23.8 sync/atomic 맛보기
락이 무거워 보이는 경우가 있다. “카운터 하나 증가시키는 데 락까지 잡아야 하나?”
이런 단순한 경우엔 원자 연산(atomic operations) 이 더 가볍다.
표준 패키지 sync/atomic 이 제공한다.
기본 사용
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1) // 1 증가
v := atomic.LoadInt64(&counter) // 안전한 읽기
atomic.StoreInt64(&counter, 100) // 안전한 쓰기
CPU 가 지원하는 원자 명령어로 직접 동작한다. 잠금/해제 없이 하나의 작업이 통째로 일어난다.
카운터 다시
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println(atomic.LoadInt64(&counter))
뮤텍스 버전과 결과는 같지만 더 가볍다.
Go 1.19+ 의 atomic 타입
새 코드에선 타입화된 API 가 더 깔끔하다.
var counter atomic.Int64
counter.Add(1)
v := counter.Load()
counter.Store(100)
atomic.Int64, atomic.Uint64, atomic.Pointer[T] 등이 있다.
한계
원자 연산은 단일 값에 대해서만 동작한다.
- 두 변수를 한꺼번에 일관성 있게 바꾸려면 못 한다
- 맵이나 슬라이스 같은 복합 자료구조는 보호 못 한다
복잡한 상태가 필요하면 결국 락이나 채널이다.
선택 가이드
| 보호 대상 | 추천 |
|---|---|
| 카운터, 플래그 같은 단일 정수 | atomic |
| 작은 상태 묶음 | Mutex |
| 데이터 흐름, 소유권 이전 | 채널 (24장) |
atomic 은 “락의 가벼운 대체” 가 아니라 “락이 필요 없을 만큼 단순한 경우의 도구” 다. 영역이 좁다는 걸 의식하고 쓰자.
23.9 정리
이 장에서 살펴본 내용:
counter++처럼 평범한 연산도 동시에 일어나면 결과가 망가진다- 이런 버그는 데이터 레이스라 부르며 결과가 정의되지 않는다
go run -race,go test -race가 대부분의 레이스를 잡아 준다sync.Mutex는 가장 기본적인 보호 도구이며defer mu.Unlock()이 관례다sync.RWMutex는 읽기가 압도적으로 많을 때 유용하다- 락 두 개 이상을 잡을 땐 순서를 일관되게 한다 (데드락 방지)
- 락을 든 채로 채널 송수신, I/O, 콜백 호출은 피한다
sync/atomic은 단일 값에 한해 락보다 가볍다
여기까지가 락 기반 동시성의 기본기다. 잘 쓰면 강력하지만 코드가 빨리 복잡해지는 단점이 있다. 락이 여러 개 얽히기 시작하면 디버깅이 매우 힘들어진다.
다음 장에서는 시야를 한 번 넓힌다. “애초에 락을 거의 안 쓰는 설계” 가 가능할까? Go 가 제안하는 답을 본격적으로 살펴본다.