[Golang] 고루틴

2021-12-08 hit count image

Golang에서 쓰레드를 사용하는 방법인 고루틴(Goroutine)에 대해서 알아봅시다.

개요

이번 블로그 포스트에서는 Golang에서 고루틴(Goroutine)을 사용하는 방법에 대해서 알아보도록 하겠습니다. 고루틴을 이해하기 위해 쓰레드에 대해서 간단하게 살펴볼 예정입니다. 이 블로그 포스트에서 소개하는 코드는 다음 링크를 통해 확인하실 수 있습니다.

쓰레드

쓰레드(Thread)는 프로그램내에서 실행 흐름을 의미합니다. 프로그램은 일반적으로는 하나의 실행 흐름(쓰레드)을 가지지만, 경우에 따라 하나 이상의 쓰레드를 갖는 경우도 있습니다. 이를 멀티 쓰레드라고 합니다.

CPU는 단순한 계산기입니다. 따라서 주어진 값을 계산만 할 뿐, 이 값이 어디서 왔고, 어디로 가는지는 신경쓰지 않습니다. 멀티 쓰레드인 경우, OS에서 쓰레드를 관리하고, 쓰레드의 개수가 CPU보다 많은 경우, 쓰레드를 교체해가면서 CPU를 사용하도록 합니다. 이를 컨텍스트 스위칭(Context Switching)이라고 합니다.

컨텍스트 스위칭은 하나의 CPU가 여러 쓰레드를 다룰 때, 쓰레드를 전환시키며 CPU를 사용하도록 하는 것을 의미합니다. 이렇게 컨텍스트 스위칭이 발생하면 전환 비용이 발생하므로 성능이 저하되는 문제가 발생할 수 있습니다.

반대로, CPU의 개수가 쓰레드의 개수와 동일하다면, 컨텍스트 스위칭이 발생하지 않으므로 성능에 아무 문제가 발생하지 않습니다.

고루틴

고루틴(Goroutine)은 Golang이 사용하는 경량 쓰레드를 의미합니다. Golang에서 실행되는 모든 프로그램은 고루틴에서 실행됩니다. 즉, 메인 함수도 고루틴에서 실행이 됩니다.

다시 말하면, Golang의 모든 프로그램은 반드시 하나 이상의 고루틴을 가지고 있음을 의미합니다.

Golang에서는 다음과 같이 go 키워드를 사용하여 함수를 호출하여 고루틴을 실행할 수 있습니다.

go FUNCTION()

이를 확인하기 위해 main.go 파일을 만들고 다음과 같이 수정합니다.

package main

import (
  "fmt"
  "time"
)

func PrintAlphabet() {
  alphabet := "abcdefghijklmnopqrstuvwxyz"

  for _, v := range alphabet {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%c", v)
  }
}

func PrintNumbers() {
  for i := 1; i <= 10; i++ {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%d", i)
  }
}

func main() {
  PrintAlphabet()
  fmt.Println("")

  PrintNumbers()
  fmt.Println("")

  go PrintAlphabet()
  go PrintNumbers()
  time.Sleep(3 * time.Second)
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
abcdefghijklmnopqrstuvwxyz
12345678910
a12bc34d5ef67g8hi910jklmn

알파벳을 200 밀리세컨드로 출력하는 PrintAlphabet 함수와 1~10까지의 숫자를 역시 200 밀리세컨드로 출력하는 PrintNumbers함수를, 일반적으로 호출한 경우, 함수의 동작이 완료된 후, 다음 함수가 실행되고 있음을 알 수 있습니다.

여기서 go 키워드를 사용하여 호출한 경우, 순서와 상관없이 함수가 실행되고 있음을 알 수 있습니다.

마지막으로 main 함수에서 3초간 대기하는 코드를 추가하였습니다. 해당 코드는 main 고루틴을 3초간 유지하기 위함입니다. 만약, 해당 코드가 없다면 main 함수는 PrintAlphabetPrintNumbers을 고루틴으로 실행하고 실행 결과를 기다리지 않으므로 메인 고루틴이 종료되게 됩니다. 그럼 메인 고루틴에서 실행한 고루틴(서브 고루틴)도 함께 종료되므로 어떠한 결과도 출력되지 않습니다.

서브 고루틴 대기

앞에서 살펴봤듯이 메인 고루틴이 종료되면 메인 고루틴에서 실행한 서브 고루틴도 모두 함께 종료되게 됩니다. 서브 고루틴이 어느정도 시간이 걸릴지 예상할 수 있다면 앞에서 사용한 방식으로 메인 고루틴을 유지할 수 있지만, 보통은 서브 고루틴이 언제 끝날지 알 수 없습니다.

Golang에서는 서브 고루틴이 종료될 때까지 메인 고루틴을 유지할 수 있도록 WaitGroup을 제공합니다.

var wg sync.WaitGroup

wg.Add(3)

wg.Done()
wg.Wait()

WaitGroupAdd를 통해 메인 고루틴이 대기할 서브 고루틴의 개수를 정의합니다. 이후, 서브 고루틴에서는 Done 함수를 통해 고루틴의 종료를 알리고, 메인 고루틴에서는 Wait 함수를 통해 모든 서브 고루틴이 종료될 때까지 대기하도록 할 수 있습니다.

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

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

func PrintAlphabet() {
  alphabet := "abcdefghijklmnopqrstuvwxyz"

  for _, v := range alphabet {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%c", v)
  }

  wg.Done()
}

func PrintNumbers() {
  for i := 1; i <= 10; i++ {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%d", i)
  }

  wg.Done()
}

var wg sync.WaitGroup

func main() {
  wg.Add(2)

  go PrintAlphabet()
  go PrintNumbers()

  wg.Wait()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
a1b23cd4e56fg78hi910jklmnopqrstuvwxyz%

이전과는 다르게 메인 고루틴이 모든 서브 고루틴이 끝나고 종료되는 것을 확인할 수 있습니다.

고루틴 동작 원리

고루틴은 OS 쓰레드를 이용하는 경량 쓰레드(lightweight thread)입니다. 고루틴은 쓰레드가 아니고 쓰레드를 이용합니다.

고루틴은 OS 쓰레드별로 실행이 되고, OS 쓰레드보다 고루틴이 많이 실행되는 경우, 고루틴은 다른 고루틴이 끝날 때까지 대기하게 됩니다. OS 쓰레드를 사용하고 있는 고루틴이 완료되면, 대기중에 있던 고루틴이 실행되게 됩니다.

또는 고루틴이 시스템콜(파일 읽기, 쓰기 / 네트워크 읽기, 쓰기)를 수행하게 되면, OS가 응답하기 전까지 고루틴이 할 일이 없으므로, 해당 고루틴은 대기열에 들어가고 대기열에 있던 다른 고루틴이 실행되게 됩니다.

고루틴을 아무리 많이 생성해도 OS 쓰레드를 이용하기 때문에 OS 레벨의 컨텍스트 스위칭 비용이 발생하지 않습니다.

동시성 프로그래밍의 주의점

고루틴과 같이 쓰레드를 이용하는 프로그래밍을 동시성 프로그래밍(Concurrent programming)이라고 합니다. 이때, 동일한 메모리 자원을 여러 고루틴(쓰레드)에서 접근하게 되면 동시성 문제가 발생하게 됩니다.

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

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

type Account struct {
  Balance int
}

func DepositAndWithdraw(account *Account) {
  fmt.Println("Balance:", account.Balance)
  if account.Balance < 0 {
    panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
  }
  account.Balance += 1000
  time.Sleep(time.Millisecond)
  account.Balance -= 1000
}

func main() {
  var wg sync.WaitGroup

  account := &Account{Balance: 10}
  wg.Add(10)
  for i := 0; i < 10; i++ {
    go func() {
      for {
        DepositAndWithdraw(account)
      }
    }()
  }
  wg.Wait()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Balance: 6010
Balance: 10
Balance: 7010
Balance: 6010
Balance: 5010
panic: Balance should not be negative value: -1990

고루틴을 사용하기 때문에 위와 같은 결과가 아닐 수 있습니다. DepositAndWithdraw 함수는 Account 변수의 값을 1000 증가시키고 감소 시킵니다. 예제는 이 함수를 무한히 반복하는 고루틴을 10개 가지고 있습니다.

만약 함수가 고루틴을 사용하지 않고 순차적으로 실행된다면, 음수값은 절대로 나올 수 없으므로, panic이 발생하지 않습니다. 하지만, 여러 고루틴이 동일한 메모리 주소의 값을 변경하고 있어 위와 같이 동시성 문제로 panic이 발생하는 것을 알 수 있습니다. (패닉이 발생하지 않는다면, 프로그램을 종료시키고 다시 실행해보세요.)

뮤텍스

이와 같은 동시성 문제를 해결하기 위해 뮤텍스(Mutex, Mutual Exclusion)을 사용하여 메모리 자원을 하나의 고루틴에서만 접근하도록 Lock을 걸 수 있습니다.

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

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

var mutex sync.Mutex

type Account struct {
  Balance int
}

func DepositAndWithdraw(account *Account) {
  mutex.Lock()
  defer mutex.Unlock()

  fmt.Println("Balance:", account.Balance)
  if account.Balance < 0 {
    panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
  }
  account.Balance += 1000
  time.Sleep(time.Millisecond)
  account.Balance -= 1000
}

func main() {
  var wg sync.WaitGroup

  account := &Account{Balance: 10}
  wg.Add(10)
  for i := 0; i < 10; i++ {
    go func() {
      for {
        DepositAndWithdraw(account)
      }
    }()
  }
  wg.Wait()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
Balance: 10
Balance: 10
Balance: 10
Balance: 10
Balance: 10
Balance: 10

뮤텍스는 간단하게 동시성 문제를 해결할 수 있지만, 다음과 같은 문제점을 가지고 있습니다.

  1. 동시성 프로그래밍으로 인한 성능 향상을 얻을 수 없다. 심지어 과도한 락킹으로 성능이 하락할 수 있다.
  2. 고루틴을 완전히 멈추게 만드는 데드락(Deadlock) 문제가 발생한다.

따라서 뮤텍스를 사용할 경우는 주의를 해야 합니다.

데드락

동시성 프로그래밍의 문제점중 하나인 데드락을 확인하기 위해 식사하는 철학자들(Dining Philosophers Problem) 문제를 살펴보도록 하겠습니다.

식사하는 철학자들에 관한 자세한 내용은 Wiki를 참고하시기 바랍니다.

이를 확인하기 위해 main.go 파일을 다음과 같이 수정합니다.

package main

import (
  "fmt"
  "math/rand"
  "sync"
  "time"
)

var wg sync.WaitGroup

func diningProblem(name string, first, second *sync.Mutex, firstName, secondName string) {
  for i := 0; i < 100; i++ {
    fmt.Printf("(%s) Try to eat\n", name)
    first.Lock()
    fmt.Printf("(%s) Grab %s\n", name, firstName)
    second.Lock()
    fmt.Printf("(%s) Grab %s\n", name, secondName)
    fmt.Printf("(%s) Eating\n", name)
    time.Sleep(time.Duration(rand.Int()) * time.Millisecond)
    second.Unlock()
    first.Unlock()
  }
  wg.Done()
}

func main() {
  rand.Seed(time.Now().UnixNano())

  wg.Add(2)
  fork := &sync.Mutex{}
  spoon := &sync.Mutex{}

  go diningProblem("A", fork, spoon, "Fork", "Spoon")
  go diningProblem("B", spoon, fork, "Spoon", "Fork")

  wg.Wait()
}

이를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

# go run main.go
(B) Try to eat
(A) Try to eat
(A) Grab Fork
(B) Grab Spoon
fatal error: all goroutines are asleep - deadlock!

뮤텍스는 간단하게 동시성 문제를 해결할 수 있지만, 데드락과 같은 문제로 프로그램이 의도하지 않게 종료될 수 있습니다.

완료

이것으로 Golang에서 쓰레드를 사용하기 위한 고루틴에 대해서 알아보았습니다. 또한 서브 고루틴을 기다리는 방법과 뮤텍스를 사용하여 동시성 문제를 해결하는 방법에 대해서도 알아보았습니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.

스무디 한 잔 마시며 끝내는 React Native, 비제이퍼블릭
스무디 한 잔 마시며 끝내는 리액트 + TDD, 비제이퍼블릭
[심통]현장에서 바로 써먹는 리액트 with 타입스크립트 : 리액트와 스토리북으로 배우는 컴포넌트 주도 개발, 심통
Posts