[Go 동시성 프로그래밍] 읽기 - 3장 Go의 동시성 구성 요소

smpl published on
7 min, 1296 words

Go 동시성 프로그래밍 책의 3장 Go의 동시성 구성 요소 챕터를 읽고, 읽은 내용을 정리해봅니다.

고루틴

모든 go 프로그램에는 적어도 하나의 고루틴이 있다. main 함수 또한 사실은 main 고루틴이다.

익명 고루틴

	go func() { ... }() // 즉시 호출 필요

익명 함수를 고루틴으로 호출

something := func() {}
go something()

고루틴은 OS 쓰레드도 아니고, 그린쓰레드도 아니다. 코루틴이라는 더 높은 수준의 추상화이다.

비선점적이지만, 중단(suspend)하거나 재진입(reentry)할 수 있는 여러 지점을 갖고 있다.

go 런타임은 고루틴의 실행시 동작을 관찰해, 고루틴이 멈춰서 대기 중일 때 자동으로 일시 중단 시키고, 대기가 끝나면 다시 시작시킨다. go 런타임은 고루틴이 멈춰있는 지점에서만 선점 가능하다.

M:N 스케줄러로 구현되어 있다. (M개의 그린쓰레드를 N개의 OS 쓰레드에 맵핑한다는 의미)

사용가능한 그린쓰레드보다 더 많은 고루틴이 있을 경우, 스케줄러는 사용가능한 쓰레드들로 고루틴을 분배하고 이 고루틴들이 대기 상태가 되면 다른 고루틴이 실행될 수 있도록 한다.

fork-join 모델로 실행. (실행 흐름이 분기점에서 fork하고, 합류지점에서 join) 단, fork-join 모델은 동시성이 수행되는 방법에 대한 논리적인 모델일 뿐, 메모리가 관리되는 방식에 대해서는 아무 것도 말해주지 않는다.

합류 지점은 프로그램의 정확성을 보증하고 레이스 컨디션을 제거하는 요소이다. 일반적으로 sync.WaitGroup을 사용한다.

go의 클로저는 자신들이 생성된 어휘 범위(lexical scope)를 폐쇄해 변수들을 캡처한다. 클로저가 고루틴으로 실행될 경우, go 런타임은 클로저에 의해 캡처된 변수를 계속 참조할 수 있도록 힙으로 옮긴다.

클로저가 참조를 사용하게 하기보다, 사본을 전달하는 것이 좀더 올바르다.

// 이렇게 하기 보다는
var wg sync.WaitGroup
for _, salutation := range []string{ "hello", "greetings", "good day" } {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(salutation)
	}()
}
wg.Wait()

// 이렇게 하는게 더 낫다.
var wg sync.WaitGroup
for _, salutation := range []string{ "hello", "greetings", "good day" } {
	wg.Add(1)
	go func(salutation string) {
		defer wg.Done()
		fmt.Println(salutation)
	}(salutation)
}
wg.Wait()

고루틴은 서로 동일한 주소공간에서 작동하며, 단순히 함수를 호스팅하는 것이기 때문에, 참조된 변수는 gc 기반으로 메모리 상에서 잘 처리될 수 있으나, 동기화에 대해서는 여전히 sync 패키지나 채널을 이용하는 것을 고려해야 한다.

고루틴은 매우 가벼워서, 새로 만들어질 때 몇킬로바이트만 사용하며, 스택은 자유롭게 늘어나고, CPU 오버헤드도 거의 없어서 한번 함수 호출에 약 3개 정도의 명령어만 사용된다.

GC를 위해 runtime.GC, 메모리 사용량 측정을 위해 runtime.MemStats 를 이용할 수 있음.

벤치마크 방법. go test -bench=. -cpu=1 벤치마크코드.go

sync 패키지

WaitGroup

동시에 수행된 연산의 결과를 신경쓰지 않거나, 결과를 수집할 다른 방법이 있는 경우 동시에 수행될 연산집합을 기다릴 때 유용.

동시에 실행해도 안전한(concurrent-safe) 카운터라고 생각해도 된다. - 🤔 세마포어?

Add에 대한 호출을 고루틴의 외부에서 수행하는 것이 추적에 도움이 된다. (고루틴들이 돌면서 레이스 컨디션이 일어나는 것을 방지)

Mutex와 RWMutex

공유 리소스에 대해 동시에 실행해도 안전한 방식의 배타적 접근을 나타내는 방법을 제공.

채널은 통신을 통해 메모리를 공유하는 반면, Mutex는 개발자가 메모리에 대한 접근을 동기화하기 위해 따라야 하는 "규칙"을 만들어 메모리를 공유.

Unlock은 defer 구문에서 호출하도록 함. 패닉이 발생하는 경우를 포함해 모든 경우에 Unlock이 호출된다는 것을 확실하게 보장한다. 이렇게 하지 않으면 프로그램이 데드락에 빠질 수 있다.

임계영역에서 시간이 소비되는 병목을 줄이기 위한 전략 중 하나는, 임계영역의 단면을 줄이는 것이다. (RWMutex가 약간의 효과를..)

Cond

고루틴들이 대기하거나, 어떤 이벤트의 발생을 알리는 집결 지점(rendezvous point)

이벤트란, 두 개 이상의 고루틴 사이에서 어떤 사실이 발생했다는 사실 외에는 아무런 정보를 전달하지 않는 임의의 신호를 말한다.

// #1
for conditionTrue() == false { }

// #2
for conditionTrue() == false {
	time.Sleep(1 * time.Millisecond)
}

// #3
c := sync.NewCond(&sync.Mutex{})
c.L.Lock()
for conditionTrue() == false {
	c.Wait()
}
c.L.Unlock()

Wait을 호출하면 몇몇 다른 작업도 이루어진다. 진입할 때 Cond 변수의 Locker에서 Unlock이 호출되고, Wait가 종료되면 Cond 변수의 Locker에서 Lock이 호출된다.

이 복잡성이 이 메소드의 숨겨진 부작용이다. 조건이 발생할 때까지 기다리면서 계속 이 lock을 가지고 있는 것처럼 보이지만 실제로는 그렇지 않다. 주의해야 한다.

Signal 메소드 : Cond 타입이 Wait 호출에서 멈춰서 대기하는 고루틴들에게 조건이 발생하였음을 알림.

Broadcast 메소드 : 모든 기다리고 있는 고루틴들에게 신호를 보냄.

Once

언제나 하나의 호출(심지어 서로 다른 고루틴들 사이에서도)만이 인수로 전달된 함수를 호출할 수 있도록 함.

sync.Once{}.Do에 전달된 각 함수가 호출된 횟수가 아니라, Do가 호출된 횟수를 센다.

언제나 한번만, 한 순간에 하나의 호출만 가능하기 때문에, 순환 호출이나 여러 쓰레드가 서로 호출하는 경우는 데드락을 발생시킬 수 있다.

Pool

동시에 실행해도 안전한 객체 풀. 가비지 컬렉션을 하지 않고 빠르게 객체를 할당받고 보관하기 위해 쓴다.

생성 시 New 메소드를 주고, Get메소드로 객체를 받고, Put 메소드로 돌려준다.

메모리 할당량을 조절하고, 더 빠르게 객체를 사전로딩하는 두가지 목적에 유용하다.

사용 시에는, 다음을 주의해야 한다.

  • sync.Pool을 인스턴스화 할 때, 호출 시 쓰레드로부터 안전한 New 멤버 변수를 전달한다.
  • Get에서 인스턴스를 받았을 때, 돌려받은 객체의 상태에 대한 가정을 해서는 안된다.
  • Pool에서 꺼낸 객체로 작업을 마치면 반드시 Put을 호출한다. defer를 이용하자.
  • 풀 내에 있는 객체들은 구조가 거의 균일해야 한다.

채널

<-chan interface{] // 읽기만 가능한 채널
chan<- interface{} // 쓰기만 가능한 채널

go는 필요할 때 양방향 채널을 묵시적으로 단방향 채널로 변환한다.

가득찬 채널에 쓰려고 하는 고루틴은 채널이 비워질 때까지 기다리고, 비어 있는 채널에서 읽으려는 고루틴은 적어도 하나의 항목이 있을 때까지 기다린다.

읽기 연산은 두번째 리턴값을 받을 수 있는데, 첫번째 값이 프로세스 어딘가에서 쓰기 연산을 통해 생성된 값인지, 아니면 채널이 닫혀서 생성된 기본값인지 나타내는데 사용된다. (닫혔다면 false)

채널을 닫는 의미는, "송신 측에서는 더이상 값을 쓰지 않을 것이니 하고 싶은 작업을 하시오"라는 뜻이다.

두번째 리턴값을 잘 핸들링하는 방법은, range 키워드를 쓰는 것이다. 훨씬 낫다.

// #1
for {
	v, ok := <- valueStream
	if ok == false {
		break
	}
	...
}

// #2
for v := range valueStream {
	...
}

채널을 닫는 것은 여러개의 고루틴에 동시에 신호를 보낼 수 있는 방법이기도 하다.

버퍼링되지 않은 채널은 단순히 여유용량이 0인 버퍼링된 채널과 같다. (버퍼링되지 않은 채널의 용량은 0이기 때문에 쓰기 전에 이미 가득 차 있다.)

버퍼가 가득찬 채널에 쓰기 연산을 하면, 버퍼가 비워질 때까지 대기한다. 송신자가 없는 버퍼가 빈 채널에 읽기 연산을 하면, 송신이 발생할 때까지 대기한다.

버퍼링된 채널이 비어 있는데 수신자가 있는 경우는 버퍼가 무시되고 값이 송신자에서 수신자로 직접 전달된다.

채널의 버퍼링은 성급한 최적화가 되는 경우가 많으며, 데드락이 발생하기 어렵게 만들어 데드락을 숨기게 될 수 있다. (개발 중에는 버퍼링을 하지 않는게 더 좋다.)

초기화되지 않은 채널(nil 채널)을 이용하는 것은 런타임에 데드락 혹은 패닉을 일으킬 수 있다.

채널의 상태에 따른 연산 결과

위와 같은 채널의 상태에 따른 연산을 잘 다루기 위해서 가장 먼저 해야하는 일은, 소유권을 올바르게 할당하고 정립해야 한다.

채널을 인스턴스화 하고, 쓰고, 닫는 고루틴이 소유권을 가지고 있다고 정의한다.

채널 소유자는 채널에 대한 쓰기 접근 권한 측면(chan 혹은 chan←)을 가지고 있으며, 채널의 활용자는 읽기 전용 측면(←chan)을 가진다.

채널을 소유한 고루틴은 반드시 다음을 수행해야 한다.

  • 채널을 인스턴스화 한다.
  • 쓰기를 수행하거나 다른 고루틴으로 소유권을 넘긴다.
  • 채널을 닫는다.
  • 이 목록에 있는 앞의 세가지를 캡슐화하고, 이를 읽기 채널을 통해 노출한다.

이렇게 책임을 소유자에게 부여하면, 다음과 같은 효과가 있다.

  • 우리가 채널을 초기화하기 때문에 nil 채널에 쓰는 것으로 인한 데드락의 위험을 제거할 수 있다.
  • 우리가 채널을 초기화하기 때문에 nil 채널을 닫을 위험이 없다.
  • 우리가 채널이 닫히는 시기를 결정하기 때문에, 닫힌 채널에 쓰는 것으로 인한 패닉의 위험을 없앨 수 있다.
  • 우리가 채널이 닫히는 시점을 결정하기 때문에 채널을 두번 이상 닫는 것으로 인한 패닉의 위험을 제거할 수 있다.
  • 우리 채널에 부적절한 쓰기가 일어나는 것을 방지하기 위해 컴파일 시점에 타입 검사기를 사용한다.

채널의 소비자는 이제 두가지 사항만 신경쓰면 된다.

  • 언제 채널이 닫히는지 아는 것.
  • 어떤 이유로든 대기가 발생하면 책임있게 처리하는 것.
// 모범적인 예시
chanOwner := func() <-chan int {
	resultStream := make(chan int, 5)
	go func() {
		defer close(resultStream)
		for i := 0; i <= 5; i++ {
			resultStream <- i
		}
	}()
	return resultStream
}

resultStream := chanOwner()
for result := range resultStream {
	fmt.Printf("Received : %d\n", result)
}

fmt.Printf("Done receiving!")

select 구문

switch 블록과는 다릴, select 블록의 case 문은 순차적으로 테스트되지 않으며, 조건이 하나도 충족되지 않는다고 다음 조건으로 넘어가지도 않는다.

대신 모든 채널 읽기와 쓰기를 동시에 고려한다. 준비된 채널이 없는 경우 select 문 전체가 중단되 대기한다. 그런 다음 채널들 중 하나가 준비되면 해당 연산이 진행되고 관련 구문들이 실행된다.

Go의 런타임은 case 구문의 집합에 대해 균일한 의사 무작위(pseudo-random uniform) 선택을 수행한다.

타임아웃 구현을 위해 ←time.After(1 * time.Second) 와 같은 함수를 쓸 수 있다.

모든 채널이 차단되어 대기하는 경우에는 default 절을 허용한다. 이를 이용하면 다른 고루틴이 결과를 보고하기를 기다리는 동안 작업을 진행할 수 있다.

빈 select문 select {} 이 문은 영원히 대기한다.

GOMAXPROCS 레버

동시에 실행되는 작업 대기열 수를 설정하는 함수이다.

Go 1.5 이전에는 항상 이 값이 기본으로 1이었으나, 지금은 논리적인 코어 수로 자동 설정된다. 그래서 과거에는 go 코드들에는 아래와 같은 코드가 항상 있었으나, 지금은 필요하지 않다.

runtime.GOMAXPROCS(runtime.NumCPU())

여전히 이 함수가 유용할 때가 있는데, 레이스컨디션을 유발하기 위해 코어 수 이상으로 늘리고 싶을 때와 같은 경우들이다.

// fin.