Skip to content

Latest commit

 

History

History
404 lines (286 loc) · 22.7 KB

114-128.md

File metadata and controls

404 lines (286 loc) · 22.7 KB

동시성

Mechanics [재방문: 다른 번역자분들과 의견 맞추기]

고루틴(Goroutine)

Go 스케줄러의 내부

Go 프로그램이 시작할 때, 사용 가능한 코어의 갯수를 확인한다. 그리고 논리 프로세서를 생성한다.

OS(Operating System) 스케줄러는 선점 스케줄러로 간주되고 커널에서 실행 된다. OS 스케줄러는 runnable state에 있는 스레드를 실행(run)할 수 있도록 한다. 이 알고리즘은 매우 복잡하다(대기, 스레드 바운싱, 스레드 메모리 유지, 캐싱 등). 이 모든 작업을 OS가 수행하며, 멀티코어 프로세서(multicore processor)에서도 잘 동작한다. Go는 이를 잘 활용하기 위해 OS의 최상단에 위치한다.

여전히 OS는 OS 스레드에 대한 책임이 있고, 이를 효율적으로 스케줄링한다. 2개의 코어가 있는 머신에서 수천 개의 스레드를 스케줄링하는 것은 힘든 일이다. 어떤 작업을 하는지 모르는 일부 OS 스레드를 문맥교환(Context switch)하는 것은 비용이 큰 작업이다.

또한 가능한 모든 상태를 저장해야 그대로 스레드를 복원할 수 있다. 스레드 수가 적을 경우, 다시 스케줄링 되기 전까지 더 많은 실행 시간을 가질 수 있다. 스레드 수가 많을 경우, 각 스레드는 상대적으로 적은 실행 시간을 갖는다.

"Less is more"는 동시성 소프트웨어를 작성할 때 매우 중요한 컨셉이다. 선점형 스케줄러에 영향을 끼치기 위해 Go의 스케줄러의 논리 프로세서는 일반적으로 어플리케이션이 동작하고 있는 유저 모드(user mode)에서 실행한다. 그래서 Go의 스케줄러는 비선점 스케줄러라고 해야 한다. 유저 랜드(user land)에서는 여전히 선점형 스케줄러로 동작하는 것처럼 보인다. 여기서 훌륭한 것은 작업을 조정하는 런타임이다. "Less is more" 컨셉이 가져온 현재의 모습과 앞으로 해야 할 더 많은 작업을 적은 자원으로 실행하는 모습을 확인할 수 있을 것이다. 적은 쓰레드 수로 얼마나 많은 작업을 하는가가 관점이다.

프로세서는 하이퍼스레딩, 코어 마다 할당된 다수의 스레드, 클럭 주기 등의 이유로 복잡하기 때문에 쉽게 생각해보자. 코어에 대해 OS 스레드는 한번에 하나만 실행될 수 있다. 만약 하나의 코어만 있다면, 한번에 하나의 스레드만을 실행 할 수 있다. 실행 가능한 상태의 스레드가 소유한 코어보다 더 많으면 항상 부하, 실행 지연이 발생하며 우리가 원하는 것보다 더 많은 작업을 수행 하게 된다. 모든 스레드가 반드시 동시에 활성화 되어야 하는 것은 아니기 때문에 작업에 균형을 잡는 것이 필요하다. 이러한 모든 것은 우리가 작성하고 있는 소프트웨어의 작업량을 알아내고, 이해하는 것으로 귀결된다.

Go 프로그램이 시작되고 사용 가능한 코어수는 1개라고 가정하자. 그러면 해당 코어에 논리 프로세스 P가 생성된다.

다시 말하지만, OS는 OS 스레드와 관련된 일을 스케줄링 한다. 프로세서 P는, OS가 스케쥴링 하고 코드가 실행될 수 있도록 하는 OS 스레드 m을 할당받는다.

리눅스 스케줄러에는 실행 대기열이 존재한다. 스레드는 특정 코어 또는 코어군의 실행 대기열에 배치되며 스레드가 실행될 때 지속적으로 바운드된다[재방문: family of cores, bound]. Go는 이와 동일하다. Go는 Global Run Queue(GRQ)의 실행 대기열이 존재하며, 모든 P에는 Local Run Queue(LRQ)가 존재한다.

고루틴(Goroutine) [재방문: 번역 확인]

고루틴이란, 실행의 경로, 스레드의 실행경로, 스케줄링 되어야 하는 실행의 경로이다. Go에서는 모든 함수와 메소드는 고루틴으로 생성 될 수 있으며 특정 코어, 특정 OS 스레드에 의해 독립적 실행이 되도록 스케줄링이 가능하다.

Go 프로그램을 시작할 때 런타임은 고루틴을 생성한다. 그리고 특정 프로세서P의 LRQ에 넣는다. 우리는 1개의 프로세서 P가 있다고 가정하고, 생성된 고루틴은 프로세서 P에 포함될 것이다.

고루틴은 스레드와 같이 sleeping, executing 그리고 하드웨어에 의해 실행될때까지 대기하는 실행 가능한 상태인 runnable 중 하나가 될 것이다. 런타임에 고루틴이 생성되면, 프로세서 P에 위치하며 해당 스레드 위에서 다중화(multiplex) 된다. 스레드를 스케줄링하고 코어에 배치하고 실행 하는 것은 OS의 역할이며, 따라서 Go 스케줄러는 고루틴의 실행 경로와 관련된 모든 코드를 가져 와서 스레드에 배치하고 OS에 대상 스레드가 runnable 상태이며 실행할 수 있음을 요청한다. 요청이 가능하다면, 특정 코어에서 이를 실행한다.

메인 고루틴이 실행중일 때, 더 많은 실행 경로를 생성, 더 많은 고루틴을 생성하고자 할 수 있다.

만약 그렇다면, 해당 고루틴을 GRQ에서 찾을 수 있을 것이다. 이들은 runnable 상태지만, 아직 프로세서 P에 할당되지 않은 상태이다. 이후에 LRQ에 할당되고, 실행 요청을 한다.

이 대기열은 FIFO(First-In-First-Out)을 꼭 따르진 않는다. 모든것은 OS 스케줄러처럼 결정적이지 않다. 모든 조건이 동일해도 스케줄러가 무엇을 수행할지 예측할 수 없다. 우리가 이러한 고루틴의 실행에 대한 조정하는 방법을 배워, 오케스트레이션 할 수 있을 때 까지는 예측할 수 없다.

아래의 도표는 이에 대한 예제를 표현한 맨탈모델이다.

117-1

프로세서 Pm을 위한 Gm이 실행하고, 2개의 G1G2 고루틴을 생성한다. 이것은 협조적 스케줄러(cooperating scheduler)이기에, 고루틴은 비선점적으로 스케줄링되고 운영체제 스레드인 m은 문맥교환(context switch)이 생기는 것을 의미한다.

스케줄러가 스케줄링을 하게 되는 4가지 상황이 있다.[재방문]

  • go 키워드를 통해 고루틴들을 생성할 때. 이는 여러개의 프로세서(P)를 가지고 있을 때, 스케줄러가 균형을 다시 맞출 수 있다.
  • 시스템 콜. 시스템 콜은 이미 항상 발생 하는 경향이 있다.
  • 뮤텍스(Mutex)를 사용하는 채널. (추후 학습)
  • 가비지 컬렉션.

다시 예제로 돌아와, 스케줄러는 Gm이 실행되기까지 충분한 시간이 남았을때, Gm을 실행 대기열(run queue)에 넣고 G1이 해당 m에서 실행되도록 혀용한다(문맥교환).

117-2

고루틴 G1에서 파일을 연다고 해보자. 파일을 여는 작업에 걸리는 시간이 얼마나 소요될지는 알 수 없다. 만약 파일을 열 때 이 고루틴(G1)이 OS 스레드를 블록(block) 시킨다면, 더 이상의 다른 작업을 완료할 수가 없다. 이 예제는 하나의 프로세서(P)와 싱글 스레드(m)로 동작하는 어플리케이션이다. 모든 고루틴은 프로세서(P)에 할당된 스레드(m)에서만 동작한다. 만약 고루틴이 프로세서(P)에 할당된 스레드(m)을 오랫동안 블록(block)시킨다면 어떻게 될까? 작업이 완료될 때까지 아무것도 할 수 없다. 이런일이 발생하지 않도록 하기 위해 스케줄러는 mG1을 분리한다. 새로운 mm2를 가져오고, 실행 대기열(run queue)에서 다음에 실행할 G, 즉 G2를 결정한다.

118-1

이제 싱글 스레드로 작성된 프로그램에 2개의 스레드가 있다. 우리의 관점에서 여전히 싱글 스레드 인데, 모든 고루틴과 관련된 코드는 프로세서(P)와 OS 스레드(m)에 대해서만 실행할 수 있기 때문이다. 하지만 어떤 m이 처리되고 있는지 알 수는 없다. 스레드(M)은 교체될 수 있고, 여전히 싱글 스레드로 이다.

G1이 파일 열기 작업을 끝냈을 때, 스케줄러는 G1을 실행 대기열(run queue)에 넣고나서 특정 스레드, 예제에서는 m2를 다시 실행할 수 있다. m은 다시 사용하기 위해 남겨지고, 여전히 2개의 스레드를 유지하고 있다. 전체 과정은 다시 일어날 수 있다.

119-1

이렇듯 하나의 스레드 상에서 더 많은 작업을 수행함으로써, 해당 스레드로부터 최대의 가용성을 끌어 낼 수 있는 훌륭한 방법이다. 따라서 추가적인 스레드없이도 충분한 작업을 할 수 있다.

네트워크 폴러가 있고, 모든 로우 레벨(low level)의 비동기 네트워킹 작업을 수행한다. 고루틴은 이러한 작업을 수행할 경우, 네트워크 풀러로 이동한 다음 다시 대기열 뒤로 가져온다. 명심할 것은 작성된 코드는 프로세서(P)에 대응한 스레드(m)에서 실행된다는 것이다. 얼마나 많은 쓰레드를 실행하는 지는 프로세서(P)를 얼마나 가지고 있는지에 달려있다.

동시성이란 이런 많은 작업들을 한번에 관리할 수 있는 것을 의미하고, 이것이 스케줄러의 역할이다. 하나의 OS 스레드(m)에 의해 3개의 고루틴은 오직 한번에 하나의 고루틴만 실행될 수 있기 때문에, 하나의 프로세서(P), 하나의 스레드(m) 그리고 3개의 고루틴 실행을 관리한다. 만약 한번에 많은 일들을 동시에 처리하고 싶다면, 다시 말해서 병렬(parallel)처리하고 싶다면 또 다른 스레드(m3)를 처리할 수 있는 프로세서(P)가 하나 더 필요하다.

119-2

멀티 프로세서는 OS에 의해 스케줄링 된다. 이제 2개의 고루틴을 병렬(parallel)로 처리할 수 있다.

2개의 스레드로 실행되는 다중 스레드 소프트웨어를 가정해보자. 이제 2개의 고루틴을 병렬(parallel)로 처리할 수 있다. 프로그램은 2개의 스레드를 실행하고, 두 스레드가 동일한 코어에 있는 경우에도 서로에게 메세지를 전달하려고 한다. OS의 관점에서는 어떤 일이 일어나는지 알아보자.

먼저 첫번째 스레드가 스케줄링 되고 특정 코어에 할당될 때까지 기다려야 한다(문맥교환 발생). 이때, 아직 스레드는 대기 상태이므로 어떤 것도 실행 상태가 아니다. 첫번째 스레드에서 메세지를 보내고, 이에 대한 응답을 받기를 기다린다. 응답을 받기 위해서, 해당 코어에 다른 스레드를 배치할 수 있고 이를 통해서 또 다른 문맥교환이 발생한다. OS가 두번째 스레드를 스케줄링하기를 기다리며 또 다른 문맥교환이 발생한다. 대기 상태의 스레드를 깨우고 실행시켜 메세지를 처리한다. 메세지 전달 과정을 통해, 스레드는 실행 가능 상태(excutable state)에서 실행 대기 상태(runnable state)로 전환되며 대기 상태(asleep state) 순으로 바뀐다. 이러한 문맥교환들은 많은 비용(cost)이 발생한다

단일 코어에서 고루틴을 사용하면 어떤지 살펴보자. G1G2에게 메세지를 보내려 하고 문맥 교환이 일어난다. 하지만 이 문맥(context)은 사용자의 공간 전환이다. 스레드에서 실행 중인 G1G2로 전환할 수 있다. OS의 관점에서 이 스레드는 sleep 상태가 되지 않는다. 이 스레드는 항상 실행중이며, 문맥교환을 할 필요가 없다. Go 스케줄러는 고루틴을 계속해서 문맥교환 시켜준다.

프로세서(P)에 할당된 특정 스레드(m)가 처리할 고루틴(G)이 없다면, 런타임 스케줄러는 해당 스레드 코어 속에서 일정 시간 유효(hot status)할 수 있도록 스핀(spin) 상태로 만들어준다. 왜냐하면, 스레드가 더 이상 유효하지 않은 상태(cold status)라면 OS는 해당 스레드를 코어에서 빼내고 다른 스레드로 교체하기 때문이다. 따라서 비어 있는 스레드에 할당되어 처리할 고루틴(G)이 있을지 확인하기 위해, 잠시 스핀(spin) 상태가 되는 것이다.

이것이 스케줄러가 작동하는 방식이다. 프로세서(P)와 스레드(m)이 있고, OS는 스케줄링 작업을 할 것이다. 코어의 갯수보다 더 많은 것을 필요로 하지 않는데, 코어의 갯수보다 더 많은 OS 스레드가 필요하지 않다. 코어의 갯수보다 더 많은 스레드가 있다는 것은 OS에 적재하는 방법 뿐인데, Go의 스케줄러는 고루틴에 대해 최소한으로 필요한 스레드 수를 유지하고 작업을 계속해서 수행할 수 있도록 한다. Go의 스케줄러는 비선점적인 스케줄링으로 호출되더라도 선점된 것처럼 보인다.

하지만 개발을 쉽게 하기 위해서 스케줄링의 작동에 대해 잊고, 모든 고루틴(G)에 대해 runnable state에 있는 고루틴들은 모두 동시에 실행이 가능하다고 이해하자.

Language Mechanics [재방문: 다른 번역자분들과 의견 맞추기, 의견: 언어 구조학]

소프트웨어가 깔끔하게 시작, 종료되도록 코드를 작성하는 것은 매우 중요하다.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

'init' 함수는 런타임 패키지에서 GOMAXPROCS을 호출한다. 환경 변수이기 때문에 대문자로 표기된다.

Go 1.5 이전에서는 코어 수의 관계 없이 하나의 프로세서(P)만 제공 되었다. 가비지 콜렉터와 스케줄러의 개선으로 모든것이 개선되었다.

스케줄러에게 하나의 논리 프로세서만 할당할 것을 명시한다.

func init() {
    runtime.GOMAXPROCS(1)
}

func main() {

wg는 동시성을 관리하는데 사용된다. wg는 제로값으로 설정된다. 또한 제로값 상태에서 사용할 수 있는 Go의 매우 특별한 타입이다. 그리고 비동기 계산 세마포어(Asynchronous Counting Semaphore)로 불린다.

세개의 Add, Done, Wait 메소드를 갖는다. n개의 고루틴은 이 메소드를 동시에 호출 할 수 있고, 모두 직렬화(serialized)되어 있다.

  • Add: 얼마나 많은 고루틴이 있는지 계산한다.
  • Done: 일부 고루틴이 종료될 예정이므로 값을 감소시킨다.
  • Wait: 해당 카운트가 0이 될 때까지 프로그램을 유지한다.
    var wg sync.WaitGroup

2개의 고루틴을 생성하자. 반대로 Add(1)을 호출하고, 1씩 증가하기 위해 반복한다. 만약 얼마나 많은 고루틴이 생성될지 모른다면, 그것은 코드 스멜(smell)이다.

    wg.Add(2)

    fmt.Println("Start Goroutines")

익명함수를 사용해 uppercase 함수에서 고루틴을 생성한다. 익명함수의 끝에는 ()을 사용해 호출한다. main 함수 내부에 익명함수가 있고, 그 앞에 go 키워드에 주목하자. 지금은 실행하지 않고, Go 스케줄러는 해당 함수를 G로 예약한다. 이를 G1이라고 하자. 그리고 P에 대해 일부 LRQ를 로드한다. 이것이 첫 G이다. 기억할것은, 모든 G에 대해서 runnable state라면, 동시에 실행할 수 있다. 싱글 프로세서(P)일지라도, 싱글 스레드일지라도 전혀 상관없다. 2개의 고루틴을 동시에 실행(main 그리고 G1)한다.

    go func() {
        lowercase()
        wg.Done()
    }()

lowercase 이후에 고루틴을 하나 더 생성한다. 따라서, 이제는 3개의 고루틴이 동시에 실행된다.

go func() {
    uppercase()
    wg.Done()
}()

고루틴이 끝날때까지 기다려보자. 메인이 종료되는 것을 대기(holding)시키는데, 메인이 종료될 때, 우리의 프로그램은 종료되고, 다른 고루틴을 신경쓰지 않기 때문이다.

여기서 중요한 것은 언제, 어떻게 고루틴이 종료되는지 모른다면 고루틴을 만들 수도 없다는 것이다. Wait는 두 개의 고루틴이 Done이 될때까지 대기(hold)하도록 한다. 2에서 0이 될 때까지 카운트하고, 0에 도달했을 때, 스케줄러는 메인 고루틴을 마저 실행하고, 종료 될 수 있도록 한다.

    fmt.Println("Waiting To Finish") wg.Wait()
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

lowercase 함수는 알파벳 소문자를 세번 반복 출력한다.

func lowercase() {
    for count := 0; count < 3; count++ {
        for r := 'a'; r <= 'z'; r++ {
            fmt.Printf("%c ", r)
        }
    }
}

uppercase 함수는 알파벳 대문자를 세번 반복 출력한다.

func uppercase() {
    for count := 0; count < 3; count++ {
        for r := 'A'; r <= 'Z'; r++ {
            fmt.Printf("%c ", r)
        }
    }
}
Start Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J
K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T
U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d
e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n
o p q r s t u v w x y z
Terminating Program

순서

lowercase 함수를 호출하고, uppercase 함수를 호출했지만, Go 스케줄러는 lowercase 함수를 먼저 호출했다. 싱글 스레드에서 동작하기에 순간에 1개의 고루틴만 실행할 수 있는 점을 기억하자. 동시성을 지키며 실행되는지 알 수 없는데, uppercase 함수가 lowercase 함수보다 먼저 실행되는지 알 수 없다. 시작, 종료에 문제가 없다.

대기를 위해 Wait하지 않으면 어떻게 될까?

uppercase 함수와 lowercase 함수의 결과를 볼 수 없다. Go 스케줄러가 프로그램 종료를 막고 새로운 고루틴을 만들어 작업을 할당하기 전에 프로그램이 종료되는 일종의 경쟁으로 보인다. 기다리지 않기 때문에 고루틴은 실행할 기회가 전혀 없다.

Done을 호출하지 않으면 어떻게 될까?

교착상태(Deadlock)가 발생한다. Go의 특별한 부분이며, 런타임에서 고루틴이 존재하지만 더 이상 진행할 수 없을 때 패닉(panic)상태가 된다.

고루틴 시분할(time slicing)

Go의 스케줄러는 선점 스케줄러(preemptive)가 아닌 협력 스케줄러(cooperating scheduler)에도 선점된 것처럼 생각되는 이유는 런타임 스케줄러가 프로그래머에게 인식되기 전에 모든 처리를 하기 때문이다.

아래의 코드는 문맥교환을 보여주고, 언제 문맥교환이 발생하는지 예상할 수 있도록 보여준다. 위 코드와 같은 패턴이지만 printPrime 함수가 새로 추가된다.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

하나의 논리 프로세서를 스케줄러에게 할당한다.

func init() {
    runtime.GOMAXPROCS(1)
}

wg는 동시성을 관리하기 위해 사용한다.

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Create Goroutines")

첫번째 고루틴을 생성하고, 생명주기를 관리한다.

    go func() {
        printPrime("A")
        wg.Done()
    }()

두번째 고루틴을 생성하고, 생명주기를 관리한다.

    go func() {
        printPrime("B")
        wg.Done()
    }()

고루틴이 종료될 때까지 대기한다.

    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("Terminating Program")
}

printPrime 함수는 5000보다 작은 소수를 출력한다. 특별한 함수는 아니지만, 완료하기 위해 약간의 시간이 필요하다. 프로그램을 실행하면 특정 소수에서 문맥교환이 일어나는 것을 볼 수 있다. 하지만 문맥교환이 언제 일어날 지는 예측할 수 없기에 Go의 스케줄러가 협력 스케줄러임에도 불구하고 선점 스케줄러처럼 보인다고 말하는 이유이다.

func printPrime(prefix string) {
next:
    for outer := 2; outer < 5000; outer++ {
        for inner := 2; inner < outer; inner++ {
            if outer%inner == 0 {
                continue next
            }
        }

        fmt.Printf("%s:%d\n", prefix, outer)
    }

    fmt.Println("Completed", prefix)
}
Create Goroutines
Waiting To Finish
B:2
B:3
B:5
B:7
B:11
B:13
B:17
B:19
...
B:4999
Completed B
A:2
A:3
A:5
A:7
A:11
A:13
A:17
A:19
...
A:4999
Completed A
Terminating Program

고루틴과 병렬

이 프로그램은 고루틴이 병렬처리 되는 것을 보여준다. 2개의 프로세서(P), 2개의 스레드(m) 그리고 2개의 고루틴이 각각의 스레드(m)에서 병렬 처리 된다. 이전 프로그램과 비슷하지만 lowercase 함수와 uppercase 함수를 없애고, 익명 함수로 처리한다.

package main
import (
    "fmt"
    "runtime"
    "sync"
)

func init() {

스케줄러에게 2개의 논리 프로세서를 할당한다.

    runtime.GOMAXPROCS(2)
}

func main() {

wg는 프로그램이 종료될때까지 기다리는데 사용한다. Add에 2를 추가함으로, 2개의 고루틴이 종료될 때까지 대기한다.

    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Start Goroutines")

소문자 알파벳을 3번 출력하는 익명 함수를 선언하고, 고루틴을 생성한다.

    go func() {
        for count := 0; count < 3; count++ {
            for r := 'a'; r <= 'z'; r++ {
                fmt.Printf("%c ", r)
            }
        }
        wg.Done() //메인(main)에게 작업이 끝났음을 알린다.
    }

대문자 알파벳을 3번 출력하는 익명 함수를 선언하고, 고루틴을 생성한다.

    go func() {
        for count := 0; count < 3; count++ {
            for r := 'A'; r <= 'Z'; r++ {
                fmt.Printf("%c ", r)
            }
        }
        wg.Done() //메인(main)에게 작업이 끝났음을 알린다.
    }()

고루틴이 끝나기를 기다린다.

    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

소문자와 대문자가 섞여서 출력되는 것을 확인할 수 있다.

Start Goroutines
Waiting To Finish
a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j
k l m n o p A B C D E F G H I J K L M N O P Q R S q r s t u v w x y z a
b c d e f g h i j k l m n o p q r s t u v w x y z T U V W X Y Z A B C D
E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N
O P Q R S T U V W X Y Z
Terminating Program