[Go] Mutex는 어떻게 동작할까?

🧠·2022년 11월 11일
2

Go

목록 보기
5/5
post-thumbnail

프로세스 동기화 문제 (Race Condition)

CS를 공부하다 보면 두 개 이상의 프로세스 또는 스레드가 동일한 자원에 동시에 접근하여 작업을 실행할 때 Race Condition이 발생하는 것을 알 수 있다.

그러한 영역을 Critical Section이라고도 하는데 Critical Section에서 발생하는 Race Condition은 Mutex와 Semaphore를 활용하여 해결한다.

  • Mutex: 임계 영역에 진입하는 프로세스는 Lock을 획득하고 빠져나올 때 반납함으로써 동시에 접근하는 상황을 방지한다.
  • Semaphore: 공유 자원에 접근할 수 있는 프로세스의 최대 허용치만큼 동시에 사용자가 접근할 수 있다.

많은 사람들이 Mutex를 설명할 때 잠겨져 있는 화장실을 예로 들곤 하는데 가장 적절한 비유라고 생각했다. 잠겨져있는 화장실에 들어가기 위해서는 Key(Lock)를 획득하여 들어갈 수 있고, 나올 땐 다시 문을 잠그고 Key를 반납하는 방식인데 Critical Section에 들어가기 위해 Lock을 획득하는 것과 비슷한 상황이라고 생각한다.

Mutex가 Race Condition을 해결하는 것은 알겠는데 Lock을 획득하는 것이 하나의 프로세스만을 보장하는 것이 이해가 가지 않았다. 결국 Lock을 획득하는 것도 어느 시점에 정확히 동시에 일어난다면 Lock이 복사되어 둘 다 가지게 되는 건 아닌가라는 생각이 문득 들었다. (화장실에 먼저 들어가기 위해 Key를 두고 싸운다던지...)

Lock, Unlock 과정

보통 mutex는 lock이라는 int 변수에 1을 할당한 뒤 Critical Section에 진입하면 lock에서 1을 빼고, 다시 나올 때 1을 더하는 방식으로 구현하는데 go 언어로는 다음과 같이 구현을 해보았다.

해결 방법 1

package main

import (
	"fmt"
	"time"
)

func main() {
	var lock int = 1

	num := 0

	go func() {
		// Lock Acquire 과정
		for lock == 1 {
			lock -= 1
		}
		fmt.Println("Lock Acquired [1]")
		for i := 0; i < 10000000; i++ {
			num += 1
		}
		// Lock Release 과정
		if lock == 0 {
			lock += 1
		}
		fmt.Println("Lock Released [1]")
	}()

	go func() {
		// Lock Acquire 과정
		for lock == 1 {
			lock -= 1
		}
		fmt.Println("Lock Acquired [2]")
		for i := 0; i < 10000000; i++ {
			num += 1
		}
		// Lock Release 과정
		if lock == 0 {
			lock += 1
		}
		fmt.Println("Lock Released [2]")
	}()

	time.Sleep(2 * time.Second)

	fmt.Println("After goroutine", num, lock)
}
[Output]

Lock Acquired [1]
Lock Acquired [2]
Lock Released [1]
Lock Released [2]
After goroutine 10013664 1

Output은 다음과 같이 나온다.

Lock을 얻고 반납하는 과정에서도 순서가 지켜지지 않고, 모든 과정이 끝나고 20000000이라는 값이 나와야 되는데 그렇지도 않다. 이렇게 구현하면 절대 Race Condition을 해결할 수 없다.

해결 방법 2

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	mu := sync.Mutex{}

	num := 0

	go func() {
		// Lock Acquire 과정
		mu.Lock()
		fmt.Println("Lock Acquired [1]")
		for i := 0; i < 10000000; i++ {
			num += 1
		}
		// Lock Release 과정
		fmt.Println("Lock Released [1]")
		mu.Unlock()
	}()

	go func() {
		// Lock Acquire 과정
		mu.Lock()
		fmt.Println("Lock Acquired [2]")
		for i := 0; i < 10000000; i++ {
			num += 1
		}
		// Lock Release 과정
		fmt.Println("Lock Released [2]")
		mu.Unlock()
	}()

	time.Sleep(2 * time.Second)

	fmt.Println("After goroutine", num)
}
[Output]

Lock Acquired [1]
Lock Released [1]
Lock Acquired [2]
Lock Released [2]
After goroutine 20000000

Output을 보면 Lock을 얻고 반납하는 과정, 그리고 기대하는 값 모두 출력되는 것을 볼 수 있다. go 자체에서 구현된 Mutex를 이용한 것인데 어떤 식으로 구현이 되었길래 Race Condition을 해결할 수 있는지 찾아보게 되었다.

Go에서의 Mutex 구현

type Mutex struct {
	state int32
	sema  uint32
}

go Mutex
https://cs.opensource.google/go/go/+/refs/tags/go1.19.3:src/sync/mutex.go;drc=a11cd6f69aec5c783656601fbc7b493e0d63f605;l=34

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
	// Fast path: grab unlocked mutex.
	if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
}

go Mutex.Lock()
https://cs.opensource.google/go/go/+/refs/tags/go1.19.3:src/sync/mutex.go;l=81

사실 race, lockSlow() 부분은 완벽히 이해하지 못하였다. 하지만 atomic.CompareAndSwapInt32에 해당하는 부분이 위 해결 방법 1에서 해결하지 못했던 부분을 해결한 것을 알 수 있었다.

go에서는 atomic operation을 sync/atomic 패키지를 통해 제공하고 있다.

여기서 atomic operation 이란 직역하면 원자적 연산이라는 뜻인데 데이터베이스 트랜잭션의 원자성과 비슷한 성질을 가지고 있다.

go에서 제공하는 atomic 함수 중 atomic.AddInt32(&a, 1)를 예로 들어서 설명하면

1. a 변수의 값을 가져온다.
2. a 변수의 값을 증가한다.
3. 변경된 a 변수를 저장한다.

위 1-3과정이 다른 프로세스나 스레드에 의해 방해받지 않고 온전히 하나의 명령어로 실행됨을 보장한다.

데이터베이스의 트랜잭션이 다른 트랜잭션에 의해 방해받지 않고 원자적으로 실행되는 것과 비슷하다.

다시 atomic.CompareAndSwapInt32로 돌아와서 해당 메소드를 살펴보자.

Compare And Swap

func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

https://cs.opensource.google/go/go/+/refs/tags/go1.19.3:src/sync/atomic/doc.go;l=83;drc=a11cd6f69aec5c783656601fbc7b493e0d63f605;bpv=1;bpt=1

해당 메소드는 말 그대로 '비교한 후 바꿔주는' 동작을 한다.

멀티 스레드 환경에서 지역 변수들은 각 스레드의 Stack 영역에 저장되어 있는데 스레드에 저장된 값과 메인 메모리에 저장된 값을 비교하여 값이 같다면 새로운 값으로 치환하는 과정을 해당 메소드가 수행한다.

atomic.CompareAndSwapInt32(&mutex, 1, 0)

위 경우 mutex에 저장된 값이 1과 같다면 0으로 바꿔주는 과정을 수행하고 true를 반환한다.

그렇지 않다면 false를 반환한다.

이렇게 되면 굉장히 빠르게 Mutex에 저장된 State 값을 비교하며 1을 0으로 바꿔주는 과정을 거치게 되고 이 과정은 원자적으로 수행되는 것을 보장하기 때문에 여러 스레드가 동시에 접근하여 변수의 값에 영향을 주는 경우는 없다.

그 결과 go에서는 Mutex.Lock() 과정에서 CompareAndSwapInt32 메소드를 활용하고, Unlock() 과정에서 AddInt32 메소드를 활용하여 Mutex를 구현하고 있다.

해결방법 3

그렇다면 위 두 메소드를 활용해서 Race Condition을 해결할 수 있지 않을까?

실제로 go에서 구현된 Mutex를 활용하지 않고 atomic 하게 구현된 메소드를 활용해서 Race Condition을 해결해 보았다. 코드는 아래와 같다.

package main

import (
	"fmt"
	"sync/atomic"
	"time"
)

func main() {
	var mutex int32 = 1

	num := 0

	go func() {
		// Lock Acquire 과정
		for !atomic.CompareAndSwapInt32(&mutex, 1, 0) {
		}
		fmt.Println("Lock Acquired [1]")
		for i := 0; i < 10000000; i++ {
			num += 1
		}
		// Lock Release 과정
		fmt.Println("Lock Released [1]")
		atomic.AddInt32(&mutex, 1)
	}()

	go func() {
		// Lock Acquire 과정
		for !atomic.CompareAndSwapInt32(&mutex, 1, 0) {
		}
		fmt.Println("Lock Acquired [2]")
		for i := 0; i < 10000000; i++ {
			num += 1
		}
		// Lock Release 과정
		fmt.Println("Lock Released [2]")
		atomic.AddInt32(&mutex, 1)
	}()

	time.Sleep(2 * time.Second)

	fmt.Println("After goroutine", num, mutex)
}
[Output]

Lock Acquired [1]
Lock Released [1]
Lock Acquired [2]
Lock Released [2]
After goroutine 20000000 1

Go에서 제공하는 Mutex를 활용했을 때와 결과가 동일하게 나오는 것을 볼 수 있다.


실제로 Go에서 Mutex가 어떻게 구현되었는지 코드를 통해 확인하면서 Lock, Unlock 과정에서 Atomic 한 Operation이 활용되는 것도 볼 수 있었다.

Reference

profile
: )

0개의 댓글