Go에서 sync vs. channel 사용 시기
Emily Parker
Product Engineer · Leapcell

sync
와 channel
중 선택하는 방법
C로 프로그래밍할 때 일반적으로 통신을 위해 공유 메모리를 사용합니다. 여러 스레드가 공유 데이터에서 동시에 작동할 때 데이터 안전성을 보장하고 스레드 동기화를 제어하기 위해 필요에 따라 뮤텍스를 사용하여 잠금 및 잠금 해제를 수행합니다.
그러나 Go에서는 채널을 사용하여 중요한 섹션 동기화 메커니즘을 완료하는 통신을 통해 메모리를 공유하는 것이 좋습니다.
즉, Go의 채널은 비교적 높은 수준의 기본 요소이며 자연스럽게 sync
패키지의 잠금 메커니즘에 비해 성능이 낮습니다. 관심이 있다면 간단한 벤치마크 테스트를 직접 작성하여 성능을 비교하고 결과를 댓글에서 논의할 수 있습니다.
또한 sync
패키지를 사용하여 동기화를 제어할 때 struct 객체의 소유권을 잃지 않으며 여러 goroutine이 중요한 섹션 리소스에 대한 액세스를 동기화하도록 여전히 허용할 수 있습니다. 따라서 요구 사항이 이 시나리오에 맞는 경우 동기화를 위해 sync
패키지를 사용하는 것이 더 합리적이고 효율적이므로 여전히 권장됩니다.
동기화를 위해 sync
패키지를 선택해야 하는 이유:
- 여러 goroutine이 중요한 섹션 리소스에 안전하게 액세스할 수 있도록 하면서 struct 제어를 잃고 싶지 않은 경우.
- 더 높은 성능이 필요한 경우.
sync
의 Mutex 및 RWMutex
sync
패키지의 소스 코드를 살펴보면 다음과 같은 구조가 포함되어 있음을 알 수 있습니다.
- Mutex
- RWMutex
- Once
- Cond
- Pool
atomic
패키지의 Atomic operations
이 중에서도 Mutex
가 가장 일반적으로 사용됩니다. 특히 채널 사용에 아직 익숙하지 않은 경우 Mutex가 매우 편리하다는 것을 알게 될 것입니다. 대조적으로 RWMutex
는 덜 자주 사용됩니다.
Mutex
와 RWMutex
를 사용할 때의 성능 차이에 주의를 기울인 적이 있습니까? 대부분의 사람들은 기본적으로 뮤텍스를 사용하므로 간단한 데모를 작성하여 성능을 비교해 보겠습니다.
var ( mu sync.Mutex murw sync.RWMutex tt1 = 1 tt2 = 2 tt3 = 3 ) // Mutex를 사용하여 데이터 읽기 func BenchmarkReadMutex(b *testing.B) { b.RunParallel(func(pp *testing.PB) { for pp.Next() { mu.Lock() _ = tt1 mu.Unlock() } }) } // RWMutex를 사용하여 데이터 읽기 func BenchmarkReadRWMutex(b *testing.B) { b.RunParallel(func(pp *testing.PB) { for pp.Next() { murw.RLock() _ = tt2 murw.RUnlock() } }) } // RWMutex를 사용하여 데이터 읽기 및 쓰기 func BenchmarkWriteRWMutex(b *testing.B) { b.RunParallel(func(pp *testing.PB) { for pp.Next() { murw.Lock() tt3++ murw.Unlock() } }) }
세 가지 간단한 벤치마크 테스트를 작성했습니다.
- 뮤텍스 잠금으로 데이터 읽기。
- 읽기-쓰기 잠금의 읽기 잠금으로 데이터 읽기。
- 읽기-쓰기 잠금으로 데이터 읽기 및 쓰기。
$ go test -bench . bbb_test.go --cpu 2 goos: windows goarch: amd64 cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz BenchmarkReadMutex-2 39638757 30.45 ns/op BenchmarkReadRWMutex-2 43082371 26.97 ns/op BenchmarkWriteRWMutex-2 16383997 71.35 ns/op $ go test -bench . bbb_test.go --cpu 4 goos: windows goarch: amd64 cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz BenchmarkReadMutex-4 17066666 73.47 ns/op BenchmarkReadRWMutex-4 43885633 30.33 ns/op BenchmarkWriteRWMutex-4 10593098 110.3 ns/op $ go test -bench . bbb_test.go --cpu 8 goos: windows goarch: amd64 cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz BenchmarkReadMutex-8 8969340 129.0 ns/op BenchmarkReadRWMutex-8 36451077 33.46 ns/op BenchmarkWriteRWMutex-8 7728303 158.5 ns/op $ go test -bench . bbb_test.go --cpu 16 goos: windows goarch: amd64 cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz BenchmarkReadMutex-16 8533333 132.6 ns/op BenchmarkReadRWMutex-16 39638757 29.98 ns/op BenchmarkWriteRWMutex-16 6751646 173.9 ns/op $ go test -bench . bbb_test.go --cpu 128 goos: windows goarch: amd64 cpu: Intel(R) Core(TM)2 Duo CPU T7700 @ 2.40GHz BenchmarkReadMutex-128 10155368 116.0 ns/op BenchmarkReadRWMutex-128 35108558 33.27 ns/op BenchmarkWriteRWMutex-128 6334021 195.3 ns/op
결과에서 볼 수 있듯이 동시성이 낮을 때는 뮤텍스 잠금 및 읽기 잠금(RWMutex에서)의 성능이 유사합니다. 동시성이 증가함에 따라 RWMutex의 읽기 잠금 성능은 크게 변하지 않지만 뮤텍스와 RWMutex 모두 동시성이 증가함에 따라 성능이 저하됩니다.
RWMutex는 읽기 중심, 쓰기 가벼운 시나리오에 적합하다는 것이 분명합니다. 많은 동시 읽기 작업이 있는 시나리오에서는 여러 goroutine이 읽기 잠금을 동시에 획득할 수 있으므로 잠금 경쟁 및 대기 시간이 줄어듭니다.
그러나 일반 뮤텍스를 사용하면 동시성 하에서 한 번에 하나의 goroutine만 잠금을 획득할 수 있습니다. 다른 goroutine은 차단되어 대기해야 하므로 성능에 부정적인 영향을 미칩니다.
예를 들어 실제로 일반 뮤텍스를 사용하는 경우 어떤 종류의 문제가 발생할 수 있는지 살펴보겠습니다.
sync
사용 시 주의 사항
sync
패키지의 잠금을 사용할 때는 이미 사용한 후에는 Mutex 또는 RWMutex를 복사해서는 안 됩니다. 복사해야 하는 경우 사용하기 전에만 복사하십시오.
다음은 간단한 데모입니다.
var mu sync.Mutex // 이미 사용한 후에는 Mutex 또는 RWMutex를 복사하지 마십시오. // 복사해야 하는 경우 사용하기 전에만 복사하십시오. func main() { go func(mm sync.Mutex) { for { mm.Lock() time.Sleep(time.Second * 1) fmt.Println("g2") mm.Unlock() } }(mu) mu.Lock() go func(mm sync.Mutex) { for { mm.Lock() time.Sleep(time.Second * 1) fmt.Println("g3") mm.Unlock() } }(mu) time.Sleep(time.Second * 1) fmt.Println("g1") mu.Unlock() time.Sleep(time.Second * 20) }
이 코드를 실행하면 "g3"
이 인쇄되지 않는 것을 알 수 있습니다. 즉, "g3"
을 포함하는 goroutine이 데드락되어 잠금 해제를 호출할 기회를 얻지 못합니다.
이유는 Mutex의 내부 구조에 있습니다. 살펴보겠습니다.
//... // Mutex는 처음 사용한 후에는 복사해서는 안 됩니다. //... type Mutex struct { state int32 sema uint32 }
Mutex struct에는 내부 state
(Mutex 상태를 나타냄)와 sema
(Mutex의 세마포어를 제어하는 데 사용됨)가 포함되어 있습니다. Mutex가 초기화되면 둘 다 0입니다. 그러나 Mutex를 잠그면 상태가 Locked로 변경됩니다. 이 시점에서 다른 goroutine이 이 Mutex를 복사하여 자체 컨텍스트에서 잠그면 데드락이 발생합니다. 이것은 명심해야 할 중요한 세부 사항입니다.
여러 goroutine이 Mutex를 사용해야 하는 시나리오가 있는 경우 클로저를 사용하거나 잠금을 래핑하는 구조체의 포인터 또는 주소를 전달할 수 있습니다. 이렇게 하면 잠금을 사용할 때 예기치 않은 결과를 방지할 수 있습니다.
sync.Once
sync
패키지의 다른 멤버를 얼마나 자주 사용합니까? 더 자주 사용되는 것 중 하나는 sync.Once
입니다. sync.Once
를 사용하는 방법과 주의해야 할 사항을 살펴보겠습니다.
C 또는 C++에서 싱글톤(프로그램 수명 주기 동안 하나의 인스턴스만)이 필요한 경우 Singleton 패턴을 자주 사용합니다. 여기서 sync.Once
는 Go에서 싱글톤을 구현하는 데 적합합니다.
sync.Once
는 특정 함수가 프로그램 수명 동안 한 번만 실행되도록 보장합니다. 이는 패키지당 한 번 호출되는 init
함수보다 더 유연합니다.
주의할 점: sync.Once
내에서 실행되는 함수가 패닉을 발생시키더라도 여전히 실행된 것으로 간주됩니다. 그 후 sync.Once
에 다시 들어가려는 로직은 함수를 실행하지 않습니다.
일반적으로 sync.Once
는 반복적인 작업을 피하기 위해 개체/리소스 초기화 및 정리에 사용됩니다. 다음은 데모입니다.
- main 함수는 3개의 goroutine을 시작하고
sync.WaitGroup
을 사용하여 자식 goroutine이 종료될 때까지 관리하고 기다립니다. - 모든 goroutine을 시작한 후 main 함수는 2초 동안 기다린 다음 인스턴스를 만들고 가져오려고 시도합니다.
- 각 goroutine은 또한 인스턴스를 가져오려고 시도합니다.
- goroutine 중 하나가 Once에 들어가 로직을 실행하자마자 패닉이 발생합니다.
- 패닉이 발생한 goroutine은 예외를 catch합니다. 이 시점에서 전역 인스턴스가 이미 초기화되었고 다른 goroutine은 여전히 Once 내부의 함수에 들어갈 수 없습니다.
type Instance struct { Name string } var instance *Instance var on sync.Once func GetInstance(num int) *Instance { defer func() { if err := recover(); err != nil { fmt.Println("num %d ,get instance and catch error ... \n", num) } }() on.Do(func() { instance = &Instance{Name: "Leapcell"} fmt.Printf("%d enter once ... \n", num) panic("panic....") }) return instance } func main() { var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go func(i int) { ins := GetInstance(i) fmt.Printf("%d: ins:%+v , p=%p\n", i, ins, ins) wg.Done() }(i) } time.Sleep(time.Second * 2) ins := GetInstance(9) fmt.Printf("9: ins:%+v , p=%p\n", ins, ins) wg.Wait() }
출력에서 볼 수 있듯이 goroutine 0이 Once에 들어가 패닉이 발생하므로 해당 goroutine에 대한 GetInstance에서 반환된 결과는 nil입니다.
main을 포함한 다른 모든 goroutine은 예상대로 instance
의 주소를 가져올 수 있으며 주소는 동일합니다. 이는 초기화가 전역적으로 한 번만 발생함을 보여줍니다.
$ go run main.go 0 enter once ... num %d ,get instance and catch error ... 0 0: ins:<nil> , p=0x0 1: ins:&{Name:Leapcell} , p=0xc000086000 2: ins:&{Name:Leapcell} , p=0xc000086000 9: ins:&{Name:Leapcell} , p=0xc000086000
Go 프로젝트 호스팅을 위한 최고의 선택, Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하십시오.
무료로 무제한 프로젝트 배포
- 사용량에 대해서만 비용을 지불합니다. 요청 없음, 요금 없음.
최고의 비용 효율성
- 유휴 요금 없이 사용량 기반으로 지불합니다.
- 예: $25는 평균 응답 시간 60ms에서 694만 개의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 지표 및 로깅.
손쉬운 확장성 및 고성능
- 고도의 동시성을 쉽게 처리하도록 자동 확장.
- 운영 오버헤드가 없습니다. 빌드에만 집중하십시오.
설명서에서 자세히 알아보십시오!
X에서 팔로우하세요: @LeapcellHQ