Go의 sync 패키지: 동시성 동기화 기술 세트
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Go 언어의 sync
표준 라이브러리 패키지에 대한 상세 설명
Go 언어의 동시성 프로그래밍에서 sync
표준 라이브러리 패키지는 동시성 동기화를 구현하기 위한 일련의 타입을 제공합니다. 이러한 타입은 다양한 메모리 순서 요구 사항을 충족할 수 있습니다. 채널과 비교할 때 특정 시나리오에서 사용하면 효율적일 뿐만 아니라 코드 구현을 더욱 간결하고 명확하게 만들 수 있습니다. 다음은 sync
패키지에서 일반적으로 사용되는 몇 가지 타입과 그 사용 방법을 자세히 소개합니다.
1. sync.WaitGroup
타입 (대기 그룹)
sync.WaitGroup
은 Goroutine 간의 동기화를 달성하는 데 사용되며, 하나 이상의 Goroutine이 다른 여러 Goroutine이 작업을 완료할 때까지 기다릴 수 있도록 합니다. 각 sync.WaitGroup
값은 내부적으로 카운트를 유지하며, 이 카운트의 초기 기본값은 0입니다.
1.1 메서드 소개
sync.WaitGroup
타입은 세 가지 핵심 메서드를 포함합니다.
Add(delta int)
:WaitGroup
이 유지하는 카운트를 변경하는 데 사용됩니다. 양의 정수delta
가 전달되면 카운트는 해당 값만큼 증가합니다. 음수가 전달되면 카운트는 해당 값만큼 감소합니다.Done()
:Add(-1)
의 동일한 단축키이며, Goroutine 작업이 완료되면 카운트를 1씩 감소시키는 데 일반적으로 사용됩니다.Wait()
: Goroutine이 이 메서드를 호출할 때 카운트가 0이면 이 작업은 no-op(no operation)입니다. 카운트가 양의 정수이면 현재 Goroutine은 차단 상태가 되며 카운트가 0이 될 때까지 실행 상태로 다시 들어가지 않습니다. 즉,Wait()
메서드가 반환됩니다.
wg.Add(delta)
, wg.Done()
및 wg.Wait()
는 각각 (&wg).Add(delta)
, (&wg).Done()
및 (&wg).Wait()
의 약어입니다. Add(delta)
또는 Done()
호출로 인해 카운트가 음수가 되면 프로그램이 패닉 상태가 됩니다.
1.2 사용 예시
package main import ( "fmt" "math/rand" "sync" "time" ) func main() { rand.Seed(time.Now().UnixNano()) // Go 1.20 이전에는 필수 const N = 5 var values [N]int32 var wg sync.WaitGroup wg.Add(N) for i := 0; i < N; i++ { i := i go func() { values[i] = 50 + rand.Int31n(50) fmt.Println("Done:", i) wg.Done() // <=> wg.Add(-1) }() } wg.Wait() // 모든 요소가 초기화되었는지 확인합니다. fmt.Println("values:", values) }
위의 예제에서 주 Goroutine은 wg.Add(N)
을 통해 대기 그룹의 카운트를 5로 설정한 다음 5개의 Goroutine을 시작합니다. 각 Goroutine은 작업을 완료한 후 wg.Done()
을 호출하여 카운트를 1씩 감소시킵니다. 주 Goroutine은 wg.Wait()
를 호출하여 5개의 Goroutine이 모두 작업을 완료하고 카운트가 0이 될 때까지 차단한 다음 후속 코드를 계속 실행하여 각 요소의 값을 출력합니다.
또한 Add
메서드 호출은 다음과 같이 여러 번 분할될 수도 있습니다.
... var wg sync.WaitGroup for i := 0; i < N; i++ { wg.Add(1) // 5번 실행됩니다. i := i go func() { values[i] = 50 + rand.Int31n(50) wg.Done() }() } ...
*sync.WaitGroup
값의 Wait
메서드는 여러 Goroutine에서 호출할 수 있습니다. 해당 sync.WaitGroup
값에 의해 유지되는 카운트가 0으로 떨어지면 이러한 Goroutine은 모두 알림을 받고 차단 상태를 종료합니다.
func main() { rand.Seed(time.Now().UnixNano()) // Go 1.20 이전에는 필수 const N = 5 var values [N]int32 var wgA, wgB sync.WaitGroup wgA.Add(N) wgB.Add(1) for i := 0; i < N; i++ { i := i go func() { wgB.Wait() // 브로드캐스트 알림을 기다립니다. log.Printf("values[%v]=%v \n", i, values[i]) wgA.Done() }() } // 다음 루프는 위의 wg.Wait 호출이 끝나기 전에 실행되도록 보장됩니다. for i := 0; i < N; i++ { values[i] = 50 + rand.Int31n(50) } wgB.Done() // 브로드캐스트 알림을 보냅니다. wgA.Wait() }
WaitGroup
은 Wait
메서드가 반환된 후 재사용할 수 있습니다. 그러나 WaitGroup
값에 의해 유지되는 기본 숫자가 0일 때 양의 정수 인수를 사용하여 Add
메서드를 호출하는 것은 Wait
메서드 호출과 동시에 실행할 수 없습니다. 그렇지 않으면 데이터 경쟁 문제가 발생할 수 있습니다.
2. sync.Once
타입
sync.Once
타입은 동시 프로그램에서 코드 조각이 한 번만 실행되도록 하는 데 사용됩니다. 각 *sync.Once
값에는 func()
타입의 매개변수를 허용하는 Do(f func())
메서드가 있습니다.
2.1 메서드 특징
주소 지정 가능한 sync.Once
값 o
의 경우 o.Do()
(즉, (&o).Do()
의 약어) 메서드 호출은 여러 Goroutine에서 동시에 여러 번 실행할 수 있으며 이러한 메서드 호출의 인수는 동일한 함수 값이어야 합니다(필수 사항은 아님). 이러한 호출 중에서 인자 함수(값) 중 하나만 호출되고 호출된 인자 함수는 모든 o.Do()
메서드가 반환되기 전에 종료되도록 보장됩니다. 즉, 호출된 인자 함수 내부의 코드는 모든 o.Do()
메서드가 호출을 반환하기 전에 실행됩니다.
2.2 사용 예시
package main import ( "log" "sync" ) func main() { log.SetFlags(0) x := 0 doSomething := func() { x++ log.Println("Hello") } var wg sync.WaitGroup var once sync.Once for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() once.Do(doSomething) log.Println("world!") }() } wg.Wait() log.Println("x =", x) // x = 1 }
위의 예제에서 5개의 Goroutine이 모두 once.Do(doSomething)
을 호출하지만 doSomething
함수는 한 번만 실행됩니다. 따라서 "Hello"는 한 번만 출력되는 반면 "world!"는 5번 출력되고 "Hello"는 모든 5개의 "world!" 출력 전에 반드시 출력됩니다.
3. sync.Mutex
(뮤텍스 잠금) 및 sync.RWMutex
(읽기-쓰기 잠금) 타입
*sync.Mutex
및 *sync.RWMutex
타입은 모두 sync.Locker
인터페이스 타입을 구현합니다. 따라서 이러한 두 타입 모두 데이터를 보호하고 여러 사용자가 동시에 읽고 수정하는 것을 방지하는 데 사용되는 Lock()
및 Unlock()
메서드를 포함합니다.
3.1 sync.Mutex
(뮤텍스 잠금)
- 기본 특징:
Mutex
의 제로 값은 잠금 해제된 뮤텍스입니다. 주소 지정 가능한Mutex
값m
의 경우 잠금 해제 상태일 때m.Lock()
메서드를 호출해야만 성공적으로 잠글 수 있습니다.m
값이 잠기면 새 잠금 시도로 인해 현재 Goroutine은m.Unlock()
메서드를 호출하여 잠금 해제될 때까지 차단 상태가 됩니다.m.Lock()
및m.Unlock()
은 각각(&m).Lock()
및(&m).Unlock()
의 약어입니다. - 사용 예시
package main import ( "fmt" "runtime" "sync" ) type Counter struct { m sync.Mutex n uint64 } func (c *Counter) Value() uint64 { c.m.Lock() defer c.m.Unlock() return c.n } func (c *Counter) Increase(delta uint64) { c.m.Lock() c.n += delta c.m.Unlock() } func main() { var c Counter for i := 0; i < 100; i++ { go func() { for k := 0; k < 100; k++ { c.Increase(1) } }() } // 이 루프는 데모 목적으로만 사용됩니다. for c.Value() < 10000 { runtime.Gosched() } fmt.Println(c.Value()) // 10000 }
위의 예제에서 Counter
구조체는 Mutex
필드 m
을 사용하여 필드 n
이 여러 Goroutine에 의해 동시에 액세스되고 수정되지 않도록 하여 데이터의 일관성과 정확성을 보장합니다.
3.2 sync.RWMutex
(읽기-쓰기 뮤텍스 잠금)
- 기본 특징:
sync.RWMutex
는 내부적으로 쓰기 잠금과 읽기 잠금의 두 잠금을 포함합니다.Lock()
및Unlock()
메서드 외에도*sync.RWMutex
타입에는 여러 읽기 프로그램이 동시에 데이터를 읽을 수 있도록 지원하지만 데이터를 작성기와 다른 데이터 액세스 프로그램(읽기 프로그램 및 작성기 포함)이 동시에 사용하는 것을 방지하는 데 사용되는RLock()
및RUnlock()
메서드도 있습니다.rwm
의 읽기 잠금은 카운트를 유지합니다.rwm.RLock()
호출이 성공하면 카운트가 1씩 증가합니다.rwm.RUnlock()
호출이 성공하면 카운트가 1씩 감소합니다. 카운트가 0이면 읽기 잠금이 잠금 해제 상태임을 나타내고 카운트가 0이 아니면 읽기 잠금이 잠긴 상태임을 나타냅니다.rwm.Lock()
,rwm.Unlock()
,rwm.RLock()
및rwm.RUnlock()
은 각각(&rwm).Lock()
,(&rwm).Unlock()
,(&rwm).RLock()
및(&rwm).RUnlock()
의 약어입니다. - 잠금 규칙
rwm
의 쓰기 잠금은 쓰기 잠금과 읽기 잠금이 모두 잠금 해제 상태일 때만 성공적으로 잠글 수 있습니다. 즉, 쓰기 잠금은 언제든지 최대 하나의 데이터 작성기에서만 성공적으로 잠글 수 있으며 쓰기 잠금과 읽기 잠금을 동시에 잠글 수 없습니다.rwm
의 쓰기 잠금이 잠긴 상태이면 새 쓰기 잠금 또는 읽기 잠금 작업으로 인해 현재 Goroutine은 쓰기 잠금이 잠금 해제될 때까지 차단 상태가 됩니다.rwm
의 읽기 잠금이 잠긴 상태이면 새 쓰기 잠금 작업으로 인해 현재 Goroutine은 차단 상태가 됩니다. 그리고 새로운 읽기 잠금 작업은 특정 조건(차단된 쓰기 잠금 작업이 발생하기 전)에서 성공합니다. 즉, 읽기 잠금은 여러 데이터 읽기 프로그램이 동시에 보유할 수 있습니다. 읽기 잠금에 의해 유지되는 카운트가 0으로 지워지면 읽기 잠금이 잠금 해제 상태로 돌아갑니다.- 데이터 작성기가 부족해지는 것을 방지하기 위해 읽기 잠금이 잠긴 상태이고 차단된 쓰기 잠금 작업이 있는 경우 후속 읽기 잠금 작업이 차단됩니다. 데이터 읽기 프로그램이 부족해지는 것을 방지하기 위해 쓰기 잠금이 잠긴 상태이면 쓰기 잠금이 잠금 해제된 후 이전에 차단된 읽기 잠금 작업이 반드시 성공합니다.
- 사용 예시
package main import ( "fmt" "time" "sync" ) func main() { var m sync.RWMutex go func() { m.RLock() fmt.Print("a") time.Sleep(time.Second) m.RUnlock() }() go func() { time.Sleep(time.Second * 1 / 4) m.Lock() fmt.Print("b") time.Sleep(time.Second) m.Unlock() }() go func() { time.Sleep(time.Second * 2 / 4) m.Lock() fmt.Print("c") m.Unlock() }() go func () { time.Sleep(time.Second * 3 / 4) m.RLock() fmt.Print("d") m.RUnlock() }() time.Sleep(time.Second * 3) fmt.Println() }
위의 프로그램은 읽기-쓰기 잠금의 잠금 규칙을 설명하고 확인하는 데 사용되는 abdc
를 출력할 가능성이 가장 높습니다. 프로그램에서 Goroutine 간의 동기화를 위해 time.Sleep
호출을 사용하는 것은 프로덕션 코드에서 사용해서는 안 됩니다.
실제 애플리케이션에서 읽기 작업이 빈번하고 쓰기 작업이 적은 경우 Mutex
를 RWMutex
로 바꾸어 실행 효율성을 높일 수 있습니다. 예를 들어 위의 Counter
예제에서 Mutex
를 RWMutex
로 바꿉니다.
... type Counter struct { //m sync.Mutex m sync.RWMutex n uint64 } func (c *Counter) Value() uint64 { //c.m.Lock() //defer c.m.Unlock() c.m.RLock() defer c.m.RUnlock() return c.n } ...
또한 sync.Mutex
및 sync.RWMutex
값은 알림을 구현하는 데 사용할 수도 있지만 이는 Go에서 가장 세련된 구현은 아닙니다. 예를 들어:
package main import ( "fmt" "sync" "time" ) func main() { var m sync.Mutex m.Lock() go func() { time.Sleep(time.Second) fmt.Println("Hi") m.Unlock() // 알림을 보냅니다. }() m.Lock() // 알림을 기다립니다. fmt.Println("Bye") }
이 예제에서는 Mutex
를 통해 Goroutine 간의 간단한 알림을 구현하여 "Hi"가 "Bye"보다 먼저 인쇄되도록 합니다. sync.Mutex
및 sync.RWMutex
값과 관련된 메모리 순서 보장에 대해서는 Go의 메모리 순서 보장에 대한 관련 문서를 참조할 수 있습니다.
sync
표준 라이브러리 패키지의 타입은 Go 언어의 동시성 프로그래밍에서 중요한 역할을 합니다. 개발자는 효율적이고 안정적이며 스레드 안전한 동시 프로그램을 작성하기 위해 특정 비즈니스 시나리오 및 요구 사항에 따라 이러한 동기화 타입을 합리적으로 선택하고 올바르게 사용해야 합니다. 동시에 동시 코드를 작성할 때는 데이터 경쟁, 교착 상태 등과 같은 동시 프로그래밍의 다양한 개념과 잠재적인 문제에 대한 심층적인 이해가 필요하며 충분한 테스트와 검증을 통해 동시 환경에서 프로그램의 정확성과 안정성을 보장해야 합니다.
Leapcell: 최고의 서버리스 웹 호스팅
마지막으로, Go 서비스를 배포하는 데 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
🚀 좋아하는 언어로 빌드
JavaScript, Python, Go 또는 Rust로 간편하게 개발하세요.
🌍 무제한 프로젝트를 무료로 배포
사용한 만큼만 지불하세요. 요청도 없고 요금도 없습니다.
⚡ 사용량에 따라 지불, 숨겨진 비용 없음
유휴 요금 없이 원활한 확장성만 제공됩니다.
🔹 Twitter에서 팔로우하세요: @LeapcellHQ