[Go] Go Routine

Hoplin·2023년 8월 17일
0
post-thumbnail

Go Routine이란

Thread

스레드란 일종의 실행 단위이다. CPU 코어는 한번에 하나의 명령만 실행할 수 있다. 하나의 코어는 여러개의 스레드를 가질 수 있다. CPU가 싱글코어라고 가정했을때, 하나의 코어는 여러개의 스레드를 빠르게 전환해가며 수행함으로서 여러개의 작업이 동시에 수행되는것과 같은 효과를 낸다.

Context Switching

코어가 스레드간 전환을 할때 Context Switching이 발생한다. 스레드간의 전환을 하기 위해서는 스레드의 상태를 보관해야한다. 이때 명령 포인터, 스택 메모리 등의 정보를 저장하게 되고 이를 Thread Context라고 한다.

Thread Context는 스레드가 전환될때마다 저장하고 복원을 해야하는데 이로 인해 Context Switching비용이 발생한다. 결론적으로 스레드가 많아질수록 성능은 저하될 수 밖에 없다.

Go Routine

고루틴은 경량 스레드로서 함수나 명령을 동시에 실행할 때 사용한다. 모든 Go 소스코드의 시작점인 main() 함수 또한 Go Routine에 의해 실행된다.

경량스레드라고 하는 이유를 100% 이해하지는 못했으나, 현재까지 이해한것을 정리해본다. 우선 Go언어는 CPU 코어마다 OS 스레드를 하나만 할당하게 된다. 그리고 GoRoutine은 Go런타임에 의해서 스케줄링된다. Go Routine은 OS쓰레드와 1:1 매핑되지 않고 더 적은 OS 쓰레드를 사용한다. 반면에 Java의 Thread의 경우에는 운영체제에 의해 스케줄링이 된다. 그리고 Java의 스레드는 OS에 의해 스케줄링되며, OS의 커널 스레드와 1:1 매핑된다.

Go Routine의 경우에는 런타임에서 Go Routine에 대한 관리를 따로 하지 않는다. 또한 새로운 Go Routine에 대해서 생성 제거 작업 비용이 적다. 반면 Java의 Thread의 경우에는 OS로부터 리소스 요청, 리소스 반환이 이루어지므로 비용이 발생하고, 너무 많은 스레드로인한 성능 저하를 막기 위해 스레드풀을 사용하게 된다.

또한 메모리 및 Context Switching 비용 측면에서 Go Routine은 2kb의 스택공간과 필요에 따라 늘리게 되고, 고루틴 교체시 3개의 CPU 레지스터(PC,SP,DX)만 저장 복원된다. 반대로 스레드는 1MB의 스택공간을 최소로 하며 16개의 XMM레지스터, 16개의 AVX레지스터 등을 저장 복원해야한다.

  • PC : Program Counter 레지스터로, 다음 인출될 명령어의 주소를 가지고 있는 레지스터이다.
  • SP : Stack Pointer는 주소 레지스터의 일종이다. CPU는 현재 실행하고 있는 프로그램 메모리 영역을 스택으로 관리하는데, SP는 현재 스택 영역의 가장 최근에 저장된것을 가리키게된다.
  • DX : Data Register로서,데이터를 일시적으로 저장하고 조작하는 역할을 수행한다.

Golang에서 멀티코어 활용하기

Golang의 경우에는 1.5버전 이전에는 1개의 코어를 사용하는것이 기본이었지만, 1.5버전 이후부터는 사용 가능한 모든 코어를 사용하는것이 기본값이 되었다. 만약 가용 코어 개수를 조절하고 싶다면 runtime.GOMAXPROCS()를 사용하며, 인자로는 코어 개수를 넘겨준다. 만약 최대 코어 개수를 반환받고 싶은 경우 runtime.NumCPU()를 사용하면된다.

func main() {
	runtime.GOMAXPROCS(runtime.NumCPU())
    
    ...

Go Routine 사용하기

이제 Go Routine을 사용해본다. 앞서 말했듯이 기본적으로 main함수 또한 Go Routine으로 실행이 된다. 그리고 이를 메인 루틴이라고 한다. Go Routine을 생성하기 위해서는 아래와 같이 go키워드를 함수호출 앞에 붙여주면 된다.

go (함수 호출)

예시 코드를 작성해본다.

package main

import (
	"fmt"
	"runtime"
	"time"
)

func PrintNumber() {
	for i := 1; i <= 5; i++ {
		time.Sleep(time.Millisecond * 300)
		fmt.Printf("%d ", i)
	}
}

func PrintAlphabet() {
	for _, v := range []rune{'a', 'b', 'c', 'd', 'e'} {
		time.Sleep(400 * time.Millisecond)
		fmt.Printf("%c ", v)
	}
}

func main() {
	runtime.GOMAXPROCS(runtime.NumCPU())
	go PrintNumber()
	go PrintAlphabet()
	time.Sleep(4 * time.Second)
}

// 결과
1 a 2 b 3 c 4 5 d e 

위 코드를 보면 PrintNumber()PrintAlphabet()이 동시에 실행되는것을 알 수 있다. 의아할 수 있는점은 time.Sleep()을 한다는 것이다. 이 이유는 go키워드로 실행된 함수 호출의 경우에는 새로운 Go Routine에서 실행되게 된다 그렇기 때문에, 메인 루틴은 새로운 고루틴을 생성만 할뿐, 생성된 Go Routine을 기다리지 않는다. 만약 time.Sleep()을 지우고 실행하게 되면, PrintNumber()PrintAlphabet()의 결과가 출력되지 않는것을 볼 수 있다.

함수 리터럴(Function Literal,익명함수)를 Go Routine으로 활용하기

함수 리터럴 또한 일종의 함수이므로 Go Routine으로 실행할 수 있다.

단 주의할 점이 있다. 일반적인 함수 리터럴의 경우에는 반복문 내에서 리터럴이 실행된다.

func main() {
	s := "Hello world"
	for i := 0; i < 100; i++ {
		func(n int) {
			fmt.Println(s, n)
		}(i)
		fmt.Println("Loop : ", i)
	}
}

//결과
Hello world 0
Loop :  0
Hello world 1
Loop :  1
Hello world 2
Loop :  2
Hello world 3
Loop :  3
Hello world 4
...

하지만 고루틴으로 실행된 클로저의 경우에는 반복문(해당 고루틴이 생성되는 루프)이 끝난 뒤에 고루틴이 실행된다.

func main() {
	s := "Hello world"
	for i := 0; i < 100; i++ {
		go func(n int) {
			fmt.Println(s, n)
		}(i)
		fmt.Println("Loop : ", i)
	}
}

//결과
Loop :  0
Loop :  1
Loop :  2
Loop :  3
Loop :  4
Loop :  5
Loop :  6
Loop :  7
Loop :  8
Loop :  9
Loop :  10
Loop :  11
Loop :  12
Loop :  13
Loop :  14
Loop :  15
Loop :  16
Loop :  17
Loop :  18
Loop :  19
Loop :  20
Loop :  21
Loop :  22
Loop :  23
Loop :  24
Hello world 0
Loop :  25
Hello world 1
Hello world 2

sync.WaitGroup을 활용해 서브 Go Routine이 종료될때까지 기다리기

앞의 예시에서 Go Routine을 기다리기 위해 time.Sleep()을 했었다. 하지만, 이 방법은 안전하지 않은 방법이다. 함수가 완전히 실행되는데 드는 시간을 보장할 수 없기 때문이다. 이를 방지하기 위해 sync 패키지의 WaitGroup 객체를 사용하여 Go Routine이 모두 끝날때까지 기다릴 수 있다. WaitGroup객체에는 총 3개의 메소드가 있다.

  • Add(n int) : WaitGroup Counter에 작업 개수를 더한다.
  • Done() : 작업이 완료될때마다 호출한다
  • Wait() : WaitGroup Counter의 값이 0이 될때까지 기다린다.

한가지 짚고 넘어가자면 WaitGroup 또한 구조체이다.

type WaitGroup struct {
	noCopy noCopy

	state atomic.Uint64 // high 32 bits are counter, low 32 bits are waiter count.
	sema  uint32
}

예시 코드를 작성해본다.

func SumAToB(a, b int, wg *sync.WaitGroup) {
	sum := 0
	for i := a; i <= b; i++ {
		sum += i
	}
	fmt.Printf("%d 에서 %d까지의 합계는 %d이다\n", a, b, sum)
	defer wg.Done()
}

func main() {
	var wg = &sync.WaitGroup{}
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go SumAToB(1, 1000000000, wg)
	}
	wg.Wait()
	fmt.Println("계산끝")
}

//결과
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
1 에서 1000000000까지의 합계는 500000000500000000이다
계산끝

main 함수에서 WaitGroup을 선언한 후, Add 메소드를 통해 총 10개의 작업을 등록하였다. 그리고, Wait 메소드를 통해 WaitGroup Counter가 0이 될때까지 기다리도록 설정하고, Go Routine으로 실행할 SumAToB함수에 WaitGroup의 주소를 넘겨주어, 함수 실행이 끝날때마다 WaitGroup에 작업이 끝났음을 알려준다(defer키워드는 안써도 되지만 그냥 써보고싶었다).

위와 같이 작성하면, 10개의 작업이 끝날때까지 기다리는것을 확인할 수 있다.

profile
더 나은 내일을 위해 오늘의 중괄호를 엽니다

0개의 댓글