공유 데이터 보호하기
sync.Mutex는 Lock과 Unlock 사이의 코드에 한 번에 한 고루틴만 들여보냅니다. 뮤텍스는 보호하는 데이터 옆에, 보통 같은 구조체 안에 둡니다:
이 코드는 항상 hits: 10000을 출력합니다. 잠금이 없으면 100개의 고루틴이 같은 맵에 쓰면서 대개 fatal error: concurrent map writes로 프로그램이 죽습니다. 이 오류는 런타임의 최선 노력(best-effort) 검사에서 나오며, 복구할 수 없고, 죽지 않았다고 해서 코드가 올바르다는 증거가 되지도 않습니다.
중요한 세부 사항:
sync.Mutex의 제로 값은 잠기지 않은 상태이고 바로 쓸 수 있습니다. 생성자가 필요 없습니다.- 메서드는 포인터 리시버(
*Counter)를 씁니다. 값 리시버는 뮤텍스의 복사본을 잠그므로 아무것도 보호하지 못합니다. Lock바로 다음의defer c.mu.Unlock()은 모든 반환 경로와 패닉에서도 잠금을 풀어 줍니다.- 읽기를 포함한 모든 접근이 잠금을 거쳐야 합니다. 다른 고루틴이 쓰는 동안 잠금 없이 읽는 것도 데이터 레이스입니다.
임계 영역은 작게
defer는 함수가 끝날 때 잠금을 풉니다. 위와 같은 짧은 메서드에는 맞는 방식입니다. 긴 함수에서는 공유 데이터를 더 이상 건드리지 않는 즉시 잠금을 풀어서, 다른 고루틴이 잠금이 필요 없는 작업을 기다리지 않게 하세요:
func (s *Store) Save(key string) error {
s.mu.Lock()
data := s.items[key] // copy what you need
s.mu.Unlock()
return writeToDisk(key, data) // slow I/O, outside the lock
}
네트워크 호출, 디스크 입출력, 채널 전송을 하는 동안 잠금을 잡고 있는 것은 느린 동시성 프로그램의 가장 흔한 원인이며, 잠금을 잡은 채 채널에 보내는 것은 교착 상태의 흔한 원인입니다.
읽기 위주 데이터를 위한 RWMutex
sync.RWMutex에는 두 가지 모드가 있습니다. RLock/RUnlock은 여러 고루틴이 동시에 잡을 수 있는 공유 읽기 잠금을 잡습니다. Lock/Unlock은 독점 쓰기 잠금을 잡으며, 모든 읽기가 떠날 때까지 기다립니다.
RWMutex는 읽기가 대부분이고 각 읽기가 잠금 아래에서 실제 작업을 할 때 효과가 있습니다. 맵 조회 한 번 같은 아주 작은 임계 영역에서는 읽기 잠금에도 자체 관리 비용이 있어서 평범한 Mutex가 대개 똑같이 빠릅니다. 고르기 전에 벤치마크하세요.
읽기 잠금을 쓰기 잠금으로 업그레이드할 수는 없습니다. 같은 고루틴에서 RLock을 잡은 채로 Lock을 호출하면 교착 상태가 됩니다. 읽기 잠금을 먼저 풀고, 쓰기 잠금을 잡은 뒤 조건을 다시 확인하세요. 그 사이에 다른 쓰기 고루틴이 데이터를 바꿨을 수 있기 때문입니다.
값 하나에는 sync/atomic
카운터나 플래그 하나에는 sync/atomic이 뮤텍스보다 단순하고 저렴합니다. 타입이 있는 래퍼(Go 1.19)를 쓰세요:
atomic은 한 번에 값 하나만 보호합니다. 두 값이 함께 바뀌어야 한다면(잔액과 거래 건수, 맵과 그 크기) 곧바로 뮤텍스를 쓰세요. 별개의 atomic 연산 두 개 사이에는 다른 고루틴이 끼어들 수 있습니다.
sync.Once
sync.Once는 몇 개의 고루틴이 동시에 호출하든 함수를 정확히 한 번 실행합니다. Do를 호출한 모두가 첫 번째 호출이 끝날 때까지 기다립니다. 지연 초기화의 표준 방법입니다:
"runs once" 줄은 정확히 한 번 나옵니다. Do에 넘긴 함수가 패닉을 일으켜도 Once는 완료된 것으로 치고 다시 시도하지 않습니다. sync.OnceValues는 값 두 개(보통 값과 오류)를 반환하는 함수에 같은 일을 합니다.
뮤텍스, 채널, sync.Map
| 상황 | 사용할 것 |
|---|---|
| 여러 고루틴이 제자리에서 갱신하는 구조체나 맵 | 구조체 안의 sync.Mutex |
| 대부분 읽기이고 가끔 쓰며, 읽기가 실제 작업을 함 | sync.RWMutex |
| 카운터나 플래그 하나 | sync/atomic |
| 일회성 초기화 | sync.Once, sync.OnceValue |
| 한 고루틴에서 다른 고루틴으로 데이터 넘기기 | 채널 |
| 키가 한 번 쓰이고 여러 번 읽히는 캐시, 또는 고루틴들이 서로 다른 키만 건드림 | sync.Map |
sync.Map은 잠금을 건 맵을 대체하는 범용 도구가 아닙니다. 타입 매개변수가 없어서 값이 any로 돌아오고, 표의 두 경우에만 더 빠릅니다. 뮤텍스와 일반 맵으로 시작하세요.
흔한 실수
- 뮤텍스 복사.
sync.Mutex를 담은 구조체를 값으로 넘기거나 값 리시버를 쓰면 잠금이 복사됩니다.go vet이passes lock by value나copies lock value를 보고합니다. - 한 고루틴에서 두 번 잠금. Go 뮤텍스는 재진입이 불가능합니다.
Inc가Get을 호출하고 둘 다 잠금을 잡는다면Inc는 영원히 블록됩니다. 공개 메서드가 잠그고, 비공개 헬퍼는 잠금이 잡혀 있다고 가정하게 하세요. - 이른 반환에서 잠금 해제를 잊음. 이유가 없다면
defer를 쓰세요. - 서로 다른 순서로 잠금. 한 고루틴은 A 다음 B를, 다른 고루틴은 B 다음 A를 잠그면 둘이 서로를 영원히 기다릴 수 있습니다. 여러 잠금은 항상 같은 순서로 잡으세요.
- 보호 대상 데이터를 노출함. 메서드가 내부 맵을 반환하면 호출자가 잠금 없이 읽고 쓸 수 있습니다. 복사본(
maps.Clone, Go 1.21)이나 값 하나를 반환하세요. - 쓰기만 보호함. 잠금을 건 쓰기와 동시에 일어나는 잠금 없는 읽기도 레이스입니다. 테스트를
go test -race로 실행하세요.
자주 묻는 질문
Go에서 뮤텍스란 무엇인가요?
sync.Mutex는 mu.Lock()과 mu.Unlock() 사이의 코드를 한 번에 한 고루틴만 실행하게 하는 잠금입니다. 맵이나 구조체처럼 여러 고루틴이 읽고 쓰는 데이터를 보호할 때 씁니다. 제로 값은 잠기지 않은 뮤텍스이며 바로 쓸 수 있습니다.
Mutex 대신 RWMutex는 언제 써야 하나요?
읽기가 쓰기보다 훨씬 많고, 각 읽기가 잠금을 의미 있는 시간 동안 잡고 있을 때입니다. RLock은 여러 읽기를 동시에 들여보내고, Lock은 독점 접근을 기다립니다. 임계 영역이 짧으면 평범한 Mutex가 같거나 더 빠른 경우가 많으니, 바꾸기 전에 측정하세요.
Go 맵은 동시에 써도 안전한가요?
아니요. 동시 읽기는 괜찮지만, 다른 읽기나 쓰기와 동시에 일어나는 쓰기는 데이터 레이스이며, 런타임이 대개 이를 감지해 fatal error: concurrent map writes(또는 concurrent map read and map write)로 프로그램을 죽입니다. 맵을 sync.Mutex나 sync.RWMutex로 보호하거나, 특정한 용도라면 sync.Map을 쓰세요.
Go의 sync.Mutex는 재진입 가능한가요?
아니요. 잠금을 잡은 고루틴이 다시 Lock을 호출하면 자기 자신을 기다리며 영원히 블록됩니다. 공개 메서드가 잠금을 잡고, 잠금이 이미 잡혀 있다고 가정하는 비공개 헬퍼를 호출하는 구조로 코드를 짜세요.