[Go] 17. 고루틴

2024. 3. 11. 18:06Go

반응형

고루틴은 경량 스레드로, 함수 or 명령을 동시에 실행할 때 사용합니다.

main() 함수는 고루틴에 의해 실행되는 함수로, Go 프로그램은 최소 1개 이상의 고루틴으로 구성되어있습니다.

원래의 CPU코어는 한번에 하나의 명령을 수행하지만, 컨텍스트 스위칭을 이용하여 여러개의 스레드를 전환하며 실행할 수 있습니다.
다만, 컨텍스트 스위칭은 스레드 전환 이전에 instruction pointer(명령 포인터), 스택 메모리 등의 정보를 저장하여 작업중인 스레드를 기록하는 과정을 거치고, 복원하는 일을 거치는데 이러한 부분에 의해 컨텍스트 스위칭 비용 이 발생되게 됩니다.

CPU코어 수에 비해 스레드 수가 너무 많을시(CPU코어수의 2배이상으로 스레드를 설정), 컨텍스트 스위칭 비용에 의한 성능저하가 발생될 수 있습니다.

Golang 에서는 CPU코어마다 OS스레드를 하나만 할당 하기 때문에, 컨텍스트 스위칭 비용이 발생되지 않습니다.

고루틴은 함수 앞에 go 라는 키워드를 붙여 생성합니다.
고루틴 내에서 go 키워드로 고루틴을 생성하면 새로운 고루틴이 생성되어 새로운 고루틴에서 명령을 수행합니다.

Golang에서는 고루틴을 이용하여 OS스레드를 활용한 동시성 프로그래밍을 합니다.


1. 간단한 예제

각각 200ms, 300ms 씩 간격을 갖고 영문자와 숫자 한자씩 출력하는 함수를 고루틴으로 출력하는 예제입니다.

package main

import (
	"fmt"
	"time"
)

func printAlphabets() {
	alphabets := []rune{'A', 'B', 'C', 'D', 'E', 'F'}
	for _, alphabet := range alphabets {
		time.Sleep(200 * time.Millisecond)
		fmt.Printf("%c ", alphabet)
	}
}

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

func main() {
	go printAlphabets()
	go printNumbers()

	fmt.Println("main 1")
	time.Sleep(3 * time.Second)
	fmt.Println("main 2")
}
main 1
A 0 B C 1 D 2 E 3 F 4 5 6 main 2

go루틴으로 각각 호출했기 때문에 동시에 실행되며,
두 고루틴함수 내에는 문자 출력 이전에 time.sleep 을 갖고 있기 때문에 main 1 이 가장 먼저 출력된것을 확인할 수 있습니다.

여기에서, main함수내에 있는 time.Sleep 부분을 아래와 같이 600 밀리초로 수정할 경우,
printAlphabets()와 printNumbers() 고루틴을 마치기 전에 종료되는 것을 확인할 수 있습니다.

이는 메인 함수가 종료될 경우, 서브 고루틴(메인 내에서 호출한 모든 고루틴)이 즉시 종료 되기 때문입니다.

func main() {
	go printAlphabets()
	go printNumbers()

	fmt.Println("main 1")
	time.Sleep(600 * time.Millisecond)
	fmt.Println("main 2")
}
main 1
A 0 B main 2
1

2. 서브 고루틴 종료시 까지 기다리기

메인함수에서 서브고루틴이 모두 종료될때까지 기다리기 위해, time.Sleep 하는 방법도 있겠지만, 언제 종료될지 모르는 경우도 많기 때문에 우리는 보다 확실한 방법을 사용할 필요가 있습니다.

Golang에서는 고루틴이 종료될 때까지 대기하기 위한 sync패키기의 WaitGroup 객체를 지원합니다.

아래와 같이 sync.WaintGroup 객체를 생성한 후,

var wg sync.WaitGroup

총 이용할 서브 고루틴 개수를 Add 하고,

wg.Add(2)

서브고루틴의 동작을 모두 마친 후 Done 함수를 호출하면 작업 하나가 완료처리됩니다.

wg.Done()

서브 고루틴이 호출되는 상위 고루틴에서는, 작업그룹이 모두 완료될 때까지 대기하기 위해 Wait() 함수를 호출합니다.

wg.Wait()

2.1. sync.WaitGroup 적용

위에서 만들었었던 알파벳과 숫자를 출력하는 예제코드에 sync.WaitGroup 을 적용해봅시다.

package main

import (
	"fmt"
	"sync"
	"time"
)

var wg sync.WaitGroup

func printAlphabets() {
	alphabets := []rune{'A', 'B', 'C', 'D', 'E', 'F'}
	for _, alphabet := range alphabets {
		time.Sleep(200 * time.Millisecond)
		fmt.Printf("%c ", alphabet)
	}
	wg.Done()
}

func printNumbers() {
	for i := 0; i < 7; i++ {
		time.Sleep(300 * time.Millisecond)
		fmt.Printf("%d ", i)
	}
	wg.Done()
}

func main() {
	wg.Add(2)
	go printAlphabets()
	go printNumbers()
	wg.Wait()
	fmt.Println("main 2")
}
A 0 B 1 C D 2 E 3 F 4 5 6 main 2

실행하고자하는 고루틴함수가 2개이기 때문에, wg.Add(2) 설정하여 실행해보니
메인함수에 time.Sleep 를 설정하지 않아도 서브고루틴이 모두 종료될때까지 대기하는 것을 확인할 수 있었습니다.

2.2. 두번째 예제

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func sumAtoB(i, a, b int) {
	sum := 0
	for i := a; i <= b; i++ {
		sum += i
	}
	fmt.Printf("%d) %d 부터 %d 까지의 총 합 = %d\n", i, a, b, sum)
	wg.Done()
}
func main() {
	cnt := 10
	wg.Add(cnt)
	for i := 0; i < cnt; i++ {
		go sumAtoB(i, 1, 1000000)
	}
	wg.Wait()
}
0) 1 부터 1000000 까지의 총 합 = 500000500000
3) 1 부터 1000000 까지의 총 합 = 500000500000
9) 1 부터 1000000 까지의 총 합 = 500000500000
6) 1 부터 1000000 까지의 총 합 = 500000500000
7) 1 부터 1000000 까지의 총 합 = 500000500000
1) 1 부터 1000000 까지의 총 합 = 500000500000
4) 1 부터 1000000 까지의 총 합 = 500000500000
5) 1 부터 1000000 까지의 총 합 = 500000500000
8) 1 부터 1000000 까지의 총 합 = 500000500000
2) 1 부터 1000000 까지의 총 합 = 500000500000

3. 고루틴 사용시 주의 사항

고루틴을 활용한 동시성 프로그래밍을 이용할 때에는 공유자원 관련 이슈를 반드시 유의해야 합니다.
동시에 실행되는 작업에서 동일한 자원을 이용하게 될 경우, 작업이 실행도중 공유자원 값이 변경되어 원하는 동작과 다르게 결과가 나와 예상하지 못한 결과를 만날 수 있습니다.

이러한 이유로, 공유자원(객체 또는 DB자원 등) 을 이용할 경우에는 다른 고루틴이 자원을 변경하지 않도록 고려하여 코드를 작성해야합니다.

3.1. mutex

동시성 프로그래밍에서 공유자원 사용시 원치 않은 오류가 발생되지 않도록 하는 가장 단순한 방법으로는 mutex를 이용하는 방법입니다.

sync 내장 패키지의 Mutex를 이용하면 mutex를 간단히 활용할 수 있습니다.

공유자원을 사용하는 함수 내에 mutex.Lock() 을 이용하여, 다른 고루틴에서 공유자원을 사용하지 못하고 대기상태를 갖도록 합니다.

defer mutex.Unlock() 함수로 고루틴 종료시, mutex를 반환하게 하면 함수 종료시 대기중인 다른 고루틴에서 뮤텍스를 획득할 수 있도록 할 수 있습니다.

뮤텍스를 이용하는 함수에서는 반드시, Unlock()을 호출하여 뮤텍스를 반환해줘야 합니다!

var mutex sync.Mutex

func testFunc(account *Account) {
	mutex.Lock()
	defer mutex.Unlock()
	...
}

deadlock

하지만, 동시성 프로그래밍을 위해 고루틴을 작성한 코드에서 뮤텍스를 이용하는것은

  1. 동시성 프로그래밍으로써의 성능 이점의 장점을 놓치는 면과
  2. 뮤텍스를 잘못 사용할 경우, deadlock이 발생될 수 있는 문제점
    과 같은 문제사항이 있습니다.

아래의 예제를 보면서 데드락 이슈에 대해 생각해봅시다.

package main  

import (  
  "fmt"  
  "math/rand"  "sync"  "time")  

var wg sync.WaitGroup  

func dining(name string, first, second *sync.Mutex, firstName, secondName string) {  
  for i := 0; i < 10; i++ {  
   fmt.Printf("%s가 밥을 먹으려합니다.\n", name)  
   first.Lock()  
   fmt.Printf("%s가 %s를 획득했습니다.\n", name, firstName)  
   second.Lock()  
   fmt.Printf("%s가 %s를 획득했습니다.\n", name, secondName)  

   fmt.Printf("%s가 밥을 먹습니다.\n", name)  
   time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)  

   second.Unlock()  
   first.Unlock()  
  }  
  wg.Done()  
}  

func main() {  
  rand.New(rand.NewSource(time.Now().UnixNano()))  

  wg.Add(2)  
  fork := &sync.Mutex{}  
  spoon := &sync.Mutex{}  

  go dining("A", fork, spoon, "fork", "spoon")  
  go dining("B", spoon, fork, "spoon", "fork")  

  wg.Wait()  
}
B가 밥을 먹으려합니다.
B가 spoon를 획득했습니다.
B가 fork를 획득했습니다.
B가 밥을 먹습니다.
A가 밥을 먹으려합니다.
B가 밥을 먹으려합니다.
B가 spoon를 획득했습니다.
A가 fork를 획득했습니다.
fatal error: all goroutines are asleep - deadlock!

뮤텍스 이름을 fork, spoon 로 정의하고, 각 뮤텍스를 점유한 경우에 fork나 spoon을 획득한 경우로 생각해봅시다.
포크랑 스푼이 모두 있는 경우에 식사를 할 수 있으며, 식사를 마친 후 뮤텍스를 반환합니다.

실행 결과를 보면, 처음에 B가 먼저 뮤텍스를 점유하며 식사를 마쳤지만,
뮤텍스 반환 후 다음 loop를 실행하려고 할 때, A가 fork 뮤텍스를 획득해버리는 바람에 양쪽 모두 나머지 뮤텍스를 획득하지 못하여 무한정 대기하는 상태인 데드락에 빠지는 현상이 발생하였습니다.

이처럼 동시성 프로그래밍에서 뮤텍스를 사용하는 경우에는 성능 저하 이슈 뿐만 아니라, 예상하지 못한 데드락 을 마주할 수 있기 때문에 유의해야 합니다.


3.2. 자원 이슈 해결하는 방법 또다른 방법

뮤텍스를 사용하는 이유는 공유자원 접근에 의한 side effect를 방지하기 위함입니다.
하지만 뮤텍스를 이용하면, 성능저하나 데드락 등의 문제가 발생할 수 있습니다.

그렇다면, 뮤텍스를 이용하지 않고 자원 이슈를 해결할 수 있는 방법은 어떤게 있을까요?

공유 자원을 동시에 접근하지 않도록 코드를 작성하면 문제는 해결됩니다.

  • 각 고루틴마다 서로 다른 영역을 작업하도록 하거나
  • 각자 다른 역할을 수행하여 작업간의 간섭이 없도록 하면 됩니다.
package main  

import (  
  "fmt"  
  "sync"  "time")  

type Job interface {  
  Do()  
}  

type PowerJob struct {  
  num int  
}  

func (pj *PowerJob) Do() {  
  fmt.Printf("%d 거듭제곱 job start\n", pj.num)  
  time.Sleep(500 * time.Millisecond)  
  fmt.Printf("%d 거듭제곱 job end! 결과는 %d\n", pj.num, pj.num*pj.num)  
}  

func main() {  
  var jobs [10]Job  
  size := 10  

  for i := 0; i < size; i++ {  
   jobs[i] = &PowerJob{i}  
  }  

  var wg sync.WaitGroup  
  wg.Add(size)  

  for i := 0; i < len(jobs); i++ {  
   job := jobs[i]  
   go func() {  
    job.Do()  
    wg.Done()  
   }()  
  }  
  wg.Wait()  

}
2 거듭제곱 job start
7 거듭제곱 job start
9 거듭제곱 job start
6 거듭제곱 job start
8 거듭제곱 job start
4 거듭제곱 job start
5 거듭제곱 job start
0 거듭제곱 job start
1 거듭제곱 job start
3 거듭제곱 job start
2 거듭제곱 job end! 결과는 4
4 거듭제곱 job end! 결과는 16
6 거듭제곱 job end! 결과는 36
8 거듭제곱 job end! 결과는 64
0 거듭제곱 job end! 결과는 0
5 거듭제곱 job end! 결과는 25
3 거듭제곱 job end! 결과는 9
9 거듭제곱 job end! 결과는 81
7 거듭제곱 job end! 결과는 49
1 거듭제곱 job end! 결과는 1

위의 예제에서, 각 고루틴은 배열 중 각기 다른 인덱스를 접근하기 때문에 공유자원을 사용하지 않아 공유자원 관련 이슈를 방지할 수 있습니다.

이렇게 코드를 작성하면 뮤텍스를 사용하지 않고도 동시성 프로그래밍 사용시 발생할 수 있는 문제를 해결할 수 있습니다.

728x90
반응형

'Go' 카테고리의 다른 글

[Go] 19. 테스트와 벤치마크  (0) 2024.03.13
[Go] 18. 채널  (0) 2024.03.12
[Go] 16. 에러 핸들링  (0) 2024.03.10
[Go] 15. 자료구조 - list, ring, map  (0) 2024.02.05
[Go] 14. 함수 고급편  (0) 2024.02.04