본문 바로가기
Golang/Tucker

[묘공단] 5주차 (2)

by 윤원용 2023. 10. 29.

이 글은 골든래빗 《Tucker의 Go 언어 프로그래밍》의 24장 써머리입니다.

24장은 Golang의 장점인 고루틴인과 동시성 프로그래밍이다. 책에서 설명하는 고루틴도 좋지만 이번주는 본인이 리뷰하기 때문에 본인의 방식대로 설명해 보겠다.

 

더보기
  • 24 고루틴과 동시성 프로그래밍
    • 24.1 스레드란?
    • 24.2 고루틴 사용
    • 24.3 고루틴의 동작 방법
    • 24.4 동시성 프로그래밍 주의점
    • 24.5 뮤텍스를 이용한 동시성 문제 해결
    • 24.6 뮤텍스와 데드락
    • 24.7 또 다른 자원 관리 기법

 

고루틴을 이해하기 앞서 프로세스를 이해하면 좋을 것 같다. 프로세스는 보조기억장치에 저장된 프로그램 즉 개발자가 코딩한 파일을 주기억장치(memory) 영역에 올린 후 CPU를 점유를 기다리는 것을 프로세스라 한다.

이제 스레드를 이해하기 위해선 프로세스의 구조와 CPU의 스케쥴링에 대해 이해해야 한다.

프로세스 구조를 보면 크게 메모리 구조와 프로세스 컨트롤 블록으로 나눠지는데 메모리 구조는 보조기억장치에 있던 프로그램 코드가 기계어로 저장되고 프로그램에서 사용하는 정적 데이터와 스택 영역, 힙 영역으로 구성된다.

PCB(프로세스 컨트롤 블록)은 프로세스가 CUP를 점유해서 실행될 때 필요한 정보들이다. 대표적으로 PID 같은 경우는 프로세스 아이디, PC는 프로그램 카운터, State는 프로세스 상태를 뜻한다.  프로세스의 State는 CPU의 스케쥴러에 의해 결정되는데 생성, 준비, 실행, 대기, 종료가 있다. 간단하게 살펴보면

cpu scheduling

위 이미지를 기준으로 프로세스 상태가 생성인 경우에는 Start 단계고 바로 Read 단계로 넘어가면서 프로세스의 상태가 준비로 변경된다. CPU의 정책에 따라 (일괄처리, 시분할처리, 분산처리, 실시간처리 등...) Running 단계로 디스패치되면 이때 프로세스가 실행되는 것이다. 여기서 눈여겨보면 좋은 내용은 Waiting 단계에서 발생하는 임계영역이지만 뒤에 이야기해야 하니 pass...

이런 과정 속에서 CPU의 처리 단위가 프로세스였기 때문에 프로세스가 많아지면 많아질수록 많은 콘텍스트 스위칭(context switching)이 발생해 성능저하가 발생했다.

참고로 콘텍스트 스위칭이란 하나의 CPU는 동시간에 하나의 프로세스만 실행시킬 수 있기 때문에 프로세스를 교체하는 과정에 발생하는 현상을 뜻한다.

콘텍스트 스위칭 문제뿐만 아니라 협력 프로세스끼리의 통신 비용과 공유자원 사용 등 여러 문제들이 있어 스레드가 탄생하게 됐다.

위 이미지는 프로세스 안에 생성된 스레드이며, CPU는 프로세스에서 스레드로 작업 단위가 변경되면서 동시성(concurrency) 효과를 얻을 수 있었다. 참고로 프로세스만큼은 아니지만 스레드 역시 콘텍스트 스위칭 비용이 발생한다.

이제 간단한 선행학습은 끝났고 본론을 시작하겠다.

 

24.2 고루틴 사용

앞서 고루틴에 대한 소개를 하자면 한 core(CPU)에 한 OS 스레드를 사용하는 경량스레드(lightweight thread)이며, 콘텍스트 스위칭 비용이 없다고 책에 나와있다. Go도 고루틴만의 스케줄러가 있는데 m:n 스케줄링이란 기법을 사용하고 동시성 모델은 fork-join concurrency model을 사용하여 sync.Wait()과 같은 기능이 가능하다고 한다.  고루틴의 기본 스택 크기는 2kb로 만들어진 후 동적으로 늘어나거나 줄어드는 반면 기존 스레드의 기본 스택 크기는 8kb라고 한다. 참조

이제 자신들의 컴퓨터에서 몇 개의 고루틴을 생성할 수 있는지 확인해 보자.

runtime package의 GOMAXPROCS 함수 호출 시 0을 인자로 넣을 경우 사용할 수 있는 OS 스레드의 수가 나온다. 1보다 크거나 같은 값을 인자로 넣으면 변경하는 것이고 무조건 큰 값을 할당한다고 성능이 좋아지는 것은 아니니 주의가 필요하다.

 

main 함수도 고루틴이고 이것을 메인루틴이라 말한다. 메인루틴이 끝나면 생성된 모든 고루틴은 종료되니 때문에 생성한 고루틴들이 끝날 때까지 메인루틴은 기다려줘야 한다.

고루틴을 생성할 땐 go 예약어를 사용하며 함수를 호출하는 형태로 사용된다. 리터럴 함수여도 괜찮고 일반 함수여도 괜찮다는 말과 같고 파라미터를 줄 때도 똑같다. 위 이미지의 코드를 빌드 후 실행하면 결과가 어떻게 나올까? hello world!!!가 나올까? hello가 나올까? 답은 hello만 나온다. 이유는 메인루틴이 생성한 고루틴이 끝나기 전에 끝났기 때문인데 이러한  상황을 논 블로킹(non blocking)이라 한다. hello world!!!가 출력되도록 코드를 수정해 보면

무한 반복문을 메인루틴에 추가해서 생성한 루틴이 끝나든 말든 계속 살아있게 할 수도 있지만 좋은 방법은 아니다. 다음 방법으로 수정해 보면

endFlag라는 bool type을 사용해 생성한 루틴이 끝나면 메인루틴도 종료될 수 있도록 수정한 코드다. 하나의 서브루틴(생성한 루틴)이라면 이 방법도 괜찮을 수 있지만 많은 서브루틴을 사용할 땐 좋지 않은 방법이다. 그럼 다음 방법으로 여러 서브루틴을 생성하는 예제로 코드를 수정해 보면

sync package의 WaitGroup 사용하여 여러 서브루틴이 끝날 때까지 기다리는 코드다. 하나씩 설명하면 글로벌변수로 wg를 생성한다. sync.WaitGroup은 포인터가 아니기 때문에 nil이 아니다. wg.Add 함수는 인자로 정수를 넣는데 루틴의 개수라고 생각하면 된다. wg.Done은 wg.Add의 총합 즉 코드에서 1씩 넣었으니 총 14가 되고 wg.Done이 14번 호출돼야 wg.Wait이 끝나 메인루틴이 정상 종료되는 것이다.  이제 빌드 후 실행하면 0부터 13까지 순서대로 출력될까?

결과는 위 이미지처럼 출력되는데 이유는 서브루틴들이 실행되는 시점의 힙메모리영역의 index와 at은 마지막인 13와 !로 저장돼 있고 서브루틴들은 생성 시점과 다른 마지막으로 저장된 시점의 index와 at을 사용하기 때문에 힙메모리가 아닌 스택메모리에 저장하도록 코드를 수정해 보면

위 이미지처럼 수정하면 모든 루틴은 다른 값을 출력하게 된다. 출력 내용을 보면

출력 내용을 보면 서브루틴을 생성한 순서대로 출력되는 것이 아니라 랜덤 하게 출력되는데 이유는 하나의 OS 스레드가 아닌 여러 개의 OS 스레드에 서브루틴들을 디스패치하기 때문에 이러한 결과가 발생하는데 이런 상황을 병렬성이라 한다. 병렬성은 여러 코어(CPU)에서 동시에 여러 스레드를 실행한다는 뜻이다. 

 

24.4 동시성 프로그래밍 주의점

고루틴들이 공유 자원에 접근할 때 문제가 발생하는데 예제로 이해해 보자.

위 코드를 보면 account라는 공유 자원을 여러 서브루틴에서 접근하고 있다. 처음에 잔고를 1000을 더한 후 잠시 쉬었다 잔고에서 1000을 빼는 간단한 로직인데 만작 잔고가 0보다 작을 경우 panic을 발생시킨다. 단순히 생각하면 무조건 1000부터 잔고에 더한 후 1000을 빼니 panic이 발생하지 않을 것 같지만 테스트해 보면 무조건 발생한다. 이런 상태를 데이터 경쟁 상태(data race condtion)라 하고 account.Balance에 접근하고 있는 코드 영역을 임계영역(critical section)이라 한다. 이제 결과를 빨리 보기 위해

go run -race main.go

위와 같은 명령으로 실행하면 바로알 수 있다.

 

24.5 뮤텍스를 이용한 동시성 문제 해결

서브루틴이 공유 자원에 접근하는 경우 다른 서브루틴들이 접근하지 못하도록 Lock을 걸고 해당 서브루틴의 작업이 끝나면 Unlock 하는 방식으로 영역을 나눠 해결하는 방법이 있다. 

뮤텍스는 mutual exclusion의 약자이며, 상호 배제라고 한다. 위 코드에 빨간 줄로 밑줄 그은 부분들인데 공유자원에 접근할 때 Lock을 걸고 작업이 끝나면 Unlock을 하기 때문에 다른 서브루틴들은 접근할 수 없어 데이터 경쟁 상태가 발생하지 않는다. 반면 뮤텍스로 해결할 경우 Lock을 걸기 때문에 멀티태스킹의 이점이 없어지고 잘못 사용하면 Deadlock이라는 끔찍한 문제도 발생할 수 있다.

 

24.6 뮤텍스와 데드락

책에는 예제로 설명하지만 본인은 말로 설명하겠다. 뮤텍스를 통해 서브루틴들 중 한 서브루틴은 공유 자원을 선점할 수 있게 됐다. 공유 자원을 선점한 서브루틴이 추가로 다른 공유 자원을 선점해야 하는 상황이 되면 데드락(deadlock)이 발생할 수 있는 상황이 생긴다. 무조건 발생하는 것이 아닌 상황이 만들어지는 거고 A라는 서브루틴과 B라는 서브루틴이 각자 공유 자원을 선점했을 때 A와 B 서브루틴이 서로 선점한 공유 자원을 요청할 때 데드락이 발생한다.

24.7 또 다른 자원 관리 기법

고루틴을 사용할 때 공유 자원을 접근할 경우 생겼던 문제점과 문제를 해결하기 위해 사용했던 뮤텍스는 고루틴이 공유자원을 접근할 때 생기는 문제이기 때문에 두 가지 방법으로 이런 복잡한 문제를 해결할 수 있다.

1. 영역을 나누는 방법

고루틴을 생성할 때 각서브루틴마다 처리할 작업을 할당하는 방법이다. 즉 서브루틴끼리 데이터를 공유하지 않고 각자 맡은 작업만 할 경우 뮤텍스를 사용하지 않아도 멀티코어 이점을 얻을 수 있다.

위 예제 코드는 간단한 홀짝 게임이고 보유 금액이 0이거나 작으면 allin이고 5000보다 크면 win money를 출력해 주는 코드이다. uuid로 고유한 id를 만든 후 id의 개수에 맞게 고루틴을 생성해 서로 자기 데이터만 갖고 로직을 처리하기 때문에 데드락을 사용할 필요도 없고 서브루틴끼리 같은 자원을 접근할 이유도 없다. 그러면서 멀티코어 이점을 얻고 있다.

 

2. 역할을 나누는 방법은 다음 챕터인 25의 내용이 들어가기 때문에 ㅃ2

'Golang > Tucker' 카테고리의 다른 글

[묘공단] 6주차 (1)  (0) 2023.11.05
[묘공단] 5주차 (3)  (0) 2023.10.29
[묘공단] 5주차 (1)  (1) 2023.10.29
[묘공단] 4주차  (2) 2023.10.22
[묘공단] 3주차 (2)  (1) 2023.10.16