Go (고): 다양한 시나리오에서 RWMutex (알더블유뮤텍스)와 Mutex (뮤텍스)의 성능 비교

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) 명령어를 사용하여 낙관적 락을 구현하여 락 경합 문제를 효과적으로 해결하고 고동시성 시나리오에서 시스템 성능을 향상시킬 수 있습니다.