동시성이 무조건 빠르다고 착각하지 마라

ms-shin·2024년 8월 7일
0

Go

목록 보기
2/2

이 장에서 다룰 내용

동시성이 무조건 빠르다고 착각하지 마라

동시성을 이용한 솔루션이 순차적인 것보다 무조건 빠르다고 잘못 알고 있는 개발자가 많습니다. 전혀 그렇지 않습니다. 솔루션의 전반적인 성능은 다양한 요인에 영향을 받습니다. 먼저 동시성에 관련된 몇 가지 기본 속성을 되짚어본 다음 구체적인 예제를 통해 알아보기로 합니다.

고 언어의 스케줄링

스레드는 OS에서 실행에 관련된 가장 작은 단위입니다. 프로세스에서 여러 동작을 동시에 실행시키려 할 때, 스레드를 여러 개 생성합니다. 이렇게 생성된 스레드는 두 가지 특성을 가집니다.

  • 동시성: 두 개 이상의 스레드가 구동되고 실행되고 완료되는 과정이 시간 축에서 볼 때 서로 겹칠 수 있습니다.
  • 병렬성: 동일한 작업을 한 번에 여러 개로 실행할 수 있습니다.

OS는 스레드가 다음과 같이 최적화된 상태로 실행되도록 스케줄링해야 합니다.

  • 모든 스레드가 CPU 사이클을 할당받으며, 아주 오래 기다리는 스레드가 없어야 합니다.
  • 워크로드는 CPU 코어마다 고르게 분배됩니다.

CPU 코어는 여러 스레드를 실행시킵니다. 스레드를 전환할 때 컨텍스트 스위칭이라 부르는 전환작업을 수행합니다. CPU 사이클을 사용하는 실행 상태에 있던 액티브 스레드는 실행 대기 상태로 전환됩니다. 이러한 컨텍스트 스위칭은 오버헤드가 큰 편입니다. OS는 스위칭하기 전에 스레드의 현재 실행 상태를 저장해야 하기 때문입니다. (PCB라고 부르는 메모리 영역에 저장됩니다.)

고 언어에서는 스레드를 직접 생성할 수 없고, 고루틴을 이용해야 합니다. OS 스레드는 CPU 코어에 대해 컨텍스트 스위칭하며 이를 OS가 처리하는 반면, 고루틴은 OS 스레드에 대해 컨텍스트 스위칭하며 고 언어의 런타임이 담당합니다. 또한 고루틴은 OS 스레드에 비해 메로리를 훨씬 적게 차지합니다. Go 1.4 기준으로 2KB 정도합니다. (OS 스레드는 OS마다 다릅니다. 리눅스(x86, 32비트)에서는 디폴트 크기가 2MB입니다). 크키가 작을수록 컨텍스트 스위칭 속도가 빠릅니다.

고루틴에 대한 컨텍스트 스위칭은 스레드에 대한 컨텍스트 스위칭에 비해 80~90%가량 빠릅니다.

고 스케줄러가 고루틴을 처리하는 방법에 대해 확인해봅시다. 고 스케줄러는 내부적으로 G(고루틴), M(OS 스레드, Machine), P: CPU 코어(Processor)와 같은 용어를 사용합니다. OS 스케줄러는 OS 스레드(M)마다 CPU코어(P)를 할당합니다. 그러면 고루틴(G)은 M에서 실행됩니다.

고루틴 생명주기는 OS 스레드보다 간단합니다. 실행 중/실행 가능/대기 상태를 가집니다. 고 스케줄링의 구현과 관련하여 알아둘 마지막 단계가 있습니다. 바로 고루틴이 생성되었지만 실행하지 않은 상태입니다. 고 런타임은 두 가지 큐를 사용합니다. 하나는 P마다 존재하는 로컬 큐고, 다른 하나는 모든 P가 공유하는 글로벌 큐입니다. 고 스케줄러는 61번째로 실행될 때마다 글로벌 큐에 고루틴이 있는지 확인합니다. 없으면 로컬 큐를 확인합니다. 글로벌 큐와 로컬 큐가 모두 비어 있다면 다른 로컬 큐에서 고루틴을 가져옵니다.

고 1.14이전에는 스케줄러가 협조형이었습니다. 몇 가지 멈춤 상황(채널 송수신, I/O, 뮤텍스 획득 대기 등)에서만 고루틴을 특정 스레드로 부터 컨텍스트 스위칭시킬 수 있었습니다. 고 1.14부터는 선제적(선점형)방식으로 변경됐습니다. 즉, 이제 일정 시간(10ms)이 지나면 우선 순위가 높은 다른 고루틴으로 컨텍스트 스위칭될 수 있습니다. 이렇게 하면 실행 시간이 긴 작업이 CPU시간을 점유하지 않고 나눠 쓰게 만들 수 있습니다.

병렬 병합 정렬

병합 정렬 알고리즘은 주어진 리스트를 두 개로 나누는 작업을 반복하다가 분리한 리스트에 원소가 하나만 남으면 지금까지 분리했던 부분 리스트를 병합하는 방식으로 정렬된 리스트를 생성합니다.

  1. 전반부
  2. 후반부
  3. 전반부와 후반부를 하나로 합칩니다.

개선
1. 전반부 작업 고루틴 생성
2. 후반부 작업 고루틴 생성
3. 전반부와 후반부를 하나로 합칩니다.

과연 병렬 버전이 빠를까요? 놀랍게도 병렬 버전이 열 배 정도 느립이다. 이는 왜 이런 결과가 나왔을까요? 이는 병렬 처리할 대상이 너무 적으면 순수 연산에 걸리는 시간이 짧기 때문에 작업을 여러 코어에 나누는 효과가 사라집니다. 고루틴을 생성하고 이를 스케줄러가 구동시키는 데 걸리는 시간은 현재 고루틴에서 직접 병합하는 작업에 비해 너무 깁니다. 그렇다면 병렬 병합 정렬은 어떻게 처리하면 가장 효율적일까요? 특정 기준의 숫자 이하면 순차 정렬하고, 기준 이상의 리스트인 경우에만 병렬 처리를 하면 성능 개선될 수 있습니다.

지금까지 고 언어의 스케줄링과 관련하여 스레드와 고루틴의 차이, 고 런타임에서 고루틴을 스케줄링하는 방식 등과 같은 기본 개념 위주로 살펴봤습니다. 또한 병합 병렬 버전을 통해 동시성이 항상 더 빠름을 보장하지 않다는 사실을 확인했습니다. 작업량이 적으면(즉, 병합할 원소가 적으면) 고루틴을 생성하는 오버헤드에 의해 병렬화 효과가 사라집니다.

그렇다면 앞으로 어떻게 해야할까요? 벤치마크를 해서 순차, 병렬 버전을 만들어서 효과가 있는지 확인합니다.

채널과 뮤텍스 중 적합한 것을 판단하라

동시성을 구현할 때 채널을 사용할지 뮤텍스를 사용할지 명확히 판단하기 힘든 경우가 있습니다. 둘 중, 어느 하나가 더 적합한 상황인지를 정리해보겠습니다. 고 언어에서 제공하는 채널은 통신 메커니즘입니다. 내부적으로 채널은 값을 보내고 받는 파이프로서 동시에 실행 중인 고루틴을 서로 연결하는 데 사용할 수 있습니다. 채널은 다음 두 가지 중 하나입니다.

  • 버퍼를 사용하지 않는 채널: 받는 고루틴이 준비될 때까지 보내는 고루틴은 기다립니다.
  • 버퍼를 사용하는 채널: 보내는 고루틴은 버퍼가 가득 찾을 때만 기다립니다.

이러한 채널의 기본적인 특성을 파악한 다음 채널과 뮤텍스 중 어느 것이 적합한지 판단하는 방법에 대해서 알아봅시다. 만약
G1 - G2 는 병렬, G1 - G3, G2 - G3는 동시 관계를 맺고 있다고 가정합니다.

  • 병렬 관계는 동일한 함수를 수행하면서 채널을 통해 메시시를 주고받거나 동일한 HTTP 핸들러를 동시에 처리할 수 있습니다.
  • G1,G2 - G3와 동시 관계라고 하는 것은 G1과 G2는 첫 번재 단계를 수행하는 반면 G3는 그 다음 단계를 수행합니다.

일반적으로 병렬 관계는 반드시 동기화해야 합니다. 예를 들어, 슬라이스 같은 공유 자원에 접근하고나 수정할 때가 그렇습니다. 동기화는 뮤텍스로 제어할 수 있지만 (버퍼를 사용한 채널을 제외한) 채널로는 할 수 없습니다. 따라서 동기화할 때는 뮤텍스를 사용해야 합니다.

반면 동시 관계는 전반적인 동작을 조율해야 합니다. 예를 들어, G3가 G1과 G2에서 나온 결과를 취합하고, G2는 G3에 시그널을 보내서 중간 결과가 새로 나왔음을 알려줘야 하는 경우가 있습니다. 이러한 조율은 통신 영역에 해당하므로 채널을 사용합니다.

정리하자면 병렬 관계인 고루틴 사이에는 뮤텍스가 필요하고, 동시 관계인 고루틴 사이에는 채널을 사용하면 됩니다.

경쟁 문제에 대해 완전히 이해하라

데이터 경쟁과 경쟁 상태의 차이점에 대해 소개한 뒤, 고 언어의 메모리 모델을 알아보겠습니다. 데이터 경쟁과 경쟁 상태이 어떤 점이 다를까요? 데이터 경쟁이란, 두 개 이상의 고루틴이 동일한 메모리 지점을 동시에 접근하면서 둘 중 어느 하나가 쓰기 작업을 수행하고 있을 때 발생합니다.

i := 0 

go func() {
    i++
}()

go func() {
    i++ 
}()

여기서 i의 최종 값은 예측할 수 없습니다. 왜 그럴까요? i++ 이란 행위는 사실 i의 값을 읽고, 읽은 값을 증가시키고, 증가시킨 값을 다시 i에 저장하는 과정을 거칩니다. 하지만 이 과정잉 병렬적으로 겹쳐서, 첫 번째 고루틴이 3스텝이 마무리되기 전에 두 번째 고루틴이 값을 읽게 되면 반영되지 않은 값을 읽었기 때문에 원하는 결과를 얻지 못하는 경우가 생길 수 있다는 것입니다. 이게 바로 데이터 경쟁입니다.

이를 방지하기 위한 방법은
1. sync/atomic 패키지를 이용하는 방법
2. 뮤텍스를 사용하는 방법
3. chan을 통해서 한 고루틴에서만 변수를 업데이트하게 만드는 방법이 있습니다.

이렇게 하면 데이터 경쟁을 막을 수 있습니다. 하지만 이렇다고 항상 일정한 결과가 나올 수 있을까요? 그렇지 않습니다. 예를 들면

i := 0 
mutex := sync.Mutex{}

go func() {
	mutex.Lock()
    defer mutex.Unlock()
    i= 1
}

go func() {
	mutex.Lock()
    defer mutex.Unlock()
    i= 2
}

두 고루틴 모두 동일한 변수에 접근하지만 동시에 접근하지는 않습니다. 뮤텍스로 동시 접근을 제어하기 때문입니다. 하지만 이는 항상 일정한 결과가 나올까요.. 물론 그렇지 않을겁니다. 실행 순서에 따라서 값이 1이 될 수 있고, 2가 될 수 있습니다. 데이터 경쟁은 발생하지 않지만 경쟁 상태는 있습니다. 경쟁 상태란, 이벤트 타이밍이나 순서에 따라 동작이 달라지고, 이를 통제할 수 없는 상태를 말합니다. 고루틴이 일정한 순서로 실행되게 보장하는 것이 조정의 목표입니다.

정리하면, 동시성 애플리케이션을 구현할 때는 데이터 경쟁과 경쟁 상태를 반드시 구분해야 합니다. 데이터 경쟁은 여러 고루틴이 동일한 메모리 지점에 동시에 접근하면서 그중 하나 이상의 고루틴이 쓰기 작업을 수행할 때 발생합니다. 데이터 경쟁은 의도하지 않은 동작을 가리킵니다. 반면 데이터 경쟁이 없는 애플리케이션이라고 해서 항상 일정한 결과가 나오는 것은 아닙니다. 데이터 경쟁이 없더라도 제어할 수 없는 이벤트(고루틴 실행, 채널에 메시지를 게시하는 속도, 데이터베이스 호출 시간 등)에 따라 동작이 달라질 수 있습니다. 이런 상황을 경쟁 상태라고 합니다. 두 개념 모두 확실히 이해해야 동시성 애플리케이션을 잘 설계할 수 있습니다.

고 메모리 모델

동기화를 시키는 대표적인 기법이 있습니다. 원자 연산, 뮤텍스, 채널 등이 있습니다. 하지만 이를 사용할 떄 반드시 알아야 하는 핵심 원칙이 있습니다. 예를 들어, 버퍼를 사용하는 채널과 버퍼를 사용하지 않는 채널은 보장하는 범위가 다릅니다.

고 메모리 모델은 어느 한 고루틴에서 변수에 값을 쓰고 난 이후에 다른 고루틴에서 읽도록 보장할 수 있는 조건을 정의하는 규격입니다. 쉽게 말해 데이터 경쟁을 방지하고 결정론적인 결과를 보장하기 위해 반드시 명심해야할 조건을 제시합니다.

func main() {
    i := 0
	go func() {
		i++
	}()
	fmt.Println(i)
}
// 출력 : 0

https://go.dev/play/p/YbpbvcPHdSv

아래 코드를 설명하자면, 채널에서는 받는 동작이 끝나고 나서야 그 채널에 보내는 동작이 실행됩니다.

변수 증가 < 채널 송신 < 채널 수신 < 변수 읽기

따라서 i에 대한 데이터 경쟁을 피할 수 있습니다.

func main() {
	i := 0
	ch := make(chan struct{})
	go func() {
		<-ch
		fmt.Println(i)
	}()
	i++
	ch <- struct{}{}
	time.Sleep(time.Second)
}
// 출력 : 1

https://go.dev/play/p/-cScpJwl-B4

아래와 같이 채널을 닫는 경우에도 닫고 나서야 그 채널을 닫았음을 수신합니다. 이 경우에도 데이터 경쟁을 피할 수 있습니다.

func main() {
	i := 0
	ch := make(chan struct{})
	go func() {
		<-ch
		fmt.Println(i)
	}()
	i++
	close(ch)
	time.Sleep(time.Second)
}

https://go.dev/play/p/DZUVuvZWiXP

아래와 같이 버퍼가 있는 채널을 사용하게 되면 채널의 받는 동작없이도 보내는게 동작합니다.

고루틴 1 : go -> 보내기 -> i 읽기
고루틴 2 : 고루틴 생성 ----> i 쓰기 -> 받기

i의 읽기 쓰기가 동시에 진행될 수 있다는 점을 인지해야 합니다.

func main() {
	i := 0
	ch := make(chan struct{}, 1)
	go func() {
		i = 1
		<-ch
	}()
	ch <- struct{}{}
	fmt.Println(i)
}

https://go.dev/play/p/vra0CRuSxLe

버퍼를 없는 채널은 어떨까요? 이 때는 받는 동작이 실행한 뒤 보내는 동작이 실행되기 때문에 i 쓰기가 먼저 실행될 수 밖에 없어 데이터 경쟁이나 경쟁 상태가 발생하는 상황을 만들지 않습니다.

고루틴 1 : go ----------------> 보내기 -> i 읽기
고루틴 2 : 고루틴 생성 -> i 쓰기 -> 받기

func main() {
	i := 0
	ch := make(chan struct{})
	go func() {
		i = 1
		<-ch
	}()
	ch <- struct{}{}
	fmt.Println(i)
}

https://go.dev/play/p/vHj8Clefrk5

참고

https://product.kyobobook.co.kr/detail/S000211704725

profile
지식을 깊게 파고드는 개발자입니다.

0개의 댓글