Skip to content

sync.Mutex 뮤텍스

락은 운영체제에서 중요한 동기화 프리미티브 중 하나로, Go 언어는 표준 라이브러리에서 뮤텍스와 읽기/쓰기 락 두 가지 구현을 제공합니다. 각각 다음과 같습니다.

  • sync.Mutex, 뮤텍스: 읽기 - 읽기 상호 배타, 읽기 - 쓰기 상호 배타, 쓰기 - 쓰기 상호 배타
  • sync.RWMutex, 읽기/쓰기 락: 읽기 - 읽기 공유, 읽기 - 쓰기 상호 배타, 쓰기 - 쓰기 상호 배타

이들의 비즈니스 사용 시나리오는 매우 흔하며, 동시성 상황에서 공유 메모리 순차 액세스 및 수정을 보호하는 데 사용됩니다. 아래 예제와 같습니다.

go
import (
	"fmt"
	"sync"
)

func main() {
	var i int
	var wg sync.WaitGroup
	var mu sync.Mutex

	for range 10 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			viewI := i
			mu.Unlock()

			viewI++

			mu.Lock()
			i = viewI
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println(i)
}

락의 보호가 없다면 이 함수는 매번 실행할 때마다 결과가 다를 수 있으며 예측할 수 없습니다. 대부분의 시나리오에서 우리는 이러한 상황을 원하지 않습니다. 이 사례는 대부분의 사람에게 매우 간단하며, 아마도 락 사용에 능숙할 수 있지만 Go 언어 락 내부가 어떻게 구현되는지 알지 못할 수 있습니다. 코드 자체는 복잡하지 않으므로 이 글에서 자세히 설명하겠습니다.

Locker

시작하기 전에 sync.Locker 유형을 먼저 살펴보겠습니다. 이는 Go 가 정의한 인터페이스입니다.

go
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
	Lock()
	Unlock()
}

제공하는 메서드는 매우 간단하며 락 잠금과 해제입니다. Go 의 인터페이스 구현이 약속보다 우수하기 때문에 대부분의 사람은 본 적이 없을 수 있습니다. 여기서 간단히 언급하는 이유는 중요하지 않기 때문이며, 뒤에 설명할 두 락도 이 인터페이스를 구현했습니다.

Mutex

뮤텍스 Mutex 의 유형 정의는 sync/mutex.go 파일에 있으며 구조체 유형입니다.

go
type Mutex struct {
	state int32
	sema  uint32
}

필드 설명은 다음과 같습니다.

  • state, 락 상태를 나타냅니다.
  • sema, 세마포어 semaphore 로, 이에 대한 설명은 뒤에 있습니다.

먼저 state 에 대해 설명하겠습니다.

go
const (
    mutexLocked = 1 << iota // mutex is locked
    mutexWoken
    mutexStarving
)

state 는 32 비트 정수 유형으로, 하위 3 비트는 위 세 가지 상태를 나타내며 세 가지 상태는 독립적이지 않고 공존할 수 있습니다.

  • mutexLocked=1, 잠김
  • mutexWoken=2, 깨어남
  • mutexStarving=4, 기아 모드

상위 29 비트는 대기 중인 코루틴 수를 나타내므로 이론적으로 뮤텍스는 최대 2^29+1 개 코루틴이 동시에 사용할 수 있지만 현실적으로如此 많은 코루틴은 없을 것입니다. 각각 2KB(초기 스택 공간 크기) 만 차지해도 이数量的 코루틴을 생성하는 데 약 1TB 메모리 공간이 필요합니다.

+-----------------------------------+---------------+------------+-------------+
|              waiter               | mutexStarving | mutexWoken | mutexLocked |
+-----------------------------------+---------------+------------+-------------+
|              29 bits              | 1 bit         | 1 bit      | 1 bit       |
+-----------------------------------+---------------+------------+-------------+

뮤텍스는 두 가지 실행 모드가 있습니다. 첫째는 일반 모드이고 둘째는 기아 모드입니다. 일반 모드는 코루틴이 블록 대기 큐에 도달한 순서에 따라 락을 보유하는 것으로 FIFO 입니다. 이는 일반적인 경우이며 성능이 가장 좋을 때입니다. 모두 액세스 순서에 따라 락을 보유하면 문제가 없기 때문입니다. 기아 모드는 일반적이지 않은 경우로, 여기서 기아는 대기 코루틴이 장시간 락을 보유하지 못해 블록 상태에 있는 것을 의미하며 뮤텍스가 기아 상태인 것은 아닙니다. 그렇다면 언제 코루틴이 기아 상태가 될까요? 공식 예시가 있습니다. 먼저 도착한 코루틴이 뮤텍스를 보유하지 못해 블록되고, 이후 락이 해제되어 깨어납니다. 이때 다른 코루틴이 락을 시도합니다 (새치기하는 것을 좋아하는). 후자는 실행 상태이므로 (CPU 시간 할당량 점유 중) 락을 성공적으로 경쟁할 확률이 높으며 극단적인 경우 이러한 코루틴이 많을 수 있습니다. 그러면 방금 깨어난 코루틴은 계속 락을 보유할 수 없습니다 (계속 새치기). 먼저 도착했지만 계속 락을 얻을 수 없습니다.

go
const (
	starvationThresholdNs = 1e6
)

이러한 상황을 피하기 위해 Go 는 대기 임계값 starvationThresholdNs 를 설정했습니다. 코루틴이 1ms 이상 락을 보유하지 못하면 뮤텍스는 기아 모드로 진입합니다. 기아 모드에서 뮤텍스 소유권은 대기 큐의 첫 번째 코루틴에 직접 전달되며 새 코루틴은 락을 시도하지 않고 큐의 끝에서 대기합니다. 이렇게 기아 모드에서 뮤텍스 소유권은 대기 큐의 코루틴이 하나씩 보유합니다 (줄 서는 사람이 먼저 락을 얻고 새치기는 뒤에 가도록). 코루틴이 성공적으로 락을 보유한 후 자신이 마지막 대기 코루틴이거나 대기 시간이 1ms 미만이면 뮤텍스를 일반 모드로 전환합니다. 이러한 기아 모드 설계는 일부 코루틴이 장시간 락을 보유하지 못해 "굶어 죽는" 상황을 피합니다.

TryLock

뮤텍스는 락을 얻기 위해 두 가지 메서드를 제공합니다.

  • Lock(), 블록 방식으로 락 획득
  • TryLock(), 논블록 방식으로 락 획득

먼저 TryLock 코드를 살펴보겠습니다. 구현이 더 간단합니다.

go
func (m *Mutex) TryLock() bool {
	old := m.state
	if old&(mutexLocked|mutexStarving) != 0 {
		return false
	}

	if !atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
		return false
	}

	return true
}

시작 시 검사하며 락이 이미 보유되었거나 기아 상태 (즉 많은 코루틴이 락 대기 중) 면 현재 코루틴은 락을 얻을 수 없습니다. 그렇지 않으면 CAS 작업을 통해 상태를 mutexLocked 로 업데이트하려고 시도하며 CAS 작업이 false 를 반환하면 이 기간 동안 다른 코루틴이 성공적으로 락을 얻었음을 의미하며 현재 코루틴은 락을 얻을 수 없습니다. 그렇지 않으면 성공적으로 락을 얻습니다. 여기서 코드에서 볼 수 있듯이 TryLock() 호출자는 새치기를 시도하는 사람입니다. 대기 중인 코루틴이 있는지 여부와 관계없이 직접 락을 차지하기 때문입니다 (old 는 0 이 아닐 수 있음).

Lock

다음은 Lock 코드로 CAS 작업을 사용하여 직접 락을 보유하려고 시도하지만 더 "성실"하여 코루틴이 블록 대기하지 않을 때만 직접 락을 보유합니다 (old=0).

go
func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
       return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
}

만약 코루틴이 블록 대기 중임을 발견하면 "성실하게" 뒤에 줄 서서 lockslow 스핀 프로세스에 진입하여 락을 대기합니다 (뮤텍스의 핵심). 먼저 일부 변수를 준비합니다.

go
func (m *Mutex) lockSlow() {
    var waitStartTime int64
    starving := false
    awoke := false
    iter := 0
    old := m.state
  • waitStartTime: 대기 시작 시간을 기록하며 기아 모드 진입 여부를 확인합니다.
  • starving: 현재 코루틴이 1ms 이상 락을 얻지 못했는지 나타냅니다.
  • awoke: 현재 코루틴이 깨어났는지 표시합니다.
  • iter: 카운터로 스핀 횟수를 기록합니다.
  • old: 현재 뮤텍스 상태를 가져옵니다.

그런 다음 for 루프에 진입하여 현재 코루틴이 스핀 상태에 진입할 수 있는지 판단합니다.

스핀은 멀티스레드 간 동기화 메커니즘으로 busy-waiting 이라고도 합니다. 스레드가 락을 보유하지 않았을 때 직접 중단하고 스레드 컨텍스트를 전환하지 않고 공전하며 이 과정에서 CPU 시간 할당량을 계속 점유합니다. 락 경쟁이 크지 않거나 락 보유 시간이 짧은 경우 이렇게 하면 빈번한 스레드 컨텍스트 전환을 피할 수 있어 성능을 효과적으로 향상시킬 수 있습니다. 그러나 만능은 아니며 Go 언어에서 스핀을 남용하면 다음과 같은 위험한 결과가 발생할 수 있습니다.

  • CPU 점유율 과다: 스핀 코루틴이 많으면 특히 락 점유 시간이 길 때 많은 CPU 자원을 소비합니다.
  • 코루틴 스케줄링 영향: 프로세서 P 총 수는 제한되어 있으므로 많은 스핀 코루틴이 P 를 점유하면 다른 사용자 코드를 실행하는 코루틴이 적시에 스케줄링되지 못합니다.
  • 캐시 일관성 문제: 스핀 락의 busy-waiting 특성으로 인해 스레드가 고속 캐시 (cache) 에서 락 상태를 반복해서 읽습니다. 스핀 코루틴이 다른 코어에서 실행되고 락 상태가 전역 메모리에 적시에 업데이트되지 않으면 코루틴이 읽는 락 상태가 정확하지 않으며 빈번한 캐시 일관성 동기화도 성능을 현저히 저하시킵니다.

따라서 모든 코루틴이 스핀 상태에 진입할 수 있는 것은 아니며 다음과 같은 엄격한 판단을 거쳐야 합니다.

go
for {
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
        if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
            awoke = true
        }
        runtime_doSpin()
        iter++
        old = m.state
        continue
    }
    ...
}

조건은 다음과 같습니다.

  1. 현재 락이 이미 보유되었고 기아 상태가 아니어야 합니다. 그렇지 않으면 이미 코루틴이 장시간 락을 얻지 못했다는 의미이므로 직접 블록 프로세스에 진입합니다.

  2. runtime.sync_runtime_canSpin 판단 프로세스에 진입합니다.

    go
     const (
    	active_spin = 4
    )
    func sync_runtime_canSpin(i int) bool {
    	if i >= active_spin || ncpu <= 1 || gomaxprocs <= sched.npidle.Load()+sched.nmspinning.Load()+1 {
    		return false
    	}
    	if p := getg().m.p.ptr(); !runqempty(p) {
    		return false
    	}
    	return true
    }
  3. 스핀 횟수가 runtime.active_spin 미만이어야 하며 기본값은 4 회로 횟수가 많으면 자원을 낭비합니다.

  4. CPU 코어 수가 1 보다 커야 하며 단일 코어 시스템에서 스핀은 의미가 없습니다.

  5. 현재 gomaxprocs 가 유휴 P 와 스핀 중인 P 수의 합 +1 보다 커야 하며 현재 스핀에 사용할 수 있는 프로세서가 충분하지 않음을 의미합니다.

  6. 현재 P 의 로컬 큐는 비어 있어야 하며 그렇지 않으면 다른 사용자 작업을 실행해야 하므로 스핀할 수 없습니다.

스핀할 수 있다고 판단되면 runtime.sync_runtime_doSpin 을 호출하여 스핀에 진입하며 실제로 30 회 PAUSE 명령을 실행합니다.

go
const (
	active_spin_cnt = 30
)

func sync_runtime_doSpin() {
	procyield(active_spin_cnt)
}
asm
TEXT runtime·procyield(SB),NOSPLIT,$0-0
	MOVL	cycles+0(FP), AX
again:
	PAUSE
	SUBL	$1, AX
	JNZ	again
	RET

스핀할 수 없으면 두 가지 결과만 있습니다: 성공적으로 락을 얻거나 대기 큐에 진입하여 블록에 빠집니다. 그러나 그 전에 많은 처리가 있습니다.

  1. 기아 모드가 아니면 락을 얻으려고 시도합니다.
go
new := old
if old&mutexStarving == 0 {
	new |= mutexLocked
}
  1. 락이 이미 점유되었거나 현재 기아 모드이면 대기 코루틴 수 +1
go
if old&(mutexLocked|mutexStarving) != 0 {
	new += 1 << mutexWaiterShift
}
  1. 현재 코루틴이 이미 기아 상태이고 락이 여전히 점유되면 기아 모드로 진입합니다.
go
if starving && old&mutexLocked != 0 {
	new |= mutexStarving
}
  1. 현재 코루틴이 스핀되어 깨어났으면 mutexWoken 플래그를 추가합니다.
go
if awoke {
	new &^= mutexWoken
}

그런 다음 CAS 를 통해 락 상태 업데이트를 시도하며 업데이트에 실패하면 다음 루프로 직접 진행합니다.

go
if atomic.CompareAndSwapInt32(&m.state, old, new) {
    ...
}else {
    ...
}

업데이트에 성공하면 아래 판단을 시작합니다.

  1. 원래 상태가 기아 모드가 아니고 락을 점유한 코루틴이 없으면 현재 코루틴이 직접 락을 보유할 수 있으며 프로세스를 종료하고 사용자 코드를 계속 실행합니다.

    go
    if old&(mutexLocked|mutexStarving) == 0 {
    		break
    }
  2. 락 얻기에 실패하면 대기 시간을 기록하며 LIFO 가 true 이면 큐가 후입선출이고 그렇지 않으면 선입선출입니다.

    go
    queueLifo := waitStartTime != 0
    if waitStartTime == 0 {
    	waitStartTime = runtime_nanotime()
    }
  3. 세마포어를 얻으려고 시도하며 runtime.semacquire1 함수에 진입합니다. 세마포어를 얻을 수 있으면 블록되지 않고 직접 반환되며 그렇지 않으면 runtime.gopark 를 호출하여 현재 코루틴을 중단하고 세마포어 해제를 대기합니다.

    go
    runtime_SemacquireMutex(&m.sema, queueLifo, 1)
  4. 이 단계에 도달하면 두 가지 가능성이 있습니다. 첫째는 직접 성공적으로 세마포어를 얻은 것이고 둘째는 블록되었다가 방금 깨어나 성공적으로 세마포어를 얻은 것입니다. 두 경우 모두 세마포어를 성공적으로 얻었으며 현재 기아 모드이면 직접 락을 얻을 수 있습니다.

    go
    starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
    old = m.state
    if old&mutexStarving != 0 {
    	delta := int32(mutexLocked - 1<<mutexWaiterShift)
    	if !starving || old>>mutexWaiterShift == 1 {
    		delta -= mutexStarving
    	}
    	atomic.AddInt32(&m.state, delta)
    	break
    }
  5. 기아 모드가 아니면 iter 를 재설정하고 스핀 프로세스를 다시 시작합니다.

    go
    awoke = true
    iter = 0

이로써 락 추가 프로세스가 종료되며 전체 프로세스는 상당히 복잡하며 스핀 대기와 세마포어 블록 대기 두 가지 방식을 사용하여 성능과 공정성을 균형있게 조절하며 대부분의 락 경쟁 상황에 적합합니다.

Unlock

해제 프로세스는 상대적으로 매우 간단하며 먼저 빠른 해제를 시도합니다. new 가 0 이면 현재 대기 코루틴이 없고 기아 모드가 아니므로 해제에 성공하여 직접 반환할 수 있습니다.

go
func (m *Mutex) Unlock() {
	new := atomic.AddInt32(&m.state, -mutexLocked)
	if new != 0 {
		m.unlockSlow(new)
	}
}

그렇지 않으면 unlockslow 프로세스에 진입합니다.

  1. 먼저 이미 해제되었는지 판단합니다.

    go
    if (new+mutexLocked)&mutexLocked == 0 {
    	fatal("sync: unlock of unlocked mutex")
    }
  2. 기아 모드이면 직접 세마포어를 해제하여 해제를 완료합니다. 기아 모드에서 현재 해제 코루틴은 락 소유권을 다음 대기 코루틴에 직접 전달합니다.

    go
    if new&mutexStarving == 0 {
    	...
    } else {
    	runtime_Semrelease(&m.sema, true, 1)
    }
  3. 기아 모드가 아니면 일반 해제 프로세스에 진입합니다.

    1. 대기 중인 코루틴이 없거나 다른 깨어난 코루틴이 락을 얻었거나 락이 기아 모드에 진입한 경우

      go
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
          return
      }
    2. 그렇지 않으면 세마포어를 해제하여 다음 대기 코루틴을 깨우며 현재 락 상태를 mutexWoken 으로 설정합니다.

      go
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
          runtime_Semrelease(&m.sema, false, 1)
          return
      }
      old = m.state

마지막으로 해제 프로세스가 종료됩니다.

RWMutex

읽기/쓰기 뮤텍스 RWMutex 의 유형 정의는 sync/rwmutex.go 파일에 있으며 구현도 뮤텍스를 기반으로 합니다.

go
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 atomic.Int32 // number of pending readers
	readerWait  atomic.Int32 // number of departing readers
}

각 필드 설명은 다음과 같습니다.

  • w, 뮤텍스로, 쓰기 코루틴이 이 뮤텍스를 보유하면 다른 쓰기 코루틴과 읽기 코루틴이 블록됩니다.
  • writerSem, 쓰기 세마포어로 쓰기 코루틴이 읽기 코루틴을 대기하도록 블록하는 데 사용되며 쓰기 코루틴이 세마포어를 얻고 읽기 코루틴이 세마포어를 해제합니다.
  • readerSem, 읽기 세마포어로 읽기 코루틴이 쓰기 코루틴을 대기하도록 블록하는 데 사용되며 읽기 코루틴이 세마포어를 얻고 쓰기 코루틴이 세마포어를 해제합니다.
  • readerCount, 핵심 필드로 전체 읽기/쓰기 락이 이를 통해 상태를 유지합니다.
  • readerWait, 쓰기 코루틴이 블록될 때 대기해야 하는 읽기 코루틴 수를 나타냅니다.

읽기/쓰기 락의 대략적인 원리는 뮤텍스를 통해 쓰기 코루틴 간 상호 배타를 구현하고 두 세마포어 writerSemreaderSem 를 통해 읽기 - 쓰기 상호 배타와 읽기 - 읽기 공유를 구현하는 것입니다.

readerCount

readerCount 는 변화가 많고 중요하므로 따로 설명하며 대략 다음과 같은 몇 가지 상태로 요약됩니다.

  1. 0, 현재 읽기 코루틴도 쓰기 코루틴도 활성화되지 않은 유휴 상태입니다.
  2. -rwmutexMaxReaders, 쓰기 코루틴이 뮤텍스를 보유했으며 현재 활성화된 읽기 코루틴이 없습니다.
  3. -rwmutexMaxReaders+N, 쓰기 코루틴이 쓰기 락을 보유했으며 현재 읽기 코루틴은 쓰기 코루틴이 쓰기 락을 해제할 때까지 블록 대기해야 합니다.
  4. N-rwmutexMaxReaders, 쓰기 코루틴이 뮤텍스를 보유했으며 나머지 읽기 코루틴이 읽기 락을 해제할 때까지 블록 대기해야 합니다.
  5. N, 현재 N 개의 활성화된 읽기 코루틴이 있으며 즉 N 개의 읽기 락이 추가되었고 활성화된 쓰기 코루틴이 없습니다.

여기서 rwmutexMaxReaders 는 상수 값으로 그 값은 뮤텍스가 블록할 수 있는 코루틴 수의 2 배입니다. 절반은 읽기 코루틴이고 절반은 쓰기 코루틴이기 때문입니다.

go
const rwmutexMaxReaders = 1 << 30

전체 읽기/쓰기 락 부분에서 readerCount 가 가장 복잡하며 그 변화를 이해하면 읽기/쓰기 락의 작업 흐름을 알 수 있습니다.

TryLock

여전히 가장 간단한 TryLock() 부터 살펴보겠습니다.

go
func (rw *RWMutex) TryLock() bool {
	if !rw.w.TryLock() {
		return false
	}
	if !rw.readerCount.CompareAndSwap(0, -rwmutexMaxReaders) {
		rw.w.Unlock()
		return false
	}
	return true
}

시작 시 뮤텍스의 TryLock() 을 호출하며 실패하면 직접 반환합니다. 그런 다음 CAS 작업을 통해 readerCount 값을 0 에서 -rwmutexMaxReaders 로 업데이트하려고 시도합니다. 0 은 작업 중인 읽기 코루틴이 없음을 나타내며 -rwmutexMaxReaders 는 현재 쓰기 코루틴이 뮤텍스를 보유했음을 의미합니다. CAS 작업 업데이트에 실패하면 뮤텍스를 해제하고 성공하면 true 를 반환합니다.

Lock

다음은 Lock() 으로 구현도 매우 간단합니다.

go
func (rw *RWMutex) Lock() {
	rw.w.Lock()
	r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
	if r != 0 && rw.readerWait.Add(r) != 0 {
		runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
	}
}

먼저 다른 쓰기 코루틴과 경쟁하여 뮤텍스를 보유한 다음 이렇게 작업합니다. 먼저 원자적으로 -rwmutexMaxReaders 를 빼고 새로 얻은 값을 비원자적으로 rwmutexMaxReaders 를 더합니다.

go
r = rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

두 단계로 나누어 살펴보겠습니다.

  1. 다른 읽기 코루틴에 현재 쓰기 코루틴이 락을 보유하려고 시도 중임을 알리기 위한 것으로 TryLock 부분에서 이미 설명했습니다.

    go
    rw.readerCount.Add(-rwmutexMaxReaders)
  2. rwmutexMaxReaders 를 더하여 r 을 얻으며 이 r 은 현재 작업 중인 읽기 코루틴 수를 나타냅니다.

    go
    r = rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders

그런 다음 읽기 코루틴이 작업 중인지 판단한 후 readerWait 값을 r 만큼 더하며 여전히 0 이 아니면 이러한 읽기 코루틴이 작업을 완료할 때까지 대기해야 함을 의미합니다. 그러면 runtime_SemacquireRWMutex 프로세스에 진입하여 세마포어 writerSem 를 얻으려고 시도합니다. 이 세마포어는 읽기 코루틴이 해제하며 세마포어를 얻을 수 있으면 읽기 코루틴이 작업을 완료했음을 의미하며 그렇지 않으면 블록 큐에 진입하여 대기합니다 (이 부분 세마포어 로직은 뮤텍스 부분과 기본적으로 동일합니다).

UnLock

다음은 UnLock() 으로 쓰기 락을 해제합니다.

go
func (rw *RWMutex) Unlock() {
    r := rw.readerCount.Add(rwmutexMaxReaders)
    if r >= rwmutexMaxReaders {
       fatal("sync: Unlock of unlocked RWMutex")
    }

    for i := 0; i < int(r); i++ {
       runtime_Semrelease(&rw.readerSem, false, 0)
    }

    rw.w.Unlock()
}

프로세스는 다음과 같습니다.

  1. 앞에서 락 추가 시 readerCount 를 음수로 업데이트한다고 언급했는데 여기에 rwmutexMaxReaders 를 더하면 현재 작업 중인 쓰기 코루틴이 없음을 의미하며 얻은 값은 블록 대기 중인 읽기 코루틴 수입니다.

    go
    r := rw.readerCount.Add(rwmutexMaxReaders)
  2. 0 이거나 0 보다 크면 쓰기 락이 이미 해제되었음을 의미합니다.

    go
    if r >= rwmutexMaxReaders {
    	fatal("sync: Unlock of unlocked RWMutex")
    }
  3. 세마포어 readerSem 를 해제하여 대기 중인 읽기 코루틴을 깨웁니다.

    go
    for i := 0; i < int(r); i++ {
    	runtime_Semrelease(&rw.readerSem, false, 0)
    }
  4. 마지막으로 뮤텍스를 해제하여 대기 중인 쓰기 코루틴을 깨웁니다.

    go
    rw.w.Unlock()

쓰기 락 해제가 완료됩니다.

TryRLock

다음은 읽기 락 부분으로 TryRLock 코드입니다.

go
func (rw *RWMutex) TryRLock() bool {
	for {
		c := rw.readerCount.Load()
		if c < 0 {
			return false
		}
		if rw.readerCount.CompareAndSwap(c, c+1) {
			return true
		}
	}
}

두 가지만 수행합니다.

  1. 쓰기 코루틴이 작업 중인지 판단하며 있으면 락 추가에 실패합니다.

    go
    c := rw.readerCount.Load()
    if c < 0 {
    	return false
    }
  2. readerCount 를 1 증가시키려고 시도하며 업데이트에 성공하면 락 추가에 성공합니다.

    go
    if rw.readerCount.CompareAndSwap(c, c+1) {
    	return true
    }
  3. 그렇지 않으면 계속 루프하여 판단하며 종료할 때까지 진행합니다.

여기서 의존하는 readerCount 는 모두 쓰기 락 부분에서 유지 관리되며 이것이 쓰기 락을 먼저 설명하는 이유입니다. 복잡하고 핵심적인 부분이 모두 쓰기 락 부분에서 유지 관리되기 때문입니다.

RLock

RLock 로직은 더 간단합니다.

go
func (rw *RWMutex) RLock() {
	if rw.readerCount.Add(1) < 0 {
		runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
	}
}

readerCount 값을 1 증가시키려고 시도하며 새로 얻은 값이 여전히 0 보다 작으면 쓰기 코루틴이 작업 중임을 의미하며 readerSem 세마포어 블록 프로세스에 진입하여 현재 코루틴은 블록 큐에 진입하여 대기합니다.

RUnLock

RUnLock 도 마찬가지로 간단하고 이해하기 쉽습니다.

go
func (rw *RWMutex) RUnlock() {
    if r := rw.readerCount.Add(-1); r < 0 {
       rw.rUnlockSlow(r)
    }
}

func (rw *RWMutex) rUnlockSlow(r int32) {
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		fatal("sync: RUnlock of unlocked RWMutex")
	}
	if rw.readerWait.Add(-1) == 0 {
		runtime_Semrelease(&rw.writerSem, false, 1)
	}
}

먼저 readerCount 를 1 감소시키려고 시도하며 활성화된 읽기 코루틴 수가 1 감소함을 의미합니다. 얻은 값이 0 보다 크면 직접 해제할 수 있습니다. 현재 쓰기 코루틴이 뮤텍스를 보유하지 않기 때문입니다. 0 보다 작으면 쓰기 코루틴이 뮤텍스를 보유했으며 현재 모든 읽기 코루틴이 작업을 완료할 때까지 대기 중임을 의미합니다. 그런 다음 runlockSlow 프로세스에 진입합니다.

  1. 원래 readerCount 값이 0(락이 유휴) 이거나 -rwmutexMaxReaders(쓰기 코루틴이 대기해야 하는 읽기 코루틴이 없음, 즉 읽기 락이 모두 해제됨) 면 현재 활성화된 읽기 코루틴이 없으므로 해제할 필요가 없습니다.

    go
    if r+1 == 0 || r+1 == -rwmutexMaxReaders {
    	fatal("sync: RUnlock of unlocked RWMutex")
    }
  2. 활성화된 읽기 코루틴이 있으면 readerWait 를 1 감소시키며 현재 읽기 코루틴이 마지막 활성화된 읽기 코루틴이면 writerSem 세마포어를 해제하여 대기 중인 쓰기 코루틴을 깨웁니다.

    go
    if rw.readerWait.Add(-1) == 0 {
    	runtime_Semrelease(&rw.writerSem, false, 1)
    }

읽기 락 해제 프로세스가 종료됩니다.

Semaphore

뮤텍스 내의 세마포어는 단순한 uint32 정수로 원자적으로 1 감소 및 1 증가를 통해 세마포어 획득 및 해제를 나타냅니다. 런타임에서 세마포어를 유지 관리하는 구조는 runtime.semaRoot 로 유형 정의는 runtime/sema.go 파일에 있습니다. semaRoot 는 균형 이진 트리 (treap) 를 사용하여 세마포어를 구성하고 관리하며 트리의 각 노드는 세마포어를 나타내고 노드 유형은 *sudog 입니다. 이는 양방향 링크드 리스트로 해당 세마포어의 대기 큐를 유지 관리하며 노드는 *sudog.elem(세마포어 주소) 을 통해 고유성을 유지하고 *sudog.ticket 필드를 통해 트리의 균형성을 보장합니다.

go
type semaRoot struct {
	lock  mutex
	treap *sudog        // root of balanced tree of unique waiters.
	nwait atomic.Uint32 // Number of waiters. Read w/o the lock.
}

세마포어 트리 semaRoot 는 더 낮은 수준의 뮤텍스 runtime.mutex 에 의존하여 동시성 안전성을 보장합니다.

go
var semtable semTable

// Prime to not correlate with any user patterns.
const semTabSize = 251

type semTable [semTabSize]struct {
	root semaRoot
    // 用于内存对齐,提高性能
	pad  [cpu.CacheLinePadSize - unsafe.Sizeof(semaRoot{})]byte
}

semaRoot 는 런타임에서 전역 semaTable 에 저장되며 보기에는 고정 길이 배열로 보이지만 여러 세마포어 트리의 루트 노드 집합을 저장하는 데 사용되지만 실제로 작동 방식에서는 해시 테이블입니다. 표의 각 요소는 semaRoot 와 일부 패딩 바이트 (pad) 를 포함하며 메모리 정렬 및 캐시 라인 경쟁 방지에 사용됩니다. semTabSize 는 세마포어 표 크기 상수로 표 길이를 251 로 지정하며 일반적으로 소수를 선택하여 해시 충돌을 줄이고 해시 효율성을 향상시킵니다.

go
func (t *semTable) rootFor(addr *uint32) *semaRoot {
	return &t[(uintptr(unsafe.Pointer(addr))>>3)%semTabSize].root
}

rootFor 메서드는 해시 함수와 같으며 uint32 유형 포인터 addr(즉 세마포어 주소) 을 받아 해당 주소의 semaRoot 구조체 포인터를 반환합니다. 이 코드는 먼저 addruintptr 로 변환한 후 3 비트 오른쪽으로 시프트하여 8 로 나눕니다 (1 바이트가 8 비트를 차지하므로 포인터 주소를 8 로 나누어 배열 인덱스로 매핑할 수 있음). semTabSize 로 모듈로하여 인덱스가 세마포어 표 크기 범위 내에 있도록 보장하며 인덱스를 통해 semaRoot 를 얻은 후 균형 트리에서 세마포어에 해당하는 *sudog 대기 큐를 찾습니다.

Acquire

세마포어 획득은 runtime.semacquire1 함수가 구현합니다.

go
func semacquire1(addr *uint32, lifo bool, profile semaProfileFlags, skipframes int, reason waitReason)

다음 매개변수를 받습니다.

  • addr, 세마포어 주소
  • lifo, 균형 트리의 출큐 순서에 영향을 미치며 기본값은 FIFO 이고 LIFO 은 후입선출입니다. 코루틴이 락을 대기하는 시간이 0 이 아닐 때 (적어도 한 번 블록됨) true 입니다.
  • profile, 락 성능 분석용 플래그
  • skipframes, 건너뛰는 스택 프레임 수
  • reason, 블록 이유

다음은 세마포어 획득 전체 프로세스를 간략히 설명합니다.

  1. 코루틴 상태를 판단하며 현재 코루틴이 스케줄링 중인 코루틴이 아니면 예외를 발생시킵니다.

    go
    gp := getg()
    if gp != gp.m.curg {
    	throw("semacquire not on the G stack")
    }
  2. 세마포어를 얻을 수 있는지 판단하며 논블록 방식으로 세마포어를 얻으려고 시도합니다. 얻을 수 있으면 직접 반환할 수 있습니다.

    go
    for {
    	v := atomic.Load(addr)
    	if v == 0 {
    		return false
    	}
    	if atomic.Cas(addr, v, v-1) {
    		return true
    	}
    }
  3. 논블록으로 얻을 수 없으면 정상적인 수단으로 세마포어를 얻기 위해 루프에 진입합니다. 먼저 acquireSudog() 을 통해 캐시에서 *sudog 를 얻으며 이 구조는 블록 대기 코루틴을 나타냅니다.

    s := acquireSudog()
  4. 그런 다음 전역 표에서 세마포어 트리를 얻습니다.

    go
    root := semtable.rootFor(addr)
  5. 루프에 진입하여 세마포어 트리에 락을 걸고 세마포어를 얻을 수 있는지 다시 판단합니다. 그렇지 않으면 세마포어 트리에 추가한 후 gopark 를 호출하여 중단하고 깨어날 때까지 대기하며 계속 이 프로세스를 반복하여 세마포어를 얻을 때까지 루프합니다.

    go
    for {
    	lockWithRank(&root.lock, lockRankRoot)
    	root.nwait.Add(1)
    	if cansemacquire(addr) {
    		root.nwait.Add(-1)
    		unlock(&root.lock)
    		break
    	}
    	root.queue(addr, s, lifo)
    	goparkunlock(&root.lock, reason, traceBlockSync, 4+skipframes)
    	if s.ticket != 0 || cansemacquire(addr) {
    		break
    	}
    }
  6. 마지막으로 깨어날 때 *sudog 를 해제하여 캐시에 반환합니다.

    go
    releaseSudog(s)

Release

세마포어 해제는 블록 대기 코루틴을 깨우며 이 기능은 runtime.semrelease1 함수가 구현합니다.

go
func semrelease1(addr *uint32, handoff bool, skipframes int)

다음 매개변수를 받습니다.

  • addr, 세마포어 주소
  • handoff, 현재 P 가 스케줄링 중인 G 를 깨어난 G 로 직접 전환할지 여부를 나타내며 기아 모드일 때만 true 입니다.
  • skipframes, 건너뛰는 스택 프레임 수

다음은 해제 전체 프로세스를 간략히 설명합니다.

  1. 세마포어 트리를 얻은 후 세마포어를 1 증가시켜 세마포어를 하나 해제함을 나타냅니다.

    go
    root := semtable.rootFor(addr)
    atomic.Xadd(addr, 1)
  2. 대기 코루틴 수가 0 이면 직접 반환합니다.

    go
    if root.nwait.Load() == 0 {
    	return
    }
  3. 세마포어 트리에 락을 걸고 대기 코루틴이 있는지 두 번째로 판단합니다.

    go
    lockWithRank(&root.lock, lockRankRoot)
    if root.nwait.Load() == 0 {
    	unlock(&root.lock)
    	return
    }
  4. 세마포어 트리에서 블록 대기 코루틴을 얻으며 nwait 을 1 감소시킨 후 세마포어 락을 해제합니다.

    go
    s, t0, tailtime := root.dequeue(addr)
    if s != nil {
    	root.nwait.Add(-1)
    }
    unlock(&root.lock)
  5. 세마포어를 얻을 수 있는지 판단합니다.

    go
    if handoff && cansemacquire(addr) {
    	s.ticket = 1
    }
  6. readyWithTime 함수는 깨어난 코루틴 G 를 P 의 다음 실행 코루틴으로 직접 전환하며 즉 *p.runnext=g 를 수정합니다.

    go
    readyWithTime(s, 5+skipframes)
  7. handofftrue 이면 goyield 는 현재 세마포어를 해제하는 코루틴 G 를 현재 M 과 언바인딩하고 P 로컬 실행 큐의 끝으로 다시 추가한 후 새 라운드 스케줄링 루프를 시작하여 깨어난 코루틴 G 가 즉시 스케줄링되도록 합니다.

    go
    if s.ticket == 1 && getg().m.locks == 0 {
    	goyield()
    }

세마포어 획득 및 해제 프로세스는 이와 같으며 Go 언어에서 세마포어를 사용하는 것은 뮤텍스뿐만 아니라 여기서 다루는 이유는 세마포어와 뮤텍스의 연관성이 가장 크기 때문입니다. 공식도 주석에 명시했습니다.

// Asynchronous semaphore for sync.Mutex.

세마포어를 이해한 후 뮤텍스를 다시 보면 더 명확해집니다.

TIP

semaRoot 세마포어 트리에 대해 출큐 및 입큐는 자체 균형 작업과 관련되어 구현이 복잡하므로 이 글의 주제와 관련이 없고 의미가 없으므로 생략했습니다. 관심이 있으면 소스 코드를 직접 확인하세요.

요약

뮤텍스 sync.Mutex 는 스핀과 세마포어 두 가지 메커니즘을 통해 코루틴 대기를 구현합니다. 스핀은 논블록이지만 CPU 자원을 소비하므로 사용에 엄격한 제한이 필요합니다. 세마포어는 블록으로 불필요한 자원 소비를 효과적으로 피할 수 있습니다. 더 공정한 경쟁 메커니즘을 구현하기 위해 Go 는 일반 모드와 기아 모드를 구분하여 코루틴이 락을 경쟁하는 과정에서 더 균형있게 보장합니다. runtime.mutex 와 같은 하위 수준 락과 비교하여 sync.Mutex 는 사용자를 위한 락으로 설계 시 더 많은 실제 사용 시나리오를 고려했습니다.

읽기/쓰기 락 sync.RWMutex 는 뮤텍스 sync.Mutex 를 통해 쓰기 - 쓰기 상호 배타를 구현하며 이를 기반으로 두 개의 세마포어를 추가로 추가하여 읽기 - 쓰기 상호 배타와 읽기 - 읽기 공유를 구현하여 다양한 동시성 시나리오를 지원합니다.

락 구현은 복잡해 보이지만 Mutex 의 원리를 이해하면 sync 표준 라이브러리의 다른 동기화 도구를 배우는 것이 훨씬 쉬워집니다.

Golang by www.golangdev.cn edit