[Go 병렬 프로그래밍] sync 패키지 사용해보기

프로그램을 병렬로 설계할 때에는 상대적으로 고려해야할 점이 많습니다. 다행히도 Go는 이를 쉽고 직관적으로 제어할 수 있는 내장 패키지를 제공하고 있습니다.

2019. 08. 15. #development #go

최근 Go를 사용할 일이 많아지고 있는데, 쓰면 쓸수록 병렬 프로그래밍에 정말 좋은 언어라는 생각이 듭니다. 고루틴을 통해 오버헤드가 거의 없는 병렬 처리 로직을 간단한 문법으로 구현할 수 있기 때문입니다.

그러나 프로그램을 병렬로 설계할 때에는 상대적으로 고려해야할 점이 많습니다. 한 자원에 동시에 접근하는 경우나, 의존성이 있는 작업간의 순서 처리를 명시적으로 제어할 필요가 있기 때문입니다. Go는 이를 쉽고 직관적으로 제어할 수 있는 내장 패키지를 제공하고 있는데, 대표적으로는 sync 패키지가 있습니다.

"Go 병렬 프로그래밍" 시리즈에서는 Go에 내장되어있는 기본 패키지를 이용해 간단하게 동시성을 제어하는 과정을 설명해보도록 하겠습니다. 오늘은 첫 번째로 sync.WaitGroup, sync.Mutex 를 소개합니다.

들어가기에 앞서...

이 포스트에서 만들 프로그램을 간단히 소개합니다.

action 함수는 인자로 넘어온 정수를 전역변수 globalValue int에 더하고, 1초를 기다리는 간단한 함수입니다.

var globalValue int

func action(i int) {
    globalValue += i
    time.Sleep(1 * time.Second)
}

우리는 action 함수에 0부터 99까지의 값을 넣어 총 100번 호출할 것입니다. 프로그램의 실행이 끝나면 globalValue 값에는 0부터 99까지의 합인 4950이 들어있기를 기대합니다.

우선 이 프로그램을 직렬로 돌리면 당연하게도 약 100초의 시간이 걸립니다.

func main() {
    startTime := time.Now()

    for i := 0; i < 100; i++ {
        action(i)
    }

    delta := time.Now().Sub(startTime)
    fmt.Printf("Result is %d, done in %.3fs.\n", globalValue, delta.Seconds())
}
$ go run main.go
Result is 4950, done in 100.010s.

고루틴으로 바꿔보자!

이제 action 함수를 고루틴으로 실행해 병렬로 처리하도록 만들어봅시다. (여기서부터 편의상 main 함수에 있었던 실행 시간을 계산하는 코드는 생략하겠습니다.)

func main() {
    for i := 0; i < 100; i++ {
        go action(i)
    }
}

action(i) 앞에 go를 붙혀줬을 뿐인데 고루틴이 됐습니다. 고루틴 처음 써봤을 때 정말 감동의 눈물을 감출 수가 없었습니다. 특히 python에서 했던 삽질을 생각해보면 가히 혁명이라고 칭하지 않을 수가 없습니다. 사실 python이 이상한 겁니다. 요즘 다른 언어들도 병렬 처리 그렇게 안복잡하거든요.

아무튼 이제 action 함수는 병렬로 실행되겠죠? 한 번 해봅시다.

$ go run main.go
Result is 65, Done in 0.000s.

?????

실행 시간은 0초에 가까워졌는데, 0부터 99까지의 합이 65가 되어버렸네요. 무슨 이유일까요?

sync.WaitGroup: 프로그램은 고루틴을 기다려주지 않는다

잠깐, 실행 시간이 0초라니 느낌이 쎄합니다. 분명 action 함수에는 1초간 잠을 자는 로직이 있었던 것 같은데, 왜 제대로 실행되지 않았을까요?

이는 고루틴은 백그라운드에서 비동기적으로 실행되며, 프로그램은 고루틴이 끝날 때까지 기다려주지 않기 때문입니다. 위 프로그램에서 100개의 고루틴이 생성되긴 했지만, 모두 실행이 완료되기 전에 이미 프로그램이 종료되어버린 것입니다. 이러한 문제는 sync.WaitGroup 을 이용해 제어합니다.

A WaitGroup waits for a collection of goroutines to finish. The main goroutine calls Add to set the number of goroutines to wait for. Then each of the goroutines runs and calls Done when finished. At the same time, Wait can be used to block until all goroutines have finished.

WaitGroup은 고루틴이 완료될 때까지 기다립니다. 메인 고루틴은 Add를 호출하여 몇 개의 고루틴을 기다릴 것인지 설정합니다. 각각의 고루틴은 작업이 완료되면 Done을 호출합니다. Wait는 모든 고루틴이 완료될 때까지 프로그램을 블락합니다.

여기서 DoneAdd(-1)과 완전히 동일하니까, 사실상 AddWait 두 개의 함수로 제어하는 것과 다름 없습니다. 예를 들어 100개의 고루틴을 WaitGroup으로 제어하는 과정을 추상적으로 설명하면 다음과 같습니다.

  1. Add(100)으로 100이라는 숫자를 기록해놓고
  2. Done()을 100번 호출하면서 기록된 숫자가 1씩 작아지다가
  3. 숫자가 0에 도달하면 Wait()의 블락이 풀리며 프로그램이 계속 진행됨

이제 우리의 예시 프로그램에 적용해봅시다.

func action(i int, wg *sync.WaitGroup) {
    ...

        wg.Done()  // 고루틴 하나가 처리 완료되었음을 알립니다.
}

func main() {
    var wg sync.WaitGroup
    wg.Add(100)  // 기다릴 고루틴의 개수를 100개로 설정합니다.

    for i := 0; i < 100; i++ {
        go action(i, &wg)
    }
    wg.Wait()  // 모든 고루틴이 실행 완료될 때까지 기다립니다.
}
$ go run main.go
Result is 4950, Done in 1.001s.

우리가 원하는 결과가 제대로 표시되었네요. 이처럼 병렬 작업이 꼭 끝난 다음에만 실행되어야하는 등의 의존성이 있는 작업은 sync.WaitGroup으로 처리하면 간단합니다.

...

그런데

sync.Mutex: 프로그램이 운빨망겜이 되어서는 안된다

앞서 짠 프로그램은 항상 4950이라는 올바른 값을 출력할 것 같지만, 세상만사 모든 일이 뜻대로만 되지는 않습니다. 사실 제가 운이 좋은 편이었습니다. 몇 번 더 실행해볼까요?

$ go run main.go
Result is 4886, Done in 1.001s.

$ go run main.go
Result is 4890, Done in 1.000s.

$ go run main.go
Result is 4950, Done in 1.000s.

$ go run main.go
Result is 4854, Done in 1.000s.

$ go run main.go
Result is 4950, Done in 1.000s.

다섯 번 돌렸는데 두 번 맞았습니다. 웬만한 가챠 게임보다는 훨씬 혜자스러운 확률이기는 하지만, 우리의 프로그램이 운빨망겜이 되어버린 모습이 안타깝기 그지없군요. 어째서 이런 일이 생겼을까요?

레이스 컨디션 (Race Condition)

보안 용어로도 자주 사용되는 레이스 컨디션은, 하나의 자원에 여러 프로세스가 동시에 접근하려고 하면서 서로 경쟁하는 상태를 말합니다.

이 예제에서 제가 굳이 globalValue라는 전역 변수를 만든 이유가 이러한 상황을 재현하기 위함이었습니다. 문제의 원인은 action 함수가 여러 개의 고루틴에서 동시에 실행되며 globalValue동시에 접근함에 있습니다.

action 함수를 다시 살펴볼까요?

func action(i int, wg *sync.WaitGroup) {
    globalValue += i

    time.Sleep(1 * time.Second)
    wg.Done()
}

사실 globalValue에 접근하는 코드는 globalValue += i 한 줄 뿐입니다. 얼핏보면 문제가 없어야할 것 같지만, 좀 더 자세히 들여다보면 += 연산자는 다음과 같은 과정으로 실행됩니다.

  1. globalValue 라는 값을 읽어와서
  2. globalValuei를 더한 값을 만들고
  3. 더한 값을 globalValue에 설정합니다.

각각의 연산이 한 큐에 실행될 것이라고는 아무도 보장해주지 않습니다. 그렇다면 2개 이상의 고루틴이 동시에 실행되면 어떤 일이 벌어질까요?

현재 globalValue는 0이고, 고루틴 1에서는 1, 고루틴 2에서는 2를 더하려고 하고 있습니다.

Goroutine 1 Goroutine 2
globalValue의 값에서 0을 읽음
globalValue의 값에서 0을 읽음
0에 2를 더해 2가 되었음
0에 1을 더해 1이 되었음
globalValue를 1로 설정함
globalValue를 2로 설정함

결과적으로 1과 2를 더하는 연산은 모두 정상적으로 실행됐지만, 결과는 3이 아닌 2가 되었습니다.

어떻게 해결해야할까요? Go는 당연히 이러한 상황에 사용해야할 기능도 제공하고 있습니다.

sync.Mutex

흔히 "락 건다"라고 표현합니다. Go 패키지의 주석이 너무 대충써져있는 관계로 위키피디아를 인용해봅시다.

In computer science, a lock or mutex (from mutual exclusion) is a synchronization mechanism for enforcing limits on access to a resource in an environment where there are many threads of execution.

컴퓨터 과학에서, '락' 또는 '뮤텍스 (상호 배제)'는 여러 스레드가 실행되는 환경에서 자원에의 접근을 제한하는 동기화 매커니즘입니다.

설명이 복잡하네요. 공유 자원을 하나의 방이라고 생각해봅시다. A와 B 두 개의 고루틴이 공유 자원에 접근하려고 하고 있습니다.

A가 먼저 도착해 방에 들어가 문을 걸어 잠갔습니다 (mutex.Lock()). 잠시 후 B가 도착해 자원에 접근하려고 하지만 문이 잠겨있네요. B는 어쩔 수 없이 A가 나올 때까지 기다려야합니다.

잠시 후, A가 공유 자원에 볼일을 다 보고 방문을 열고(mutex.Unlock()) 나옵니다. 밖에서 기다리고 있던 B는 얼른 들어가서, 혹여나 다른 고루틴이 들어올까싶어 얼른 문을 걸어잠급니다.

이 원리를 우리의 프로그램에 적용해봅시다.

func action(i int, mutex *sync.Mutex, wg *sync.WaitGroup) {
    mutex.Lock()
    globalValue += i
    mutex.Unlock()

    time.Sleep(1 * time.Second)

    wg.Done()
}

공유 자원에 접근하는 코드 앞뒤로 Lock()Unlock()을 넣어주었습니다. 똑같이 다섯 번 실행해볼까요?

$ go run main.go
Result is 4950, Done in 1.000s.

$ go run main.go
Result is 4950, Done in 1.000s.

$ go run main.go
Result is 4950, Done in 1.000s.

$ go run main.go
Result is 4950, Done in 1.000s.

$ go run main.go
Result is 4950, Done in 1.000s.

완벽하군요.

...

위의 예제는 하나의 전역 변수에 접근하는 간단한 예제였습니다. 하지만 현실에는 고루틴간에 서로 데이터를 주고받거나, 특정 조건이 되면 백그라운드에서 실행중인 고루틴을 종료시켜야하는 경우 등 아주 다양한 사례가 있습니다.

다음 글에서는 채널을 이용해 고루틴간에 데이터를 주고 받는 방법에 대해 정리해보겠습니다.

크리에이티브 커먼즈 라이선스

이 저작물은 크리에이티브 커먼즈 저작자표시-동일조건변경허락 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© 2011 - 2020 Do Hoerin, LYnLab