[Go] Goroutine(고루틴)

Seyeon_CHOI·2023년 11월 11일
0

Go

목록 보기
2/5
post-thumbnail

Goroutine(고루틴)이란?

프로그램에 있는 다른 고루틴과 관련하여 독립적으로 동시에 실행되는 함수입니다.

즉, Go 언어로 동시에 실행되는 모든 활동을 고루틴이라고 합니다.

1. Goroutine(고루틴)과 thead(스레드)의 차이?

Goroutine은 경량 스레드와 유사한 개념으로, Go 런타임 스케줄러에 의해 관리됩니다.

하지만, thead(스레드)는 OS 스레드 스케줄러에 의존하여 관리합니다.

메모리 사용량 차이

  • Goroutine
    • 매우 작은 메모리 스택을 가지고 시작하며, 필요에 따라 동적으로 확장됩니다.
    • 기본적으로 각 Goroutine은 2KB의 스택을 사용합니다.
  • 스레드
    • 전통적인 스레드는 훨씬 더 큰 스택을 가지고 시작합니다.
    • 일반적으로 몇 MB의 메모리를 사용합니다.

생성 속도

  • Goroutine
    • 메모리 사용이 적고, Go 런타임 스케줄러에 의해 관리되기 때문입니다.
  • 스레드
    • 전통적인 스레드는 생성 속도가 느리며, 더 많은 시스템 자원을 사용합니다.

동시성

  • Goroutine
    • Go는 동시성을 중요하게 생각하며, Goroutine을 사용하여 쉽게 동시에 실행되는 작업을 관리할 수 있습니다.
  • 스레드
    • 기존 스레드도 동시성을 지원하지만, 스레드 간의 컨텍스트 스위칭 비용이 높고, 더 많은 메모리를 사용합니다.

OS에서 여러 프로그래밍이 동작할 때 동시성 혹은 병렬성으로 동작합니다. 흔히 동시성이라고 하면 병렬성과 비슷한 개념이라고 생각할 수 있지만 이는 명확히 다른 개념입니다.

동시성은 싱글 코어에서 멀티 스레드를 동작시키는 논리적인 개념으로 한번에 여러개가 동시에 실행되는 것 처럼 보이게 됩니다. 하지만 병렬성은 물리적으로 동시에 여러작업을 처리할 수 있기 때문에 멀티 코어에서 멀티 스레드를 동작시키는 방식입니다.

블로킹

  • Goroutine
    • 하나의 Goroutine이 블로킹되어도 다른 Goroutine이 실행될 수 있습니다.
  • 스레드
    • 전통적인 스레드 모델에서는 하나의 스레드가 블로킹되면 해당 스레드의 자원이 낭비될 수 있습니다.

통신

  • Goroutine
    • Go의 Goroutine은 채널을 통해 안전하게 데이터를 교환할 수 있습니다.
  • 스레드
    • 전통적인 스레드는 스레드 간 통신을 위해 락이나 다른 동기화 메커니즘을 사용해야 합니다.

2. Go 런타임 스케줄러?

Go 런타임은 프로그램이 실행되는 내내 고루틴을 관리합니다. Goruntime에 포함되어 있는 Go 런타임 스케줄러가 실행하는 쓰레드 스케줄링에 의해서 실행됩니다.

모든 고루틴을 다중화된 스레드들에 할당하고 모니터링하며, 특정 고루틴이 블록되면 다른 고루틴이 실행될 수 있도록 교체하는 일을 반복합니다. 이 말은 고루틴이 블록 되더라도 다중화된 스레드는 블록 되지 않는다는 것을 의미합니다. 모든 것은 Go 런타임이 알아서 처리해 줍니다.

3. GMP 구조체

  • G (Goroutine) : 고루틴을 의미
    • 런타임이 고루틴을 관리하기 위해서 사용합니다.
    • 컨텍스트 스위칭을 위해 스택 포인터, 고루틴의 상태 등을 가지고 있습니다.
    • G는 LRQ에서 대기하고 있습니다.
  • M (Machine) : OS 스레드를 의미
    • M은 P의 LRQ로부터 G를 할당받아 실행합니다.
    • 고루틴과 OS 스레드를 연결하므로 스레드 핸들 정보, 실행중인 고루틴, P의 포인터를 가지고 있습니다.
  • P (Processor) : 논리 프로세서를 의미
    • 최대 GOMAXPROCS개를 가질 수 있습니다.
    • P는 컨텍스트 정보를 담고 있으며, LRQ를 가지고 있어서 G를 M에 할당합니다.

LRQ

  • 프로세스 P는 내부의 로컬 실행 큐 (Local Run Queue)를 가지고 있는데 큐에는 실행 가능한 고루틴이 저장되어 있습니다.
  • M은 프로세스 P의 작업 큐를 가져와서 순차적으로 실행하게 됩니다.

GRQ

  • Global Run Queue는 LRQ에 속해있지 않은 고루틴이 저장되게 됩니다. 고루틴은 최대 10ms 까지 동작합니다. 10ms가 지난 후에는 대기 상태에 들어가 GRQ에 들어가게 됩니다. 만약 하나의 P의 LRQ가 다 소진된다면 P는 먼저 GRQ로 부터 고루틴을 가져오고 GRQ도 비어있다면 다른 P의 LRQ로부터 작업을 뺏어옵니다(Work Stealing).

Goroutine 사용법

메서드 앞에 go 라는 키워드만 붙여주면 됩니다.

func main() {
	go doSomething()
    doAnotherThing()
}

이처럼 go라는 키워드를 붙혀주면 알아서 고루틴으로 동작하게 됩니다.

위처럼 작성한 코드는 아래와 같이 작동하게 됩니다.

func main() {
	runtime.newProc(...)
    doAnotherThing()
}

아래는 익명함수(함수명을 갖지 않는 함수)를 활용한 예시입니다.

func main() {
	for i := 0; i < 3; i++ {
	    go func(n int) {
	            f.Println("goroutine : ", n)
	            oneTime.Do(Hello)
	        }(i)
	    }
}

1. 진짜 동시에 실행되는 게 맞아?

아래와 같은 코드가 있다고 가정하자.

package main

import (
   "fmt"
   "time"
)

// 공유 자원
var count int

// 0 ~ 49까지 총 50번 반복
// 각 반복에서 count를 1씩 증가시키고 값을 출력

func sub() {
   for i := 0; i < 50; i++ {
      count++
      time.Sleep(time.Second / 10)
      fmt.Printf("sub: %d\n", count)
   }
}

func main() {
   go sub() // sub() 함수를 고루틴으로 시작

   for i := 0; i < 50; i++ {
      count++
      time.Sleep(time.Second / 10)
      fmt.Printf("main: %d\n", count)
   }
}

… 이하 생략

위처럼 동시에 작업이 일어남을 알 수가 있다.

하지만, 여기서 count 라는 공유자원을 sub 함수와 main 함수 두 곳에서 동시에 접근하고 변경합니다. 즉, count 값의 증가와 출력 순서가 예측 불가능해집니다. 보통 뮤텍스를 통해 해결하지만 Go에서는 채널 이라는 개념을 활용해서 해결한다.

2. 채널

Go 언어에서 채널이란 고루틴끼리 데이터를 주고받는 통로(파이프)의 역할을 수행합니다.

버퍼 없는 채널 (Unbuffered Channel)

  • 동작 방식: 데이터를 전송하는 고루틴은 수신자가 데이터를 받을 때까지 잠금 마찬가지로, 데이터를 받는 고루틴은 데이터가 전송될 때까지 블록됩니다.
  • 데이터가 반드시 교환될 수 있도록 보장한다.
  • 사용 예: 동기화와 직접적인 통신을 위해 사용됩니다.
package main
 
func main() {
  // 정수형 채널을 생성한다 
  ch := make(chan int)
 
  go func() {
    ch <- 123   // 채널에 123을 보낸다
  }()
 
  var i int
  i = <- ch  // 채널로부터 123을 받는다
  println(i)
}

버퍼 있는 채널 (Buffered Channel)

  • 동작 방식: 버퍼 크기만큼 데이터를 저장할 수 있습니다. 버퍼가 가득 차기 전까지는 데이터를 전송하는 고루틴이 잠금이 걸리지 않습니다. 마찬가지로, 버퍼에 데이터가 있으면 데이터를 받는 고루틴이 잠금이 걸리지 않습니다.
  • 데이터가 반드시 교환될 수 있도록 보장하지 않는다.
  • 생성 방법: make 함수를 사용하여 버퍼 크기를 지정합니다. 예: ch := make(chan int, 100)
  • 사용 예: 비동기적인 작업 처리, 고루틴 간의 속도 차이를 완화하기 위해 사용됩니다.
package main

import (
   "fmt"
   "time"
)

func main() {
   // 버퍼채널은 두번째 매개변수로 버퍼의 갯수를 지정하며 생성합니다.
   ch := make(chan string, 1)
   go sendToChannel(ch)
   
   <-time.After(time.Second)
   fmt.Println("main routine finish")
}

func sendToChannel(ch chan string) {
   // 버퍼채널이 아니라면 수신대기가 없다면 블락
   // 버퍼채널은 수신대기가 없어도 블락되지않고 로직은 진행
   ch <- "hello"
   fmt.Println("send finish")
}

버퍼 있는 채널의 사용 상황

  1. 비동기 처리: 데이터를 생성하는 고루틴과 처리하는 고루틴의 작업 속도가 다를 때, 버퍼를 사용하여 일시적인 데이터 증가를 처리할 수 있습니다.
  2. 속도 차이 완화: 데이터를 생산하는 고루틴이 소비하는 고루틴보다 빠르게 데이터를 생성할 때, 버퍼를 사용하여 속도 차이를 완화할 수 있습니다.
  3. 자원 제한: 버퍼 크기를 제한하여 메모리 사용량을 제어할 수 있습니다.
  4. 병렬 처리: 여러 고루틴이 동시에 데이터를 처리할 때, 버퍼를 사용하여 각 고루틴이 독립적으로 작업을 수행할 수 있도록 합니다.

주의할 점

  • 데드락: 버퍼 크기와 고루틴의 동작 방식을 잘 이해하지 않으면 데드락이 발생할 수 있습니다.
  • 메모리 사용: 버퍼 크기가 너무 크면 메모리 사용량이 증가할 수 있습니다.
  • 디버깅 어려움: 버퍼를 사용하면 프로그램의 흐름이 복잡해져 디버깅이 어려워질 수 있습니다.
profile
오물쪼물 코딩생활 ๑•‿•๑

0개의 댓글