본문 바로가기
Language/Go

Go - Sync

by ocwokocw 2022. 1. 20.

- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/sync

- 개요

병렬 프로세스에 안전한(thread-safe) counter를 만들어본다. single-thread에서 동작하는 counter를 만들고 난 후, 여러 개의 goroutine으로 unsafe함을 확인하고 Sync를 이용해서 이를 고쳐보자. race condition을 해결하기 위한 Mutex도 사용해본다.

- Counter

Counter를 증가시키는 메소드와 Counter의 값을 반환하는 메소드를 만들었다고 가정한 후 test 코드를 작성해보자.
 
func TestCounter(t *testing.T) {
	t.Run("incrementing the counter 3 times leaves it at 3", func(t *testing.T) {
		counter := Counter{}
		counter.Inc()
		counter.Inc()
		counter.Inc()

		if counter.Value() != 3 {
			t.Errorf("got %d, want %d", counter.Value(), 3)
		}
	})
}
 
Compile만 가능하게 code를 작성하여 test를 수행하고 이 코드가 실패함을 확인해보자.
 
func (c *Counter) Inc() {

}

func (c *Counter) Value() int {
	return 0
}
 
이 코드가 Pass하도록 만들어보자. 사실 그렇게 어렵진 않다. struct에 내부 상태 field를 하나 선언하고, Inc가 호출될때마다 해당 상태변수를 증감시키면 된다.
 
type Counter struct {
	value int
}

func (c *Counter) Inc() {
	c.value++
}

func (c *Counter) Value() int {
	return c.value
}
 
리팩토링할게 많지는 않다. assert하는 부분만 더 읽기 쉽도록 helper 함수로 추출해주도록 하자.
 
t.Run("incrementing the counter 3 times leaves it at 3", func(t *testing.T) {
	counter := Counter{}
	counter.Inc()
	counter.Inc()
	counter.Inc()

	assertCounter(t, counter, 3)
})

............

func assertCounter(t testing.TB, got Counter, want int) {
	t.Helper()
	if got.Value() != want {
		t.Errorf("got %d, want %d", got.Value(), want)
	}
}
 
기능적으로는 충분하지만 요구사항은 병렬환경에서도 safe 해야하므로 이에 대한 test 코드를 작성해주도록 하자.

- Concurrency

t.Run("it runs safely concurrently", func(t *testing.T) {
	wantedCount := 1000
	counter := Counter{}

	var wg sync.WaitGroup
	wg.Add(wantedCount)

	for i := 0; i < wantedCount; i++ {
		go func() {
			counter.Inc()
			wg.Done()
		}()
	}
	wg.Wait()

	assertCounter(t, counter, wantedCount)
})
 
위의 코드는 for문을 순회하면서 counter.Inc()를 goroutine으로 호출하고 있다. 코드에서 sync.WaitGroup을 사용했는데 병렬 프로세스 호출시 동기화를 편하게 할 수 있다.
 
WaitGroup은 gouroutine의 collection이 끝나기까지 기다린다. main goroutine 은 Add를 호출하는데 인자로 기다려야 하는 goroutine의 수를 넘겨준다. 그러면 각 goroutine 들이 실행하면서 끝날때 Done을 호출한다. 동시에 Wait를 호출하면 모든 goroutine이 종료될때까지 block된다.
 
단언문을 수행하기전에 wg.Wait()가 끝날때까지 기다리면 모든 goroutine이 Counter를 증가시키려는 시도를 했다는것을 확인할 수 있다.
 
=== RUN   TestCounter/it_runs_safely_in_a_concurrent_envionment
--- FAIL: TestCounter (0.00s)
    --- FAIL: TestCounter/it_runs_safely_in_a_concurrent_envionment (0.00s)
    	sync_test.go:26: got 939, want 1000
FAIL
 
test를 수행해보면 숫자는 다르더라도 실패할것이다. 결과를 보면 알겠지만 여러 개의 goroutine이 동시에 counter의 값을 변경하려고 시도할 때 동작하지 않는다는것을 말해주고 있다.

- Mutex

test 코드가 통과하기 위해서는 Counter에 Mutex lock을 추가해야한다. Mutex는 상호 배제 lock이다. Mutex의 0값은 잠금 해제된 mutex를 가리킨다.
 
type Counter struct {
	mu    sync.Mutex
	value int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}
 
위처럼 코드를 작성하면 어떤 goroutine이 Inc를 호출할 때 처음 goroutine은 Counter에 대한 lock을 획득할것이다. 나머지 다른 goroutine들은 접근하기 전에 Unlock가 될때까지 기다려야 한다.
 
만약 다시 test를 수행하면 pass하는것을 볼 수 있는데 각 goroutine들이 값을 변경하기전에 자신의 차례를 기다려야 하기 때문이다.
 
다른 예제코드에서 sync.Mutex를 struct안에 내장형으로 선언한것을 본적이 있을수도 있다.
 
type Counter struct {
	sync.Mutex
	value int
}
 
위처럼 struct안에 내장형으로 선언하면 사용시 아래코드처럼 더 우아하게 사용할 수 있다고 주장할 수 있다.
 
func (c *Counter) Inc() {
	c.Lock()
	defer c.Unlock()
	c.value++
}
 
보기에는 더 좋아보이지만 프로그래밍은 매우 주관적인 학문이며 이런 방식은 나쁘고 잘못되었다라고 말할 수도 있다. 내장형으로 선언하면 해당 형의 메소드들은 원하지 않아도 public 인터페이스가 된다. 공개 API가 되는것에 대해서 매우 조심해야 하는데, 다른 코드와 결합도가 생길 수 있기 때문이다. 당연히 이런 불필요한 결합도는 피해야 한다.
 
Lock과 Unlock을 노출하면 기껏해봐야 혼란스러워지는 정도라고 생각할 수도 있다. 하지만 최악의 경우 만약 해당 형을 사용하는 사람이 이런 노출된 메소드들을 호출하기 시작하면 잠재적으로 software에 매우 해로워질 수 있다.

- Mutex 복사

test는 통과하지만 여전히 코드는 위험요소를 갖고 있다. 만약 go vet를 실행해보면 아래와 같은 error가 출력되는것을 확인할 수 있을것이다.
 
sync/v2/sync_test.go:16: call of assertCounter copies lock value: v1.Counter contains sync.Mutex
sync/v2/sync_test.go:39: assertCounter passes lock by value: v1.Counter contains sync.Mutex
 
sync.Mutex 문서에서 이유를 말해주고 있는데 Mutex는 반드시 처음 사용된 후에 복사되면 안된다.
 
Counter를 assertCounter에 넘기면 mutex의 복사본을 만들려고 할것이다. 이를 해결하기 위해 Counter의 포인터를 넘겨야하므로 assertCounter의 시그니처를 포인터형으로 변경한다.
 
func assertCounter(t testing.TB, got *Counter, want int)
 
이렇게 하면 Compile이 되지 않는데 *Counter가 아니라 Counter를 넘기려고 하고 있기 때문이다. &를 붙여서 넘겨도 되지만 생성자를 만들때 API 독자들에게 유형을 직접 초기화하지 않는것이 좋다는것을 보여주는게 좋다.
 
func NewCounter() *Counter {
	return &Counter{}
}
 
 

'Language > Go' 카테고리의 다른 글

Go - Reading files  (0) 2022.03.13
Go - Context  (0) 2022.02.27
Go - reflection  (0) 2022.01.15
Go - select  (0) 2022.01.12
Go - Concurrency  (0) 2022.01.04

댓글