Go: 다양한 시나리오에서 RwMutex와 Mutex의 성능 비교
Min-jun Kim
Dev Intern · Leapcell

Golang 잠금 성능에 대한 연구 및 분석
소프트웨어 개발 분야에서 Golang 잠금의 성능을 테스트하는 것은 실질적인 작업입니다. 최근에 한 친구가 슬라이스에 대해 스레드로부터 안전한 읽기 및 쓰기 작업을 수행할 때 읽기-쓰기 잠금(rwlock) 또는 뮤텍스 잠금(mutex)을 선택해야 하는지, 그리고 어떤 잠금이 더 나은 성능을 보이는지에 대한 질문을 제기했습니다. 이 질문은 심층적인 논의를 촉발했습니다.
I. 잠금 성능 테스트의 배경 및 목적
다중 스레드 프로그래밍 시나리오에서 데이터의 스레드 안전성을 보장하는 것은 매우 중요합니다. 슬라이스와 같은 데이터 구조에 대한 읽기 및 쓰기 작업의 경우 적절한 잠금 메커니즘을 선택하면 프로그램 성능에 큰 영향을 미칠 수 있습니다. 이 연구의 목표는 개발자가 다양한 시나리오에서 읽기-쓰기 잠금과 뮤텍스 잠금의 성능을 비교하여 실제 응용 프로그램에서 잠금 메커니즘을 선택하는 데 참고 자료를 제공하는 것입니다.
II. 다양한 시나리오에서 서로 다른 잠금 메커니즘의 성능 분석
(I) 읽기-쓰기 잠금(Rwmutex)과 뮤텍스 잠금(Mutex) 간의 성능 비교에 대한 이론적 논의
어떤 시나리오에서 읽기-쓰기 잠금이 뮤텍스 잠금보다 더 나은 성능을 보이는지는 심층적인 분석이 필요한 질문입니다. 잠금(lock) 및 잠금 해제(unlock) 프로세스 중에 입력-출력(io) 로직 및 복잡한 계산 로직이 없는 경우 이론적으로 뮤텍스 잠금이 읽기-쓰기 잠금보다 더 효율적일 수 있습니다. 현재 커뮤니티에는 다양한 읽기-쓰기 잠금 설계 및 구현 방법이 있으며, 대부분 두 개의 잠금과 읽기 카운터를 추상화하여 구현됩니다.
(II) C++ 환경에서 잠금 성능 비교를 위한 참고
이전에 C++ 환경에서 뮤텍스 잠금(lock)과 읽기-쓰기 잠금(rwlock) 간의 성능 비교가 수행되었습니다. 간단한 할당 로직의 시나리오에서 벤치마크 테스트 결과는 예상과 일치합니다. 즉, 뮤텍스 잠금의 성능이 읽기-쓰기 잠금보다 좋습니다. 중간 로직이 비어 있는 io 읽기-쓰기 작업인 경우 읽기-쓰기 잠금의 성능이 뮤텍스 잠금보다 높으며, 이는 일반적인 상식과도 일치합니다. 중간 로직이 맵 조회인 경우 읽기-쓰기 잠금도 뮤텍스 잠금보다 더 높은 성능을 보입니다. 이는 맵이 복잡한 데이터 구조이기 때문입니다. 키를 조회할 때 해시 코드를 계산하고 해시 코드를 통해 배열에서 해당 버킷을 찾은 다음 연결 목록에서 관련 키를 찾아야 합니다. 구체적인 성능 데이터는 다음과 같습니다.
- 간단한 할당:
- raw_lock 소요 시간: 1.732199s
- raw_rwlock 소요 시간: 3.420338s
- io 작업:
- simple_lock 소요 시간: 13.858138s
- simple_rwlock 소요 시간: 8.94691s
- 맵:
- lock 소요 시간: 2.729701s
- rwlock 소요 시간: 0.300296s
(III) Golang 환경에서 sync.rwmutex 및 sync.mutex 성능 테스트
Golang 환경에서 읽기-쓰기 잠금과 뮤텍스 잠금의 성능을 심층적으로 탐구하기 위해 다음과 같은 테스트를 수행했습니다. 테스트 코드는 다음과 같습니다.
package main import ( "fmt" "sync" "time" ) var ( num = 1000 * 10 gnum = 1000 ) func main() { fmt.Println("only read") testRwmutexReadOnly() testMutexReadOnly() fmt.Println("write and read") testRwmutexWriteRead() testMutexWriteRead() fmt.Println("write only") testRwmutexWriteOnly() testMutexWriteOnly() } func testRwmutexReadOnly() { var w = &sync.WaitGroup{} var rwmutexTmp = newRwmutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.get(in) } }() } w.Wait() fmt.Println("testRwmutexReadOnly cost:", time.Now().Sub(t1).String()) } func testRwmutexWriteOnly() { var w = &sync.WaitGroup{} var rwmutexTmp = newRwmutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.set(in, in) } }() } w.Wait() fmt.Println("testRwmutexWriteOnly cost:", time.Now().Sub(t1).String()) } func testRwmutexWriteRead() { var w = &sync.WaitGroup{} var rwmutexTmp = newRwmutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { if i%2 == 0 { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.get(in) } }() } else { go func() { defer w.Done() for in := 0; in < num; in++ { rwmutexTmp.set(in, in) } }() } } w.Wait() fmt.Println("testRwmutexWriteRead cost:", time.Now().Sub(t1).String()) } func testMutexReadOnly() { var w = &sync.WaitGroup{} var mutexTmp = newMutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.get(in) } }() } w.Wait() fmt.Println("testMutexReadOnly cost:", time.Now().Sub(t1).String()) } func testMutexWriteOnly() { var w = &sync.WaitGroup{} var mutexTmp = newMutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.set(in, in) } }() } w.Wait() fmt.Println("testMutexWriteOnly cost:", time.Now().Sub(t1).String()) } func testMutexWriteRead() { var w = &sync.WaitGroup{} var mutexTmp = newMutex() w.Add(gnum) t1 := time.Now() for i := 0; i < gnum; i++ { if i%2 == 0 { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.get(in) } }() } else { go func() { defer w.Done() for in := 0; in < num; in++ { mutexTmp.set(in, in) } }() } } w.Wait() fmt.Println("testMutexWriteRead cost:", time.Now().Sub(t1).String()) } func newRwmutex() *rwmutex { var t = &rwmutex{} t.mu = &sync.RWMutex{} t.ipmap = make(map[int]int, 100) for i := 0; i < 100; i++ { t.ipmap[i] = 0 } return t } type rwmutex struct { mu *sync.RWMutex ipmap map[int]int } func (t *rwmutex) get(i int) int { t.mu.RLock() defer t.mu.RUnlock() return t.ipmap[i] } func (t *rwmutex) set(k, v int) { t.mu.Lock() defer t.mu.Unlock() k = k % 100 t.ipmap[k] = v } func newMutex() *mutex { var t = &mutex{} t.mu = &sync.Mutex{} t.ipmap = make(map[int]int, 100) for i := 0; i < 100; i++ { t.ipmap[i] = 0 } return t } type mutex struct { mu *sync.Mutex ipmap map[int]int } func (t *mutex) get(i int) int { t.mu.Lock() defer t.mu.Unlock() return t.ipmap[i] } func (t *mutex) set(k, v int) { t.mu.Lock() defer t.mu.Unlock() k = k % 100 t.ipmap[k] = v }
테스트 결과는 다음과 같습니다. 뮤텍스와 rwmutex가 여러 goroutine에서 사용되는 시나리오에서 읽기 전용, 쓰기 전용, 읽기-쓰기의 세 가지 테스트 시나리오를 각각 테스트했습니다. 결과에 따르면 쓰기 전용 시나리오에서만 뮤텍스의 성능이 rwmutex보다 약간 더 높은 것 같습니다.
- 읽기 전용:
- testRwmutexReadOnly 소요 시간: 455.566965ms
- testMutexReadOnly 소요 시간: 2.13687988s
- 쓰기 및 읽기:
- testRwmutexWriteRead 소요 시간: 1.79215194s
- testMutexWriteRead 소요 시간: 2.62997403s
- 쓰기 전용:
- testRwmutexWriteOnly 소요 시간: 2.6378979159s
- testMutexWriteOnly 소요 시간: 2.39077869s
더 나아가 맵의 읽기-쓰기 로직을 카운터의 전역 증가 및 감소로 대체했을 때 테스트 결과는 위의 상황과 유사합니다. 즉, 쓰기 전용 시나리오에서 뮤텍스의 성능이 rwlock보다 약간 더 높습니다.
- 읽기 전용:
- testRwmutexReadOnly 소요 시간: 10.483448ms
- testMutexReadOnly 소요 시간: 10.808006ms
- 쓰기 및 읽기:
- testRwmutexWriteRead 소요 시간: 12.405655ms
- testMutexWriteRead 소요 시간: 14.571228ms
- 쓰기 전용:
- testRwmutexWriteOnly 소요 시간: 13.453028ms
- testMutexWriteOnly 소요 시간: 13.782282ms
III. Golang에서 sync.RwMutex의 소스 코드 분석
Golang에서 sync.RwMutex의 구조는 읽기 잠금, 쓰기 잠금 및 읽기 카운터를 포함합니다. 커뮤니티의 일반적인 구현 방법과의 가장 큰 차이점은 읽기 카운터에 대한 작업에 atomic 명령(atomic)을 사용한다는 것입니다. 구체적인 구조 정의는 다음과 같습니다.
type RWMutex struct { w Mutex // held if there are pending writers writerSem uint32 // semaphore for writers to wait for completing readers readerSem uint32 // semaphore for readers to wait for completing writers readerCount int32 // number of pending readers readerWait int32 // number of departing readers }
(I) 읽기 잠금 획득 프로세스
읽기 잠금 획득은 atomic을 사용하여 빼기 연산을 직접 사용합니다. readerCount가 0보다 작으면 쓰기 작업이 대기 중임을 나타내며, 이때 읽기 잠금을 기다려야 합니다. 코드 구현은 다음과 같습니다.
func (rw *RWMutex) RLock() { if race.Enabled { _ = rw.w.state race.Disable() } if atomic.AddInt32(&rw.readerCount, 1) < 0 { // A writer is pending, wait for it. runtime_Semacquire(&rw.readerSem) } if race.Enabled { race.Enable() race.Acquire(unsafe.Pointer(&rw.readerSem)) } }
(II) 읽기 잠금 해제 프로세스
읽기 잠금 해제도 atomic을 사용하여 카운트에 대해 작동합니다. 읽는 사람이 없으면 쓰기 잠금이 해제됩니다. 관련 코드는 다음과 같습니다.
func (rw *RWMutex) RUnlock() { if race.Enabled { _ = rw.w.state race.ReleaseMerge(unsafe.Pointer(&rw.writerSem)) race.Disable() } if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 { if r+1 == 0 || r+1 == -rwmutexMaxReaders { race.Enable() throw("sync: RUnlock of unlocked RWMutex") } // A writer is pending. if atomic.AddInt32(&rw.readerWait, -1) == 0 { // The last reader unblocks the writer. runtime_Semrelease(&rw.writerSem, false) } } if race.Enabled { race.Enable() } }
(III) 쓰기 잠금 획득 및 해제 프로세스
쓰기 잠금을 획득하는 과정에서 먼저 읽기 작업이 있는지 판단합니다. 읽기 작업이 있으면 읽기 작업이 완료된 후 깨어나기를 기다립니다. 쓰기 잠금을 해제할 때 읽기 잠금이 동시에 해제된 다음 읽기 잠금을 기다리는 goroutine이 깨어납니다. 관련 코드는 다음과 같습니다.
func (rw *RWMutex) Lock() { if race.Enabled { _ = rw.w.state race.Disable() } // First, resolve competition with other writers. rw.w.Lock() // Announce to readers there is a pending writer. r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders // Wait for active readers. if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { runtime_Semacquire(&rw.writerSem) } if race.Enabled { race.Enable() race.Acquire(unsafe.Pointer(&rw.readerSem)) race.Acquire(unsafe.Pointer(&rw.writerSem)) } } func (rw *RWMutex) Unlock() { if race.Enabled { _ = rw.w.state race.Release(unsafe.Pointer(&rw.readerSem)) race.Release(unsafe.Pointer(&rw.writerSem)) race.Disable() } // Announce to readers there is no active writer. r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) if r >= rwmutexMaxReaders { race.Enable() throw("sync: Unlock of unlocked RWMutex") } // Unblock blocked readers, if any. for i := 0; i < int(r); i++ { runtime_Semrelease(&rw.readerSem, false) } // Allow other writers to proceed. rw.w.Unlock() if race.Enabled { race.Enable() } }
IV. 요약 및 제안
잠금 경합 문제는 항상 고 동시성 시스템이 직면한 주요 과제 중 하나였습니다. 위의 시나리오에서 맵이 뮤텍스와 함께 사용되는 경우 Go 버전 1.9 이상에서는 sync.Map을 대체하는 것을 고려할 수 있습니다. 읽기 작업이 빈번하고 쓰기 작업이 적은 시나리오에서 sync.Map은 sync.RwMutex와 맵의 조합보다 훨씬 더 큰 장점이 있습니다.
sync.Map의 구현 원리에 대한 심층적인 연구 결과 쓰기 작업 성능이 비교적 낮다는 것을 알 수 있습니다. 읽기 작업은 copy on write 방법을 통해 잠금 없는 읽기를 달성할 수 있지만 쓰기 작업은 여전히 잠금 메커니즘을 포함합니다. 잠금 경합의 압력을 완화하기 위해 Java의 ConcurrentMap과 유사한 분할 잠금 방법을 참고할 수 있습니다.
분할 잠금 외에도 atomic compare and swap(atomic cas) 명령을 사용하여 낙관적 잠금을 구현하여 잠금 경합 문제를 효과적으로 해결하고 고 동시성 시나리오에서 시스템 성능을 향상시킬 수 있습니다.
Leapcell: Golang 앱 호스팅을 위한 차세대 서버리스 플랫폼
마지막으로 Golang 서비스를 배포하는 데 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
1. 다국어 지원
- JavaScript, Python, Go 또는 Rust로 개발합니다.
2. 무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오. 요청 없음, 요금 없음.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼만 지불합니다.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장됩니다.
- 제로 운영 오버헤드 — 빌드에만 집중하십시오.
Leapcell Twitter: https://x.com/LeapcellHQ