
Go (고): 다양한 시나리오에서 RWMutex (알더블유뮤텍스)와 Mutex (뮤텍스)의 성능 비교
Golang (고랭) 락(Lock) 성능 연구 및 분석
소프트웨어 개발 분야에서 Golang (고랭) 락의 성능을 테스트하는 것은 실용적인 작업입니다.
최근 한 친구가 슬라이스에 대한 스레드 안전 읽기 및 쓰기 작업을 수행할 때 읽기-쓰기 락(rwlock)을 선택해야 할지, 아니면 뮤텍스 락(mutex)을 선택해야 할지, 그리고 어떤 락의 성능이 더 좋은지에 대한 질문을 제기했습니다.
이 질문은 심도 있는 논의를 촉발했습니다.
1. 락 성능 테스트의 배경 및 목적
다중 스레드 프로그래밍 시나리오에서 데이터의 스레드 안전성을 보장하는 것은 매우 중요합니다.
슬라이스와 같은 데이터 구조에 대한 읽기 및 쓰기 작업의 경우 적절한 잠금 메커니즘을 선택하면 프로그램 성능에 큰 영향을 미칠 수 있습니다.
이 연구의 목적은 다양한 시나리오에서 읽기-쓰기 락과 뮤텍스 락의 성능을 비교하여 개발자가 실제 애플리케이션에서 잠금 메커니즘을 선택하는 데 참고 자료를 제공하는 것입니다.
2. 다양한 시나리오에서 서로 다른 잠금 메커니즘의 성능 분석
(1) 읽기-쓰기 락(RWMutex)과 뮤텍스 락(Mutex) 간의 성능 비교에 대한 이론적 논의
어떤 시나리오에서 읽기-쓰기 락이 뮤텍스 락보다 성능이 우수한지는 심층 분석할 가치가 있는 질문입니다.
락의 잠금(lock) 및 잠금 해제(unlock) 과정에서 입출력(io) 로직과 복잡한 계산 로직이 없다면 이론적으로 뮤텍스 락이 읽기-쓰기 락보다 효율적일 수 있습니다.
현재 커뮤니티에는 다양한 읽기-쓰기 락 설계 및 구현 방법이 있으며, 대부분은 두 개의 락과 리더 카운터를 추상화하여 구현됩니다.
(2) C++ 환경에서 락 성능 비교 참고 자료
이전에 C++ 환경에서 뮤텍스 락(lock)과 읽기-쓰기 락(rwlock) 간의 성능 비교가 수행된 적이 있습니다.
단순 할당 로직 시나리오에서 벤치마크 테스트 결과는 예상과 일치하며, 즉 뮤텍스 락의 성능이 읽기-쓰기 락보다 우수합니다.
중간 로직이 빈 io 읽기-쓰기 작업일 때 읽기-쓰기 락의 성능은 뮤텍스 락보다 높으며, 이는 일반적인 상식과도 일치합니다.
중간 로직이 맵 조회일 때 읽기-쓰기 락은 뮤텍스 락보다 높은 성능을 보입니다.
이는 맵이 복잡한 데이터 구조이기 때문입니다.
키를 조회할 때 해시 코드를 계산하고 해시 코드를 통해 배열에서 해당 버킷을 찾은 다음 연결 리스트에서 관련 키를 찾아야 합니다.
구체적인 성능 데이터는 다음과 같습니다.
단순 할당:raw_lock (로우 락) 소요 시간: 1.732199초;raw_rwlock (로우 알더블유락) 소요 시간: 3.420338초
io 작업:simple_lock (심플 락) 소요 시간: 13.858138초;simple_rwlock (심플 알더블유락) 소요 시간: 8.94691초
맵:lock (락) 소요 시간: 2.729701초;rwlock (알더블유락) 소요 시간: 0.300296초
(3) 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()
}
// RWMutex 읽기 전용 테스트 함수
func testRwmutexReadOnly() {
var w = &sync.WaitGroup{}
var rwmutexTmp = newRwmutex() // RWMutex를 사용하는 맵 래퍼 생성
w.Add(gnum) // 고루틴 수만큼 WaitGroup 카운터 증가
t1 := time.Now() // 시작 시간 기록
for i := 0; i < gnum; i++ { // 고루틴 생성
go func() {
defer w.Done() // 고루틴 종료 시 WaitGroup 카운터 감소
for in := 0; in < num; in++ { // 각 고루틴 내에서 반복 작업
rwmutexTmp.get(in) // 맵에서 읽기 작업 수행
}
}()
}
w.Wait() // 모든 고루틴 완료 대기
fmt.Println("testRwmutexReadOnly cost:", time.Now().Sub(t1).String()) // 소요 시간 출력
}
// RWMutex 쓰기 전용 테스트 함수
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())
}
// RWMutex 읽기 및 쓰기 테스트 함수
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())
}
// Mutex 읽기 전용 테스트 함수
func testMutexReadOnly() {
var w = &sync.WaitGroup{}
var mutexTmp = newMutex() // Mutex를 사용하는 맵 래퍼 생성
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())
}
// Mutex 쓰기 전용 테스트 함수
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())
}
// Mutex 읽기 및 쓰기 테스트 함수
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())
}
// RWMutex를 사용하는 맵 래퍼 생성 함수
func newRwmutex() *rwmutex {
var t = &rwmutex{}
t.mu = &sync.RWMutex{} // RWMutex 초기화
t.ipmap = make(map[int]int, 100) // 맵 초기화
// 맵에 초기 데이터 채우기
for i := 0; i < 100; i++ {
t.ipmap[i] = 0
}
return t
}
// RWMutex를 사용하는 맵 래퍼 구조체
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 // 키 값을 0-99 범위로 조정
t.ipmap[k] = v
}
// Mutex를 사용하는 맵 래퍼 생성 함수
func newMutex() *mutex {
var t = &mutex{}
t.mu = &sync.Mutex{} // Mutex 초기화
t.ipmap = make(map[int]int, 100) // 맵 초기화
// 맵에 초기 데이터 채우기
for i := 0; i < 100; i++ {
t.ipmap[i] = 0
}
return t
}
// Mutex를 사용하는 맵 래퍼 구조체
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 // 키 값을 0-99 범위로 조정
t.ipmap[k] = v
}
테스트 결과는 다음과 같습니다.
여러 고루틴에서 뮤텍스와 RWMutex (알더블유뮤텍스)를 사용하는 시나리오에서 읽기 전용, 쓰기 전용, 읽기-쓰기 세 가지 테스트 시나리오를 각각 테스트했습니다.
결과는 쓰기 전용 시나리오에서만 뮤텍스의 성능이 RWMutex (알더블유뮤텍스)보다 약간 높은 것으로 보입니다.
읽기 전용:testRwmutexReadOnly cost: 455.566965ms (테스트알더블유뮤텍스리드온리 코스트)testMutexReadOnly cost: 2.13687988s (테스트뮤텍스리드온리 코스트)
읽기 및 쓰기:testRwmutexWriteRead cost: 1.79215194s (테스트알더블유뮤텍스라이트리드 코스트)testMutexWriteRead cost: 2.62997403s (테스트뮤텍스라이트리드 코스트)
쓰기 전용:testRwmutexWriteOnly cost: 2.6378979159s (테스트알더블유뮤텍스라이트온리 코스트)testMutexWriteOnly cost: 2.39077869s (테스트뮤텍스라이트온리 코스트)
더 나아가 맵의 읽기-쓰기 로직을 카운터의 전역 증가 및 감소로 대체했을 때 테스트 결과는 위 상황과 유사하며, 즉 쓰기 전용 시나리오에서 뮤텍스의 성능이 rwlock (알더블유락)보다 약간 높습니다.
읽기 전용:testRwmutexReadOnly cost: 10.483448ms (테스트알더블유뮤텍스리드온리 코스트)testMutexReadOnly cost: 10.808006ms (테스트뮤텍스리드온리 코스트)
읽기 및 쓰기:testRwmutexWriteRead cost: 12.405655ms (테스트알더블유뮤텍스라이트리드 코스트)testMutexWriteRead cost: 14.571228ms (테스트뮤텍스라이트리드 코스트)
쓰기 전용:testRwmutexWriteOnly cost: 13.453028ms (테스트알더블유뮤텍스라이트온리 코스트)testMutexWriteOnly cost: 13.782282ms (테스트뮤텍스라이트온리 코스트)
3. Golang (고랭)의 sync.RWMutex (씽크 점 알더블유뮤텍스) 소스 코드 분석
Golang (고랭)의 sync.RWMutex (씽크 점 알더블유뮤텍스) 구조에는 읽기 락, 쓰기 락 및 리더 카운터가 포함됩니다.
커뮤니티의 일반적인 구현 방법과의 가장 큰 차이점은 리더 카운터에 대한 작업에 원자적 명령어(atomic)를 사용한다는 것입니다.
구체적인 구조 정의는 다음과 같습니다.
// RWMutex는 리더-라이터 뮤텍스입니다.
// 락은 여러 리더 또는 단일 라이터가 보유할 수 있습니다.
type RWMutex struct {
w Mutex // 라이터가 보류 중인 경우 보유되는 뮤텍스 (쓰기 락 자체)
writerSem uint32 // 라이터가 완료 중인 리더를 기다리기 위한 세마포어
readerSem uint32 // 리더가 완료 중인 라이터를 기다리기 위한 세마포어
readerCount int32 // 보류 중인 리더 수 (음수이면 라이터가 보류 중임을 의미)
readerWait int32 // 떠나는 리더 수 (활성 리더 수)
}(1) 읽기 락 획득 과정
읽기 락 획득은 직접 원자적(atomic) 연산을 사용하여 뺄셈을 수행합니다.readerCount (리더카운트)가 0보다 작으면 쓰기 작업이 대기 중임을 나타내며, 이때 읽기 락을 기다려야 합니다.
코드 구현은 다음과 같습니다.
// RLock은 읽기 위해 rw를 잠급니다.
// 락이 이미 쓰기 위해 잠겨 있으면 RLock은 쓰기 락이 해제될 때까지 차단됩니다.
func (rw *RWMutex) RLock() {
if race.Enabled { // 레이스 디텍터 활성화 시
_ = rw.w.state // 쓰기 락 상태 접근 (레이스 감지용)
race.Disable() // 레이스 디텍터 일시 비활성화
}
// readerCount를 원자적으로 1 증가시킵니다.
// 만약 결과가 음수이면, 라이터가 보류 중이거나 이미 락을 획득한 상태입니다.
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// 라이터가 보류 중이므로 기다립니다.
runtime_Semacquire(&rw.readerSem) // readerSem 세마포어를 사용하여 대기합니다.
}
if race.Enabled { // 레이스 디텍터 다시 활성화
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem)) // 읽기 락 획득 기록
}
}(2) 읽기 락 해제 과정
읽기 락 해제도 원자적(atomic) 연산을 사용하여 카운트를 조작합니다.
리더가 없으면 쓰기 락이 해제됩니다.
관련 코드는 다음과 같습니다.
// RUnlock은 단일 RLock 호출을 실행 취소합니다.
// RWMutex가 읽기 위해 잠겨 있지 않으면 RUnlock은 런타임 오류를 발생시킵니다.
func (rw *RWMutex) RUnlock() {
if race.Enabled { // 레이스 디텍터 활성화 시
_ = rw.w.state // 쓰기 락 상태 접근
race.ReleaseMerge(unsafe.Pointer(&rw.writerSem)) // 쓰기 세마포어 해제 병합 기록
race.Disable() // 레이스 디텍터 일시 비활성화
}
// readerCount를 원자적으로 1 감소시킵니다.
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
// readerCount가 음수가 되면, 라이터가 대기 중이거나 상태가 잘못된 것입니다.
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
// r+1 == 0: RUnlock 호출 전 readerCount가 0이었음을 의미 (잘못된 RUnlock)
// r+1 == -rwmutexMaxReaders: RUnlock 호출 전 readerCount가 -rwmutexMaxReaders + 1이었음을 의미 (잘못된 RUnlock)
race.Enable() // 레이스 디텍터 다시 활성화
throw("sync: RUnlock of unlocked RWMutex") // 잠금 해제되지 않은 RWMutex의 RUnlock 호출 시 패닉
}
// 라이터가 보류 중입니다.
// readerWait (활성 리더 수)를 원자적으로 1 감소시킵니다.
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
// 마지막 리더가 라이터를 차단 해제합니다.
runtime_Semrelease(&rw.writerSem, false) // writerSem 세마포어를 해제합니다.
}
}
if race.Enabled { // 레이스 디텍터 다시 활성화
race.Enable()
}
}(3) 쓰기 락 획득 및 해제 과정
쓰기 락을 획득하는 과정에서 먼저 읽기 작업이 있는지 판단합니다.
읽기 작업이 있으면 읽기 작업이 완료된 후 깨어나기를 기다립니다.
쓰기 락을 해제할 때 읽기 락도 동시에 해제된 다음 읽기 락을 기다리는 고루틴(goroutine)을 깨웁니다.
관련 코드는 다음과 같습니다.
// Lock은 쓰기 위해 rw를 잠급니다.
// 락이 이미 읽기 또는 쓰기를 위해 잠겨 있으면 Lock은 락이 해제될 때까지 차단됩니다.
func (rw *RWMutex) Lock() {
if race.Enabled { // 레이스 디텍터 활성화 시
_ = rw.w.state // 쓰기 락 상태 접근
race.Disable() // 레이스 디텍터 일시 비활성화
}
// 먼저 다른 라이터와의 경쟁을 해결합니다. (rw.w는 일반 Mutex)
rw.w.Lock()
// 라이터가 보류 중임을 리더에게 알립니다.
// readerCount에서 rwmutexMaxReaders를 빼서 음수로 만듭니다.
// r은 Lock 호출 전의 readerCount 값입니다.
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
// 활성 리더를 기다립니다.
// r != 0 (활성 리더가 있었음) 이고 readerWait를 r만큼 증가시킨 결과가 0이 아니면 (즉, 여전히 기다려야 할 리더가 있음)
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_Semacquire(&rw.writerSem) // writerSem 세마포어를 사용하여 대기합니다.
}
if race.Enabled { // 레이스 디텍터 다시 활성화
race.Enable()
race.Acquire(unsafe.Pointer(&rw.readerSem)) // 읽기 세마포어 획득 기록
race.Acquire(unsafe.Pointer(&rw.writerSem)) // 쓰기 세마포어 획득 기록
}
}
// Unlock은 쓰기를 위해 rw를 잠금 해제합니다.
// RWMutex가 쓰기를 위해 잠겨 있지 않으면 Unlock은 런타임 오류를 발생시킵니다.
func (rw *RWMutex) Unlock() {
if race.Enabled { // 레이스 디텍터 활성화 시
_ = rw.w.state // 쓰기 락 상태 접근
race.Release(unsafe.Pointer(&rw.readerSem)) // 읽기 세마포어 해제 기록
race.Release(unsafe.Pointer(&rw.writerSem)) // 쓰기 세마포어 해제 기록
race.Disable() // 레이스 디텍터 일시 비활성화
}
// 활성 라이터가 없음을 리더에게 알립니다.
// readerCount에 rwmutexMaxReaders를 더하여 원래 값으로 복원하거나 양수로 만듭니다.
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
// r이 rwmutexMaxReaders보다 크거나 같으면, Unlock 호출 전 readerCount가 이미 양수였음을 의미 (잘못된 Unlock)
if r >= rwmutexMaxReaders {
race.Enable() // 레이스 디텍터 다시 활성화
throw("sync: Unlock of unlocked RWMutex") // 잠금 해제되지 않은 RWMutex의 Unlock 호출 시 패닉
}
// 차단된 리더가 있으면 차단 해제합니다.
// r은 Unlock 호출 전의 (음수였던) readerCount + rwmutexMaxReaders 값, 즉 대기 중이던 리더의 수입니다.
// 하지만 실제로는 r은 현재 readerCount 값이며, 여기서 반복은 r번 수행됩니다.
// 소스 코드 주석에 따르면 r은 "number of pending readers"를 나타냅니다.
// AddInt32 후의 r은 실제로는 (원래 readerCount) + rwmutexMaxReaders 입니다.
// 만약 원래 readerCount가 -rwmutexMaxReaders (쓰기 락 상태, 리더 없음)였다면 r = 0.
// 만약 원래 readerCount가 (-rwmutexMaxReaders + k) (k명의 리더가 쓰기 락 대기)였다면 r = k.
// 따라서 r은 깨워야 할 리더의 수입니다.
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false) // readerSem 세마포어를 해제하여 리더를 깨웁니다.
}
// 다른 라이터가 진행하도록 허용합니다.
rw.w.Unlock() // 내부 뮤텍스(쓰기 락 자체)를 해제합니다.
if race.Enabled { // 레이스 디텍터 다시 활성화
race.Enable()
}
}4. 요약 및 제안
락 경합 문제는 항상 고동시성 시스템이 직면하는 주요 과제 중 하나였습니다.
맵과 뮤텍스를 함께 사용하는 위 시나리오의 경우 Go (고) 버전 1.9 이상에서는 sync.Map (씽크 점 맵)을 대체재로 고려할 수 있습니다.
읽기 작업이 빈번하고 쓰기 작업이 적은 시나리오에서 sync.Map (씽크 점 맵)의 성능은 sync.RWMutex (씽크 점 알더블유뮤텍스)와 맵의 조합보다 훨씬 뛰어납니다.
sync.Map (씽크 점 맵)의 구현 원리를 심층적으로 연구한 결과, 쓰기 작업 성능이 비교적 낮다는 것을 알 수 있습니다.
읽기 작업은 쓰기 시 복사(copy on write) 방법을 통해 락 없는 읽기를 달성할 수 있지만, 쓰기 작업에는 여전히 잠금 메커니즘이 포함됩니다.
락 경합의 압력을 완화하기 위해 Java (자바)의 ConcurrentMap (컨커런트맵)과 유사한 세그먼트 락 방법을 참고할 수 있습니다.
세그먼트 락 외에도 원자적 비교 및 교환(atomic cas) 명령어를 사용하여 낙관적 락을 구현하여 락 경합 문제를 효과적으로 해결하고 고동시성 시나리오에서 시스템 성능을 향상시킬 수 있습니다.
'Go' 카테고리의 다른 글
| Golang (고랭) 타이머 정밀도: 얼마나 정확할 수 있을까요? (6) | 2025.05.17 |
|---|---|
| Hugo (휴고) 심층 분석: 이상적인 정적 블로그 프레임워크 (3) | 2025.05.17 |
| Go (고) 컴파일러 성능 최적화 팁과 트릭 (0) | 2025.05.17 |
| Go (고) 언어 panic (패닉) 및 recover (리커버) 심층 해부: 알아야 할 모든 것! (0) | 2025.05.17 |
| ErrGroup (에러그룹): Go (고) 동시성 프로그래밍의 숨겨진 보석 (0) | 2025.05.17 |