Go와 동시성 문제 Concurrency Problem

ALRDTP·2023년 1월 31일
0

Go 동시성 프로그래밍, 캐서린 콕스 부데이 저, 이상식 역, 에이콘, 2019


동시성 문제란 : 여러 스레드가 동시에 동일한 자원을 공유하면서 발생하는 문제

Race Condition

여러개의 프로세스가 동일한 공유 자원에 접근하면서 순서에 따라 실행 결과가 달라지므로 원하는 결과가 나오는 것을 보장할 수 없는 상태
개발자가 코드가 순차적으로 실행될 것이라고 생각하기 때문에 이러한 문제가 발생한다.

Sleep을 준다?

이 경우 오류 가능성이 낮아지기 때문에 문제가 해결된 것처럼 보일 수도 있지만 사실 해결되지 않았다.

메모리 접근 동기화

[스레드]{
  lock()
  [메모리 접근]
  unlock()
}

lock()
[메모리 접근]
unlock()

데이터 레이스는 해결했지만 레이스컨디션은 해결하지 못했다. 메모리 접근만 동기화했을 뿐이지 여전히 누가 먼저 메모리에 접근할 지는 알 수 없다.

메모리 접근 동기화의 문제

데드락

func deadLock(a, b){
	a.lock()
    defer a.unlock()
    
    b.lock()
    defer b.unlock()
}

var x, y
[스레드]deadLock(x, y)
[스레드]deadLock(y, x)

첫 호출에서는 x를 먼저 잠그고 y를 잠그기 위해 기다린다. 두번째 호출에서는 y를 먼저 잠그고 x를 잠그기 위해 기다린다. 두개의 스레드는 각각 x, y를 잠근 채 다른 자원을 잠그기 위해 무한히 기다린다.

데드락 발생 조건
1. 상호배제 Mutual Exclusion : 프로세스들이 배타적 통제권을 갖는다.
2. 대기조건 Wait For Condition : 프로세스들이 자원을 가지고 있는 채로 다른 자원을 기다린다.
3. 비선점 No Preemption : 프로세스가 자원의 사용이 끝나기 전까지는 사용 해제 할 수 없다, 프로세스에 의해서만 release된다.
4. 순환대기 Circular Wait : 동시에 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다.

라이브락

데드락이 자원을 획득하지 못해 무한정 대기하는 상황이었다면, 라이브락은 동작을 하고 있지만 실질적으로는 자원을 획득하지 못하고 무한정 같은 동작만 반복하는 상황을 말한다. 라이브락은 기아상태의 부분집합이다.

기아상태 starvation

동시 프로세스가 작업하는데 필요한 모든 리소스를 얻을 수 없는 모든 상황을 말한다.
greedy 프로세스가 임계 영역을 벗어난 후에도 불필요하게 공유잠금을 보유함으로써 기아상태를 일으킨다. 이는 CPU, 메모리, 파일 핸들, 데이터베이스 연결 등 코드 외부의 공유 프로세스에도 적용될 수 있는 문제라는 걸 염두해두어야 한다.

코드는 사람이 짠다

//위 책의 예제

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

위의 예시에는

  • 누가 동시성을 책임지는가? //CalculatePi 함수 내부적으로?
  • 문제 공간은 동시성 기본 요소에 어떻게 매핑되는가? //뭔소리임?
  • 동기화는 누가 담당하는가? //구조체 내부

에 대한 설명을 포함한다.
해당 내용들이 포함되도록 주석을 최대한 자세히 남기도록 하자.

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

위의 함수 시그니처를 아래와 같이 변경해 어떤 일이 일어나는지 외부로 알려주는 신호를 보내도록 할 수 있다.
이때 사용하는 것이 go의 Channel이다. (해당 함수가 하나 이상의 고루팅을 가지게 될 것이므로, 별도로 고루틴을 생성해서는 안된다.)

Go와 동시성 프로그래밍

개선된 가비지 컬렉터

garbage collector는 실행될 때, stop-the-world가 발생하는데, 이는 garbage collector를 제외한 모든 스레드를 중단시키는 것이다. 따라서 실시간 성능이 필요한 경우 garbage collector가 있는 언어가 정말로 유용하지에 대해서는 논쟁의 여지가 있으며, 이 경우 java의 garbage collector tuning은 거의 필수적인 것처럼 보인다.
Go의 경우 garbage collector의 성능이 매우 뛰어나서 G1.8이후로는 garbage collector로 인한 프로그램의 일시정지는 일반적으로 10~100 마이크로초 사이이다.

goroutine

go에서는 다른 많은 언어의 스레드를 사용할 때와 달리 이런 다중화 기능을 자동적으로 처리한다. 즉, 일부의 언어에서는 스레드 풀을 생성하고 들어오는 연결을 스레드로 매핑하고, CPU 시간을 할당 받을 수 있도록 하는 등의 스레드 관리 로직이 구현되어야 하지만 go는 함수를 작성한 후 이를 호출할 때, go 키워드를 붙여주면 런타임이 스레드 관련 로직을 자동으로 처리한다. 또한 goroutine은 가볍다.

go channel

goroutine간의 데이터를 주고받는 통로이다. <- 연산자를 사용해 값을 주고받는데, 상대편이 준비될 때까지 채널에서 대기하게 된다. 이걸 사용해 concurrent-safe한 방식으로 통신할 수 있다.

CSP중심 설계, 그러나 전통적인 동시성 코드 작성방식 또한 지원

https://github.com/golang/go/wiki/MutexOrChannel
"Use whichever is most expressive and/or most simple."
이에 대해서 책에서는 결정 트리를 제시한다.

if 성능상의 임계 영역인가?{						//1
	기본요소사용
}else if 데이터의 소유권을 이전하려고 하는가?{		//2
	채널사용
}else if 구조체의 내부 상태를 보호하려고 하는가?{   //3
	기본요소사용
}else if 여러 부분의 논리를 조정해야 하는가?{		//4
	채널사용
}else{
	기본요소사용
}

1. 성능상의 임계 영역인가?
해당 역역이 병목지점이라면 뮤텍스를 사용하는 게 도움이 될 수 있다; 채널이 동작할 때 메모리 접근 동기화를 사용하기 때문에 채널이 더 느릴수도 있기 때문이다.

2. 데이터의 소유권을 이전하려고 하는가?
결과를 산출하는 동작이 결과를 다른 코드와 공유하려는 경우, 해당 데이터의 소유권을 이전한다. 채널은 메모리 소유권의 개념을 전달한다.

3. 구조체의 내부 상태를 보호하려고 하는가?

type Counter struct{
	mu sync.Mutex
    value int
}
func (c *Counter) Increment(){
	c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

구조체의 내부 상태를 보호하려고 하는 경우는 채널을 사용하지 말고 메모리 접근 동기화 기본 요소를 사용할 수 있는 훌륭한 후보이다. 이 경우에 잠금을 행하는 내부 영역을 호출자에게 공개하지 않을 수 있다. "잠금을 작은 어휘 범위(lexical scope)로 제한해보자."

4. 여러 부분의 논리를 조정해야 하는가?
메모리 접근 동기화 기본 요소보다 더 쉽게 구성가능하다.

0개의 댓글