Go · 심화
동시성과 서버 설계로 깊어지는 Go
경쟁 상태 잡기 - race 검출기와 동기화 도구
go run -race, sync.Mutex·RWMutex, sync/atomic, sync.Once, 맵 동시 접근 오류, 잠금 범위 줄이기
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 취소 신호를 고루틴 사이로 전달하는 법을 다뤘다. 고루틴이 여러 개 돌기 시작하면 다음 문제는 같은 메모리를 동시에 건드리는 일이다. 이 장에서는 배달 주문 중계 서비스의 접수 집계를 예로 들어, 경쟁 상태(race condition)를 검출기로 찾고 표준 라이브러리의 동기화 도구로 고치는 과정을 따라간다.
go run -race로 데이터 경쟁을 찾고 보고서를 읽을 수 있다.sync.Mutex와sync.RWMutex로 맵과 슬라이스 같은 공유 상태를 보호하고, 둘 중 무엇을 쓸지 판단할 수 있다.sync/atomic의 타입으로 단순한 카운터를 잠금 없이 다루고, 그 한계를 설명할 수 있다.sync.Once로 초기화를 한 번만 실행하게 만들 수 있다.- 잠금 안에서 하는 일을 줄여 대기 시간을 줄일 수 있다.
문제 상황
중계 서비스는 가게에서 들어오는 주문을 여러 고루틴이 나누어 처리한다. 운영팀이 가게별 접수 건수를 보고 싶다고 해서 다음과 같은 집계를 넣었다고 하자.
counts := make(map[string]int)
for _, o := range orders {
go func() {
counts[o.Store]++
}()
}
주문이 적은 개발 환경에서는 잘 돌아간다. 그러나 주문이 몰리는 저녁 시간에 프로세스가 fatal error: concurrent map writes 메시지를 남기고 종료된다. 이 오류는 recover 로 잡을 수 없다. 맵을 동시에 쓰는 것을 런타임이 발견하면 프로그램을 즉시 멈추도록 만들어 두었기 때문이다.
맵이 아니라 정수 하나를 센다면 더 조용하게 틀린다. 프로세스는 죽지 않고, 집계가 실제보다 적게 나올 뿐이다. 이런 종류의 버그는 재현이 어렵고, 재현되더라도 로그만 봐서는 원인을 짚기 어렵다. 그래서 먼저 도구로 찾고, 그다음 구조로 고치는 순서를 밟는다.
데이터 경쟁과 race 검출기
데이터 경쟁이란
데이터 경쟁(data race)은 두 고루틴이 같은 메모리 위치에 동시에 접근하고, 그중 적어도 하나가 쓰기이며, 둘 사이에 순서를 정해 주는 동기화가 없을 때 생긴다. count++ 는 한 줄이지만 실제로는 읽기, 더하기, 쓰기 세 단계다. 두 고루틴이 이 단계를 겹쳐 실행하면 증가가 한 번 사라진다.
그림에서 고루틴 A와 B는 모두 0을 읽었다. 각자 1을 쓰면 두 번 증가시켰는데도 결과는 1이다. 먼저 쓴 쪽의 결과를 나중에 쓴 쪽이 덮어쓴 것이다.
-race 로 실행하기
Go 도구는 검출기를 내장하고 있다. 실행, 테스트, 빌드 어디에나 -race 플래그를 붙인다.
go run -race main.go
go test -race ./...
go build -race
검출기는 실행 중에 실제로 일어난 메모리 접근을 추적한다. 경쟁이 발견되면 WARNING: DATA RACE 로 시작하는 보고서를 표준 오류로 출력하고, 프로그램이 끝날 때 종료 코드 66 을 돌려준다. 보고서에는 보통 다음 항목이 들어 있다.
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
main.main.func1()
/path/to/main.go:12 +0x...
Previous write at 0x00c000012345 by goroutine 7:
main.main.func1()
/path/to/main.go:12 +0x...
Goroutine 8 (running) created at:
main.main()
/path/to/main.go:10 +0x...
주소와 고루틴 번호는 실행마다 다르다. 읽을 곳은 접근의 종류(Write, Read), 파일과 줄 번호, 그 고루틴을 만든 위치 세 가지다. 같은 줄이 두 번 나오면 같은 코드를 두 고루틴이 동시에 실행했다는 뜻이다.
사용할 때 알아 둘 점이 있다.
- 검출기는 실행된 경로에서 일어난 경쟁만 찾는다. 테스트가 그 경로를 지나가지 않으면 보고하지 못한다. 동시성이 있는 코드는 여러 고루틴으로 실제로 돌려 보는 테스트가 있어야 한다.
- CPU 와 메모리를 몇 배 더 쓴다. 개발과 CI 에서 켜고, 운영 바이너리에는 보통 넣지 않는다.
- 보고가 없다고 경쟁이 없다는 증명은 아니다. 보고가 있으면 거의 확실히 고쳐야 할 버그다.
- 일부 환경에서는 C 컴파일러가 필요할 수 있다. 자세한 조건은 공식 race 검출기 문서에서 확인한다.
동기화 도구 고르기
경쟁을 없애는 방법은 크게 둘이다. 공유를 없애거나(채널로 값을 넘기고 한 고루틴만 상태를 가진다), 접근 순서를 강제한다. 이 절은 뒤쪽을 다룬다. 어떤 도구를 쓸지는 보호할 대상의 모양이 정한다.
| 상황 | 도구 | 이유 | 주의할 점 |
|---|---|---|---|
| 맵·슬라이스·여러 필드를 함께 갱신 | sync.Mutex | 구간 전체를 한 번에 하나만 실행 | 잠금 범위를 짧게 유지 |
| 읽기가 압도적으로 많고 쓰기는 드묾 | sync.RWMutex | 읽기끼리는 동시에 허용 | 쓰기가 잦으면 이득이 작음 |
| 정수 카운터, 플래그 하나 | sync/atomic | 잠금 없이 단일 연산 보장 | 값 둘 이상의 일관성은 보장 못 함 |
| 한 번만 실행할 초기화 | sync.Once | 여러 고루틴이 불러도 한 번 실행 | 실패를 다시 시도하지 않음 |
sync.Mutex
뮤텍스(mutex)는 상호 배제를 위한 잠금이다. Lock 과 Unlock 사이의 구간은 한 번에 한 고루틴만 실행한다. 제로 값으로 바로 쓸 수 있고, 한 번 쓴 뒤에는 복사하면 안 된다. 보호할 데이터와 같은 구조체에 두고, 메서드 안에서 잠그는 방식이 읽기 쉽다. 호출하는 쪽이 잠금을 알 필요가 없기 때문이다.
func (b *StoreBoard) Add(store string) {
b.mu.Lock()
defer b.mu.Unlock()
b.counts[store]++
}
defer 로 해제하면 중간에 반환하거나 패닉이 나도 잠금이 풀린다.
sync.RWMutex
RWMutex 는 읽기 잠금(RLock)과 쓰기 잠금(Lock)을 나눈다. 읽기 잠금은 여럿이 동시에 쥘 수 있고, 쓰기 잠금은 읽기와도 쓰기와도 배타적이다. 배달비 요금표처럼 주문마다 읽고 하루에 몇 번만 고치는 데이터에 어울린다. 다만 RWMutex 는 Mutex 보다 내부 작업이 많다. 임계 구간이 아주 짧거나 쓰기가 잦다면 Mutex 가 더 빠를 수 있으므로, 바꾸기 전에 측정한다. 측정은 이후 벤치마크를 다루는 장에서 본다.
sync/atomic
접수 건수처럼 숫자 하나를 올리기만 한다면 잠금 대신 원자적 연산이면 충분하다. Go 1.19 이후에는 atomic.Int64, atomic.Bool, atomic.Pointer[T] 같은 타입이 있어 메서드로 쓴다. 변수에 함수를 적용하는 옛 방식보다 실수할 곳이 적다.
var received atomic.Int64
received.Add(1)
n := received.Load()
원자적 연산은 연산 하나가 쪼개지지 않는다는 보장이다. "읽어서 조건을 보고 올린다"처럼 두 연산을 묶은 흐름은 보호하지 못한다. 그런 흐름에는 CompareAndSwap 을 반복하거나 뮤텍스를 쓴다.
sync.Once
설정 파일을 읽거나 연결 풀을 만드는 초기화는 한 번만 실행해야 한다. once.Do(f) 는 여러 고루틴이 동시에 불러도 f 를 한 번만 실행하고, 실행이 끝날 때까지 다른 호출을 기다리게 한다. Do 가 반환한 뒤에는 f 안에서 쓴 값이 모든 호출자에게 보인다. 값을 돌려주는 형태가 필요하면 sync.OnceValue 도 있다. f 가 실패해도 Do 는 다시 실행하지 않으므로, 재시도가 필요한 초기화에는 맞지 않는다.
맵 동시 접근 오류와 잠금 범위
맵은 읽기만 동시에 해도 되는가
맵을 읽기만 하는 고루틴이 여럿이면 문제가 없다. 쓰는 고루틴이 하나라도 있으면 다른 고루틴의 읽기와 쓰기 모두 동기화가 필요하다. 런타임은 읽기와 쓰기가 겹친 것도 감지해 fatal error: concurrent map read and map write 로 종료한다. 이 감지는 보장이 아니라 보조 수단이다. 감지되지 않고 넘어가면 조용히 틀린 값을 읽는다. 그러므로 오류가 안 났다는 사실을 안전의 근거로 삼지 않는다.
맵을 순회해 결과를 돌려줄 때도 주의한다. 맵 자체를 반환하면 호출자가 잠금 없이 읽게 되므로, 잠금 안에서 복사본을 만들어 돌려주는 편이 안전하다.
잠금 범위 줄이기
잠금을 쥔 동안 다른 고루틴은 기다린다. 그래서 잠금 안에는 공유 상태를 읽고 쓰는 일만 두고, 문자열 만들기나 계산처럼 공유 상태와 무관한 일은 밖으로 뺀다.
코드로 비교하면 다음과 같다.
// 넓은 잠금: 줄을 만드는 동안에도 다른 고루틴이 기다린다
r.mu.Lock()
line := fmt.Sprintf("주문 %02d %s", o.ID, o.Store)
r.lines = append(r.lines, line)
r.mu.Unlock()
// 좁은 잠금: 추가할 때만 잠근다
line := fmt.Sprintf("주문 %02d %s", o.ID, o.Store)
r.mu.Lock()
r.lines = append(r.lines, line)
r.mu.Unlock()
범위를 줄일 때 지킬 기준은 하나다. 같은 불변식에 속한 읽기와 쓰기는 한 번의 잠금 안에 둔다. 예를 들어 "남은 수량을 확인하고 차감한다"는 하나의 구간이어야 한다. 확인과 차감을 따로 잠그면 그 사이에 다른 고루틴이 끼어든다.
완성 코드
아래 프로그램은 열두 건의 주문을 고루틴 네 개가 나누어 처리한다. 가게별 집계는 뮤텍스, 요금표는 읽기·쓰기 잠금, 총 건수와 배달비 합계는 원자적 연산, 설정 적재는 sync.Once 가 맡는다. wg.Go 는 Go 1.25 에서 추가된 메서드다.
package main
import (
"fmt"
"maps"
"slices"
"sync"
"sync/atomic"
)
type Order struct {
ID int
Store string
District string
}
type Config struct {
MaxFee int
FallbackFee int
BaseFees map[string]int
}
// Settings 는 설정을 처음 요청받을 때 한 번만 적재한다.
type Settings struct {
once sync.Once
loads atomic.Int32
cfg Config
}
func (s *Settings) Get() Config {
s.once.Do(func() {
s.loads.Add(1)
s.cfg = Config{
MaxFee: 5000,
FallbackFee: 3000,
BaseFees: map[string]int{"동구": 3000, "서구": 3500, "북구": 4000},
}
})
return s.cfg
}
// StoreBoard 는 가게별 접수 건수를 센다.
type StoreBoard struct {
mu sync.Mutex
counts map[string]int
}
func NewStoreBoard() *StoreBoard {
return &StoreBoard{counts: make(map[string]int)}
}
func (b *StoreBoard) Add(store string) {
b.mu.Lock()
defer b.mu.Unlock()
b.counts[store]++
}
func (b *StoreBoard) Snapshot() map[string]int {
b.mu.Lock()
defer b.mu.Unlock()
cp := make(map[string]int, len(b.counts))
for k, v := range b.counts {
cp[k] = v
}
return cp
}
// FeeTable 은 지역별 배달비 표다. 읽기가 많고 교체는 드물다.
type FeeTable struct {
mu sync.RWMutex
fees map[string]int
}
func NewFeeTable(base map[string]int) *FeeTable {
return &FeeTable{fees: maps.Clone(base)}
}
func (t *FeeTable) Get(district string) (int, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
fee, ok := t.fees[district]
return fee, ok
}
// Replace 는 새 표로 통째로 바꾼다. next 는 호출 뒤 수정하지 않는다.
func (t *FeeTable) Replace(next map[string]int) {
t.mu.Lock()
t.fees = next
t.mu.Unlock()
}
// Recorder 는 처리 기록을 모은다.
type Recorder struct {
mu sync.Mutex
lines []string
}
func (r *Recorder) Add(line string) {
r.mu.Lock()
r.lines = append(r.lines, line)
r.mu.Unlock()
}
func (r *Recorder) Sorted() []string {
r.mu.Lock()
cp := slices.Clone(r.lines)
r.mu.Unlock()
slices.Sort(cp)
return cp
}
type Relay struct {
settings *Settings
board *StoreBoard
fees *FeeTable
log *Recorder
received atomic.Int64
totalFee atomic.Int64
}
func (r *Relay) Handle(o Order) {
r.received.Add(1)
r.board.Add(o.Store)
cfg := r.settings.Get()
fee, ok := r.fees.Get(o.District)
if !ok {
fee = cfg.FallbackFee
}
fee = min(fee, cfg.MaxFee)
r.totalFee.Add(int64(fee))
line := fmt.Sprintf("주문 %02d %s %s %d원", o.ID, o.Store, o.District, fee)
r.log.Add(line)
}
func makeOrders(n int) []Order {
stores := []string{"해돋이분식", "청솔약국", "별빛꽃집"}
districts := []string{"동구", "서구", "북구"}
orders := make([]Order, 0, n)
for i := 1; i <= n; i++ {
orders = append(orders, Order{
ID: i,
Store: stores[i%3],
District: districts[(i/2)%3],
})
}
return orders
}
func main() {
settings := &Settings{}
relay := &Relay{
settings: settings,
board: NewStoreBoard(),
fees: NewFeeTable(settings.Get().BaseFees),
log: &Recorder{},
}
jobs := make(chan Order)
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for o := range jobs {
relay.Handle(o)
}
})
}
for _, o := range makeOrders(12) {
jobs <- o
}
close(jobs)
wg.Wait()
fmt.Println("접수 건수:", relay.received.Load())
fmt.Println("가게별 접수:")
snap := relay.board.Snapshot()
for _, name := range slices.Sorted(maps.Keys(snap)) {
fmt.Printf(" %s %d\n", name, snap[name])
}
fmt.Printf("배달비 합계: %d원\n", relay.totalFee.Load())
fmt.Println("설정 적재 횟수:", settings.loads.Load())
lines := relay.log.Sorted()
for _, l := range lines[:3] {
fmt.Println(l)
}
next := maps.Clone(settings.Get().BaseFees)
for d, f := range next {
next[d] = f + 500
}
relay.fees.Replace(next)
fee, _ := relay.fees.Get("서구")
fmt.Printf("요금 개정 후 서구: %d원\n", fee)
}
줄별 해설
Settings.Get 은 once.Do 안에서 설정을 만든다. 호출은 main 에서 한 번, 각 주문 처리에서 열두 번 일어나지만 적재는 한 번이다. loads 를 원자적 타입으로 둔 것은 적재 횟수를 나중에 읽는 쪽이 어느 고루틴이든 안전하게 하려는 선택이다. BaseFees 맵은 적재 뒤 아무도 수정하지 않는다. 쓰기가 없는 맵은 여러 고루틴이 읽어도 된다.
StoreBoard 의 뮤텍스는 counts 맵 하나를 지킨다. Add 는 defer 로 해제하고, Snapshot 은 잠금 안에서 복사본을 만들어 돌려준다. 호출자는 복사본을 잠금 없이 마음껏 순회할 수 있다.
FeeTable 의 Get 은 RLock 을 쓴다. 네 고루틴이 동시에 조회해도 서로 기다리지 않는다. Replace 는 쓰기 잠금 안에서 맵 참조만 바꾼다. 새 맵은 잠금 밖에서 만들어 오고, 교체 뒤에는 건드리지 않는다는 약속이 있어야 안전하다. NewFeeTable 이 maps.Clone 으로 복사하는 것도 설정의 맵과 표가 같은 맵을 공유하지 않게 하려는 것이다.
Recorder 의 Add 는 슬라이스에 붙이는 동안만 잠근다. Sorted 는 잠금 안에서 복사만 하고, 정렬은 잠금을 푼 뒤에 한다. 정렬은 공유 상태가 아니라 복사본에 대한 일이기 때문이다.
Relay.Handle 에서 접수 건수와 배달비 합계는 atomic.Int64 로 올린다. 두 값 사이에 "합계는 건수와 항상 맞아야 한다"는 요구가 없으므로 따로 올려도 된다. 문자열은 fmt.Sprintf 로 먼저 만든 뒤 Recorder.Add 에 넘긴다. 잠금 안에서 할 일을 줄이는 구조다.
main 은 작업 채널 하나에 고루틴 네 개를 붙인다. 어떤 고루틴이 어떤 주문을 처리하는지는 실행마다 달라진다. 그러나 출력은 합계, 정렬한 키, 정렬한 줄만 쓰므로 항상 같다. wg.Wait() 가 반환된 뒤에는 모든 처리가 끝났고 그 결과가 main 에 보이므로, 이후의 읽기는 안전하다. 마지막에는 새 맵을 만들어 Replace 하고 바뀐 요금을 조회한다.
실행 결과
$ go run -race main.go
접수 건수: 12
가게별 접수:
별빛꽃집 4
청솔약국 4
해돋이분식 4
배달비 합계: 42000원
설정 적재 횟수: 1
주문 01 청솔약국 동구 3000원
주문 02 별빛꽃집 서구 3500원
주문 03 해돋이분식 서구 3500원
요금 개정 후 서구: 4000원
검출기가 켜진 상태에서 경고 없이 끝났다. 동기화 도구 중 하나를 지우고 다시 실행해 보자. 예를 들어 StoreBoard.Add 의 Lock 과 Unlock 을 주석 처리하면 WARNING: DATA RACE 보고서가 나오거나 맵 오류로 종료된다.
실무에서 자주 틀리는 것
쓰기가 있는데 읽기는 잠그지 않는다
읽기는 값만 보는 일이라 괜찮다고 생각하기 쉽다. 그러나 쓰기와 겹치는 읽기도 경쟁이다.
// 틀림: 쓰기는 잠그고 읽기는 잠그지 않는다
func (b *StoreBoard) Count(store string) int {
return b.counts[store]
}
// 고침: 읽기도 같은 잠금으로 보호한다
func (b *StoreBoard) Count(store string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.counts[store]
}
뮤텍스를 가진 구조체를 값으로 복사한다
값 수신자 메서드는 호출할 때마다 구조체를 복사한다. 복사된 뮤텍스는 원본과 별개라서 아무것도 지키지 못한다. go vet 이 copylocks 로 알려 준다.
// 틀림: b 가 복사본이라 잠금도 증가도 원본에 반영되지 않는다
func (b StoreBoard) Add(store string) {
b.mu.Lock()
defer b.mu.Unlock()
b.counts[store]++
}
// 고침: 포인터 수신자를 쓴다
func (b *StoreBoard) Add(store string) {
b.mu.Lock()
defer b.mu.Unlock()
b.counts[store]++
}
잠금을 쥔 채 같은 잠금을 다시 잡는다
Go 의 뮤텍스는 재진입을 지원하지 않는다. 잠금을 쥔 메서드가 같은 객체의 잠그는 메서드를 부르면 자기 자신을 기다리는 교착(deadlock)에 빠진다.
// 틀림: Add 가 이미 잠긴 mu 를 다시 잠근다
func (b *StoreBoard) AddAll(stores []string) {
b.mu.Lock()
defer b.mu.Unlock()
for _, s := range stores {
b.Add(s)
}
}
// 고침: 잠금 없는 내부 함수를 나누어 쓴다
func (b *StoreBoard) AddAll(stores []string) {
b.mu.Lock()
defer b.mu.Unlock()
for _, s := range stores {
b.add(s)
}
}
func (b *StoreBoard) add(store string) {
b.counts[store]++
}
원자적 연산 두 개를 한 연산처럼 쓴다
상한을 확인하고 올리는 흐름은 두 단계다. 두 고루틴이 같은 값을 읽고 둘 다 통과하면 상한을 넘는다.
// 틀림: Load 와 Add 사이에 다른 고루틴이 끼어든다
if r.received.Load() < limit {
r.received.Add(1)
}
// 고침: 읽은 값이 그대로일 때만 바꾼다
for {
cur := r.received.Load()
if cur >= limit {
return false
}
if r.received.CompareAndSwap(cur, cur+1) {
return true
}
}
한눈에 보기
| 항목 | 핵심 | 쓰는 곳 | 확인 방법 |
|---|---|---|---|
| go run -race | 실행 중 접근을 추적해 경쟁을 보고 | 개발·CI·테스트 | 종료 코드 66, DATA RACE 보고서 |
| Mutex | 구간을 한 번에 한 고루틴만 실행 | 맵, 슬라이스, 여러 필드 | 복사 금지, go vet |
| RWMutex | 읽기 동시 허용, 쓰기 배타 | 읽기 위주 표 | 측정으로 이득 확인 |
| atomic | 단일 연산의 원자성 | 카운터, 플래그 | 두 연산 묶음은 CAS 나 뮤텍스 |
| Once | 초기화를 한 번만 실행 | 설정, 자원 준비 | 실패 재시도 없음 |
| 잠금 범위 | 공유 상태 접근만 안에 둔다 | 모든 잠금 코드 | 잠금 안의 Sprintf, I/O 점검 |
| 접근 형태 | 안전한가 | 이유 | 대응 |
|---|---|---|---|
| 여럿이 읽기만 | 안전 | 쓰기가 없다 | 적재 뒤 수정하지 않는다 |
| 여럿이 쓰기 | 안전하지 않음 | 런타임이 종료시킬 수 있다 | Mutex |
| 쓰기 하나와 읽기 여럿 | 안전하지 않음 | 읽기가 쓰기와 겹친다 | RWMutex |
| 맵을 통째로 교체 | 잠금이 있어야 안전 | 참조 읽기와 쓰기가 겹친다 | 쓰기 잠금 안에서 교체 |
연습 문제
- 문제 상황의 코드(
counts[o.Store]++를 고루틴마다 실행)를 뮤텍스로 고쳐 쓰고,WaitGroup으로 끝을 기다린 뒤 출력하는 코드의 핵심 부분을 작성하라. FeeTable.Get의RLock을Lock으로 바꾸면 결과가 틀려지는가, 달라지는 점은 무엇인가. 어떤 경우에 이 변경이 오히려 합리적인가.- 다음 코드에서 잠금 안에 있어도 되지 않는 일을 찾아 밖으로 옮겨라.
func (r *Recorder) AddOrder(o Order) { r.mu.Lock() defer r.mu.Unlock() line := fmt.Sprintf("주문 %02d %s", o.ID, o.Store) r.lines = append(r.lines, line) } - 접수 건수가
limit을 넘지 않게 예약하는TryReserve(limit int64) bool을atomic.Int64로 작성하라.
정답과 해설
- 맵을 구조체에 넣고 뮤텍스로 감싼다. 고루틴이 끝나기 전에 읽지 않도록
Wait이후에 출력한다.
읽는 쪽도var ( mu sync.Mutex counts = make(map[string]int) wg sync.WaitGroup ) for _, o := range orders { wg.Go(func() { mu.Lock() counts[o.Store]++ mu.Unlock() }) } wg.Wait() fmt.Println(len(counts))Wait뒤에 있으므로 별도의 잠금은 필요 없다. 고루틴이 계속 도는 중에 읽는다면 읽기에도 잠금이 필요하다. - 결과는 틀려지지 않는다. 쓰기 잠금도 읽기를 막으므로 올바르게 동작한다. 달라지는 것은 읽기끼리도 서로 기다리게 된다는 점이다. 임계 구간이 맵 조회 한 번처럼 아주 짧고 경쟁이 심하지 않다면
Mutex가 더 단순하고 비슷하거나 더 빠를 수 있다. 읽기 구간이 길고 읽는 고루틴이 많을 때RWMutex가 이득을 낸다. 어느 쪽인지는 측정으로 정한다. fmt.Sprintf는 공유 상태를 건드리지 않는다. 밖으로 옮긴다.func (r *Recorder) AddOrder(o Order) { line := fmt.Sprintf("주문 %02d %s", o.ID, o.Store) r.mu.Lock() defer r.mu.Unlock() r.lines = append(r.lines, line) }- 읽은 값이 바뀌지 않았을 때만 증가시키고, 바뀌었으면 다시 읽는다.
func (r *Relay) TryReserve(limit int64) bool { for { cur := r.received.Load() if cur >= limit { return false } if r.received.CompareAndSwap(cur, cur+1) { return true } } }Load후Add로 쓰면 두 고루틴이 같은 값을 보고 둘 다 통과할 수 있다.CompareAndSwap은 하나만 성공하므로 상한이 지켜진다.
다음 장에서는 이렇게 고친 코드가 계속 올바르게 유지되도록, 표 주도 테스트와 서브테스트, 퍼징으로 테스트를 깊게 만드는 방법을 다룬다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.