본문 바로가기
카테고리 없음

[Go] Java 개발자의 GoLang 튜토리얼 - Goroutine & GMP Model

by 사바라다 2023. 10. 10.

개요

GoLang을 잘 모르시는 분들이라도 Go하면 Goroutine을 사용한 효율적인 Thread의 운용으로 동시성에 대해서 좋은 퍼포먼스와 쉬운 사용을 보여주는 언어라는 사실 정도는 들어보신적이 있으실것 같습니다. 오늘은 GoLang의 Thread 모델과 이론에 대해서 간단히 본 후 goroutine의 사용방법에 대해서 알아보도록 하겠습니다.

먼저 실습을 통해서 돌아가는 모습을 확인해보겠습니다. 그리고 기반이 되는 이론에 대해서 잠시 살펴보도록 하겠습니다. 주의하실 점은 해당 포스팅에서의 이론은 깊지 않습니다. 좀 더 깊은 내용은 레퍼런스를 참고해주세요.

goroutine (고루틴)

goroutine은 Go의 Runtime에 의해서 관리되는 경량 thread(aka. green thread)입니다.

새로운 goroutine은 go keyword를 통해서 사용할 수 있습니다. goroutine은 아래의 코드처럼 이용할 수 있습니다. 아래의 코드는 goroutine을 이용해서 코드를 실행 시키고 main goroutine과 새롭게 생성한 goroutine에서 각각 본인의 goroutine Id를 출력하도록 코드를 작성한 것입니다.

func Say(s string) {
  time.Sleep(100 * time.Millisecond)
  printStackWithMessage(s)
}

func main() {
  go Say("Hello")
  go Say("Karol")  
  printStackWithMessage("Main")
  time.Sleep(200 * time.Millisecond)
}

// goroutineId와 message를 노출
func printStackWithMessage(m string) {
  gr := bytes.Fields(debug.Stack())[1]
  log.Printf("message: %s Lambda goroutineId: %s\n", m, string(gr))
}

위 코드는 메시지를 노출하기 전 100ms 씩 goroutine을 정지시킵니다. 즉 하나의 goroutine에서 blocking 된다고한다면 위 코드는 위에서부터 순차적으로 Hello -> Karol -> Main, 그리고 각 출력의 사이에 100ms 씩의 대기가 있을것입니다. 하지만 결과는 아래와 같았으며 Hello, Karol, Main의 출력 사이에 대기는 없었습니다. 따라서 goroutine을 활용하면 프로그램에 동시성을 줄 수 있다는 사실을 알 수 있었습니다.

2023/10/09 02:07:05 message: Main Lambda goroutineId: 1
2023/10/09 02:07:05 message: Karol Lambda goroutineId: 21
2023/10/09 02:07:05 message: Hello Lambda goroutineId: 20

channel

channel은 goroutine간의 통신하는 큐입니다. 큐이기 때문에 해당 큐에 쌓아두는 생산자와 큐를 소비하는 소비자가 필요합니다. go는 여타 언어들과 동일하게 heap 메모리를 통해서도 goroutine간의 데이터를 공유해서 사용할 수 있습니다. 이 경우 공유 데이터간의 동시성 문제 등에 대해서 주의를 기울여야합니다. channel을 이용한다는 것은 goroutine간의 데이터를 복제해서 사용한다는 의미입니다. 이 말은 즉, 객체에 대해서 동시성에 대한 이슈없이 프로그래밍이 가능하다는 말입니다.

두번째 예제에서는 채널에 넘기는것을 단순한 type이 아닌 커스텀 struct 타입을 넘기는것으로 예제를 구성해보았습니다.

func Say(s string, c chan Hello) {
  var object Hello
  object = <-c // channel로 부터의 데이터 수신
  time.Sleep(100 * time.Millisecond)
  printStackWithMessage(object.message + s)
}

type Hello struct {
  message string
}

func main() {
  c := make(chan Hello, 1) // buffer size 1의 channel 생성
  c <- Hello{ // <- 키워드를 통한 채널로의 데이터 전달
    message: "Hello",
  }
  go Say("World", c)

  time.Sleep(200 * time.Millisecond)
}

func printStackWithMessage(m string) {
  gr := bytes.Fields(debug.Stack())[1]
  log.Printf("message: %s Lambda goroutineId: %s\n", m, string(gr))
}

위 코드는 main goroutine에서 데이터를 전달하고 새롭게 만든 goroutine으로 수신하여 문자열을 합쳐 log를 출력하는 예제입니다. 채널을 사용하여 goroutine간의 데이터의 교환을 확인할 수 있었습니다. 위 코드에서는 go Say가 c channel에 데이터를 넣은 이후에 호출되는 것을 알 수 있습니다. 이 경우에는 channel에 데이터가 미리 들어가 있기 때문에 코드의 blocking 없이 진행됩니다. 하지만 반대로 channel을 먼저 컨슘하고 있으면 channel에 데이터가 producing 되기 까지는 blocking 되는 사실을 알아야합니다.

2023/10/09 03:08:56 message: HelloWorld Lambda goroutineId: 18

select

seelct 문은 goroutine에 적용되는 switch 구문과 동일합니다. 즉, 여러 경우를 상정해서 로직을 실행하는것이 가능합니다. goroutine이 select 구문을 만나면 channel에 값이 들어올때가지 대기하게됩니다. 만약 default 값이 있다면 default값이 실행됩니다. select 문을 사용하는 코드는 아래와 같습니다.

func SaySelect(start, end chan Hello) {
  var startObject Hello
  var endObject Hello

  for {
    select { // select 구문에는 case가 위치하며 각 케이스는 channel의 컨슘 부분을 붙일 수 있습니다.
    case startObject = <-start:
      printStackWithMessage(startObject.message)
    case endObject = <-end:
      printStackWithMessage(endObject.message)
      return
    default: // 디폴트 값
      printStackWithMessage("waiting")
    }
  }
}

type Hello struct {
  message string
}

func main() {
  start := make(chan Hello, 1)
  end := make(chan Hello, 1)

  go SaySelect(start, end)

  start <- Hello{
    message: "Hello",
  }
  end <- Hello{
    message: "World",
  }

  time.Sleep(200 * time.Millisecond)
}

func printStackWithMessage(m string) {
  gr := bytes.Fields(debug.Stack())[1]
  log.Printf("message: %s Lambda goroutineId: %s\n", m, string(gr))
}

위 코드는 start와 end channel을 만든 후 이 두 값을 swtich로 구분하여 로직을 실행할 수 있도록 구성하였습니다. 결과적으로 start와 end channel에 값이 인입되었고 switch가 2번 실행되었던 것을 확인할 수 있었습니다.

2023/10/09 21:19:14 message: Hello Lambda goroutineId: 18
2023/10/09 21:19:14 message: World Lambda goroutineId: 18

GMP 모델

그렇다면 Go는 Goroutine을 활용해서 어떻게 CPU의 동시성을 만들어내는지 모델에 대해서 간단히 알아보도록 하겠습니다. 먼저 GMP 모델에 대해서 알아야합니다. GMP 모델이란 Go Runtime에서 thread를 관리하는 방법입니다.

Go Runtime의 Scheduling 되는 전체적인 이미지는 위와 같습니다. 그리고 구성요소는 각각 아래와 같습니다.

  • G(Goroutine) : Goroutine의 구현채, 런타임에서 Goroutine을 관리하기 위해 사용
  • M(Machine) : OS에서 관리하는 kernel level의 thread를 의미 (최대 갯수 10000)
  • P(Processor) : 실제 물리적인 Thread 갯수인 M과는 달리 가상의 Processor (Virtual CPU Core) P의 숫자는 go의 runtime의 GOMAXPROCS 설정으로 변경 가능

P에는 Local Runnable Queue(이하 LRQ)라고 하는 Queue를 가지고 있습니다. 프로그램이 실행되면 P가 할당되고 각 P에는 실행할 G가 배치됩니다. M은 P로부터 G를 가져와서 실행합니다. M과 P가 분리되어있는 이유는 M이 P 중 1개를 보았을 때 G가 없다면 이를 work-stealing scheduling 등을 통해서 재 스케줄링하는 방법을 취할 수 있도록 하기 위함입니다. 만약 10ms를 넘어가는 작업이 있다면 해당작업은 LRQ가 아닌 Global Runnable Queue(이하 GRQ)에서 관리되게 됩니다. system call이 발생하면 LRQ 또는 GRQ에 적재하여 즉시 실행할 수 있는 코드를 실행합니다. 이러한 방식으로 context switching의 발생 빈도를 줄여 스레드 사용의 성능 극대화를 이루는 방식을 택하고 있습니다.

또한 thread에 의한 context switching이 아닌 goroutine의 context switching을 합니다. goroutine은 thread에 비해 더 작은 메모리를 필요로 합니다. 고루틴 생성에는 2KB의 Stack만 필요로 하고 필요에 따라 Heap을 사용합니다. 반면 thread는 스레드의 간 메모리 보호 역할(Guard page) 을 하는 공간을 포함하여 1MB 정도의 스택을 필요로 합니다. 이러한 이유도 성능 극대화에 큰 영향을 줍니다.

마무리

오늘은 goroutine의 실제 사용방법 및 go에서 사용하는 GMP 모델에 대해서 알아보았습니다.

여기에 더 나아간 내용을 공부하기 위해서는 Go의 Memory 구조에 대해서 알고 TCMalloc과 같은 내용도 알면 좋을것 같습니다.

해당 내용은 다음 기회에 다시한번 정리하여 공유할 수 있으면 좋을것 같습니다.

감사합니다.

참조

[1] https://ssup2.github.io/theory_analysis/Golang_Goroutine_Scheduling/

[2] https://ykarma1996.tistory.com/188

[3] https://d2.naver.com/helloworld/0814313

[4] https://www.sobyte.net/post/2023-03/gpm/

댓글