동시성 소개

검프·2020년 6월 25일
17

Concurrency in Go

목록 보기
2/6

Go 동시성 프로그래밍의 내용을 참고하여 작성했습니다.

왜 동시성 프로그래밍이 필요한가?

동시성 프로그래밍은 최근 인기 있는 주제입니다. 동시성 프로그래밍이 새롭게 등장한 개념도 아닌데 왜 인기를 끄는 것일까요?

무어의 법칙Moores Law^{Moore's\ Law}은 발열과 경제성 문제가 더해지면서 한계를 보이기 시작합니다. 그리고 이 한계를 극복하기 위해서 멀티 코어 프로세서가 등장합니다. 하지만 멀티 코어를 잘 활용하는 것은 쉽지 않은 일입니다.

허브 셔터Herb Sutter^{Herb\ Sutter}"공짜 점심은 끝났다."라는 말을 통해서 하드웨어를 따라가지 못하는 소프트웨어 산업의 문제점을 제기했습니다. 단순히 더 빠른 하드웨어가 출시되기를 기다리기만 하면 소프트웨어의 속도가 자동으로 빨라지는 시대는 끝났다는 것이죠.

결국, 멀티 코어 프로세서가 제공하는 병렬성을 잘 활용해야 하는 과제가 동시성 프로그래밍을 인기 있는 주제로 만든 것 같습니다.


동시성과 병렬성의 차이

롭 파이크Rob Pike^{Rob\ Pike}는 동시성과 병렬성은 다르다고 말합니다. 그는 Heroku Waza 컨퍼런스에서 Concurrency is not parallelism이라는 제목의 강연을 했고 아래와 같이 이야기를 합니다.

동시성은 여러 일을 한꺼번에 다루는 데 관한 것이다.
병렬성은 여러 일을 한꺼번에 처리하는 데 관한 것이다.



동시성Concurrency^{Concurrency}은 여러 개의 논리적 통제 흐름을 갖습니다. 이는 병렬로 실행될 수도 있고 아닐 수도 있습니다. 병렬성Pararrelism^{Pararrelism}은 연산을 한꺼번에 실행함으로써 순차적인 처리에 비해서 성능을 높입니다.
동시성과 병렬성의 차이를 몇가지 정리해 봤습니다.

동시성 (Concurrency)병렬성 (Parallelism)
동시에 실행되는 것 처럼 보이는 것실제로 동시에 실행되는 것
소프트웨어적 성질 (싱글코어도 가능)하드웨어적 성질 (싱글코어는 불가능)
한꺼번에 여러 일을 다룸한꺼번에 여러 일을 처리
논리적 개념물리적 개념

아래 그림은 순차성, 동시성, 병렬성을 잘 표현합니다.



또 다른 영상으로 Mythbusters Demo GPU versus CPU 영상을 소개합니다. 병렬성의 특징을 잘 보여주는 영상입니다.


동시성이 어려운 이유

동시성 문제가 발생하는 일반적인 원인과 해결 방법을 알아보겠습니다.

레이스 컨디션

레이스 컨디션Race condition^{Race\ condition}은 둘 이상의 작업이 순서대로 실행되어야 하지만 이것이 보장되지 않을 때 발생하는 문제를 말합니다.

package main

import (
	"fmt"
	_ "time"
)

func main() {
	var data int

	go func() { // 1. 고루틴 생성
		data++ // 2.
	}()

	// time.Sleep(1 * time.Second)	// 5.

	if data == 0 { // 3.
		fmt.Printf("the value is %v.\n", data) // 4.
	}
}

여기서 2번과 3번, 4번은 data에 접근하려고 시도하는데, 순서가 보장되지 않습니다.
아래 세 가지 결과가 발생할 수 있습니다.

  • 아무것도 출력되지 않음 : 2번 -> 3번 실행
  • "the value is 0." 출력 : 3번 -> 4번 실행, 2번 실행?
  • "the value is 1." 출력 : 3번 -> 2번 -> 4번 실행

제 경우 실제로 테스트를 진행해보면 100% "the value is 0."이 출력되는 것을 확인했습니다. 하지만 어디까지나 제 테스트 환경에서의 결과일 뿐 논리적으로 순서가 보장되지 않는 다는 것에는 변함이 없습니다.

5번과 같이 시간을 지연 시킬 경우 "아무것도 출력되지 않음" 결과를 확인할 확률이 높아질 뿐 실제로는 레이스 컨디션 문제가 해결되지 않아서 여전히 세 가지 결과를 확인할 수 있습니다. 레이스 컨디션 문제를 해결하는 방법은 논리적인 완벽함을 추구하는 과정에서 이루어집니다.


원자성

책에서는 레이스 컨디션데이터 레이스Data race^{Data\ race}를 구분하여 설명하고 있고 유사해 보이지만 실제로 다른 개념입니다. 데이터 레이스는 메모리로 접근을 동기화하여 보호하지 않았을때 발생하는데요, 왜 그런것인지 이해하기 위해서는 원자성Atomicity^{Atomicity}을 이해해야합니다. 아래 예제를 살펴보겠습니다.

package main

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

func main() {
	var counter int

	var wg sync.WaitGroup
	var inc = func(count int) {
		defer wg.Done()
		for i := 0; i < count; i++ {
			counter++ // 이 연산은 원자적인가?
		}
	}

	wg.Add(2)
	go inc(5000)
	go inc(5000) // 2개의 고루틴이 counter 변수를 증가 시키기 시작
	wg.Wait()

	fmt.Println("Count: ", counter)
}

<출력>
Count:  8744

각 고루틴이 5,000번씩 counter를 증가 시켰기 때문에 10,000이 나오기를 기대했지만 결과는 8,744입니다. 왜 이런 결과가 나온 것일까요?

동시성 프로그래밍에서 원자성은 특정 실행 컨텍스트Context^{Context}에서 더 이상 나눌 수 없는 연산을 이야기합니다. 여기서 실행 컨텍스트는 상대적인 특성을 갖습니다. 예시로 프로세스에서 원자적인 연산이 운영체제의 컨텍스트에서는 원자적이지 않을 수 있습니다.

컴퓨터 프로그래밍 세계에서는 이러한 상대성이 자주 보이는데요, 예를 들어 Javascript의 입장에서는 웹 브라우저가 런타임 환경Runtime Environment^{Runtime\ Environment}이고 웹 브라우저의 입장에서는 운영체제가 런타임 환경이됩니다. 런타임 환경이라는 말이 무엇을 기준으로하느냐에 따라서 의미하는 바가 달라지는 것입니다.

원자성은 불가분Indivisible^{Indivisible}중단 불가Uninterruptible^{Uninterruptible}이라는 특징을 갖습니다.
이는 사용자가 정의한 컨텍스트에서 나눌수 없는 연산이 한번에 수행되며, 해당 컨텍스트 내에서는 동시에 수행되지 않는다는 것을 의미합니다.

간단한 예제로 아래 연산은 원자성의 개념을 보여줍니다.

counter++ // 이 연산은 원자적인가?

얼핏 원자적인 연산처럼 보이지만, 실제로는 연산을 세분화 할 수 있습니다.

  • counter의 값을 가져온다.
  • counter의 값을 증가시킨다.
  • counter의 값을 저장한다.

각 연산은 원자적이지만, 세 연산의 조합인 counter++은 원자적이지 않습니다. 나눌 수 없기 때문에 원자적인 건데 나눌 수 있기 때문에 당연히 원자적이지 않습니다. 하지만, counter 를 다른 동시성 요소에 공유하지 않는 컨텍스트라면 counter++ 코드 또한 원자적이라고 이야기 할 수 있습니다.

원자성이 중요한 이유는 원자적이라면 논리적으로 동시성 문제로부터 안전하기 때문입니다. 우리가 사용하는 대부분의 프로그래밍적 요소(함수, 제어 구문 등)들은 원자적이지 않은데, 다양한 기법을 이용하여 원자성을 확보할 수 있습니다.

자바 병렬 프로그래밍 51페이지에 보면 레이스 컨디션과 데이터 레이스를 구분하여 설명하고 있습니다.

스레드가 다음에 다른 스레드가 읽을 수 있는 변수에 값을 쓰거나 다른 스레드가 마지막에 수정했을 수도 있는 변수를 읽을 때 두 스레드 모두 동기화를 하지 않으면 데이터 경쟁이 생길 위험이 있다. ..중략.. 모든 경쟁 조건이 데이터 경쟁인건 아니고, 모든 데이터 경쟁이 경쟁 조건인 것도 아니다. 하지만 경쟁 조건이든 데이터 경쟁이든 병렬 프로그램을 예측할 수 없이 실패하게 만든다


메모리 접근 동기화

아래 예제 코드에서는 두 개의 고루틴이 동일한 메모리 영역에 접근하려고 시도하여 레이스 컨디션이 발생합니다.

package main

import "fmt"

func main() {
	var value int
	go func() {
		value++ // 1.
	}()

	if value == 0 { // 2.
		fmt.Println("the value is 0.")
	} else {
		fmt.Printf("the value is %v.\n", value) // 3.
	}
}

왜 두개의 고루틴인지 의아해 하실 수도 있는데요, main()함수도 고루틴으로 실행됩니다. 아래 예제를 통해서 이를 간단히 확인할 수 있습니다.

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("Number of goroutine:", runtime.NumGoroutine())

	go func() {}()
	go func() {}()

	fmt.Println("Number of goroutine:", runtime.NumGoroutine())
}

// Number of goroutine: 1
// Number of goroutine: 3

다시 원래의 예제로 돌아와서, 1~3번 라인에서 레이스 컨디션이 발생하여 이 프로그램의 출력은 논리적으로 예측할 수 없습니다.

프로그램이 의도대로 동작하는 것을 보장하려면 공유 자원인 value를 사용하는 영역에 대해서 단 하나의 고루틴만 독점적으로 사용할 수 있도록 보호해주어야 하는데, 이런 영역을 임계 영역Critical section^{Critical\ section}이라고 합니다.

임계 영역을 보호하는 방법은 다양한데, 다음 예제는 임계 영역 간의 메모리 접근을 동기화Synchronize^{Synchronize}하는 방식으로 임계 영역을 보호합니다.

// 예제 A
package main

import (
	"fmt"
	"sync"
)

func main() {
	var memoryAccess sync.Mutex
	var value int

	go func() {
		memoryAccess.Lock()
		value++
		memoryAccess.Unlock()
	}()

	memoryAccess.Lock()
	if value == 0 {
		fmt.Println("the value is 0.")
	} else {
		fmt.Printf("the value is %v.\n", value)
	}
	memoryAccess.Unlock()
}

sync.MutexLock(), Unlock() 함수를 통하여 호출 사이의 상태에 대해서 현재 실행 중인 고루틴이 독점적으로 사용할 것임을 선언하는 것으로 메모리 접근을 동기화 해줍니다. 참고로 Java도 뮤텍스Mutual exclusion^{Mutual\ exclusion}를 지원하는데 바로 ReentrantLock입니다.

Go 언어에서는 원자성 카운터Atomic counter^{Atomic\ counter}라는 기능도 제공하는데, 위 예제를 아래와 같이 작성할 수 있습니다.

// 예제 B
package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var value int64

	go func() {
		atomic.AddInt64(&value, 1)
	}()

	if atomic.LoadInt64(&value) == 0 {
		fmt.Println("the value is 0.")
	} else {
		fmt.Printf("the value is %v.\n", atomic.LoadInt64(&value))
	}
}

얼핏 동일한 동기화 처리가 된 것 같아 보이지만, 사실 위 예제 A와 B는 다르게 동작합니다. 어디가 다르게 동작하고 있을까요? 직접 찾아보세요!

예제 A는 데이터 레이스를 해결했지만 레이스 컨디션을 해결하지는 못했습니다. 연산 순서는 고루틴과 if..else 블록 중 무엇이 먼저 실행될지는 여전히 알 수 없습니다.

다른 문제도 있는데요, 동기화를 진행하는 과정에서 성능에 영향을 미쳐 프로그램을 느리게 만들 수도 있습니다. 또한 Lock()Unlock()의 동기화 규칙이 잘 지켜지지 않아 예상할 수 없는 문제를 만들 수도 있습니다. 이 책에서 이런 주제들에 대해서 더 나은 방법을 제시할 것 같습니다. 기대되네요!


데드락

데드락Deadlock^{Deadlock}이란, 동시에 실행 중인 프로세스가 서로 원하는 리소스를 상대방이 할당하고 있어서 무한정 기다리게 되는 상태를 말합니다. 교착 상태라고도 하는데요, 이 상태에서는 외부 개입 없이는 결코 프로그램을 복구할 수 없습니다. Go 런타임이 일부 데드락을 탐지하지만, 실질적으로 데드락을 방지할 수 있는 것은 아닙니다.

package main

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

type value struct {
	mu    sync.Mutex
	value int
}

func main() {
	var wg sync.WaitGroup
	printSum := func(v1, v2 *value) {
		defer wg.Done()
		v1.mu.Lock() // 1.
		defer v1.mu.Unlock() // 2.

		time.Sleep(2 * time.Second) // 3.
		// time.Sleep(1 * time.Nanosecond) // 4.

		v2.mu.Lock()
		defer v2.mu.Unlock()

		fmt.Printf("sum=%v\n", v1.value, v2.value)
	}

	var a, b value
	wg.Add(2)
	go printSum(&a, &b)
	go printSum(&b, &a)
	wg.Wait()
}

// fatal error: all goroutines are asleep - deadlock!
// Stack trace에 runtime.gopark 네이밍 오지네요..

1번 v1을 위한 임계 영역에 진입하고 2번에서 함수 리턴 전에 임계 영역을 해제합니다. 3번에서 sleep()하여 임의의 작업을 수행하여 데드락 조건을 완성합니다. 결과를 확인하면 3개의 고루틴이 모두 데드락에 빠지고 Go 런타임은 프로그램을 종료합니다.

책에 역설 이라는 작성된 박스에 time.Sleep()사용하게 됨에 따라서 레이스 컨디션이 나타난다고 설명하고 있습니다. 실제로 4번과 같이 Sleep()시간을 짧게 설정하고 반복해서 실행을 하다보면 데드락에 걸리지 않고 프로그램이 정상적으로 실행/종료되는 경우가 있습니다. 이는 time.Sleep()이 레이스 컨디션을 만들어서 어떤 경우에는 하나의 고루틴에서 온전하게 v1.mu.Lock() -> v1.mu.Unlock() -> v2.mu.Lock() -> v2.mu.Unlock()이 실행된 후 다른 고루틴이 실행되었기 때문입니다.

위 예제가 논리적으로 "완벽한" 데드락을 시뮬레이션하려면 정확한 동기화가 필요합니다. 거듭 강조하지만 동시성 프로그래밍에서는 논리적인 정확함을 추구하는 것이 정말로 중요하네요!

1971년에 에드가 코프먼Edgar Coffman^{Edgar\ Coffman}이 데드락의 발생 조건을 정리하였습니다.

  • 상호 배제Mutual Exclusion^{Mutual\ Exclusion} : 동시에 실행되는 프로세스가 어떤 임의의 시점에 하나의 리소스에 대한 배타적 권리를 보유한다.
  • 대기 조건Wait For Condition^{Wait\ For\ Condition} : 동시에 실행돠는 프로세스는 하나의 리소스를 보유하고 있는 동시에 또 다른 추가 리소스를 기다린다.
  • 비선점No Preemption^{No\ Preemption} : 동시에 실행되는 프로세스 중 하나를 보유하고 있는 리소스는 해당 프로세스에 의해서만 사용 해제될 수 있다.
  • 순환 대기Circular Wait^{Circular\ Wait} : 동시에 실행되는 프로세스 중 하나(P1)가 다른 동시 프로세스(P2)로 이어지는 체인에서 기다려야 하며, P2는 최종적으로 P1을 기다려야 한다.

코프먼 조건에 의하면 위 예제는 확실히 데드락에 해당합니다. 코프먼 조건들 중 하나라도 참이 아니라면 데드락의 발생을 막을 수 있습니다. 하지만 이 조건들을 추론하여 예방하는 것도 쉽지 않은 일입니다.


라이브락

라이브락Livelock^{Livelock}이란, 동시에 실행 중인 프로세스가 락의 획득과 해제를 무한히 반복하는 상태를 말합니다. 책의 예제를 통해서 이해해 보겠습니다.

package main

import (
	"bytes"
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	cadence := sync.NewCond(&sync.Mutex{})
	go func() {
		for range time.Tick(1 * time.Millisecond) {
			cadence.Broadcast()
		}
	}()

	takeStep := func() {
		cadence.L.Lock()
		cadence.Wait()
		cadence.L.Unlock()
	}

	tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool { // 1.
		fmt.Fprintf(out, " %v", dirName)
		atomic.AddInt32(dir, 1) // 2.
		takeStep()              // 3.

		if atomic.LoadInt32(dir) == 1 {
			fmt.Fprint(out, ". Success!")
			return true
		}

		takeStep()
		atomic.AddInt32(dir, -1) // 4.
		return false
	}

	var left, right int32
	tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out) }
	tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out) }

	walk := func(walking *sync.WaitGroup, name string) {
		var out bytes.Buffer
		defer func() { fmt.Println(out.String()) }()
		defer walking.Done()

		fmt.Fprintf(&out, "%v is trying to scoot:", name)
		for i := 0; i < 5; i++ { // 5.
			if tryLeft(&out) || tryRight(&out) { // 6.
				return
			}
		}
		fmt.Fprintf(&out, "\n%v tosses her hands up in exasperation!", name)
	}

	var peopleInHallway sync.WaitGroup // 7.
	peopleInHallway.Add(2)
	go walk(&peopleInHallway, "Alice")
	go walk(&peopleInHallway, "Barbara")
	peopleInHallway.Wait()
}

// Barbara is trying to scoot: left right left right left right left right left right
// Barbara tosses her hands up in exasperation!
// Alice is trying to scoot: left right left right left right left right left right
// Alice tosses her hands up in exasperation!

위 예제는 동시에 실행되는 여러 프로세스가 데드락을 방지하려고 시도하는 상황을 보여줍니다. 복도를 지나다가 맞은 편에서 오던 사람과 마주 했습니다. 상대방이 한쪽으로 피해주었으나 나도 같은 방향으로 피해줍니다. 그래서 반대편으로 움직였지만 상대방도 반대편으로 이동합니다. 이 상황이 영원히 지속되는 시나리오를 시뮬레이션한 예제입니다.

1번 tryDir 함수는 특정 방향으로 움직이고 그것이 성공했는지 리턴하는 함수입니다. 2번에서 주어진 방향으로 1증가 시킨 후 3번에서 잠시 대기 합니다. 일정하게 좌/우로 움직이는 것을 시뮬레이션 한 것입니다. 4번에서는 시도한 방향으로 움직일 수 없음을 알고 포기하는 상태를 시뮬레이션 합니다.

5번은 프로그램이 종료할 수 있도록 인위적인 종료 조건을 추가 했습니다. 6번은 왼쪽으로 이동하려고 시도했다가 실패하면 오른쪽으로 이동을 시도합니다. 7번에서는 두명의 사람이 복도에서 마주한 상황을 시뮬레이션합니다.

두개의 고루틴이 tryDir함수를 실행하면서 dir을 각각 증가시켜 2의 값을 갖게되고 결국 +1 +1 -> 2 != 1 -> -1 -1 -> return false를 반복하게 됩니다.

예제에서 sync.Cond는 대기하고 있는 고루틴들 중 하나 또는 전체를 깨우는데 사용합니다. 책에서는 깨우는 행위를 "이벤트"라는 말로 표현하는데 "이벤트"는 어떤 사실이 발생했다는 사실 외에 아무런 데이터도 전달하지 않는 일종의 신호입니다. Cond를 초기화할 때는 sync.Locker 인터페이스를 만족하는 타입을 인수로 받습니다. 이 때문에 동시에 실행해도 안전한 방식으로 사용할 수 있습니다.

라이브락은 주로 데드락을 피하기 위해서 작성된 코드가 불안정할 경우 발생합니다.


기아상태

기아 상태Starvation^{Starvation}란, 동시에 실행되는 한 프로세스가 연산에 필요한 모든 리소스를 획득할 수 없는 상황을 의미합니다. 라이브락에서 고루틴이 대기했던 것은 공유 잠금Shared lock^{Shared\ lock}이었습니다. 라이브락에서는 모든 프로세스가 동일하게 리소스를 얻지 못해서 모든 작업이 완료되지 않는 상태라면, 기아 상태는 동시에 실행되는 프로세스들이 효율적으로 수행되지 못하도록 방해하는 프로세스가 하나 이상 존재하는 것을 의미합니다. 작업은 완료가 되겠지만 특정 프로세스가 시스템 자원을 과도하게 사용하여 프로그램 전체적으로는 최적의 성능을 낼 수 없는 상태인 것이죠.

package main

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

func main() {
	// runtime.GOMAXPROCS(1) // 3.

	var wg sync.WaitGroup
	var sharedLock sync.Mutex
	const runtime = 1 * time.Second

	greedyWorker := func() { // 1.
		defer wg.Done()

		var count int
		for begin := time.Now(); time.Since(begin) <= runtime; {
			sharedLock.Lock()
			time.Sleep(3 * time.Nanosecond)
			sharedLock.Unlock()
			count++
		}

		fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
	}

	politeWorker := func() { // 2.
		defer wg.Done()

		var count int
		for begin := time.Now(); time.Since(begin) <= runtime; {
			sharedLock.Lock()
			time.Sleep(1 * time.Nanosecond)
			sharedLock.Unlock()

			sharedLock.Lock()
			time.Sleep(1 * time.Nanosecond)
			sharedLock.Unlock()

			sharedLock.Lock()
			time.Sleep(1 * time.Nanosecond)
			sharedLock.Unlock()

			count++
		}

		fmt.Printf("Polite worker was able to execute %v work loops.\n", count)
	}

	wg.Add(2)
	go greedyWorker()
	go politeWorker()

	wg.Wait()
}

// Polite worker was able to execute 488865 work loops.
// Greedy worker was able to execute 569903 work loops

1번에 greedyWorker함수는 자신의 전체 작업 루프 동안 공유 잠금을 가지고 있습니다. 2번에 politeWorker함수는 공유 자원에 접근할 때만 공유 잠금을 가지고 있습니다. 둘 모두 3ns 동안 Sleep()하지만 greedyWorker가 훨씬 많은 루프를 돌게 됩니다.

3번에서 프로그램이 사용 가능한 최대 코어 수를 변경해 가면서 테스트를 진행해 봤습니다. 모든 설정에서 greedyWorker가 더 많은 루프를 돌게됩니다. 코어를 1개만 사용하도록 설정한 경우가 가장 큰 차이를 보이고, 코어 수를 늘릴 수록 그 격차는 줄어드는 것을 확인했습니다. 테스트 중 난감한 사실을 하나 발견했는데요, 코어를 1개만 사용할때의 결과가 코어를 여러개 사용하는 경우보다 더 많은 루프를 돌고있... 이거 뭘까요?

특정 프로세스가 과도하게 공유 잠금을 가지고 있더라도 멀티 코어를 사용하고 시스템 사용률이 여유가 있는 상태라면 기아 상태로인한 영향을 덜 받는다는 것을 알 수 있습니다. 물론 시스템 사용률이 높다면 코어를 1개만 사용한 상태의 결과에 수렴하지 않을까 생각됩니다.

멀티 스레드Multi Thread^{Multi\ Thread} 프로그래밍을 할 경우 스레드 우선 순위Thread Priority^{Thread\ Priority}를 설정해보신 경험이 있을 수도 있습니다. 스레드 우선 순위를 설정할때 주의할 사항 중 하나가 기아상태에 대한 고려입니다. 특히, CPU 사용률이 높은 시스템에서 스레드 우선 순위를 잘못 설정하게될 경우 우선순위가 높은 스레드가 CPU를 독점하게되어 우선순위가 낮은 스레드는 다른 작업이 완료될때까지 무한정 대기하게 될 수 있습니다.

기아상태의 해결책 중 하나가 공정성Fairness^{Fairness}입니다. 공정성이란, 각 동시성 요소가 공평하게 자신의 작업을 수행할 수 있는 기회를 갖는 것을 의미합니다. Java에서 synchronized 대신 ReentrantLock 사용을 추천하는 이유 중 하나도 ReentrantLock이 제공하는 더 나은 공정성에 있습니다.

Go 언어에서 뮤텍스는 공정성을 높이기위해서 스케줄링하는데 이에 대한 설명이 mutex.go에 주석으로 작성되어 있습니다. 또한, 채널 select는 훌륭한 공정성을 구현하고 있습니다.


동시실행 안전성 판단

모든 동시성 코드는 사람이 작성합니다. 동시성 프로그램이 어려운 이유 중 하나는 바로 사람의 실수입니다. 동시성 문제가 아니더라도 도메인을 풀기위한 방법을 구축하는 것만으로도 어려운 일인데, 여기에 동시성과 관련된 논의까지 해야하는 상황이 더 어렵게 만듭니다. 이런 문제를 다소 해소하기 위해서 책에서 추천하는 방법으로 충분한 주석과 동시성을 고려한 함수 모델링입니다.

// CalculatePi 함수는 시작(begin)과 끝(end) 사이의
// 파이(Pi) 자릿수를 계산한다.
func CalculatePi(begin, end int64, pi *Pi)

원주율(Pi) 계산은 병렬 처리를 통해서 성능 향상을 많이 누릴 수 있는 알고리즘 중 하나입니다. 그것을 책에서는 몇가지 의문점을 던집니다.

  • 이 함수를 어떻게 동시에 수행할 것인가?
  • 이 함수를 동시에 여러 번 호출하는 것은 누가 담당하는가?
  • 함수의 모든 인스턴스가 Pi를 공유하여 사용하는데 동기화 책임은 누구에게 있는가?

이런 의문에 대해서 주석은 큰 도움이 됩니다.

// CalculatePi 함수는 시작(begin)과 끝(end) 사이의
// 파이(Pi) 자릿수를 계산한다.
//
// 내부적으로, CalculatePi는 CalculatePi를 재귀(recursively) 호출하는
// FLOOR((end-begin)/2) 개의 동시 프로세스를 생성할 것이다.
// pi 변수에 쓰는 작업에 대한 동기화는 Pi 구조체 내부에서 처리한다.
func CalculatePi(begin, end int64, pi *Pi)

위 주석은 아래와 같은 내용에 답을 줍니다.

  • 누가 동시성을 책임지는가?
  • 문제 공간은 동시성 기본 요소에 어떻게 매핑되는가?
  • 동기화는 누가 담당하는가?

위 함수의 모호함은 잘못된 모델링에서 비롯된다고도 설명하고 있습니다. 예를 들어 순수 함수Pure function^{Pure\ function}를 통한 사이드 이팩트Side effect^{Side\ effect} 제거가 가능하다고 설명합니다.

func CalculatePi(begin, end uint64) []uint

위 함수 시그니처는 포인터를 사용하지 않습니다. 특정 입력에 대해서 동일한 결과가 반환될 것임을 예상하게 만드는 함수 시그니처입니다. 이런 개선에도 동시성에 대한 의문은 그대로 존재합니다. 그래서 이 부분에 대한 추가 개선을 진행합니다.

func CalculatePi(begin, end int64) <-chan uint

이제 채널Channel^{Channel}을 사용함으로써 CalculatePi 함수가 최소한 하나 이상의 고루틴으로 동작함을 알려줍니다. 즉, 별도의 고루틴을 만들어 병렬처리해서는 안된다고 전달하는 것입니다.

이런 개선 과정은 명확성을 높여주지만 성능에도 영향을 줍니다. 명확성과 성능은 양립가능하지만 효과적으로 사용하는 것은 몹시 어렵습니다. 다행히 Go는 이런 부분들을 고려하여 발전해왔으며, Go 언어에서 추천하는 동시성 코드 모델링은 정확성Correctness^{Correctness}, 합성 가능성Composability^{Composability}, 확장성Scalability^{Scalability}을 높여줍니다.


복잡성 속의 단순함

동시성 프로그램은 몹시 어렵지만 Go 언어가 제공하는 동시성 기본 요소들을 사용하면 보다 안전하고 명확한 프로그래밍이 가능합니다.


가비지 컬렉터

Go의 가비지 컬렉터Garbage Collector^{Garbage\ Collector}는 성능이 매우 뛰어납니다. Go 1.8 이후, 가비지 컬렉션으로 인한 프로그램 일시 정지는 일반적으로 10~100마이크로초 사이라고 합니다. 대단히 빠르기 때문에 가비지 컬렉션에 의한 프로그램 일시 중지 문제를 크게 줄였습니다. 특히 동시성 모델에서 메모리 관리가 결합되면 정상적으로 동작하는 코드를 작성하는 것이 대단히 어려운데, 빠른 가비지 컬렉션으로인해 메모리 관리를 크게 신경쓰지 않고 도메인에 집중할 수 있도록 해줍니다.


스레드 다중화

Go의 런타임은 스레드 다중화Thread multiplexing^{Thread\ multiplexing}도 자동으로 처리해줍니다. 세부적인 동작은 아직 정확히 알 수 없지만, 스레드의 라이프 사이클 관리 및 작업을 스레드에 균등하게 매핑하는 스케줄링을 자동화 해줘서 동시성과 관련된 복잡함을 줄였습니다. 우리는 그냥 동시성 생성자Concurrent construct^{Concurrent\ construct}를 사용만 하면됩니다. Go! 인거죠.

또한 고루틴 간의 통신을 위하여 채널Channel^{Channel}을 제공하며 채널은 동시 실행에 안전한 방식Concurrent safe^{Concurrent\ safe}으로 고루틴 간 통신 방법을 제공합니다.


2장부터는 본격적으로 Go의 동시성의 강점에 대해서 알아봅니다.

profile
권구혁

1개의 댓글

comment-user-thumbnail
2020년 6월 25일

발표중 GC와 비교하여 잠시 언급된 iOS의 ARC에 대한 간단한 비교자료를 첨부합니다.

https://medium.com/@jang.wangsu/ios-swift-rc-arc-와-mrc-란-그리고-strong-weak-unowned-는-간단하게-적어봤습니다-988a293c04ac

답글 달기