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
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.