ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Golang] 고루틴
    언어/Golang 2017. 2. 22. 20:40

    개요

    고루틴은 가벼운 스레드와 같은 것으로 현재 수행 흐름과 별개의 흐름을 만들어줍니다.  "go" 키워드를 사용하여 함수를 호출하면, 런타임시 새로운 goroutine을 실행합니다. goroutine은 비동기적으로(asynchronously) 함수루틴을 실행하므로, 여러 코드를 동시에(Concurrently) 실행하는데 사용됩니다.

    goroutine은 OS 쓰레드보다 훨씬 가볍게 비동기 Concurrent 처리를 구현하기 위하여 만든 것으로, 기본적으로 Go 런타임이 자체 관리합니다. Go 런타임 상에서 관리되는 작업단위인 여러 goroutine들은 종종 하나의 OS 쓰레드 1개로도 실행되곤 합니다. 즉, Go루틴들은 OS 쓰레드와 1 대 1로 대응되지 않고, Multiplexing으로 훨씬 적은 OS 쓰레드를 사용합니다. 메모리 측면에서도 OS 쓰레드가 1 메가바이트의 스택을 갖는 반면, goroutine은 이보다 훨씬 작은 몇 킬로바이트의 스택을 갖습니다(필요시 동적으로 증가). Go 런타임은 Go루틴을 관리하면서 Go 채널을 통해 Go루틴 간의 통신을 쉽게 할 수 있도록 하였습니다.


    고루틴을 생성하는 방법은 아주 간단합니다. 

    go f(x,y,z)

    앞에 go를 붙여 이와 같이 함수를 호출하게 되면 f(x,y,z) 호출과 현재 함수의 흐름은 메모리를 공유하는 논리적으로 별개의 흐름이 됩니다. 여기서 논리적으로 별개의 흐름이라고 한 이유는 물리적으로 별개의 흐름이 되는 것과는 구분되기 때문입니다.

    병렬성과 병행성

    물리적으로 별개의 흐름이란 정말로 동시에 각각의 흐름이 수행되는 경우를 뜻합니다. 두 사람이 동시에 각각 업무를 보고 있다면 물리적으로 별개로 업무를 수행하는 것입니다. 이것을 병렬성(Parallelism)이라 합니다.

    반면 커피를 마시면서 신문을 보고 있는 사람이 있다면 물리적으로 두 흐름이 동시에 수행되는 것은 아닙니다. 커피를 마시기 위하여 신문 보는 것을 짧은 시간 잠시 중단하고 커피를 한 모금 마신 뒤에 다시 신문 보는 일로 돌아오는 경우는 물리적으로 두 흐름이 있지는 않지만, 동시에 두 가지를 하고 있습니다. 이것을 동시성 혹은 병행성(concurrency)라고 합니다. 물리적으로 동시에 수행되지는 않지만 순차적으로 수행되는 것과는 큰 차이가 있습니다. 즉, 커피 한잔을 모두 다 마시는 동안 신문을 전혀 보지 않고, 반드시 커피를 다 마시고 난 다음에 신문을 보기 시작해야 하는 것이 아닙니다. 이 과정에서 신문 기사 10문단을 읽는 사이에 커피 10모금을 마신다고 할 때 2번째 커피 모금을 넘기는 것과 신문 기사의 다섯 번째 문단을 읽는 것중에서 어느 것을 먼저 해야하는지는 그다지 상관이 없는 것이 됩니다.  

    이때 동시성이 있는 두 루틴은 서로 의존관계가 없습니다. 

    동시성은 병렬성과 다르지만 동시성이 있어야 병렬성이 생기게 됩니다. 서로 어느 것이 먼저 되어야 하는 의존관계가 있는 것은 함께 진행될 수 없습니다. 장갑을 끼면서 동시에 구두를 신을 수는 있지만, 양말을 신으면서 동시에 구두를 신을 수는 없습니다.즉 양말과 구두를 신는 것에 대해 동시성이 없으므로 병렬성이 성립되지 않습니다.


    아래 예제에서 In goroutine과 In main routine 중에서 어느 것이 먼저 출력될 지 알 수 없게 됩니다.


    func main() {
    	go func() {
    		fmt.Println("In goroutine")
    	}()
    	fmt.Println("In main routine")
    }

    심지어 메인 함수가 끝나버리면 고루틴이 모두 수행되지 않을 수도 있습니다. 즉 위 코드만 실행하면 In goroutine이 실행되지 않을 수도 있습니다.

    고루틴 익명함수 (클로저 고루틴으로 실행)

    고루틴(goroutine)은 익명함수(anonymous function)에 대해 사용할 수도 있습니다. 즉, go 키워드 뒤에 익명함수를 바로 정의하는 것으로, 이는 익명함수를 비동기로 실행하게 됩니다. 아래 예제에서 첫번째 익명함수는 간단히 Hello 문자열을 출력하는데, 이를 goroutine으로 실행하면 비동기적으로 그 익명함수를 실행하게 됩니다. 두번째 익명함수는 파라미터를 전달하는 예제로 익명함수에 파라미터가 있는 경우, go 익명함수 바로 뒤에 파라미터(Hi)를 함께 전달하게 됩니다.

    package main
     
    import (
        "fmt"
        "sync"
    )
     
    func main() {
        // WaitGroup 생성. 2개의 Go루틴을 기다림.
        var wait sync.WaitGroup
        wait.Add(2)
     
        // 익명함수를 사용한 goroutine
        go func() {
            defer wait.Done() //끝나면 .Done() 호출
            fmt.Println("Hello")
        }()
     
        // 익명함수에 파라미터 전달
        go func(msg string) {
            defer wait.Done() //끝나면 .Done() 호출
            fmt.Println(msg)
        }("Hi")
     
        wait.Wait() //Go루틴 모두 끝날 때까지 대기

    여기서 sync.WaitGroup을 사용하고 있는데, 이는 기본적으로 여러 Go루틴들이 끝날 때까지 기다리는 역할을 합니다. WaitGroup을 사용하기 위해서는 먼저 Add() 메소드에 몇 개의 Go루틴을 기다릴 것인지 지정하고, 각 Go루틴에서 Done() 메서드를 호출합니다 (여기서는 defer 를 사용). 그리고 메인루틴에서는 Wait() 메서드를 호출하여, Go루틴들이 모두 끝나기를 기다립니다.

    고루틴 기다리기

    따로 노는 고루틴들을 제어하기 위해 싱크 라이브러리가 제공됩니다. 이는 위에서 이미 사용한 sync.WaitGroup 라이브러리입니다. 위의 코드를 예제로 다시 설명해보겠습니다.

    wait 변수에는 기본값이 0으로 맞춰져 있는 카운터가 들어 있습니다.

    Wait() 함수는 이 카운터가 0이 될 때까지 기다립니다.

    Add()함수는 호출될 때마다 숫자를 더합니다. 

    Done()함수는 사실상 Add(-1)과 같다고 보면 되지만 다 되었다는 것을 표현해두는 것이 더 알기 쉽기 때문에 Done()을 쓰는 것이 좋습니다.

    그렇기 떄문에 처음에 WaitGroup을 만들자마자 wait을 할 개수만큼 카운터를 증가시키고 각각의 고루틴에서는 작업이 완료될 때마다 카운터를 감소시킵니다. 모든 고루틴이 끝나면 카운터가 0이 되는데 이 상태가 되기 전까지는 Wait() 부분에 멈춰 있게 됩니다. 


    미리 고루틴이 몇 개 생길지 알기 때문에 이렇게 작성이 가능하지만, 고루틴이 몇 개 생길지 알기 어렵거나 따로 수를 세어야 알 수 있는 경우에는 고루틴을 띄워보내기 전에 Add(1)을 수행하여 하나씩 카운터를 증가시킬 수 있습니다.

    var wg sync.WaitGroup
    for _, url := range urls {
    	wg.Add(1)
    	go func(url string) {
    		defer wg.Done()
    		if _, err := download(url); err != nil {
    			log.Fatal(err)
    		}
    	}(url)
    }
    wg.Wait()

    주의할 점은 wg.Add(1)을 고루틴 내부에 포함시키면 안되는 점입니다.  대체로 문제없이 동작하겠지만 고루틴 내부의 wg.Add(1)이 수행되기 전에 메인 고루틴이 wg.Wait()을 통과해버릴 가능성이 있기 때문입니다. 이런 상태를 레이스 컨디션(race condition)이라고 합니다.

    다중 CPU 처리 (멀티코어 활용)

    Go 언어는 CPU 코어를 한 개만 사용하도록 설정되어 있습니다. 즉, 여러 개의 Go 루틴을 만들더라도, 1개의 CPU Go 1.5 버전 이상에서는 기본값으로 물리CPU 개수만큼 사용하도록 설정이 되어(1.5 이하는 CPU 1개 기본값) 물리 CPU개수에서 작업을 시분할하여 처리합니다(Concurrent 처리). 만약 머신이 복수개의 CPU를 가진 경우, Go 프로그램을 다중 CPU에서 병렬처리 (Parallel 처리)하게 할 수 있는데, 병렬처리를 위해서는 아래와 같이 runtime.GOMAXPROCS(CPU수) 함수를 호출하여야 합니다(여기서 CPU 수는 Logical CPU 수를 가리킴). 다음은 시스템의 모든 CPU 코어를 사용하는 방법입니다.

    package main
    
    import (
    	"fmt"
    	"runtime"
    )
    
    func main() {
    	runtime.GOMAXPROCS(runtime.NumCPU()) // CPU 개수를 구한 뒤 사용할 최대 CPU 개수 설정
    
    	fmt.Println(runtime.GOMAXPROCS(0)) // 설정 값 출력
    
    	s := "Hello, world!"
    
    	for i := 0; i < 100; i++ {
    		go func(n int) { // 익명 함수를 고루틴으로 실행
    			fmt.Println(s, n)
    		}(i)
    	}
    
    	fmt.Scanln()
    }

    runtime.NumCPU() 함수로 현재 시스템의 CPU 코어 개수를 구한 뒤 runtime.GOMAXPROCS() 함수에 설정해줍니다. 이렇게 하면 모든 CPU 코어에서 고루틴을 실행할 수 있습니다. runtime.GOMAXPROCS() 함수는 CPU 코어 개수를 구하지 않고, 특정 값을 설정해도 됩니다. 그리고 runtime.GOMAXPROCS() 함수에 0을 넣으면 설정 값은 바꾸지 않고 현재 설정된 값만 리턴합니다.

    공유메모리와 병렬 최소값 찾기

    고루틴들은 서로 메모리를 공유합니다. 고루틴이 변수의 포인터를 받아서 해당 변수에 원하는 값을 넣어줄 수도 있습니다. 큰 배열에 계산 결과를 넣어야 한다고 해도 영역별로 슬라이스로 나눠서 여러 고루틴에 각각의 슬라이스들은 나눠주어서 합쳐진 결과값을 받을 수 있습니다. 모든 고루틴이 다 끝났는지는 위에서 배운 WaitGroup과 같은 것을 이용하면 되는 것입니다.

    공유 메모리를 이용하여 매우 병렬화가 잘 되는 예제를 설명하겠습니다. 최소값 찾기 예제는 서로 소통할 필요가 거의 없는 종류의 예제입니다. 아주 큰 배열에서 가장 작은 수를 찾고 싶다고 가정합니다. 일단 병렬화하지 않고 가장 작은 수를 찾는 함수부터 설명하겠습니다.

    package main
    
    import "fmt"
    
    func Min(a []int) int {
    	if len(a) == 0 {
    		return 0
    	}
    	min := a[0]
    	for _, e := range a[1:] {
    		if min > e {
    			min = e
    		}
    	}
    	return min
    }
    
    func main() {
    	fmt.Println(Min([]int{83, 46, 49, 23, 92, 48, 39, 91, 44, 99, 25, 42, 35, 56, 23}))
    }
    // 결과
    // 23


    이제 병렬버젼을 설명하겠습니다. 사람 4명이서 가장 작은 수를 찾는다고 생각해보면, 전체를 넷으로 나눠서 각자 가장 작은 수를 찾은 다음에 그중에서 가장 작은 수를 한번 더 찾으면 됩니다. 그래서 아주 쉬운 병렬(embarrassingly parallel) 문제입니다.
    아래 함수에서 매개변수 n은 몇 개의 고루틴을 생성할 것인지를 지정합니다.


    package main
    
    
    import (
    	"fmt"
    	"sync"
    )
    
    func Min(a []int) int {
    	if len(a) == 0 {
    		return 0
    	}
    	min := a[0]
    	for _, e := range a[1:] {
    		if min > e {
    			min = e
    		}
    	}
    	return min
    }
    
    func ParallelMin(a []int, n int) int {
    	if len(a) < n {
    		return Min(a)
    	}
    	mins := make([]int, n)
    	size := (len(a) + n - 1) / n
    	var wg sync.WaitGroup
    	for i := 0; i < n; i++ {
    		wg.Add(1)
    		go func(i int) {
    			defer wg.Done()
    			begin, end := i*size, (i+1)*size
    			if end > len(a) {
    				end = len(a)
    			}
    			mins[i] = Min(a[begin:end])
    		}(i)
    	}
    	wg.Wait()
    	return Min(mins)
    }
    
    func main() {
    	fmt.Println(ParallelMin([]int{83, 46, 49, 23, 92, 48, 39, 91, 44, 99, 25, 42, 35, 56, 23}, 4))
    }
    // 결과
    // 23
    
    

    위의 예제에서는 고루틴 내에서 mins[i]에 결과를 넣어줍니다. 고루틴들이 메모리를 공유하는 모델이기 때문에 같은 배열에 접근이 가능한 것입니다. i 번째 고루틴은 i번째에 값을 넣게 됩니다. 


    댓글