Post

22. 고루틴과 동시성 프로그래밍

22. 고루틴과 동시성 프로그래밍

✅ 1. 스레드

  • 프로세스 안의 작업 단위
  • CPU 코어가 스레드를 빠르게 전환하며 수행하면 사용자 입장에서는 마치 동시에 수행하는 것처럼 보인다

1.1 컨텍스트 스위칭 비용

  • CPU 코어가 여러 스레드를 전환하며 수행하는 것을 컨텍스트 스위칭(context switching)이라고 한다
  • 스레드의 명령 포인터가 현재 스레드의 상태를 저장하고, 저장한 데이터를 스레드 컨텍스트라고 한다
  • 스레드가 전환될 때마다 명령 포인터가 컨텍스트를 저장하므로 전환 비용이 발생하며 따라서 적정 개수를 넘어 너무 많은 스레드를 수행하면 성능이 저하된다
  • 하지만 GO언어에서는 CPU 코어마다 OS 스레드를 하나만 할당하기 때문에 스위칭 비용이 발생하지 않는다

✅ 2. 고루틴 사용

  • 모든 프로그램은 고루틴을 한 개 이상 가진다. 고루틴은 main() 함수와 함께 시작되고 main() 함수가 종료되면 종료된다. 또, 메인 루틴이 종료되면 프로그램도 종료된다
1
go 함수_호출
  • go 키워드와 함수 호출로 새로운 고루틴을 생성한다. 호출된 함수는 현재 고루틴이 아닌 새로운 고루틴에서 실행된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
	"fmt"
	"time"
)

func PrintHangeul() {
	hangeuls := []rune{'가', '나', '다'}

	for _, v := range hangeuls {
		time.Sleep(300 * time.Millisecond)
		fmt.Printf("%c ", v)
	}
}

func PrintNum() {
	nums := []int{1, 2, 3}

	for _, v := range nums {
		time.Sleep(400 * time.Millisecond)
		fmt.Printf("%d ", v)
	}
}

func main() {
	go PrintHangeul()
	go PrintNum()

	time.Sleep(3 * time.Second) // 가 1 나 2 다 3
}
  • 위 예제에서 메인 루틴이 3초 대기하지 않으면 메인루틴이 먼저 종료되므로 남은 고루틴들은 즉시 종료되어 결과를 출력하지 않는다

2.1 서브 고루틴이 종료될 때까지 대기하기

  • sync 패키지의 WaitGroup 객체를 사용하면 고루틴이 종료될 때까지 대기할 수 있다
    1
    2
    3
    4
    5
    
      var wgs sync.WaitGroup
    	
      wg.Add(3) // 작업 개수 설정
      wg.Done() // 작업이 완료될 때마다 호출
      wg.Wait() // 모든 작업이 완료될 때까지 대기
    
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func Sum(a, b int) {
	sum := 0
	defer wg.Done()

	for i := a; i <= b; i++ {
		sum += i
	}
	fmt.Printf("Sum a to b : %v\n", sum)
}

func main() {
	wg.Add(10)

	for range 10 {
		go Sum(1, 1000000000)
	}

	wg.Wait()
	fmt.Println("작업 완료")
}
  • 10개의 고루틴을 생성하고 각 고루틴이 종료될 때 wg.Done()를 호출한다
  • wg.Wait()은 남은 작업 개수가 0이 되면 종료된다

✅ 3. 고루틴 동작 방법

2코어 컴퓨터를 가정한다

3.1 고루틴이 세 개일 때

  • 첫 번째 코어와 두 번째 코어가 각각 OS 스레드를 생성해서 고루틴1, 고루틴2를 실행한다
  • 세 번째 고루틴은 코어가 남아있지 않기 때문에 남는 코어가 생길 때까지 대기한다(?)
    • 세 번째 고루틴은 남는 코어가 생길 때까지 실행되지 않고 멈춰있는다(?)
    • Goroutine 스케줄링
  • 1.14 버전부터 선점형 스케줄링을 사용해서 고루틴을 돌린다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

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

func spin(id int) {
	start := time.Now()
	for time.Since(start) < 2*time.Second {
		// 바쁘게 돌기
	}
	fmt.Println("done", id)
}

func main() {
	runtime.GOMAXPROCS(2) // 2코어처럼 동작
	go spin(1)
	go spin(2)
	go spin(3)
	go spin(4)
	time.Sleep(3 * time.Second) // 4 2 3 1
}

3.2 시스템 콜 호출 시

  • 시스템 콜이란 운영체제가 지원하는 서비스를 호출할 때를 말한다. 시스템콜을 호출하면 운영체제에서 해당 서비스가 완료될 때까지 대기해야 한다
  • 고루틴이 시스템콜 작업을 하는 경우 대기 상태로 보내고 다른 고루틴을 OS스레드에 할당하여 실행하도록 한다

3.3 장점

  • OS 스레드가 직접 컨텍스트 스위칭을 하지 않고 고루틴만 변경되어 스위칭 비용이 매우 적다

✅ 4. 동시성 프로그래밍 주의점

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
	var wg sync.WaitGroup

	account := &Account{0}

	wg.Add(1000)
	for range 1000 {
		go func() {
			account.Balance += 1000
			defer wg.Done()
		}()
	}
	wg.Wait()
	fmt.Println(account.Balance) // 879000
}
  • 위 예시에서 Balance값이 10000이 될 것 같지만, Balance가 순차적으로 1000씩 더하는 것이 아니기 때문에 매번 다른 값이 출력될 수 있다

✅ 5. 뮤텍스

  • 뮤텍스(mutex)를 이용하면 자원 접근 권한을 통제할 수 있다
  • 뮤텍스는 mutual exclusion의 약자로, 우리 말로 상호 배제로 직역할 수 있다
  • 뮤텍스의 Lock() 메서드를 호출해 뮤텍스를 획득할 수 있으며, 만약 다른 고루틴이 뮤텍스를 획득했다면 나중에 호출된 고루틴은 뮤텍스가 반납될 때까지 대기한다
  • 사용 중이던 뮤텍스는 Unlock() 메서드로 반납하며, 이후 대기하던 고루틴 중 하나가 뮤텍스를 획득한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
	"fmt"
	"sync"
)

type Account struct {
	Balance int
}

var mutex sync.Mutex

func main() {
	var wg sync.WaitGroup

	account := &Account{0}

	wg.Add(1000)
	for range 1000 {
		go func() {
			mutex.Lock() // mutex를 Lock하고자 하는 고루틴은 Unlock을 기다린다
			defer mutex.Unlock()
			account.Balance += 1000
			defer wg.Done()
		}()
	}
	wg.Wait()
	fmt.Println(account.Balance) // 1000000
}

✅ 6. 뮤텍스와 데드락

  • 뮤텍스의 단점
    1. 오직 하나의 고루틴만 공유 자원에 접근하도록 제한하므로 동시성 프로그래밍으로 인한 성능 향상을 얻지 못한다
    2. 데드락이 발생할 수 있다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main() {
	rand.Seed(time.Now().UnixNano())

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

	go Eat("A", fork, spoon, "fork", "spoon")
	go Eat("B", spoon, fork, "spoon", "fork")
	wg.Wait()
}

func Eat(name string, first, second *sync.Mutex, firstName, secondName string) {
	for range 100 {
		fmt.Printf("%v 밥을 먹으려 합니다.\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)

		first.Unlock()
		second.Unlock()
	}
	wg.Done()
}
// fatal error: all goroutines are asleep - deadlock!
  • 포크와 수저를 모두 획득해야 밥을 먹을 수 있지만 서로 하나씩 가지고 있는 상태에서 뮤텍스를 획득하지 못해 무한히 대기하고, Go언어에서는 데드락을 감지하여 에러를 반환한다
  • 뮤텍스는 꼬이지 않도록 좁은 범위에서 데드락에 걸리지 않는지 철저히 확인 후 사용하면 유용하고 손쉬운 방식이다
This post is licensed under CC BY 4.0 by the author.