Go에서 고루틴끼리 대화하는 주된 수단은 채널(Channel)입니다.
하지만 채널이 유일한 방법은 아닙니다.
때로는 조건 변수(Condition Variable)나 원자적 초기화 같은 도구가 더 효율적일 때가 있습니다.
이번 장에서는 채널 너머의 고급 동기화 기법들을 탐험해 봅시다.
시그널링 (Signaling)과 조건 변수 (Cond)#
어떤 숫자가 준비될 때까지 기다렸다가, 준비되면 “행운의 숫자인지” 확인하는 로직이 있다고 합시다.
채널을 쓰면 간단하지만, 채널 없이 sync.Cond를 써서 구현할 수도 있습니다.
sync.Cond는 **뮤텍스(Mutex)**와 짝꿍입니다.
cond := sync.NewCond(&sync.Mutex{}) // 뮤텍스와 함께 생성
주요 메서드는 두 가지입니다:
Wait(): 뮤텍스를 잠시 풀고, 신호가 올 때까지 대기(블로킹)합니다. 깨어나면 다시 뮤텍스를 잠급니다.Signal(): 대기 중인 고루틴 하나를 깨웁니다.
// 생성자 (Generator)
wg.Go(func() {
time.Sleep(10 * time.Millisecond)
cond.L.Lock() // (1) 잠금
num = 1 + rand.IntN(100)
cond.Signal() // (2) "준비됐어!" 신호 보냄
cond.L.Unlock() // (3) 잠금 해제
})
// 확인자 (Checker)
wg.Go(func() {
cond.L.Lock()
for num == 0 { // (4) 아직 준비 안 됐으면
cond.Wait() // (5) 대기 (잠금 풀고 잠들었다가, 신호 오면 깨서 다시 잠금)
}
// ... 숫자 확인 ...
cond.L.Unlock()
})
핵심 포인트: Wait()는 항상 for 루프 안에서 호출해야 합니다.
신호를 받고 깨어났더라도, 그 찰나의 순간에 다른 고루틴이 상태를 바꿔버렸을 수도 있기 때문입니다.
(이를 “Spurious wakeup"이라고도 합니다.)
브로드캐스팅 (Broadcasting)#
Signal()은 대기자 중 딱 한 명만 깨웁니다.
만약 여러 고루틴이 동시에 이 숫자를 기다리고 있다면요? 모두에게 “일어나!“라고 소리치고 싶다면 Broadcast()를 쓰면 됩니다.
// 생성자
func (l *Lucky) Guess() {
l.cond.L.Lock()
l.num = 1 + rand.IntN(100)
l.cond.Broadcast() // (1) "모두 일어나세요!"
l.cond.L.Unlock()
}
이제 Wait() 하고 있던 모든 고루틴이 동시에 깨어나서 각자 할 일을 합니다.
이런 일대다(1:N) 통신은 채널로 구현하기가 까다로운데, Cond를 쓰면 아주 직관적입니다.
채널로 브로드캐스팅?#
채널을 닫아서(close) 브로드캐스팅 효과를 낼 수도 있습니다.
채널이 닫히면 대기하던 모든 수신자가 깨어나니까요.
하지만 채널은 딱 한 번만 닫을 수 있다는 치명적인 단점이 있습니다.
반복적인 이벤트 알림에는 sync.Cond가 제격입니다.
발행/구독 (Publish/Subscribe) 패턴#
Cond를 쓰지 않고 채널만으로 1:N 통신을 하려면 직접 발행/구독 시스템을 만들어야 합니다.
구독자 리스트를 관리하고, 이벤트가 발생하면 for 문을 돌며 구독자들의 채널에 메시지를 쏘는 방식이죠.
func (l *Lucky) Guess() {
subs := <-l.sbox // 구독자 목록 가져오기
num := 1 + rand.IntN(100)
for _, sub := range subs {
select {
case sub <- num: // 각 채널에 전송
default: // 못 받으면 스킵 (Non-blocking)
}
}
l.sbox <- subs
}
이 방식은 구현이 좀 복잡하지만, select를 활용해 “준비 안 된 구독자는 건너뛰기” 같은 유연한 처리가 가능합니다.
단 한 번만 실행 (Run once) - sync.Once#
초기화 로직처럼 프로그램 수명 동안 딱 한 번만 실행되어야 하는 코드가 있다면 sync.Once가 정답입니다.
여러 고루틴이 동시에 달려들어도, 오직 승리한 한 명만 실행하고 나머지는 그 실행이 끝날 때까지 기다립니다.
var once sync.Once
func (c *CurrencyConverter) Convert(...) float64 {
once.Do(c.init) // (1) c.init은 평생 딱 한 번만 실행됨
return ...
}
이제 뮤텍스나 플래그(initialized = true) 검사를 직접 짤 필요가 없습니다.
데이터 경쟁 없이 안전하고 깔끔하게 초기화할 수 있죠.
편리한 Once 함수들 (Go 1.21+)#
최신 Go에서는 더 편리한 함수들을 제공합니다.
sync.OnceFunc: 한 번만 실행되는 함수를 반환합니다.sync.OnceValue: 한 번만 실행되고, 그 결과값을 계속 반환하는 함수를 만듭니다. (캐싱 효과!)
// 랜덤 숫자를 딱 한 번만 생성하고, 이후엔 계속 그 값만 리턴
initN := sync.OnceValue(randomN)
fmt.Println(initN()) // 42
fmt.Println(initN()) // 42 (다시 계산 안 함)
객체 풀 (Object pool) - sync.Pool#
메모리 할당(make)은 공짜가 아닙니다.
짧은 수명을 가진 객체를 반복적으로 생성하고 버리면 가비지 컬렉터(GC)가 힘들어합니다.
이때 sync.Pool을 쓰면 객체를 재사용할 수 있습니다.
pool := sync.Pool{
New: func() any { return new([]byte) }, // (1) 없으면 새로 생성
}
// 사용
buf := pool.Get().(*[]byte) // (2) 풀에서 꺼내기
// ... 작업 ...
pool.Put(buf) // (3) 다 썼으면 반납
벤치마크를 돌려보면 메모리 할당 횟수가 획기적으로 줄어드는 것을 볼 수 있습니다.
하지만 주의할 점은 풀에 들어간 객체는 언제든 GC에 의해 수거될 수 있다는 것입니다.
따라서 영구적인 저장소로는 사용할 수 없고, 임시 버퍼 등을 최적화할 때 주로 쓰입니다.
이번 장에서는 채널 외의 동기화 도구들을 살펴봤습니다.
- sync.Cond: 1:N 알림 (브로드캐스팅)
- sync.Once: 단 한 번 실행 보장 (초기화)
- sync.Pool: 객체 재사용 (GC 부담 경감)
이 도구들은 특정한 상황에서 채널보다 훨씬 강력하고 효율적입니다.
하지만 남용하지는 마세요. 대부분의 경우엔 여전히 채널이 가장 Go다운 해결책입니다.
다음 장에서는 동시성 프로그래밍의 가장 밑바닥, 원자적 연산(Atomics)의 세계로 내려가 보겠습니다.