10장 Go의 동시성

2024.04.23

  • 동시성은 단일 프로세스를 독립적인 컴포넌트로 분리하고 해당 컴포넌트가 안전하게 데이터를 공유하는 방법을 지정하는 컴퓨터 과학 용어이다.
  • 대부분의 언어는 잠금(lock)을 획득하여 공유 데이터를 접근하는 운영체제 레벨 스레드를 사용하는 라이브러리를 통해 동시성을 제공하지만 Go는 다르게 처리한다.
  • Go는 주요 동시성 모델로 순차적 프로세스들의 통신(Communicating Sequential Processes, CSP)에 기반한다.

10.1 동시성 사용 시점

  • 많은 동시성은 어떤 것을 자동으로 더 빠르게 만들지는 않으며 코드를 이해하기 더 어렵게 만들 수 있다. 동시성(concurrency)은 병렬성(parallelism)이지 않다는 것을 이해하는 것이 중요하다.
  • 동시성은 동시에 실행되는 것이 시간이 얼마 걸리지 않을 때 사용하는 것은 좋지 않다. 동시성은 공짜가 아니다.
  • 많은 일반적인 인메모리(in-memory) 알고리즘은 너무 빨라서 동시성을 통해 값을 전달하는 오버헤드가 병렬적으로 동시성 코드를 수행하여 얻는 잠재적 시간절약을 압도할 수 있다. 이래서 동시성 수행은 보통 I/O 작업을 위해 사용된다.

10.2 고루틴

  • 고루틴은 Go의 동시성 모델에 핵심 개념이다.
  • 고루틴을 이해하기 위해서 용어 몇개를 정의해 보자.
    • 프로세스(process)
      • 프로세스는 컴퓨터의 운영체제에서 수행 중인 프로그램의 인스턴스이다.
      • 운영체제는 프로세스와 메모리와 같은 자원은 연결시키고 다른 프로세스에서 접근할 수 없도록 보장한다.
      • 프로세스는 하나 이상의 스레드(thread)로 구성된다.
    • 스레드(thread)
      • 스레드는 운영체제가 주어진 시간동안 수행되는 실행의 단위이다.
      • 프로세스 내에 스레드는 자원들의 접근을 공유한다.
      • CPU는 코어의 수에 따라 하나 이상의 스레드의 명령어를 동시에 실행할 수 있다.
  • 운영체제의 역할 중 하나는 모든 프로세스가 수행될 수 있는 기회를 얻을 수 있도록 보장하기 위해 스레드를 CPU에 스케줄링하는 것이다.
  • 고루틴은 Go 런타임에서 관리하는 가벼운 프로세스이다.
  • Go 프로그램이 실행이 되면, Go 런타임은 여러 스레드를 생성하고 프로그램을 실행하기 위해 단일 고루틴을 시작한다. 프로그램에서 생성된 모든 고루틴은 초기에 생성된 하나를 포함하여, 운영체제에서 CPU 코어에 따라 스레드를 스케줄링 하듯이 Go 런타임 스케줄러가 자동으로 스레드들을 할당한다.
  • 고루틴의 몇 가지 이점
    • 고루틴 생성은 운영체제 레벨 자원을 생성하지 않기 때문에 스레드 생성보다 빠르다.
    • 고루틴의 초기 스택 크기는 스레드의 스택 크기보다 작으며 필요하다면 늘릴 수 있다. 고루틴은 메모리를 더 효율적으로 사용할 수 있게 한다.
    • 고루틴 간의 전환은 완전히 프로세스 내에서 일어나서 (상대적으로) 느린 운영체제 시스팀 호출을 회피하기 때문에 스레드 사이의 전환보다 빠르다.
    • 스케줄러는 Go 프로세스의 일부이기 때문에 스케줄링 결정을 최적화할 수 있다. 스케줄러는 네트워크를 확인하는 작업과 함께 수행되어 I/O가 블로킹되어 고루틴이 스케줄링 되지 않는 시점을 감지할 수 있다. 가비지 컬렉터와 통합되어 작업이 Go 프로세스에 할당된 모든 운영체제 스레드에서 균형을 이루도록 한다.
  • 고루틴은 함수의 실행 전에 go 키워드를 둠으로써 시작한다. 다른 함수 들과 같이 상태를 초기화하기 위해 파라미터를 전달할 수도 있다. 하지만 함수에서 반환되는 모든 값은 무시한다.
func process(val int) int {
    // val 변수로 뭔가를 처리
}

func runThingConcurrently( in <-chan int, out chan<- int) {
    go func() {
        for val := range in {
            result := process(val)
            out <- result
        }
    }()
}

10.3 채널

  • 고루틴은 채널을 통해 통신한다. 슬라이스와 맵과 같이 채널은 make 함수를 사용하여 생성할 수 있는 내장 타입이다.
ch := make(chan int)
  • 맵과 같이 채널은 참조 타입이다. 채널을 함수로 전달하면, 실제로 채널에 대한 포인터를 전달하는 것이다.

10.3.1 읽기, 쓰기 그리고 버퍼링

  • <- 연산자를 사용하여 채널과 상호작용한다. 채널 변수의 왼쪽에 <- 연산자를 두어 채널로부터 데이터를 읽고 오른쪽에 두어 채널에 데이터를 쓴다.
a := <-ch // ch에서 값을 읽어 a에 할당한다.
ch <- b // b의 값을 ch에 쓴다.
  • 채널에 쓰여진 각 값은 한 번에 하나씩 읽을 수 있다. 다중 고루틴이 같은 채널에서 읽기를 한다면 채널에 쓰인 하나의 값은 다중 고루틴 중 하나만 읽을 수 있다.
  • 하나의 고루틴으로 같은 채널을 읽고 쓰기를 하는 것은 드물다.
    • 고루틴이 채널에서 읽기만 가능하다는 것을 나타내기 위해 chan 키워드(ch ←chan int) 앞에 화살표 연산을 사용하자.
    • 고루틴이 채널에 쓰기만 가능하도록 하기 위해 chan 키워드(ch chan<- int)뒤에 화살표를 사용하자.
    • 이렇게 하면 Go 컴파일러가 채널을 함수에서 읽기 전용 혹은 쓰기 전용으로만 사용하도록 할 수 있다.
  • 기본적으로 채널은 버퍼가 없다. 버퍼가 없는 열린 채널에 쓰기를 할 때마다 다른 고루틴에서 같은 채널을 읽을 때까지 해당 고루틴은 일시 중지된다. 비슷하게, 버퍼가 없는 열린 채널에 읽기를 하면 다른 고루틴에서 같은 채널에 쓰기를 할 때까지 해당 고루틴을 일시 중지된다.
  • Go는 버퍼가 있는 채널(buffered channel)도 가지고 있다.
    • 이런 블로킹 없이 제한된 쓰기의 버퍼를 가진다.
    • 채널에서 읽어가는 것 없이 버퍼가 다 채워지면, 채널이 읽어질 때까지 쓰기 고루틴은 일시 중지된다.
    • ch := make(chan int, 10) 채널을 생성할 때, 버퍼의 수용력을 지정할 수 있다.

10.3.2 for-range와 채널

  • for-range 루프를 이용하여 채널의 값을 읽을 수 있다.
for v := range ch {
    fmt.Println(v)
}
  • 다른 for-range 루프와 다르게 채널을 위해 선언된 값을 가지는 단일 변수만 있다.

10.3.3 채널 닫기

  • 채널에 쓰기를 완료했을 때, close 내장 함수를 이용해 채널을 닫을 수 있다.
close(ch)
  • 채널을 닫으면 채널에 쓰기를 시도하거나 다시 닫으려 한다면 패닉을 발생시킨다.
  • 닫힌 채널에 읽기를 시도하는 것은 언제나 성공한다.
  • 콤마 OK 관용구를 사용하여 채널의 닫힘 유무를 확인할 수 있다.
v, ok := <-ch
// ok가 true면 채널은 열린 것, false라면 닫힌 것
  • 채널을 닫아야 하는 책임은 채널에 쓰기를 하는 고루틴에 있다.
  • 채널은 GO의 동시성 모델을 구분하는 두 가지 중에 하나이다. 코드를 일련의 단계로 생각하게 하고 데이터 의존성을 명확하게 하여 동시성에 대해 더 쉽게 추론할 수 있도록 안내한다.
  • 다른 언어들은 스레드 간에 통신하기 위해 전역에 공유된 상태에 의존한다. 이런 변경 가능한 공유 상태는 데이터가 프로그램의 전반을 흐르는 방식을 이해하기 어렵게 만들고, 결과적으로는 두 개의 스레드가 실제로 독립적인지 여부를 이해하기 어렵게 만든다.

10.3.4 채널 동작 방식

버퍼가 없고, 열림버퍼가 없고, 닫힘버퍼가 있고, 열림버퍼가 있고, 닫힘
읽기뭔가 써질 때까지 일시 중지제로 값 반환
(닫힘 확인 위해 콤마 OK 관용구 사용)
버퍼가 빌 때까지 일시 중지버퍼에 남은 값을 반환
버퍼가 비어 있다면 제로 값을 반환
(닫힘 확인 위해 콤마 OK 관용구 사용)
쓰기뭔가 읽을 때까지 일시 중지패닉버퍼가 가득 찰 때까지 일시 중지패닉
닫기동작함패닉동작함, 남은 값이 있을 수 있음패닉

10.4 select 문

  • select 문은 동시성 모델을 구분하는 다른 하나이다. Go에서 동시성을 위한 제어 구조이고, 일반적인 문제를 훌륭하게 해결한다.
  • 두 개의 동시성 연산을 수행해야 한다면, 어떤 것을 먼저 실행할까? 어떤 하나는 다른 것보다 더 선호하거나 어떤 경우를 아예 처리하지 않을 수 없다. 이를 기아(starvation)이라 부른다.
  • select 키워드는 여러 채널의 세트 중 하나에 읽기를 하거나 쓰기를 할 수 있는 고루틴을 허용한다.
select {
case v := <-ch:
    fmt.Println(v)
case v := <-ch2:
    fmt.Println(v)
case ch3 <- x:
    fmt.Println("wrote", x)
case <-ch4:
    fmt.Println("got value on ch4, but ignored it")
}
  • switch 문과 비슷하게 select 내에 case 는 자신만의 블록을 생성한다.
  • select 알고리즘은 단순하다. 진행이 가능한 여러 case 중 하나를 임의로 선택한다. 순서는 중요하지 않다.
  • 다른 case 보다 선호되는 것이 없고 모두 동시에 확인되기 때문에 기아 문제를 깔끔하게 해결한다.

기아(starvation)는 자원을 제대로 분배하지 못해 발생하는 문제이다.

위에서는 모든 case 문이 동일한 우선 순위로 확인이 되기 때문에, 특정 case만 계속 처리되는 경우는 발생하지 않는다는 의미이다.

  • select 임의 선택의 다른 장점은 교착 상태의 가장 일반적인 원인 중 하나인 일관성 없는 순서로 잠금 획득 방지이다. 동일한 두 개의 채널을 접근하는 두 개의 고루틴이 있다면, 두 고루틴내에서 같은 순서로 반드시 접근해야 교착 상태(deadlock)에 빠지지 않는다.
  • 교착 상태의 고루틴 예제
func main() {
  ch1 := make(chan int)
  ch2 := make(chan int)
  go func() {
    v := 1
    ch1 <- v
    v2 := <-ch2
    fmt.Println(v, v2)
  }()
  v := 2
  ch2 <- v
  v2 := <-ch1
  fmt.Println(v, v2)
}

// 위 코드를 실행하면, 다음과 같은 메시지를 볼 수 있다.
// fatal error: all goroutines are asleep - deadlock!

// 고루틴은 ch1을 읽을 때까지 계속 진행할 수 없고
// main 고루틴은 ch2를 읽을 때까지 진행할 수 없다.
  • select로 채널을 접근하도록 하여 교착 상태를 피한 고루틴 예제
func main() {
  ch1 := make(chan int)
  ch2 := make(chan int)
  go func() {
    v := 1
    ch1 <- v
    v2 := <-ch2
    fmt.Println(v, v2)
  }()
  v := 2
  var v2 int
  select {
    case ch2 <- v:
    case v2 = <-ch1:
  }
  fmt.Println(v, v2)
}

// 아래와 같이 출력됨
// 2 1

// select가 진행할 수 있는 case를 확인하기 때문에, 교착 상태를 피할 수 있다.
// ch1에 1의 값을 쓰는 것을 고루틴에서 진행했고
// main 고루틴에서 ch1을 읽어 v2 변수에 넣는 것도 정상적으로 처리됨.
  • switch 문과 마찬가지로 select 문은 default 절을 가질 수 있다. switch 와 마찬가지로 default 는 읽고 쓰기를 할 수 있는 채널이 어떤 case 에도 없는 경우에 선택된다.
    • 채널에서 비동기 읽기와 쓰기를 구현하고 싶다면, default 와 함께 select를 사용하자.
select {
case v:= <-ch:
    fmt.Println("read from ch:", v)
default
    fmt.Println("no value written to ch")
}

10.6 채널 대신에 뮤텍스를 사용해야 하는 경우

  • 다른 프로그래밍 언어에서 스레드들 간에 데이터 접근을 관리해야 한다면, 아마도 뮤텍스를 사용할 것이다.
  • 이것은 상호 배제(mutual exclusion)의 약어이고, 뮤텍스의 역할은 공유된 데이터의 조각의 접근이나 어떤 코드의 동시 실행을 제한한다. 임계 영역(critical section)이라 불리는 부분을 보호한다.
  • 뮤텍스의 주요한 문제는 프로그램에 걸쳐 데이터의 흐름을 모호하게 하는 것이다. 값이 일련의 채널을 통해 고루틴에서 고루틴으로 전달될 때, 데이터의 흐름은 명확하다.

‘메모리를 공유하여 통신하지 말고, 통신을 통해 메모리를 공유하자’

  • 때로는 뮤텍스를 사용하는 것이 더 명확할 수 있다. 가장 일반적인 경우는 고루틴이 공유된 값을 읽거나 쓰는 경우이지만 값을 처리하진 않는 것이다.
// 예제로 멀티플레이어 게임을 위한 인메모리 득점판이 있다고 가정할 때
func scoreboardManager(in <-chan func(map[string]int), done <-chan struct{}) {
    scoreboard := map[string]int{}
    for {
        select {
            case <-done:
                return
            case f := <-int:
                f(scoreboard)
        }
    }
}

// 득점판으로부터 현재 값을 읽는 것은 번거롭고 한 번에 하나의 읽기만 허용한다.
func (csm ChannelScoreboardManager) Read(name string) (int, bool) {
    var out int
    var ok bool
    done := make(chan sturct{})
    csm <- func(m map[string]int) {
        out, ok = m[name]
        close(done)
    }
    <-done
    return out, ok
}

// 더 나은 접근은 뮤텍스를 사용하는 것이다.
// 표준 라이브러리의 sync 패키지의 RWMutex를 사용한 예시
// 읽기 잠금은 공유되어, 한 번에 임계 영역 내에 여러 독자가 있을 수 있다.
func (msm *MutexScoreboardManager) Read(name string) (int, bool) {
    msm.l.RLock()
    defer msm.l.RUnlock()
    val, ok := msm.scoreboard[name]
    return val, ok
}
  • 캐서린 콕스 부데이의 훌륭한 책(Concurrency in GO)에서 채널과 뮤텍스 사용의 결정을 도와 주기 위한 결정 트리
    • 고루틴들을 조정하거나 고루틴에 의해 변경되는 값을 추적하는 경우에는 채널을 사용하자.
    • 구조체에 항목을 공유하여 접근하는 경우에는 뮤텍스를 사용하자.
    • 채널을 사용했을 때, 중대한 성능의 문제를 발견했고 어떤 다른 방법으로도 해당 이슈가 고쳐지지 않는 경우에는 뮤텍스를 사용하여 구현해보도록 하자.
  • 위 예시의 scoreboard는 구조체의 항목이고 데이터가 메모리에 저장되기 때문에 뮤텍스가 좋게 쓰여진 경우이다. 데이터가 HTTP 서버나 데이터베이스와 같은 외부 서비스에 저장되어 있다면, 시스템의 접근을 보호하기 위해 뮤텍스를 사용하지 않도록 하자.

10.7 원자적 연산

  • 뮤텍스 외에도 Go는 다중 스레드간의 데이터 일관성을 유지하기 위한 다른 방법을 제공한다.
  • sync/atomic 패키지는 단일 레지스터에 맞는 값을 추가, 교환, 로드, 저장 혹은 비교 및 교환(Compare and Swap)하기 위한 최신 CPU에 내장된 원자 변수(atomic variable) 연산에 대한 접근을 제공한다.