Go의 sync.WaitGroup 내부: 고루틴 동기화의 숨겨진 이야기
Wenhao Wang
Dev Intern · Leapcell

sync.WaitGroup 원리와 응용에 대한 심층 분석
1. sync.WaitGroup의 핵심 기능 개요
1.1 동시성 시나리오에서의 동기화 필요성
Go 언어의 동시성 프로그래밍 모델에서 복잡한 작업을 여러 독립적인 하위 작업으로 분할하여 병렬로 실행해야 하는 경우, 고루틴의 스케줄링 메커니즘으로 인해 하위 작업이 완료되지 않은 상태에서 메인 고루틴이 조기에 종료될 수 있습니다. 이때 메인 고루틴이 모든 하위 작업이 완료될 때까지 기다린 후 후속 로직을 계속 실행하도록 보장하는 메커니즘이 필요합니다. sync.WaitGroup은 이러한 고루틴 동기화 문제를 해결하기 위해 설계된 핵심 도구입니다.
1.2 기본 사용 패러다임
핵심 메서드 정의
- Add(delta int): 대기할 하위 작업 수를 설정하거나 조정합니다.
delta
는 양수 또는 음수일 수 있습니다(음수 값은 대기 수를 줄이는 것을 의미). - Done(): 하위 작업이 완료될 때 호출되며,
Add(-1)
과 같습니다. - Wait(): 대기할 모든 하위 작업이 완료될 때까지 현재 고루틴을 블록합니다.
일반적인 코드 예제
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup wg.Add(2) // 대기할 하위 작업 수를 2로 설정 go func() { defer wg.Done() // 하위 작업이 완료될 때 표시 fmt.Println("하위 작업 1 실행됨") }() go func() { defer wg.Done() fmt.Println("하위 작업 2 실행됨") }() // 모든 하위 작업이 완료될 때까지 블록 fmt.Println("메인 고루틴이 계속 실행됨") }
실행 논리 설명
- 메인 고루틴은
Add(2)
를 통해 2개의 하위 작업을 기다려야 한다고 선언합니다. - 하위 작업은
Done()
을 통해 완료를 알리고 내부적으로Add(-1)
을 호출하여 카운터를 줄입니다. Wait()
는 카운터가 0에 도달할 때까지 계속 블록되고 메인 고루틴은 실행을 재개합니다.
2. 소스 코드 구현 및 데이터 구조 분석(Go 1.17.10 기반)
2.1 메모리 레이아웃 및 데이터 구조 설계
type WaitGroup struct { noCopy noCopy // 구조체가 복사되는 것을 방지하는 마커 state1 [3]uint32 // 복합 데이터 저장 영역 }
필드 분석
-
noCopy 필드 Go 언어의
go vet
정적 검사 메커니즘을 통해WaitGroup
인스턴스가 복사되어 발생하는 상태 불일치를 방지하기 위해 복사를 금지합니다. 이 필드는 본질적으로 사용되지 않는 구조체이며 컴파일 시간 검사를 트리거하는 데만 사용됩니다. -
state1 배열 32비트 및 64비트 시스템의 메모리 정렬 요구 사항과 호환되는 세 가지 유형의 핵심 데이터를 저장하기 위해 컴팩트한 메모리 레이아웃을 사용합니다.
- 64비트 시스템:
state1[0]
: 카운터, 완료해야 할 남은 하위 작업 수를 기록합니다.state1[1]
: 웨이터 수,Wait()
를 호출한 고루틴 수를 기록합니다.state1[2]
: 세마포어, 고루틴 간의 블로킹 및 웨이크업에 사용됩니다.
- 32비트 시스템:
state1[0]
: 세마포어.state1[1]
: 카운터.state1[2]
: 웨이터 수.
- 64비트 시스템:
메모리 정렬 최적화
counter
와 waiter
를 64비트 정수로 결합하여(상위 32비트는 counter
, 하위 32비트는 waiter
) 64비트 시스템에서 자연 정렬이 보장되어 원자적 작업의 효율성이 향상됩니다. 32비트 시스템에서는 64비트 데이터 블록의 주소 정렬을 보장하기 위해 세마포어의 위치가 조정됩니다.
2.2 핵심 메서드의 구현 세부 정보
2.2.1 state() 메서드: 데이터 추출 로직
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { // 메모리 정렬 방법을 결정 if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { // 64비트 정렬: 처음 두 개의 uint32가 상태를 형성하고 세 번째는 세마포어입니다. return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2] } else { // 32비트 정렬: 마지막 두 개의 uint32가 상태를 형성하고 첫 번째는 세마포어입니다. return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0] } }
- 포인터 주소의 정렬 특성을 통해 배열에서 데이터의 분포를 동적으로 결정합니다.
unsafe.Pointer
를 사용하여 기본 메모리 액세스를 달성하고 플랫폼 간 호환성을 보장합니다.
2.2.2 Add(delta int) 메서드: 카운터 업데이트 로직
func (wg *WaitGroup) Add(delta int) { statep, semap := wg.state() // 카운터(상위 32비트)를 원자적으로 업데이트합니다. state := atomic.AddUint64(statep, uint64(delta)<<32) v := int32(state >> 32) // 카운터를 추출합니다. w := uint32(state) // 웨이터 수를 추출합니다. // 카운터는 음수일 수 없습니다. if v < 0 { panic("sync: 음수 WaitGroup 카운터") } // Wait가 실행 중일 때 Add를 동시에 호출하는 것을 금지합니다. if w != 0 && delta > 0 && v == int32(delta) { panic("sync: WaitGroup 오용: Wait와 동시에 Add가 호출되었습니다.") } // 카운터가 0이고 웨이터가 있는 경우 세마포어를 해제합니다. if v == 0 && w != 0 { *statep = 0 // 상태를 재설정합니다. for ; w > 0; w-- { runtime_Semrelease(semap, false, 0) // 대기 중인 고루틴을 웨이크업합니다. } } }
- 핵심 로직: 원자적 작업을 통해 카운터 업데이트의 스레드 안전성을 보장합니다. 카운터가 0이고 대기 중인 고루틴이 있는 경우 세마포어 해제 메커니즘을 통해 모든 웨이터를 웨이크업합니다.
- 예외 처리: 프로그램 논리 오류를 방지하기 위해 음수 카운터 및 동시 호출과 같은 잘못된 작업을 엄격하게 검사합니다.
2.2.3 Wait() 메서드: 블로킹 및 웨이크업 메커니즘
func (wg *WaitGroup) Wait() { statep, semap := wg.state() for { state := atomic.LoadUint64(statep) // 상태를 원자적으로 읽습니다. v := int32(state >> 32) w := uint32(state) if v == 0 { // 카운터가 0이면 직접 반환합니다. return } // CAS 작업을 사용하여 웨이터 수를 안전하게 늘립니다. if atomic.CompareAndSwapUint64(statep, state, state+1) { runtime_Semacquire(semap) // 현재 고루틴을 블록하고 세마포어가 해제될 때까지 기다립니다. // 상태 일관성을 확인합니다. if *statep != 0 { panic("sync: 이전 Wait가 반환되기 전에 WaitGroup이 재사용되었습니다.") } return } } }
- 스핀 대기: 루프 CAS 작업을 통해 웨이터 수의 안전한 증가를 보장하여 경쟁 조건을 방지합니다.
- 세마포어 블로킹:
Add
또는Done
작업이 고루틴을 웨이크업하기 위해 세마포어를 해제할 때까지 블로킹 상태로 들어가기 위해runtime_Semacquire
를 호출합니다.
2.2.4 Done() 메서드: 빠른 카운터 감소
func (wg *WaitGroup) Done() { wg.Add(-1) // 카운터를 1씩 감소시키는 것과 같습니다. }
3. 사용 사양 및 주의 사항
3.1 주요 사용 원칙
-
순서 요구 사항 초기화되지 않은 카운터로 인해 대기 논리가 실패하는 것을 방지하려면
Add
작업이Wait
호출 전에 완료되어야 합니다. -
카운트 일관성
Done
호출 수는Add
에서 설정한 초기 카운트와 일치해야 합니다. 그렇지 않으면 카운터가 0에 도달하지 못하여 영구적인 블로킹이 발생할 수 있습니다. -
동시 작업 금지
Wait
가 실행되는 동안Add
를 동시에 호출하는 것은 엄격히 금지되며, 그렇지 않으면 패닉이 발생합니다.WaitGroup
을 재사용할 때는 이전Wait
가 반환되었는지 확인하여 상태 혼동을 방지하십시오.
3.2 일반적인 오류 시나리오
오류 작업 | 결과 | 예제 코드 |
---|---|---|
음수 카운터 | 패닉 | wg.Add(-1) (초기 카운트가 0인 경우) |
Add 및 Wait의 동시 호출 | 패닉 | 메인 고루틴이 Wait 를 호출하는 동안 하위 작업이 Add 를 호출합니다. |
Done의 페어링되지 않은 호출 | 영구적 블로킹 | wg.Add(1) 후 Done 이 호출되지 않습니다. |
4. 요약
sync.WaitGroup
은 Go 언어 동시성 프로그래밍에서 고루틴 동기화를 처리하기 위한 기본 도구입니다. 이 도구의 디자인은 메모리 정렬 최적화, 원자적 작업 안전 및 오류 검사와 같은 엔지니어링 실무 원칙을 완벽하게 반영합니다. 데이터 구조와 구현 논리를 깊이 이해함으로써 개발자는 이 도구를 더 안전하고 효율적으로 사용하고 동시 시나리오에서 일반적인 함정을 피할 수 있습니다. 실제 적용에서는 프로그램의 정확성과 안정성을 보장하기 위해 카운트 일치 및 순차적 호출과 같은 사양을 엄격히 준수해야 합니다.
Leapcell: 최고의 서버리스 웹 호스팅
마지막으로 Go 서비스를 배포하는 데 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
JavaScript, Python, Go 또는 Rust로 간편하게 개발하십시오.
사용한 만큼만 지불하십시오. 요청이나 요금이 없습니다.
유휴 요금이 없고 원활한 확장성만 있습니다.
🔹 Twitter에서 팔로우하세요: @LeapcellHQ