Go의 동시성
본 글은 Golang을 공부하며 주요 내용이라 생각되는 것들을 기록해둔 자료이며, Ubuntu 22.04 LTS 기준으로 작성되었습니다.
Introduction
CS에서 동시성이란 단일 프로세스를 독립적인 여러 개의 구성 요소로 분할하여, 각 구성 요소가 안전하게 데이터를 공유할 수 있을지 작성하는 것이다.
대부분의 언어는 OS 레벨의 쓰레드 라이브러리를 사용하여 동시성을 제공하고, lock을 획득하는 방식을 통해 데이터를 공유한다. 반면 Go의 주요 동시성 모델은 CSP(Communicating Sequential Processes)에 기반한다. 이는 기존 방식만큼 강력하면서도, 이해하기는 훨씬 쉽다.
이 포스트에서는 Go의 동시성에 관련된 단어들인 gorountine, channel, 및 select
키워드에 대해 알아볼 것이다.
또한 Go의 일반적인 동시성 패턴들과 특정 상황에서 유용한 로우레벨의 접근방식에 대해 알아볼 것이다.
When to Use Concurrency
먼저 주의해야 할 점이 있는데, 먼저 프로그램에 동시성이 잘 어울릴만 한지 봐야 한다. Go의 goroutine, channel 등은 만능이 아니다. 특히 이들을 적용한다고 해서 프로그램이 반드시 빠르게 돌아가는 것은 아니며, 오히려 코드가 더 복잡해질 수도 있다. 먼저, 동시성은 병렬성이 아니다(Concurrency is not parallelism)라는 것을 이해해야 한다. concurrent하게 작성된 코드가 병렬적으로 실행될 지 여부는 하드웨어나 알고리즘에 따라 달라진다. 결론은 동시성이 높아진다고 해서 속도가 올라가는 것은 아니다.
모든 프로그램은 데이터의 수집, 가공, 출력, 이렇게 세 단계를 따른다고 할 수 있다. 동시성을 프로그램에 적용해야 할지 아닌지의 여부는, 이러한 데이터의 흐름이 어떻게 흘러가는지에 달려 있다. 예를 들면 한 단계의 데이터가 다른 단계를 진행할 때 필요하지 않으면 두 단계는 동시에 진행할 수 있을 것이다. 반면 한 단계의 데이터가 다음 단계를 진행할 때 필요한 경우, 이들은 순차적으로 진행되어야 할 것이다. 독립적으로 실행할 수 있는 여러 개의 연산으로 얻은 데이터를 합치는 경우, 동시성을 적용하면 매우 유용할 것이다.
또 중요한 점은, 실행 시간이 얼마 안 걸리는 프로세스에는 동시성을 적용할 필요가 없다는 점이다. 대부분의 인메모리 알고리즘은 매우 빠르기 때문에, 동시성으로 값을 전달하는 오버헤드가 병렬적으로 코드를 실행하여 얻을 수 있는 시간적 장점을 덮어버릴 정도로 매우 크다. 그래서 동시성을 사용하는 작업은 네트워크나 디스크 입출력 등, I/O 작업에 사용되는 경우가 많다.
가령 다른 세 개의 웹 서비스를 호출하는 웹 서비스를 작성한다고 하자. 그중 두 개의 서비스로 데이터를 보내고, 결과물을 다른 한 개로 보내어 반환할 것이다. 전체 프로세스는 50ms 이내에 이루어져야 하고, 에러가 발생하면 이도 리턴되어야 한다. 이런 경우에 동시성이 적용되면 좋은 게, 상효작용 없이 I/O 작업을 해야 하는 부분이 있고, 결과값을 합치는 부분이 있고, 코드의 실행 시간 제한이 있다. 이 포스트의 마지막 부분에서 이런 코드를 어떻게 작성하는지 확인할 것이다.
Goroutines
고루틴을 정의하기에 앞서, 몇 가지 단어를 짚고 넘어가 보자. 먼저 프로세스(process)는 OS에서 실행되는 프로그램의 인스턴스이다. OS는 메모리 등의 리소스를 프로세스에 연결하고 다른 프로세스가 접근할 수 없도록 한다. 프로세스는 한 개 이상의 쓰레드(thread)로 구성되어 있다.
쓰레드는 운영 체제에서 주어진 시간만큼만 실행되는 실행 단위이며, 프로세스에 할당된 리소스들을 공유할 수 있다. CPU가 멀티코어라면 여러 개의 쓰레드를 동시에 실행할 수 있다. OS는 모든 프로세스의 모든 쓰레드가 실행될 수 있도록 쓰레드를 스케쥴링한다.
goroutine은 Go의 동시성 모델으로, Go 런타임에 의해 관리되는 경량의 프로세스이다. Go 프로그램이 시작되면 Go 런타임은 여러 개의 쓰레드를 만들고, 한 개의 고루틴을 생성되어 프로그램이 실행된다. 그 외 모든 고루틴은 우리가 작성한 프로그램에서 생성된다.
OS가 쓰레드를 코어에 할당하여 스케줄링하듯, 모든 고루틴은 Go 런타임에 의해 쓰레드에 할당되어 스케줄링된다. OS는 이미 쓰레드와 프로세스에 대한 스케줄러가 있기 때문에, 이런 방식이 비효율적인 것처럼 보일 수 있지만 몇 가지 장점이 존재한다.
- 고루틴은 쓰레드보다 생성 속도가 빠르다.
- 고루틴은 쓰레드보다 기본 스택 사이즈가 작기 때문에, 메모리 관리 측면에서 효율적이다.
- 고루틴의 스위칭은 시스템 콜을 가급적 지양하기 때문에 쓰레드의 context switching보다 빠르다.
- 스케줄러 또한 Go 프로세스의 일부이기 때문에 최적화가 가능하다. 스케줄러는 network poller와 같이 I/O blocking이 일어난 고루틴을 참지하여 unscheduling하는 작업을 할 수 있다.
- 가비지 컬렉터와 통합되어 Go 프로세스에 할당된 모든 쓰레드에서 작업이 적절히 분배되도록 한다.
이러한 장점 때문에 Go 프로그램은 100~10000개나 되는 고루틴을 돌릴 수 있다. OS 기본 쓰레드를 사용하는 다른 언어에서 이렇게 쓰레드를 많이 돌리면 더럽게 느려질 것이다.
고루틴을 생성하려면 함수 호출 앞에 go
라는 키워드를 붙이면 된다.
다른 함수처럼 파라미터를 보내서 고루틴의 상태를 설정할 수 있지만, 리턴값은 무시된다.
async
키워드로 선언된 함수만 비동기적으로 실행할 수 있는 자바스크립트와는 달리, 어떤 함수든 고루틴으로 실행할 수 있다.
하지만 closure로 비즈니스 로직을 포함하여 고루틴을 생성하는 것이 Go에서는 국룰이라고 한다.
closure는 동시성의 bookkeeping을 담당한다. (이거해석어케함) 예를 들면 closure는 channel에서 값을 읽어 비즈니스 로직으로 전달하는데, 이 로직은 고루틴으로 실행되고 있다는 것을 알 수 없고, 함수의 결과는 다른 channel에 쓰여진다. 이렇게 코드의 역할을 분리해두면 코드를 모듈화되고, 코드가 테스트하기 용이해지며, API에서 동시성을 유지한다.
func process(val int) int {
// do something with val
}
func runThingConcurrently(in <-chan int, out chan<- int) {
go func() {
for val := range in {
result := process(val)
out <- result
}
}()
}
Channels
고루틴은 channel을 통해 데이터를 주고받는다.
채널은 빌트인 타입으로, slice나 map처럼 make()
함수를 사용하여 생성한다.
ch := make(chan int)
채널은 map처럼 레퍼런스 타입이다.
따라서 채널의 Zero value는 nil
이며, 함수의 파라미터로 채널을 보내면 실제로는 해당 채널의 포인터를 전달하게 된다.
Reading, Writing, and Buffering
<-
연산자를 사용하면 채널을 사용할 수 있다.
<-
연산자가 채널 왼쪽에 있다면 채널로부터 값을 읽어오게 되며, <-
연산자가 채널 오른쪽에 있다면 채널에 값을 저장한다.
a := <-ch
ch <- b
채널에 저장된 각 값들은 한 번씩만 읽을수 있다. 만약 여러 개의 고루틴이 동일한 채널에서 값을 읽어오려고 한다면, 그 중 한 개의 고루틴만 채널에 저장된 값을 읽어오게 될 것이다.
고루틴이 동일한 채널에 읽기와 쓰기를 동시에 하는 경우는 드물다.
일반적으로 변수나 필드값, 혹은 파라미터로 채널을 선언할 때 보통 chan
키워드 앞뒤에 <-
연산자를 붙여서 읽기 전용/쓰기 전용을 명시한다.
이를테면 ch <-chan int
와 같이, chan
키워드 앞에 <-
가 붙으면, 해당 고루틴이 읽기 전용임을 의미한다.
반대로 ch chan<- int
와 같이, chan
키워드 앞에 <-
가 붙으면, 해당 고루틴이 쓰기 전용임을 의미한다.
이렇게 하면 Go 컴파일러가 해당 채널이 함수에서 읽기/쓰기 중 어느 것에 사용되는지 알 수 있다.
기본적으로 채널은 버퍼링되지 않는다. 한 고루틴이 unbuffered channel에 값을 쓰면, 다른 고루틴에서 그 채널의 값을 읽을 때까지 멈춘다. 마찬가지로 한 고루틴이 unbuffered channel로부터 값을 읽으면, 다른 고루틴에서 그 채널에 값을 쓸 때까지 멈춘다. 즉 적어도 두 개의 고루틴을 동시에 돌리는 게 아니면, unbuffered channel에는 값을 읽거나 쓸 수 없다.
buffered channel도 있다. 이 채널들은 blocking이 되지 않는 대신 제한된 크기만큼만 읽기/쓰기가 가능하다. 만약 어느 고루틴에서 가득 찬 채널에 쓰기를 시도할 경우, 다른 고루틴에서 채널의 값이 읽을 때까지 멈춘다. 마찬가지로 비어 있는 채널에서 읽기를 시도할 경우, 다른 고루틴에서 그 채널에 값을 쓸 때까지 멈춘다.
buffered channel은 아래와 같이 선언한다.
ch := make(chan int, 10)
buffered channel에는 빌트인 함수인 len()
과 cap()
을 사용할 수 있다.
len()
은 현재 버퍼에 몇 개의 값들이 저장되어 있는지를 반환하며, cap()
은은 버퍼의 최대 크기를 반환한다.
버퍼의 최대 크기는 변경할 수 없다.
unbuffered channel은 값들을 저장할 버퍼가 없기 때문에
len()
이나cap()
에 넘기면 0을 반환한다.
대부분의 경우 unbuffered channel을 사용할텐데, 특정한 경우에는 buffered channel을 사용할 것이다. 이에 대해서는 이후 알아볼 것이다.
for-range and Channels
for-range
루프를 사용하여 channel의 값을 읽을 수 있다.
for v := range ch {
fmt.Println(v)
}
다른 일반적인 for-range
루프와는 달리, 이터레이션을 돌며 값을 저장하는 변수가 단 한 개만 사용된다.
해당 루프는 break
나 return
문을 만나거나, close()
함수로 채널이 닫힐 때까지 계속된다.
Closing a Channel
채널에 대한 읽기/쓰기가 끝났다면 빌트인 함수 close()
를 사용하여 채널을 닫아준다.
close(ch)
채널이 닫힌 상태로 채널에 값을 쓰려고 하면 panic이 발생한다. 반면, 놀랍게도 닫힌 채널에서 값을 읽으려는 시도는 반드시 성공한다. 만약 채널이 버퍼링을 사용하고 아직 읽지 않은 값이 존재한다면 그 순서대로 반환될 것이다. 만약 채널이 버퍼링을 사용하지 않거나, 버퍼링을 사용하지만 더 이상 값이 없다면, 그 채널 타입의 Zero value가 반환된다.
자 그렇다면 이 때 반환된 Zero value가 채널에서 읽어온 값인지, 아니면 채널이 닫히고 읽어올 값이 없어서 반환된 것인지 어떻게 구분해야 할까? 이렇게 map을 사용할 때와 유사한 의문이 생길 수 있다. 해결책도 map과 유사한데, 바로 comma ok idiom을 사용하는 것이다.
v, ok := <-ch
만약 ok
의 값이 true
라면 채널이 열려 있는 것이며, false
라면 닫혀 있는 것이다.
언제 닫힐지 모르는 채널로부터 값을 읽어오는 경우, 이렇게 comma ok idiom을 사용해주는 것이 좋을 것이다.
How Channels Behave
채널은 여러 종류와 state가 존재하기 때문에, read, write, close를 시도할 때마다 행동 양상이 달라진다. 아래 표를 확인해보자.
Unbuffered, Open | Unbuffered, Closed | Buffered, Open | Buffered, Closed | Nil | |
---|---|---|---|---|---|
Read | 다른 고루틴이 값을 읽을 때까지 멈춤 |
해당 타입의 Zero value를 반환 | 버퍼가 비어 있다면 멈춤 | 버퍼의 남은 값을 반환 만약 버퍼가 비었다면 Zero value 반환 |
영원히 멈춤 |
Write | 다른 고루틴이 값을 쓸 때까지 멈춤 |
Panic | 버퍼가 가득 차있다면 멈춤 | Panic | 영원히 멈춤 |
Close | 성공 | Panic | 성공 | Panic | Panic |
일반적인 패턴은, 채널에 값을 쓰는 고루틴이 더 이상 쓸 값이 없다면 채널을 닫게끔 코드를 작성하는 것이다.
이 때 여러 개의 고루틴이 동일한 채널에 값을 쓴다고 하면 문제가 더 복잡해진다.
동일한 채널에 대해 close()
를 여러번 호출하거나, 한 고루틴에서 닫은 채널에 다른 고루틴이 쓰기를 시도하면 panic이 발생한다.
관련 내용은 이후 포스트에서 다룰 것이다.
nil
채널도 상당히 위험하지만, 일종의 쓰임새가 있다. 마찬가지로 이후 포스트에서 다룰 예정이다.
select
Go만의 독자적인 동시성 모델을 형성하는 것 중 하나가 바로 select
문이다.
select
문은 Go의 동시성을 제어하는 제어문이며, 동시성의 유서깊은 문제를 해결할 수 있다.
바로, 두 개의 작업을 동시에 수행해야 한다면 어떤 작업을 먼저 수행할 것인지에 대한 것이다.
만일 한 작업만 중점적으로 수행한다면 다른 작업들은 수행할 수 없게 되며, 이를 starvation이라고 한다.
select
키워드는 고루틴이 여러 개의 채널 중 하나로부터 값을 읽거나 쓰게 해준다.
겉보기에는 switch
문과 굉장히 유사하게 생겼다!
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
문 내의 각 case
키워드 뒤에 각 채널에 대한 읽기/쓰기가 온다.
만약 해당 읽기/쓰기가 가능한 경우 이를 수행하고 case
블록을 실행한다.
중요한 점으로, select
문과 switch
문은 아주 큰 차이점이 존재한다.
switch
문의 경우 여러 case
블록의 조건이 부합하더라도, 그중 가장 위의 case
블록을 실행한다.
반면 select
문의 경우, 만약 case
블록에 쓰인 채널 중 읽기/쓰기가 가능한 채널이 여러 개라면 그중 무작위로 하나를 골라 case
블록을 실행한다.
다시 말해 select
문에서 case
블록의 순서는 중요치 않은 것이다.
이렇게 무작위적으로 case
블록을 채택하는 방식 때문에 starvation이 발생하지 않게 된다.
일관성 없는 순서로 lock을 획득하는 것은 데드락을 발생시키는 가장 흔한 원인 중 하나인데, select
문의 무작위성은 이를 방지한다.
가령, 두 채널에 동시에 액세스하는 두 개의 고루틴이 있다고 하자.
이들은 정해진 순서로 채널에 액세스해야만 하고, 그렇지 않으면 데드락이 발생한다.
만약 Go 프로세스에 데드락이 발생하면, Go 런타임은 프로그램을 강제로 종료할 것이다.
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!
main()
함수가 돌아가는 함수도 고루틴이므로, 위 예제에선 총 두 개의 고루틴이 돌아간다.
생성된 고루틴의 경우 ch1
이 읽히기 전까지, main 고루틴의 경우 ch2
가 읽히기 전까지 멈춘다.
이 때 모든 고루틴이 멈춘 상태가 되어 데드락이 발생하게 되고, Go 런타임은 이를 감지한 것이다.
위 예제에 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)
}
위 예제처럼 select
문은 내의 수행 가능한 case
블록만 골라서 실행하기 때문에 데드락을 회피할 수 있다.
생성된 고루틴에서 ch1
에 1을 집어넣었기 때문에 main 고루틴에서는 ch1
에서 v2
로 값을 읽어올 수 있었고, main 고루틴이 성공적으로 실행된 것이다.
select
는 여러 개의 채널에서 사용될 수 있기 때문에, for
루프와 같이 종종 사용된다.
for {
select {
case <-done:
return
case v := <-ch:
fmt.Println(v)
}
}
매우 자주 사용되는 조합인지라 for-select
루프라고도 한다.
for-select
루프를 사용할 때는 반드시 루프에서 빠져나갈 수 있게끔 해주어야 한다.
switch
문처럼 select
문도 default
절을 넣을 수 있으며, case
블록 중 읽기/쓰기가 가능한 채널이 없는 경우 default
절이 실행된다.
따라서 채널의 읽기/쓰기를 nonblocking으로 만들고자 하는 경우, select
문에 default
절을 포함하면 된다.
select {
case v := <-ch:
fmt.Println("read from ch:", v)
default:
fmt.Println("no value written to ch")
}
위 예제의 경우 ch
에 읽어올 값이 없다면, 기다리지 않고 바로 default
절을 실행한다.
for-select
루프에 default
절을 사용하면 읽거나 쓸 채널이 없으면 루프를 통해 매번 트리거된다.
따라서 for
루프가 지속적으로 돌기 때문에, 프로세싱 파워를 상당히 많이 잡아먹게 된다.
Concurrency Practices and Patterns
Keep Your APIs Concurrency-Free
API에는 구체적인 설계 내용을 가능한 한 숨겨야 하며, 동시성은 설계 단계에서 적용되는 것이기에 API에는 동시성에 관한 내용이 생략된다. 따라서 코드가 호출되는 방식을 변경하지 않고 코드를 변경할 수 있다.
API의 타입, 함수, 메소드에 채널이나 뮤텍스를 노출시키지 않아야 함을 의미한다.
만약 채널을 노출하게 되면 API 사용자에게 채널에 대한 관리의 책임을 맡기게 되는 것이다.
그렇게 되면 사용자 입장에서 채널이 열려 있는지, 닫혀 있는지, 혹은 nil
인지 고민할 필요가 생긴다.
또한 사용자가 뮤텍스나 채널에 잘못된 순서로 접근하여 데드락을 일으킬 수도 있다.
채널을 함수의 매개 변수나 구조체의 필드 타입으로 사용해서는 안되는 것은 아니다. 이렇게 설정된 함수나 구조체를 export하면 안된다는 의미이다.
다만 이러한 규칙의 예외가 몇 개 존재한다. 우리가 만든 API가 동시성에 관련된 라이브러리라면, 채널이 API의 일부가 될 수 있다.
Goroutines, for Loops, and Varying Variables
고루틴을 생성하기 위해 사용된 closure는 대부분 파라미터를 가지지 않고, 해당 환경에서 선언된 변수들을 캡쳐하여 사용한다.
하지만 for
문의 인덱스나 값을 저장하는 변수를 캡쳐하려고 하면 문제가 발생한다.
func main() {
a := []int{2, 4, 6, 8, 10}
ch := make(chan int, len(a))
for _, v := range a {
go func() {
ch <- v * 2
}()
}
for i := 0; i < len(a); i++ {
fmt.Println(<-ch)
}
}
위 예제에서 a
에 저장된 값들로 v
이터레이션을 돌기 때문에, ch
에는 각각 4, 8, 12, 16, 20이 저장되어야 할 것이다.
위와 같은 함수를 실행하면 아래와 같은 결과를 출력한다.
20
20
20
20
20
이와 같은 출력은 모든 고루틴이 ch
에 20을 썼기 때문에 발생한 결과인데, closure가 동일한 변수를 캡쳐하였기 때문이다.
for
루프의 인덱스나 값을 저장하는 변수는 매 이터레이션마다 재사용된다.
이 때 마지막으로 v
에 할당된 값은 10이며, 이 값이 고루틴에서 보게 되는 값이다.
사실 이는 for
문만의 문제는 아니며, 값이 변경될 수 있는 변수들은 값이 달라질 때마다 고루틴에 값을 전달해야 한다.
두 가지 방법이 있는데, 첫 번째 방법은 loop에서 값을 shadowing하는 것이다.
ch2 := make(chan int, len(a))
for _, v := range a {
v := v
go func() {
ch2 <- v * 2
}()
}
for i := 0; i < len(a); i++ {
fmt.Println(<-ch2)
}
만약 shadowing보다 데이터의 흐름을 명확하게 보고 싶다면, 고루틴의 파라미터로 값을 넘기는 방법도 있다.
ch3 := make(chan int, len(a))
for _, v := range a {
go func(val int) {
ch3 <- val * 2
}(v)
}
위 예제를 실행하면 아래와 같은 결과를 확인할 수 있다. 다만 동시성의 특성상 일정한 순서대로 출력되지는 않는다.
20
4
12
8
16
Always Clean Up Your Goroutines
새로 생성된 고루틴은 반드시 끝나야 한다. 사용되지 않는 변수 가비지 콜렉터에 의해 정리가 되지만, 다시 사용되지 않는 고루틴은 Go runtime이 탐지할 수 없다. 끝나지 않는 고루틴에도 스케줄러는 해당 고루틴이 실행될(실제로는 아무것도 하지 않는) 시간을 할당하기 때문에 프로그램이 느려질 수 있다. 이를 goroutine leak라고 한다.
아래 예제를 확인해 보자.
func countTo(max int) <-chan int {
ch := make(chan int)
go func() {
for i := 0; i < max; i++ {
ch <- i
}
close(ch)
}()
return ch
}
func main() {
for i := range countTo(10) {
fmt.Println(i)
}
}
먼저, 얘는 그냥 단순한 예제일 뿐이니까 이렇게 하지 말자. 이렇게 숫자들 리스트 만드는 간단한 작업에 고루틴을 쓰면 안된다.
위 예제의 대부분 경우에서는 모든 값을 사용하면 고루틴이 끝난다. 다만 만약 반복문이 일찍 끝난다면, 해당 고루틴은 값이 읽힐 때까지 영원히 기다리게 된다.
func main() {
for i := range countTo(10) {
if i > 5 {
break
}
fmt.Println(i)
}
}
The Done Channel Pattern
done channel pattern은 채널을 사용하여 고루틴에게 프로세싱을 멈추라는 신호를 보내는 방법을 제공한다. 아래 예제에서는 여러 개의 동일한 함수에 동일한 데이터를 보내고, 가장 먼저 결과를 낸 함수의 값을 사용한다.
func searchData(s string, searchers []func(string) []string) []string {
done := make(chan struct{})
result := make(chan []string)
for _, searcher := range searchers {
go func(searcher func(string) []string) {
select {
case result <- searcher(s):
case <-done:
}
}(searcher)
}
r := <-result
close(done)
return r
}
위 예제에서 done
이라는 채널을 선언했고, 해당 채널의 타입은 struct{}
이다.
이때 타입의 종류는 상관없기 때문에 가장 용량이 작은 struct{}
를 사용하며, 이 채널에는 쓰기를 하지 않고, 오직 닫기만 한다.
함수의 파라미터로 넘어온 각 searcher
마다 worker 고루틴을 생성한다.
worker 고루틴은 select
문으로 searcher
가 반환한 채널에서 읽기가 가능해지거나, done
채널에서 읽기가 가능해질 때까지 기다린다.
이때 열려있는 채널에서 값을 읽어오려면 읽어올 데이터가 존재할 때까지 기다리게 되며,
닫혀 있는 채널이라면 항상 해당 타입의 Zero value를 반환한다.
따라서 위 예제의 select
문에서는 searcher
가 값을 읽어오거나 done
채널이 닫힐 때까지 대기하게 된다.
예제의 searchData
함수에서 데이터를 읽어오면 result
채널에 값을 쓸 것이고, 그러면 done
을 닫을 것이다.
따라서 아직 실행중인 고루틴들이 닫힐 것이고 goroutine leaking이 발생하지 않을 것이다.
Using a Cancel Function to Terminate a Goroutine
done channel 패턴을 사용하여 고루틴을 중지시키는 함수를 작성할 수 있다.
앞선 countTo
예제로 돌아가보자.
func countTo(max int) (<-chan int, func()) {
ch := make(chan int)
done := make(chan struct{})
cancel := func() {
close(done)
}
go func() {
for i := 0; i < max; i++ {
select {
case <-done:
return
case ch <- i:
}
}
close(ch)
}()
return ch, cancel
}
func main() {
ch, cancel := countTo(10)
for i := range ch {
if i > 5 {
break
}
fmt.Println(i)
}
cancel()
}
countTo
두 개의 채널을 생성한다. 한 개는 데이터를를 반환하기 위한 것이고, 하나는 done
신호를 위함이다.
이 때 done
채널을 직접 반환하기보단, done
채널을 닫는 closure를 생성하여 반환하는 편이 좋다.
이렇게 closure를 반환하면 추가적인 작업이 필요한 경우 구현하기 용이하다.
When to Use Buffered and Unbuffered Channels
Go에서 숙달해야 할 동시성 문제중 가장 복잡한 문제는 언제 buffered channel을 사용해야할지 결정하는 것이다. 기본적으로 채널은 버퍼링이 되지 않으며, 복잡하지 않기 때문에 이해하기 쉽다. 한 고루틴이 값을 읽거나 쓰면 다른 고루틴이 값을 쓰거나 읽을 때까지 대기하는 구조이며, 일종의 계주 경기의 바톤같은 느낌이다.
반면 buffered channel은 더 복잡하다. buffered channel의 크기는 무한하지 않기 때문에 버퍼의 크기를 결정해야 한다. buffered channel을 적절하게 사용한다는 것은 버퍼가 가득 차있고, 값을 읽는 고루틴이 값을 읽기까지 기다리는 값을 쓰는 고루틴이 존재함을 의미한다. 보다 구체적으로, 실행할 고루틴 수를 알고 있거나, 실행할 고루틴 수를 제한하고 싶거나, 큐에서 대기 중인 작업의 양을 제한하고 싶을 때 유용하다.
채널에서 10개의 결과물을 처리하는 예제가 있다. 이 예제에서는 10개의 고루틴을 생성하여 buffered channel에 값을 쓸 것이다.
func processChannel(ch chan int) []int {
const conc = 10
results := make(chan int, conc)
for i := 0; i < conc; i++ {
go func() {
v := <-ch
results <- process(v)
}()
}
var out []int
for i := 0; i < conc; i++ {
out = append(out, <-results)
}
return out
}
위 예제에서는 우리는 정확히 10개의 고루틴이 생성된다는 것을 알고 있고, 또한 각 고루틴은 작업이 끝나면 사라지기를 원한다. 이런 경우 우리는 생성할 고루틴의 개수만큼의 크기를 갖는 buffered channel을 생성할 수 있고, 각 고루틴은 blocking 없이 데이터를 write할 수 있다. 또한 buffered channel을 통해 루프를 돌 수 있고, 채널에 값이 쓰일 때마다 읽어올 수 있다. 만약 모든 값이 읽혔다면 결과값을 반환하여 goroutine leaking을 방지할 수 있다.
Backpressure
buffered channel을 통해 구현할 수 있는 또다른 기술은 Backpressure이다.
이는 직관적이지는 않지만, 각 구성 요소가 수행하는 작업의 양을 제한할 때 시스템이 전반적으로 잘 돌아가게 된다.
우리는 buffered channel과 select
문을 통해 시스템에서 동시 요청을 제한할 수 있다.
type PressureGauge struct {
ch chan struct{}
}
func New(limit int) *PressureGauge {
ch := make(chan struct{}, limit)
for i := 0; i < limit; i++ {
ch <- struct{}{}
}
return &PressureGauge{
ch: ch,
}
}
func (pg *PressureGauge) Process(f func()) error {
select {
case <-pg.ch:
f()
pg.ch <- struct{}{}
return nil
default:
return errors.New("no more capacity")
}
}
이 코드에는 여러 개의 토큰과 버퍼링된 채널이 포함된 만든다.
기능을 사용하기 위해 고루틴이 생성될 때마다 Process()
함수를 생성한다.
select
문에서는 채널으로부터 토큰을 읽으려고 시도하며, 가능하다면 함수가 실행되고 토큰이 bufferec channel에 write된다.
만약 읽을 수 없다면 default
블록이 실행되고 에러가 반환된다.
아래 예제는 빌트인 HTTP 함수의 사용 예제이다.
func toBeLimited() string {
time.Sleep(2 * time.Second)
return "done"
}
func main() {
pg := New(10)
http.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
err := pg.Process(func() {
w.Write([]byte(toBeLimited()))
})
if err != nil {
w.WriteHeader(http.StatusToManyRequests)
w.Write([]byte("Too many requests"))
}
})
http.ListenAndServe(":8080", nil)
}
Turning Off a case in a select
여러 가지 데이터 근원에서 동시에 데이터를 받아 합치고 싶은 경우, select
키워드를 사용하면 좋다.
하지만 닫혀 있는 채널의 경우 적절하게 다루어 주어야 한다.
만약 select
문 내의 case
중에서 한 개라도 닫혀 있는 채널로부터 읽기를 시도한다면, 성공적으로 읽겠지만 반드시 Zero value를 얻게 된다.
따라서 해당 case
블록이 선택될 때마다 해당 값이 유효한지 검사하여, 유효하지 않다면 건너뛰어야 한다.
읽기에 공백이 있으면 프로그램은 쓰레기 값을 읽느라 많은 시간을 사용하게 된다.
앞서 보았듯 nil
채널로부터 읽기를 시도하면 코드가 여전히 멈추게 된다.
만약 버그 때문에 그렇게 된거라면 상당히 마음아픈 일이지만, select
문의 case
블록을 비활성화하기 위해 사용할 수도 있다.
만약 닫혀 있는 채널을 탐지하여 nil
로 설정한다면 연관된 case
블록은 값을 더이상 읽거나 쓸 수 없기 때문에 비활성화된다.
for {
select {
case v, ok := <-in:
if !ok {
in = nil // this case will never succeed again
continue
}
fmt.Println(v)
case v, ok := <-in2:
if !ok {
in2 = nil // this case will never succeed again
continue
}
fmt.Println(v)
case <-done:
return
}
}
Timeout
웹 서비스 등, 인터랙티브한 프로그램들은 일정 시간이 지나기 전에 결과를 리턴해야 한다. 이를 위해 Go에서는 요청이 들어왔을 때 얼마만큼의 시간을 부여할 것인지 설정할 수 있다. 다른 언어는 이 기능을 구현하기 위해 특수한 문법을 사용하지만, Go의 타임아웃 처리는 비교적 간단하다.
func timeLimit() (int, error) {
var result int
var err error
done := make(chan struct{})
go func() {
result, err = doSomeWork()
close(done)
}()
select {
case <-done:
return result, err
case <-time.After(2 * time.Second):
return 0, errors.New("the work timed out")
}
}
수행 시간에 제한을 두려고 하는 경우, 위와 같은 패턴을 자주 접하게 될 것이다.
위 예제에서는 select
문을 사용하여 두 가지 case
블록으로 구분하였다.
첫 번째 case
는 done channel을 사용하여 일정 시간 내에 결과를 받아올 경우, 결과를 리턴한다.
두 번째 case
에서는 time.After()
함수를 이용하여 시간 제한을 카운팅하고, 일정 시간이 지나면 타임아웃 에러를 리턴한다.
만약 위 예제에서 고루틴을 마치기 전에 timeLimit()
함수를 끝낼 경우,
고루틴은 계속 돌아가다가 결국 어떤 값을 반환하겠지만 그 값에 대해 아무것도 하지 않는다.
만약 고루틴이 계속 돌아가는 것을 원치 않고 고루틴을 종료하고 싶다면 나중에 나올 context cancellation을 확인하자.
Using WaitGroups
여러 개의 고루틴이 끝날 때까지 한 고루틴이 이들을 기다려야 하는 경우가 있다.
단 한 개의 고루틴을 기다려야 하는 경우라면, 단순히 이전에 봤던 done channel pattern을 사용하면 된다.
하지만 여러 개의 고루틴을 기다려야 한다면 표준 라이브러리의 sync.waitGroup
을 사용하는 게 좋다.
func main() {
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
doThing1()
}()
go func() {
defer wg.Done()
doThing2()
}()
go func() {
defer wg.Done()
doThing3()
}()
wg.Wait()
}
sync.WaitGroup
은 초기화할 필요가 없고, Zero value인 상태로 사용해도 무방하다.
sync.WaitGroup
의 메소드 Add()
를 통해 기다려야 할 고루틴의 개수를 명시하고, 고루틴이 끝날때마다 Done()
메소드로 카운터를 감소시켜준다.
Wait()
메소드는 고루틴들이 끝날 때까지 기다리게 되며, 모든 고루틴이 끝나서 카운터가 0이 되면 Wait()
메소드 이하의 코드가 다시 실행된다.
만약 고루틴에서 panic이 발생하더라도 Done()
메소드가 실행됨을 보장하기 위해 defer
를 사용한다.
sync.WaitGroup
자체를 파라미터로 넘겨선 안되는데, 여기에는 두 가지 이유가 있다.
먼저 sync.WaitGroup
을 사용하려면 동일한 인스턴스를 사용해야 한다는 것이다.
만약 sync.WaitGroup
를 고루틴에 파라미터로 통과시키는데 포인터를 사용하지 않으면, 함수에서는 sync.WaitGroup
인스턴스를 복사하게 된다.
따라서 Done()
함수가 본래 인스턴스의 카운터를 감소시키지 않는다.
이와 같은 이유로 예제에서는 closure를 통해 캡쳐하여 sync.WaitGroup
의 동일한 인스턴스를 레퍼런스하게 했다.
두 번째 이유는 디자인 측면이다. 동시성은 API 내부에서 유지되어야 하기 떄문이다. 채널을 다룰 때 보았듯, 고루틴을 사용하는 일반적인 패턴은 비즈니스 로직을 closure로 감싸서 고루틴을 생성하는 것이다. closure는 동시성과 관련된 문제를 담당하며, 함수는 알고리즘을 제공한다.
보다 현실적인 예제를 확인해보자. 앞서 언급했듯 여러 고루틴이 동일한 채널에 값을 써야 하는 경우, 그 채널이 한 번만 닫혔는지 확인해야 한다. 아래 예제의 함수는 채널의 값을 동시에 처리하여, 결과물들을 슬라이스로 모아 반환한다.
func processAndGather(in <-chan int, processor func(int) int, num int) []int {
out := make(chan int, num)
var wg sync.WaitGroup
wg.Add(num)
for i := 0; i < num; i++ {
go func() {
defer wg.Done()
for v := range in {
out <- processor(v)
}
}()
}
go func() {
wg.Wait()
close(out)
}()
var result []int
for v := range out {
result = append(result, v)
}
return result
}
위 예제에서는 worker 고루틴 외에도, worker 고루틴들을 모니터링하는 고루틴을 생성한다.
worker 고루틴들의 Done()
메소드가 호출되면 모니터링 고루틴은 close()
로 출력 채널을 닫는다.
버퍼가 비게 되거나 out
이 닫히면 for-range
루프도 멈추게 되고, 함수는 연산된 값을 반환한다.
sync.WaitGroup
는 참 편리하지만, 고루틴들을 관리할 때 최우선적으로 고려되어선 안된다.
고루틴들이 끝나면 결과를 기록하는 채널을 닫아야 하는 경우 등, 프로세스 이후 무언가를 청소해주어야 하는 때에 사용해주는 것이 좋다.
gorang.org/x
라는 라이브러리가 있는데, 이 라이브러리는ErrGroup
라는 타입을 포함한다ErrGroup
는WaitGroup
을 기반으로 만들어져 있으며, 고루틴들 중 한 개라도 에러를 반환했을 때 처리를 중지한다.ErrGroup
에 대해 알아보려면 여기를 참조하자
Running Code Exactly Once
앞선 포스트에서 보았던 init
함수에 대해 기억할 것이다.
init
함수는 실질적으로 불변상태인 패키지 레벨의 변수를 초기화 할 때 유용하지만, 그 때를 제외하면 되도록이면 사용하지 않는 게 좋다.
한편 lazy loading이 필요하거나, 초기화 코드를 프로그램 후 정확히 한 번만 실행해야 하는 경우가 있을 수 있다.
보통 초기화가 비교적 느리거나, 프로그램을 실행할 때마다 필요한 것이 아니기 때문에 그러하다.
sync
패키지의 Once
타입으로 이러한 기능을 구현할 수 있다.
아래 예제를 살펴보자.
type SlowComplicatedParser interface {
Parse(string) string
}
var parser SlowComplicatedParser
var once sync.Once
func Parse(dataToParse string) string {
once.Do(func() {
parser = initParser()
})
return parser.Parse(dataToParse)
}
func initParser() SlowComplicatedParser {
// do all sort of setup and loading here
}
위 예제의 패키지 레벨 변수는 SlowComplicatedParser
타입인 parser
와, sync.Once
타입인 once
이다.
sync.WaitGroup
처럼 sync.Once
의 인스턴스는 초기화할 필요가 없으며, sync.Once
의 인스턴스는 복사되지 않아야 한다.
함수 내부에서 sync.Once
인스턴스를 새로 선언하는 것은 일반적으로 잘못된 일인데,
매 함수 호출마다 새로운 인스턴스가 생성되고 해당 인스턴스에는 이전 호출에 대한 정보가 없기 때문이다.
위 예제에서는 Parse()
함수가 호출될 때 parser
가 단 한번만 초기화되어야 한다.
따라서 parser
의 값은 once.Do()
의 파라미터로 보낸 closure에서 초기화되며, once.Do()
로 보낸 closure는 다시 실행되지 않는다.
Putting Our Concurrent Tools Together
앞에서 배운 것들을 하나씩 복습해볼 시간이다.
세 개의 웹 서비스를 호출하는 함수가 있다고 하자. 우리는 그 중 두 개의 서비스에 값을 보내고, 이 둘로부터 결과값을 받아 나머지 한 개의 서비스로 값을 보내고 값을 리턴할 것이다. 모든 프로세스는 50ms 이내에 이루어져야 하며, 넘어가면 에러를 반환한다.
우리가 호출할 함수는 아래와 같이 시작한다.
func GatherAndProcess(ctx context.Context, data Input) (COut, error) {
ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
p := processor{
outA: make(chan AOut, 1),
outB: make(chan BOut, 1),
inC: make(chan CIn, 1),
outC: make(chan COut, 1),
errs: make(chan error, 2),
}
p.launch(ctx, data)
inputC, err := p.waitForAB(ctx)
if err != nil {
return COut{}, err
}
p.inC <- inputC
out, err := p.waitForC(ctx)
return out, err
}
함수 내부를 들여다보면 가장 먼저 타임아웃을 설정해준다.
context를 사용 가능한 경우 time.After()
를 사용하여 타임아웃을 설정하기보단, context.WithTimeout()
을 사용해주는 것이 좋다.
context.WithTimeout()
의 장점은 이 함수를 호출하는 함수에 의해 설정된 타임아웃을 준수할 수 있다는 점이다. 자세한 건 12장에서 context에 대해 다룰 때 알아보자.
타임아웃 시간에 도달하면 context.WithTimeout()
로 반환받은 cancel()
함수로, 실행중인 프로세스를 끝낸다.
defer
문을 이용하여 context
의 cancel()
을 반드시 호출하게끔 한다.
또한 이후 생성할 고루틴과 커뮤니케이션하기 위해 사용할 각종 채널들을 모아놓은, processor
인스턴스를 생성한다.
모든 채널은 buffered channel이므로 고루틴들은 값을 쓴 이후, 누군가 값을 읽어갈 때까지 기다릴 필요가 없다.
이 때 errs
필드의 크기는 2인데, 최대 두 개의 에러가 발생할 수 있기 때문이다.
processor
타입의 구조체는 이렇게 생겼다.
type processor struct {
outA chan AOut
outB chan BOut
outC chan COut
inC chan CIn
errs chan error
}
이제 processor
타입의 launch()
메소드에 대해 살펴보자.
이 메소드에서는 세 개의 고루틴을 생성하는데, 각각 getResultA()
, getResultB()
, getResultC()
를 실행한다.
func (p *processor) launch(ctx context.Context, data Input) {
go func() {
aOut, err := getResultA(ctx, data.A)
if err != nil {
p.errs <- err
return
}
p.outA <- aOut
}()
go func() {
bOut, err := getResultB(ctx, data.B)
if err != nil {
p.errs <- err
return
}
p.outB <- bOut
}()
go func() {
select {
case <-ctx.Done():
return
case inputC := <-p.inC:
cOut, err := getResultC(ctx, inputC)
if err != nil {
p.errs <- err
return
}
p.outC <- cOut
}
}()
}
getResultA()
와 getResultB()
를 실행하는 고루틴은 매우 비슷하다.
만약 호출시 에러가 리턴되었으면 p.errs
채널에 에러를 쓰고, 유효한 값이 리턴되었으면 각각 p.outA
, p.outB
에 값을 쓴다.
getResultC()
를 호출하는 고루틴은 살짝 더 복잡하다. getResultA()
와 getResultB()
가 50ms 안에 성공해야만 getResultC()
를 호출할 수 있기 때문이다.
이 고루틴은 select
문과 두 개의 case
블록을 포함한다.
첫 번째 case
블록의 ctx.Done()
메소드는 시간이 초과하거나 또는 컨텍스트가 명시적으로 취소될 시 값을 리턴하는 채널을 반환한다. 따라서 context가 취소될 때 트리거된다.
그리고 두 번째 case
블록은 getResultC()
를 호출하기 위한 데이터가 준비되면 트리거된다. 이 블록 내의 구조는 앞선 두 고루틴과 비슷하다.
위 고루틴들이 생성되고 실행될, processor
의 waitForAB()
메소드에 대해 살펴보자.
func (p *processor) waitForAB(ctx context.Context) (CIn, error) {
var inputC CIn
count := 0
for count < 2 {
select {
case a := <-p.outA:
inputC.A = a
count++
case b := <-p.outB:
inputC.B = b
count++
case err := <-p.errs:
return CIn{}, err
case <-ctx.Done():
return CIn{}, ctx.Err()
}
}
return inputC, nil
}
위 함수에서는 이후 getResultC()
의 파라미터로 사용할 inputC
의 값을 설정하기 위해 for-select
루프를 사용한다.
상단 두 개의 case
블록은 getResultA()
와 getResultB()
를 실행하는 고루틴들이 채널에 값을 보내면 활성화되고, inputC
의 값이 설정된다.
이 두 개의 블록이 실행되면 루프를 빠져나오고 유효한 값이 리턴된다.
하단 두 개의 case
블록은 에러 조건을 담당한다. 만약 p.errs
에 쓰인 에러가 있다면, 에러를 리턴한다.
또는 context가 취소되었다면 요청이 취소되었음을 나타내는 에러를 리턴한다.
다시 GatherAndProcess()
로 돌아와서, p.waitForAB()
호출 이후에는 전형적인 에러에 대한 nil
체크를 한다.
에러가 없다면 p.inC
채널에 inputC
값을 쓰고, processor
의 waitForC
메소드를 호출한다.
func (p *processor) waitForC(ctx context.Context) (COut, error) {
select {
case out := <-p.outC:
return out, nil
case err := <-p.errs:
return COut{}, err
case <-ctx.Done():
return COut{}, ctx.Err()
}
}
이 메소드는 한 개의 select
문으로 구성되어 있다.
getResultC()
가 성공적으로 호출되었다면 p.outC
채널에 값을 썼을 것이고, 이로부터 값을 읽어 리턴한다.
또는 getResultC()
가 에러를 반환하였다면, p.errs
채널에서 에러를 읽어 이를 반환한다.
또는 context가 취소되었다면 이를 나타내는 에러를 반환한다.
waitForC()
가 끝났다면, GatherAndProcess()
는 비로소 결과를 반환하게 된다.
getResultC()
가 제대로 동작한다고 믿는다면 이 코드는 간단하게 작성될 수 있다.
context가 getResultC()
로도 전달되었기 때문에, 이 함수는 타임아웃을 준수하고 에러가 발생하면 이를 반환하게끔 작성될 수 있다.
그러한 경우 GatherAndProcess()
내부에서 직접 getResultC()
를 호출하도록 할 수 있다.
그렇게 하면 processor
의 inC
나 outC
필드나 waitForC()
메소드 등 상당히 많은 부분들을 생략할 수 있다.
프로그램이 정확하게 동작하길 원한다면, 되도록 동시성을 최대한 지양하여 사용하는 것이 좋다.
이렇게 고루틴, 채널, select
문으로 코드를 구조화함으로써 얻을 수 있는 장점이 있다.
개별 단계를 분리하고, 독립적인 부분들이 임의의 순서로도 실행 및 완료될 수 있도록 하며, 종속적인 부분들끼리 데이터를 확실하게 교환할 수 있게 된다.
또한 프로그램의 어떠한 부분도 중단되지 않도록 보장하고, 이 함수나 앞서 호출된 함수에서 설정된 타임아웃을 적절히 처리한다.
Go가 아닌 다른 언어로 이러한 구조를 작성하는 것은 상당히 어렵다.
When to Use Mutexes Instead of Channels
다른 프로그래밍 언어에서는, 쓰레드끼리 주고받는 데이터에 대한 접근을 조율해야 하는 경우 뮤텍스(mutex)를 사용한다. 뮤텍스는 동시에 실행되었거나 공유된 데이터에 대해 접근을 제한하는 역할을 한다. 이렇게 보호된 부분은 critical section이라고 한다.
Go의 개발자들이 동시성을 관리하기 위해 채널과 select
문을 디자인한 데는 이유가 있다.
뮤텍스의 문제점은 프로그램의 데이터 흐름이 모호해진다는 것이다.
반면 값이 각종 채널을 통해 고루틴에서 또 다른 고루틴으로 넘어갈 때, 데이터 흐름은 분명하다.
값에 대한 액세스는 한 번에 단일 고루틴으로 국한된다. 한편 뮤텍스가 사용될 때는 값에 대한 액세스가 모든 프로세스에서 공유된다. 따라서 어떤 고루틴이 값을 소유하고 있는지 특정하기 어렵고, 이 때문에 프로세스 순서를 이해하기 어렵다.
Go의 표준 라이브러리에도 뮤텍스가 포함되어 있고, 특정 상황에서는 유횽하게 사용할 수 있다. 그중 한 가지 경우는 고루틴이 공유되는 값을 읽거나 쓰기는 하지만, 프로세싱하지는 않을 때이다.
아래 예제는 멀티플레이어 게임의 인메모리 스코어보드를 관리하는 함수이다. 채널을 통해 이를 어떻게 구현했는지 살펴보고, 고루틴으로 함수를 실행할 것이다.
func scoreboardManager(in <-chan func(map[string]int), done <-chan struct{}) {
scoreboard := map[string]int{}
for {
select {
case <-done:
return
case f := <-in:
f(scoreboard)
}
}
}
이 함수는 map 타입의 변수를 선언한다.
그리고 그 map을 읽고 수정하는 함수의 채널과 done 채널을 select
문으로 묶는다.
타입 하나를 정의하고, 맵에 값을 쓰는 메소드를 만들어보자.
type ChannelScoreboardManager chan func(map[string]int)
func NewChannelScoreboardManager() (ChannelScoreboardManager, func()) {
ch := make(ChannelScoreboardManager)
done := make(chan struct{})
go scoreboardManager(ch, done)
return ch, func() {
close(done)
}
}
func (csm ChannelScoreboardManager) Update(name string, val int) {
csm <- func(m map[string]int) {
m[name] = val
}
}
Update
메소드는 값을 map에 집어넣는 함수를 리턴하는, 매우 간단한 메소드이다.
근데 스코어보드에서 값을 읽으려면 어떻게 해야 할까?
done
패턴을 사용하여, ScoreboardManager
에 전달된 함수가 완료될 떄까지 기다림으로써, 값을 다시 반환하면 된다.
func (csm ChannelScoreboardManager) Read(name string) (int, bool) {
var out int
var ok bool
done := make(chan struct{})
csm <- func(m map[string]int) {
out, ok = m[name]
close(done)
}
<-done
return out, ok
}
이 코드는 동작하기는 하지만 다소 번거롭고 한 번에 한 개의 reqder만 접근할 수 있다. 이런 상황에서는 뮤텍스를 사용하는 것이 낫다.
표준 라이브러리의 sync
패키지에는 두 개의 뮤텍스가 있다.
첫 번째 것은 Mutex
타입으로, Lock()
과 Unlock()
이라는 메소드를 가지고 있다.
Lock()
을 호출하면 다른 고루틴이 critical section에 있는 동안 현재 고루틴이 멈춘다.
다른 고루틴이 critical section을 벗어나면 현재 고루틴은 Lock을 얻고, critical section을 실행한다.
Unlock()
으로 critical section의 끝을 표시한다.
두 번째 뮤텍스 타입은 RWMutex
타입인데, 이 타입은 reader lock과 writer lock을 구분지어서 관리할 수 있게 해준다.
writer lock은 critical section에 단 한 개만 존재할 수 있지만, reader lock은 공유가 가능하여 여러 개가 있을 수 있다.
writer lock은 Lock()
과 Unlock()
으로, reader lock은 RLock()
과 RUnlock()
으로 관리된다.
주의점이 있는데, 뮤텍스를 사용하면 반드시 Lock을 반환해주어야 한다는 것이다.
일반적인 패턴은 Lock()
이나 RLock()
을 호출한 후, 바로 defer
문을 통해 Unlock()
이나 RUnlock()
이 호출되도록 하는 것이다.
type MutexScoreboardManager struct {
l sync.RWMutex
scoreboard map[string]int
}
func NewMutexScoreboardManager() *MutexScoreboardManager {
return &MutexScoreboardManager{
scoreboard: map[string]int{},
}
}
func (msm *MutexScoreboardManager) Update(name string, val int) {
msm.l.Lock()
defer msm.l.Unlock()
msm.scoreboard[name] = val
}
func (msm *MutexScoreboardManager) Read(name string) (int, bool) {
msm.l.RLock()
defer msm.l.RUnlock()
val, ok := msm.scoreboard[name]
return val, ok
}
이렇게 뮤텍스를 사용하니 코드의 길이도 더 짧아지고 더 직관적으로 작성된 것을 확인할 수 있다.
뮤텍스를 사용하는 방식과, select
문 등 Go에서 사용하는 방식 중 어느 것을 선택할 것인지는 아래 내용을 참조하여 결정할 수 있다.
- 고루틴들을 조정하거나, 고루틴들에 의해 변경되는 값들을 추적해야 한다면 채널을 사용한다.
- 구조체의 필드값에 대한 액세스 권한을 공유해야 하는 경우, 뮤텍스를 사용한다.
- 채널을 사용할 때 중대한 성능 이슈가 발생하는 경우, 그리고 이 이슈를 해결할 다른 방법을 찾지 못했을 경우, 뮤텍스를 사용하도록 수정해본다.
위 예제에서 스코어보드는 구조체의 필드이고 스코어보드를 다른 고루틴으로 전달하지는 않기 때문에, 뮤텍스를 사용하는 편이 낫다. 또한 데이터가 인메모리로 저장되기 때문에 뮤텍스가 적합한 점도 있다. 반면 데이터가 HHTP 서버나 데이터베이스 등 외부 서비스에 저장될 때는, 시스템에 대한 액세스를 보호하기 위해 뮤텍스를 사용하지 않는 것이 좋다.
뮤텍스를 사용할 때는 코드를 조금 더 면밀하게 검토해야 할 필요가 있다. lock과 unlock을 반드시 짝지어주어야 하며, 그렇지 않으면 데드락에 걸릴 것이다. 위 예제의 경우 lock의 획득과 해제가 동일한 메소드 안에서 일어났기 때문에 문제가 없었다.
그리고 Go의 뮤텍스는 reentrancy(재진입성)이 없다는 문제가 있다. 만약 고루틴이 lock을 이미 얻은 상태로 동일한 lock을 얻기 위해 다시 접근한다면, 그 고루틴이 lock을 해제할 때까지 기다리는 데드락이 발생한다. 뮤텍스에 reentrancy가 존재하는 Java같은 언어와의 차이점이라고 볼 수 있다.
lock에 reentrancy가 없기 때문에 재귀함수의 뮤텍스 사용이 어려워진다. 따라서 재귀적인 함수 호출 이전에 락을 해제해주어야 한다. 웬만하면 lock을 얻은 상태로 함수를 호출할 때는, 호출 중에 어떤 lock을 획득할 지 모르기 때문에 주의해주어야 한다. 함수 호출 중 현재 들고있는 것과 동일한 lock을 획득하려고 하면 데드락에 걸릴 것이다.
sync.WaitGroup
이나 sync.Once
와 마찬가지로 뮤텍스는 복사되어져서는 안된다.
만약 함수의 파라미터로 보내지거나 구조체의 필드로 액세스되는 경우, 반드시 포인터로 이루어져야 한다.
만약 뮤텍스가 복사되면 lock은 공유되지 않을 것이다.
또한 여러 고루틴에서 뮤텍스를 획득하지 않고 한 변수에 접근하려고 시도하지 않는 것이 좋다. Race condition으로 인해, 상당히 정신나갈거같은 다양한 에러가 발생할 것이다.
sync
패키지에는Map
이라는 타입이 있는데, 이는 빌트인map
타입의 concurrency-safe 버전이다. tradeoff가 있어sync.Map
타입은 특정한 상황에만 적합하다.
- 값이 쓰이는 횟수는 한 번이고, 읽히는 횟수가 상당히 많은 key-value 페어를 공유하는 경우
- 고루틴이
map
자체는 공유하지만, 다른 key나 value에 접근하지 않는 경우게다가 Go에는 제네릭이 부족하기 때문에,
sync.Map
은 각각의 key와 value에interfaece{}
를 타입으로 사용한다. 따라서 컴파일러는 제대로 된 타입이 사용되었는지 확인해줄 수 없다. 이러한 한계 때문에, 여러 고루틴이map
타입을 공유해야하는 상황에는sync.RWMutex
로 빌트인map
을 보호하는 것이 낫다.
Atomics
Go에는 뮤텍스뿐만 아니라, 여러 쓰레드에서 데이터를 일정하게 보존할 수 있는 또다른 방법을 제공한다.
sync/atomic
패키지는 최신 CPU에 내장되어 있는 atomic variable 연산에 액세스할 수 있게 해주어,
단일 레지스터에 맞게 값을 add, swap, load, store, compare and swap(CAS)할 수 있게 해준다.
만약 비트 단위까지 최적화해서 성능을 쥐어짜서 동시성 코드를 작성하는 코딩의 빡고수라면 유용할 것이다. 그런 사람이 아니라면 그냥 얌전히 goroutine과 mutex를 쓰는 게 좋을것 같다.
References
[
](https://learning.oreilly.com/library/view/learning-go/9781492077206/)[Jon Bodner, 『Learning Go』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/learning-go/9781492077206/)