Concurrency in Go - Go의 동시성 구성 요소

Johnny·2021년 6월 28일
2

Saturday Night 스터디

목록 보기
5/8

📖 이 글은 Saturday Night 스터디에서 Concurrency in Go를 주제로 발표하기 위해 만들어졌습니다.


Go의 동시성 구성 요소

3장에서는 Go의 동시성을 지원하는 기능들을 알아보는 챕터입니다.
이번에 발표하게 된 주제는 고루틴입니다. (개발하면서 고루틴 써본적 손에 꼽는데 🙄?)

고루틴이란?

고루틴은 OS 스레드보다 가벼운 경량 스레드로 이야기되며(go 1.4 기준 2KB의 스택 공간 요구), 고루틴은 Go 프로그램을 구성하는 가장 기본적인 단위입니다. 모든 Go 프로그램은 하나 이상의 고루틴(main 함수)이 반드시 존재하게 됩니다.
또한 고루틴은 다른 코드와 함께 동시에 실행되는 함수입니다.

고루틴은 2KB가 부족하면 런타임이 알아서 스택을 저장하기 위한 메모리 공간을 조정하여 고루틴이 문제없이 실행될 수 있도록 합니다.

how to use

고루틴은 이렇게 사용합니다.

func sayHello() {
    fmt.Println("hello")
}

func main() {
    go sayHello()
    // sayHello()의 종료를 기다리기 위해 blocking 하지 않고 다음 코드를 이어서 진행함
    ...
}

Go에는 익명함수가 존재하고, 익명함수 또한 고루틴으로 동작시킬 수 있습니다.

func main() {
    go func() {
        fmt.Println("hello")
    }()
}

Go에서는 함수를 일급 함수(First-Class Function)로 취급하기 때문에 변수로 할당할 수 있으며, 따라서 고루틴으로 동작시킬 수 있습니다.

func main() {
    sayHello := func() {
        fmt.Println("hello")
    }

    go sayHello()
}

고루틴은 깃털처럼 가볍다!🪶

고루틴은 OS 스레드가 아니기 때문에 가볍고 (OS에 스레드 사용을 위한 리소스 요청이 없음), 런타임 라이브러리나 VM 환경에서 관리되는 그린 스레드도 아닙니다.

Green Thread
그린 스레드는 OS에 의존하지 않고 멀티스레드 환경을 흉내내며 커널 영역 대신 사용자 영역에서 관리되는 스레드입니다.

고루틴은 코루틴과 같은 높은 수준의 추상화 개념입니다.

Coroutine
코루틴은 비선점형 멀티태스킹을 위한 서브 루틴 개념입니다. (루틴은 어떠한 일을 실행하기 위한 단위)
예를 들어 메인 함수가 실행되면 해당 시점이 메인 루틴이 되고, 메인 함수 내에서 특정 함수를 호출하면 해당 특정 함수가 서브 루틴이 됩니다.
(코루틴에 대한 자세한 설명은 바이너리님께...🙏)

비선점형 멀티태스킹 (Non-preemptive Multi-tasking)
프로그램이 실행되면 프로세스가 되고 프로세스는 여러 스레드를 실행시키는데 이 때, 여러 프로그램에서 만들어지는 스레드들은 CPU라는 한정된 자원을 가지고 사용하기 위해 경쟁하게 됩니다.
운영체제는 CPU를 시분할하여 스레드들을 돌아가며 실행시키는데 CPU를 차지하고 있는 스레드가 자발적으로 중지될 경우(작업을 완료한 경우)에만 운영체제가 회수할 수 있도록 작동하는 개념을 비선점형 멀티태스킹이라고 합니다.

코루틴은 실행시킨 스레드의 동작을 중단(suspend)하거나 중단한 코루틴을 다시 실행시키기 위해 재진입(re-entry)하여 관리할 수 있다고 하는데 고루틴에는 이러한 기능을 존재하지 않습니다.
대신 Go 런타임이 고루틴을 관리하여 대기중(block)일 때 일시 중지 처리, 재개 처리 등의 작업을 합니다.

책에서는 이런 부분을 런타임과 고루틴간의 우아한 파트너십이라고 표현하네요. 😒?

코-고루틴은 무조건 동시에 실행된다?🤔

코루틴이나 고루틴은 동시성과 함께 이야기되지만 동시성은 코-고루틴이 포함하는 특성이 아닙니다.
고루틴을 실행시킨다고 해서 그것이 꼭 동시에 실행된다고 보장할 수 없으며, 병렬로 처리된다고 이야기할 수 없습니다.
서브 고루틴을 실행시킬 호스트 고루틴이 여러 고루틴을 실행시킬 수 있어야 동시에 실행이 가능한겁니다. (🙄 뭔 말이지)

Go는 M:N 스케줄러 매커니즘⏰

Go는 M:N 스케줄러 매커니즘을 구현하고 있습니다. 이는 M개의 그린 스레드를 N개의 커널 스레드에 매핑한다는 의미인데요. 스레드의 종류는 다음과 같이 3개로 분류할 수 있습니다.

  • N:1: 여러 user-level 스레드가 하나의 OS 스레드 위에서 돌아감
    • 컨텍스트 스위칭 속도가 빠르지만 멀티코어를 활용할 수 없음
  • 1:1: 1개의 스레드를 1개의 OS 스레드와 매핑
    • 멀티코어를 제대로 활용할 수 있지만 컨텍스트 스위칭 속도가 매우 느림
  • M:N: 여러 개의 커널 스레드 위에 여러개의 루틴을 매핑
    • 컨텍스트 스위칭 속도가 빠르며, 멀티코어도 활용할 수 있지만 구현이 어려움

Go언어는 M:N 스케줄러 매커니즘을 언어 차원에서 구현해 제공하여 프로그래머들이 쉽게 사용할 수 있도록 제공하고 있습니다.

GMP 스케줄러

Go 스케줄러 내부를 확인해보면 G(Goroutines), M(Machine=Thread), P(Processor)로 구성되어 돌아가고 있습니다. 프로세서는 고루틴의 스케줄러이며, M은 스레드입니다.
G, M, P는 Go SDK의 runtime 패키지에 정의되어 있습니다.
Go SDK/src/runtime/runtime2.go파일 내에 각각의 구조체가 정의되어 있습니다.


(이 링크를 참고했습니다.)

위 그림을 기준으로 스케줄러는 다음의 형태로 수행된다고 합니다.

  • Global Queue: 고루틴(G)을 실행시키기 위해 대기시키는 저장소
  • P local Queue: 고루틴(G)이 생성되면 먼저 로컬 큐에 추가함. 만약 로컬 큐가 가득차면 생성된 고루틴을 글로벌 큐로 저장시킴. (각 프로세서의 로컬 큐에는 최대 256개의 고루틴만 저장 가능함.)
  • P: 프로그램 초기화 시 runtime.GOMAXPROCS(n)에 의해 n만큼 프로세서가 생성됨. (단, 실제 코어 최대 갯수 만큼만 생성 가능함. 싱글코어 = 1P = 1 P's 로컬 큐)
  • M: 스레드(M)는 프로세서(P)의 로컬 큐에서 고루틴(G)를 가져옴. 만약 프로세서 내 큐가 비어있으면 스레드는 글로벌 큐에서 고루틴 묶음을 가져와서 프로세서의 로컬 큐에 넣거나, 다른 프로세서들의 로컬 큐에 쌓인 고루틴 묶음을 절반씩 뺏어서 비어있는 프로세서의 로컬 큐에 넣는 일을 함.

fork-join🍽

Go는 fork-join 모델이라는 동시성 모델을 따르고 있습니다. 특정 시점에 자식 분기(fork)를 만들어 부모와 동시에 실행해 나가고 미래의 어느 시점에 부모와 자식 분기가 합류(join)하여 하나로 합쳐지는 형태입니다. (Git으로 치면 branch를 통해 협업하는 형태네요.)

예제와 그림을 통해 알아봅시다.

예제 코드

func main() {
    var wg sync.WaitGroup
    
    sayHello := func() {
        defer wg.Done()
        fmt.Println("hello")
    }
    
    wg.Add(1) // Add(n) 메소드의 인자는 실행할 고루틴의 수를 넘긴다.
    go sayHello()
    wg.Wait() // 이 시점에 서브 고루틴에서 wg.Done()이 호출되기 전까지 메인 고루틴을 일시 중지함
    
    
    fmt.Println("Done")
}

// output
hello
Done

Program exited.

그림으로 표현하면 메인 함수에서 sayHello() 함수를 고루틴으로 호출하고(fork) sync.WaitGroup을 통해 고루틴이 끝나는 시그널 wg.done()을 보내면 메인 함수에서 기다리고 있던 지점 wg.wait()에서 만나(join) 다시 메인 루틴을 진행합니다.

고루틴의 메모리 공간

고루틴은 자신이 생성된 곳과 동일한 주소 공간에서 실행됩니다.

클로저 예제와 함께 알아봅시다.

예제 코드

func main() {
    var wg sync.WaitGroup
    
    salutation := "hello"
    
    wg.Add(1)
    go func() {
        defer wg.Done()
        salutation = "welcome"
    }()
    wg.Wait()
    
    
    fmt.Println(salutation)
}

// output 
welcome

Program exited.

위의 코드를 실행하면 출력으로 welcome이 출력됩니다.
고루틴은 자신이 생성된 곳과 동일한 주소 공간에서 실행이 되기 때문에 위와 같은 결과가 나오게 됩니다.

그럼 아래의 코드 결과는 어떨까요?

var wg sync.WaitGroup

func update2Welcome(salutation string) {
    defer wg.Done()
    salutation = "welcome"
}

func main() {
    salutation := "hello"
    
    wg.Add(1)
    go update2Welcome(salutation)
    wg.Wait()
    
    
    fmt.Println(salutation)
}

한번 코드만 보고 맞춰보세요. 😝

하나 더 예제를 보고 이해해봅시다.

예제코드

func main() {
    var wg sync.WaitGroup
    for _, salutation := range []string{"hello", "greetings", "good day"} {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(salutation)
        }()
    }

    wg.Wait()
}
// output
./prog.go:14:25: loop variable salutation captured by func literal
Go vet exited.

good day
good day
good day

Program exited.

위 코드는 얼핏 보면 hello, greetings, good day를 출력할 것 같지만 good day만 3번 출력하고 종료됩니다.
문자열 타입의 변수 salutation에 대해서 닫혀있는 클로저를 실행 중이기 때문인데요. 루프가 반복될 때 salutation에는 슬라이스 리터럴의 다음 문자열 값이 할당되게 됩니다. 그러나 스케줄링된 고루틴은(프로세서 로컬 큐 혹은 글로벌 큐에 담겨있는) 어떤 시점에서든 실행될 수가 있는데 고루틴 내부에서 어떤 값이 출력될지 결정될 수가 없기 때문에 salutation의 변수가 범위를 벗어나게 됩니다.

고루틴이 실행되기도 전에 루프가 종료되고 salutation 변수는 슬라이스의 마지막 값인 good day에 대한 참조를 저장하고 있는 힙으로 옮겨지게 되고 고루틴이 실행되어 good day가 3번 출력됩니다.

사실 위의 코드를 플레이 그라운드나 IDE에서 실행하게 되면 Go vet;정적 분석기가 "salutation 변수는 함수 리터럴에 의해 캡쳐되었다"고 힌트를 줍니다. 😏

위의 코드가 우리가 기대한 결과에 맞게 작동하게 하려면 다음과 같이 수정해야 합니다.
예제코드

func main() {
    var wg sync.WaitGroup
    for _, salutation := range []string{"hello", "greetings", "good day"} {
        wg.Add(1)
        go func(s string) { // 전달된 복사본 정보를 메모리에 저장하고(고루틴 내부 스택이겠지..?) 고루틴의 실행 시점에 저장된 값으로 동작 
            defer wg.Done()
            fmt.Println(s)
        }(salutation) // salutation을 복사본을 클로저로 전달
    }

    wg.Wait()
}

// output
good day
hello
greetings

Program exited.

Go 컴파일러는 고루틴이 실수로 할당이 해제된 메모리에 접근하지 않도록 메모리 상에 고정되어 있는 변수를 알아서 처리해주기 때문에 개발자는 메모리 관리보다 문제 공간에만 집중할 수 있습니다.

고루틴은 서로 동일한 주소 공간에서 작동하기 때문에 동기화 처리에 대해 고민해야 합니다. 고루틴이 접근하는 공유 메모리 공간에 대해 동기화된 접근을 하도록 하거나, CSP 기본 요소(채널과 sync 패키지)를 사용해 통신으로 메모리를 공유할 수 있습니다.

고루틴의 스택 크기를 알아보는 항목은 예제 코드만 남기고 넘어가겠습니다.
스택 크기 조회 예제코드


아 이 책은 뭔 이 얘기 하다가 저 얘기하다가 진짜 정신없네..😠
하나만 쭉 설명해 제발..

저는 Go를 이용해서 아래와 같은 프로그램을 개발합니다.

profile
배우면 까먹는 개발자 😵‍💫

0개의 댓글