Go

Go (고) 언어 동시성의 비밀, 고루틴 스케줄링

드리프트2 2025. 3. 22. 17:32

Go (고) 언어 동시성의 비밀, 고루틴 스케줄링

I. 고루틴 (Goroutine) 소개

 

고루틴 (Goroutine)은 Go (고) 프로그래밍 언어에서 정말 독특하고 중요한 기능인데요.

병렬 컴퓨팅을 가능하게 해주는 핵심 기술이라고 할 수 있습니다.

고루틴 (Goroutine)을 사용하는 방법은 아주 간단합니다.

go 키워드만 붙여주면 되는데요.

이렇게 시작된 고루틴 (Goroutine)은 비동기적으로 실행됩니다.

즉, 고루틴 (Goroutine)이 끝날 때까지 기다릴 필요 없이 프로그램은 다음 코드를 계속해서 실행할 수 있습니다.

go func() // go 키워드를 사용하여 함수를 실행하는 고루틴 (Goroutine) 시작

II. 고루틴 (Goroutine) 내부 원리

개념 소개

동시성 (Concurrency)

싱글 CPU (중앙처리장치) 환경에서 여러 작업을 동시에 실행하는 것처럼 보이게 하는 것을 말합니다.

아주 짧은 시간 동안 CPU (중앙처리장치)가 여러 작업 사이를 빠르게 왔다 갔다 하면서 실행하는 방식인데요.

(예를 들어, 프로그램 A를 잠깐 실행하다가 재빨리 프로그램 B로 바꿔서 실행하는 거죠.) 시간적으로 겹치는 부분이 있어서 (큰 그림으로 보면 동시에 실행되는 것처럼 보이지만, 자세히 뜯어보면 결국 순차적으로 실행되는 겁니다.) 여러 작업이 동시에 실행되는 것처럼 느껴지게 하는 것, 이걸 동시성 (Concurrency)이라고 합니다.

병렬성 (Parallelism)

시스템에 CPU (중앙처리장치)가 여러 개 있을 때, 각 CPU (중앙처리장치)가 자기 CPU (중앙처리장치) 자원을 서로 경쟁하지 않고 각자 작업을 동시에 처리하는 것을 말합니다.

말 그대로 동시에 여러 작업을 처리하는 것이죠.

이걸 병렬성 (Parallelism)이라고 합니다.

프로세스 (Process)

CPU (중앙처리장치)가 여러 프로그램을 번갈아 가며 실행할 때, 이전 프로그램의 상태 (소위 context (컨텍스트)라고 하는 것)를 저장하지 않고 바로 다음 프로그램으로 넘어가 버리면 이전 프로그램의 여러 상태가 날아가 버리겠죠?

이 문제를 해결하기 위해 프로세스 (Process)라는 개념이 등장했습니다.

프로세스 (Process)는 프로그램 실행에 필요한 자원을 할당받는 단위입니다.

프로세스 (Process)는 프로그램 실행에 필요한 기본적인 자원 단위라고 할 수 있습니다.

(프로그램 실행의 주체라고 봐도 되고요.) 예를 들어, 문서 편집 프로그램을 실행하면, 이 프로그램의 프로세스 (Process)가 텍스트 버퍼를 위한 메모리 공간, 파일 처리 자원 등 모든 자원을 관리합니다.

스레드 (Thread)

CPU (중앙처리장치)가 여러 프로세스 (Process) 사이를 왔다 갔다 하는 건 시간이 꽤 걸리는 작업입니다.

왜냐하면 프로세스 (Process)를 바꿀 때는 커널 모드로 전환해야 하고, 스케줄링할 때마다 사용자 모드 데이터를 읽어와야 하거든요.

프로세스 (Process) 수가 늘어날수록 CPU (중앙처리장치) 스케줄링에 많은 자원이 낭비됩니다.

그래서 스레드 (Thread)라는 개념이 등장했습니다.

스레드 (Thread) 자체는 자원을 별로 안 먹고, 프로세스 (Process) 안에서 자원을 공유합니다.

커널이 스레드 (Thread)를 스케줄링할 때는 프로세스 (Process)를 스케줄링할 때만큼 자원이 많이 들지 않아요.

예를 들어, 웹 서버 프로그램에서 여러 스레드 (Thread)를 사용해서 여러 클라이언트 요청을 동시에 처리하면서 서버 프로세스 (Process)의 자원 (네트워크 연결, 메모리 캐시 등)을 공유할 수 있습니다.

코루틴 (Coroutine)

코루틴 (Coroutine)은 자신만의 레지스터 context (컨텍스트)와 스택을 가지고 있습니다.

코루틴 (Coroutine)이 스케줄링되어 전환될 때, 레지스터 context (컨텍스트)와 스택을 다른 곳에 저장해두고, 다시 돌아올 때 저장해둔 레지스터 context (컨텍스트)와 스택을 복원합니다.

그래서 코루틴 (Coroutine)은 이전 호출 시점의 상태를 그대로 유지할 수 있습니다.

(모든 로컬 상태의 조합이라고 할 수 있죠.) 프로세스 (Process)에 다시 진입할 때마다 마치 이전 호출 시점의 상태로 되돌아가는 것과 같고, 다시 말해 지난번에 멈췄던 논리적 흐름의 위치로 돌아가는 것과 같습니다.

스레드 (Thread)와 프로세스 (Process) 작업은 프로그램이 시스템 인터페이스를 통해 요청하고 최종 실행 주체는 시스템인데요.

반면에 코루틴 (Coroutine) 작업은 사용자가 직접 작성한 프로그램에 의해 실행되고, 고루틴 (Goroutine)은 코루틴 (Coroutine)의 한 종류입니다.

스케줄링 모델 소개

고루틴 (Goroutine)의 강력한 동시성 구현은 GPM (고루틴, 프로세서, 스레드) 스케줄링 모델을 통해 이루어집니다.

고루틴 (Goroutine) 스케줄링 모델에 대해 자세히 알아볼까요?

Go (고) 스케줄러 내부에는 M, P, G, Sched (Sched는 그림에 없음) 이렇게 네 가지 중요한 구조체가 있습니다.

  • M (머신, Machine): 커널 레벨 스레드 (thread)를 나타냅니다. M 하나가 스레드 (thread) 하나이고, 고루틴 (Goroutine)은 M 위에서 실행됩니다. 예를 들어, 복잡한 계산을 하는 고루틴 (Goroutine)을 시작하면, 이 고루틴 (Goroutine)은 M에 할당되어 실행됩니다. M은 작은 객체 메모리 캐시 (mcache), 현재 실행 중인 고루틴 (Goroutine), 난수 생성기 등 여러 정보를 관리하는 큰 구조체입니다.
  • G (고루틴, Goroutine): 고루틴 (Goroutine) 자체를 나타냅니다. 함수 호출 정보를 저장하는 스택, 실행 위치를 가리키는 명령어 포인터, 스케줄링에 사용되는 채널 정보 등 여러 정보를 가지고 있습니다. 예를 들어, 고루틴 (Goroutine)이 채널에서 데이터를 받기 위해 기다리고 있다면, 이 정보가 G 구조체에 저장됩니다.
  • P (프로세서, Processor): 고루틴 (Goroutine)을 실행하는 역할을 주로 담당합니다. 작업 분배자라고 생각하면 쉬운데요. P는 실행해야 할 모든 고루틴 (Goroutine)을 담고 있는 고루틴 (Goroutine) 큐도 관리합니다. 예를 들어, 여러 고루틴 (Goroutine)이 생성되면 P가 관리하는 큐에 추가되어 스케줄링되기를 기다립니다.
  • Sched (스케줄러, Scheduler): 스케줄러 (Scheduler) 자체를 나타냅니다. 중앙 스케줄링 센터라고 생각하면 되는데요. M과 G의 큐, 스케줄러 (Scheduler)의 여러 상태 정보들을 관리하면서 전체 시스템의 효율적인 스케줄링을 책임집니다.

스케줄링 구현

 

그림에서 볼 수 있듯이, 2개의 물리적 스레드 M이 있고, 각 M은 프로세서 P를 가지고 있으며, 실행 중인 고루틴 (Goroutine)이 하나씩 있습니다.

  • P의 개수는 GOMAXPROCS()를 통해 설정할 수 있습니다. P의 개수는 실제로 동시성 수준, 즉 동시에 실행될 수 있는 고루틴 (Goroutine) 수를 나타냅니다.
  • 그림에서 회색 고루틴 (Goroutine)들은 실행 중이 아니고, 준비 상태로 스케줄링되기를 기다리고 있습니다. P는 이 큐 (runqueue라고 함)를 관리합니다.
  • Go (고) 언어에서 고루틴 (Goroutine)을 시작하는 방법은 아주 간단합니다. go function 이렇게만 쓰면 되는데요. 그래서 go 구문이 실행될 때마다 고루틴 (Goroutine)이 runqueue 맨 뒤에 추가됩니다. 다음 스케줄링 시점에 runqueue에서 고루틴 (Goroutine)이 하나씩 빠져나와서 실행됩니다. (어떤 고루틴 (Goroutine)을 선택할지는 어떻게 결정할까요?)
  • OS (운영체제) 스레드 M0가 블록되면 (아래 그림 참고), P는 M1으로 전환되어 실행됩니다. 그림 속 M1은 새로 생성 중이거나 스레드 캐시에서 가져온 스레드일 수 있습니다.

 

  • M0가 다시 실행될 수 있게 되면, 고루틴 (Goroutine)을 실행하기 위해 P를 얻으려고 시도합니다. 보통 다른 OS (운영체제) 스레드에서 P를 가져오려고 시도하는데요. P를 가져오는 데 실패하면 고루틴 (Goroutine)을 글로벌 runqueue에 넣고, M0 자신은 잠자리에 듭니다. (스레드 캐시에 넣습니다.) 모든 P는 주기적으로 글로벌 runqueue를 확인하고 그 안에 있는 고루틴 (Goroutine)을 실행합니다. 그렇지 않으면 글로벌 runqueue에 있는 고루틴 (Goroutine)은 영원히 실행되지 못할 수도 있습니다.
  • 또 다른 상황은 P에 할당된 작업 G가 빨리 끝나는 경우 (작업 불균형)입니다. 이 경우 해당 프로세서 P는 할 일이 없어지고, 다른 P들은 여전히 작업이 남아있게 됩니다. 글로벌 runqueue에 작업 G가 없으면 P는 실행할 작업을 다른 P에서 가져와야 합니다. 일반적으로 P가 다른 P에서 작업을 가져올 때는 run queue의 절반 정도를 가져와서 각 OS (운영체제) 스레드를 최대한 활용하도록 합니다. (아래 그림 참고)

 




III. 고루틴 (Goroutine) 사용법

기본 사용법

고루틴 (Goroutine)이 실행될 CPU (중앙처리장치) 코어 수를 설정할 수 있습니다.

최신 Go (고) 버전에서는 기본 설정이 되어 있습니다.

num := runtime.NumCPU() // 호스트의 논리 CPU (중앙처리장치) 코어 수를 가져옵니다. 나중에 동시성 수준을 설정하기 위해 준비합니다.
runtime.GOMAXPROCS(num) // 호스트 CPU (중앙처리장치) 코어 수에 따라 동시에 실행할 수 있는 최대 CPU (중앙처리장치) 코어 수를 설정하여 고루틴 (Goroutine)의 동시성 수준을 제어합니다.

사용 예시

예제 1: 간단한 고루틴 (Goroutine) 계산

package main

import (
        "fmt"
        "time"
)

// cal 함수는 두 정수의 합을 계산하고 결과를 출력합니다.
func cal(a int, b int) {
        c := a + b
        fmt.Printf("%d + %d = %d\n", a, b, c)
}

func main() {
        for i := 0; i < 10; i++ {
                go cal(i, i+1) // 10개의 고루틴 (Goroutine)을 시작하여 계산을 수행합니다.
        }
        time.Sleep(time.Second * 2) // 모든 작업이 완료될 때까지 Sleep (잠시 멈춤) 합니다.
}

 

실행 결과:

8 + 9 = 17
9 + 10 = 19
4 + 5 = 9
5 + 6 = 11
0 + 1 = 1
1 + 2 = 3
2 + 3 = 5
3 + 4 = 7
7 + 8 = 15
6 + 7 = 13

고루틴 (Goroutine) 예외 처리

여러 고루틴 (Goroutine)을 시작했을 때, 그 중 하나에서 예외가 발생했는데 예외 처리를 안 해주면 프로그램 전체가 그냥 끝나버립니다.

그래서 프로그램을 짤 때는 각 고루틴 (Goroutine)이 실행하는 함수에 예외 처리 코드를 넣어주는 게 좋습니다.

recover 함수를 사용하면 예외를 처리할 수 있습니다.

package main

import (
        "fmt"
        "time"
)

func addele(a []int, i int) {
        // defer를 사용하여 익명 함수 실행을 지연시킵니다. 가능한 예외를 처리하는 데 사용됩니다.
        defer func() {
                // recover 함수를 호출하여 예외 정보를 얻습니다.
                err := recover()
                if err != nil {
                        // 예외 정보를 출력합니다.
                        fmt.Println("add ele fail")
                }
        }()
        a[i] = i
        fmt.Println(a)
}

func main() {
        Arry := make([]int, 4)
        for i := 0; i < 10; i++ {
                go addele(Arry, i)
        }
        time.Sleep(time.Second * 2)
}

 

실행 결과:

add ele fail
[0 0 0 0]
[0 1 0 0]
[0 1 2 0]
[0 1 2 3]
add ele fail
add ele fail
add ele fail
add ele fail
add ele fail

동기화된 고루틴 (Goroutine)

고루틴 (Goroutine)은 비동기적으로 실행되기 때문에 메인 프로그램이 끝날 때쯤 되면 일부 고루틴 (Goroutine)은 아직 실행 중일 수도 있습니다.

메인 프로그램이 끝나면 실행 중이던 고루틴 (Goroutine)들도 같이 끝나버리는데요.

모든 고루틴 (Goroutine) 작업이 끝날 때까지 기다렸다가 프로그램을 종료하고 싶다면, Go (고)에서 sync 패키지와 채널을 사용해서 동기화 문제를 해결할 수 있습니다.

물론 각 고루틴 (Goroutine) 실행 시간을 예측할 수 있다면 time.Sleep을 사용해서 고루틴 (Goroutine)들이 끝날 때까지 기다렸다가 프로그램을 종료하는 방법도 있습니다.

(위 예제처럼요.)


예제 1: sync 패키지를 사용하여 고루틴 (Goroutine) 동기화

WaitGroup은 여러 고루틴 (Goroutine)이 모두 끝날 때까지 기다리는 데 사용됩니다.

메인 프로그램은 Add를 호출하여 기다려야 할 고루틴 (Goroutine) 수를 설정합니다.

각 고루틴 (Goroutine)은 실행이 끝나면 Done을 호출하고, 대기 큐의 숫자가 1씩 줄어듭니다.

메인 프로그램은 대기 큐가 0이 될 때까지 Wait에 의해 블록됩니다.

package main

import (
        "fmt"
        "sync"
)

func cal(a int, b int, n *sync.WaitGroup) {
        c := a + b
        fmt.Printf("%d + %d = %d\n", a, b, c)
        // 고루틴 (Goroutine)이 완료되면 Done 메서드를 호출하여 WaitGroup의 카운트를 1씩 줄입니다.
        defer n.Done()
}

func main() {
        var go_sync sync.WaitGroup // WaitGroup 변수를 선언합니다.
        for i := 0; i < 10; i++ {
                // 고루틴 (Goroutine)을 시작하기 전에 WaitGroup의 카운트를 1씩 늘립니다.
                go_sync.Add(1)
                go cal(i, i+1, &go_sync)
        }
        // WaitGroup의 카운트가 0이 될 때까지, 즉 모든 고루틴 (Goroutine)이 완료될 때까지 블록하고 기다립니다.
        go_sync.Wait()
}

 

실행 결과:

9 + 10 = 19
2 + 3 = 5
3 + 4 = 7
4 + 5 = 9
5 + 6 = 11
1 + 2 = 3
6 + 7 = 13
7 + 8 = 15
0 + 1 = 1
8 + 9 = 17

 

 

예제 2: 채널 (Channel)을 통해 고루틴 (Goroutine) 간 동기화 구현

구현 방법: 채널 (Channel)을 통해 여러 고루틴 (Goroutine) 간에 통신할 수 있습니다.

고루틴 (Goroutine)이 완료되면 채널 (Channel)에 종료 신호를 보냅니다.

모든 고루틴 (Goroutine)이 종료되면 for 루프를 사용하여 채널 (Channel)에서 신호를 가져옵니다.

데이터를 가져올 수 없으면 모든 고루틴 (Goroutine)이 완료될 때까지 블록됩니다.

이 방법을 사용하려면 시작된 고루틴 (Goroutine) 수를 미리 알고 있어야 합니다.

package main

import (
        "fmt"
        "time"
)

func cal(a int, b int, Exitchan chan bool) {
        c := a + b
        fmt.Printf("%d + %d = %d\n", a, b, c)
        time.Sleep(time.Second * 2)
        // 고루틴 (Goroutine)이 완료되었음을 알리는 신호를 채널 (Channel)로 보냅니다.
        Exitchan <- true
}

func main() {
        // 고루틴 (Goroutine)의 완료 신호를 저장하기 위해 용량이 10인 bool (불) 유형 채널 (Channel)을 만듭니다.
        Exitchan := make(chan bool, 10)
        for i := 0; i < 10; i++ {
                go cal(i, i+1, Exitchan)
        }
        for j := 0; j < 10; j++ {
                // 채널 (Channel)에서 신호를 받습니다. 신호가 없으면 고루틴 (Goroutine)이 완료되고 신호를 보낼 때까지 블록됩니다.
                <-Exitchan
        }
        // 채널 (Channel)을 닫습니다.
        close(Exitchan)
}

고루틴 (Goroutine) 간 통신

고루틴 (Goroutine)은 기본적으로 코루틴 (Coroutine)이기 때문에 커널이 아니라 Go (고) 스케줄러에 의해 관리되는 스레드 (thread)라고 생각하면 됩니다.

고루틴 (Goroutine) 간 통신 또는 데이터 공유는 채널 (Channel)을 통해 할 수 있습니다.

물론 전역 변수를 사용해서 데이터를 공유할 수도 있고요.

예제: 채널 (Channel)을 사용하여 생산자-소비자 패턴 흉내내기

package main

import (
        "fmt"
        "sync"
)

func Productor(mychan chan int, data int, wait *sync.WaitGroup) {
        // 채널 (Channel)로 데이터를 보냅니다.
        mychan <- data
        fmt.Println("product data:", data)
        // 생산자가 완료되었음을 표시하고 WaitGroup의 카운트를 1씩 줄입니다.
        wait.Done()
}

func Consumer(mychan chan int, wait *sync.WaitGroup) {
        // 채널 (Channel)에서 데이터를 받습니다.
        a := <-mychan
        fmt.Println("consumer data:", a)
        // 소비자가 완료되었음을 표시하고 WaitGroup의 카운트를 1씩 줄입니다.
        wait.Done()
}

func main() {
        // 생산자와 소비자 간의 데이터 전송을 위해 용량이 100인 int (정수) 유형 채널 (Channel)을 만듭니다.
        datachan := make(chan int, 100)
        var wg sync.WaitGroup
        for i := 0; i < 10; i++ {
                // 생산자 고루틴 (Goroutine)을 시작하여 채널 (Channel)로 데이터를 보냅니다.
                go Productor(datachan, i, &wg)
                // WaitGroup의 카운트를 늘립니다.
                wg.Add(1)
        }
        for j := 0; j < 10; j++ {
                // 소비자 고루틴 (Goroutine)을 시작하여 채널 (Channel)에서 데이터를 받습니다.
                go Consumer(datachan, &wg)
                // WaitGroup의 카운트를 늘립니다.
                wg.Add(1)
        }
        // 생산자와 소비자가 모두 작업을 완료할 때까지 블록하고 기다립니다.
        wg.Wait()
}

 

실행 결과:

consumer data: 4
product data: 5
product data: 6
product data: 7
product data: 8
product data: 9
consumer data: 1
consumer data: 5
consumer data: 6
consumer data: 7
consumer data: 8
consumer data: 9
product data: 2
consumer data: 2
product data: 3
consumer data: 3
product data: 4
consumer data: 0
product data: 0
product data: 1