CHAPTER 1. An Introduction to Concurrency
Moore’s Law, Web Scale, and the Mess We’re In
- 2012년 즈음까지는 Moore’s Law가 맞아들었지만, 그 이후부터는 상황이 달라졌다. 많은 회사들이 computing power를 향상시키기 위해 노력하였고, 그 결과 멀티코어 프로세서가 탄생하였다.
- 문제를 해결했다고 생각했지만 새로운 한계, Amdahl’s law를 마주했다.
- Amdahl’s law 참고) https://needjarvis.tistory.com/522
- 문제를 해결했다고 생각했지만 새로운 한계, Amdahl’s law를 마주했다.
- 2000년대 초반 cloud computing으로 인해 horizontal scaling이 매우 쉬워졌다. 직접 서버를 관리하는 대신 다른 회사의 데이터 센터를 이용할 수 있게 되었고, 저렴한 가격으로 강력한 computing power를 사용할 수 있게 되었다.
- 덕분에 여러 머신을 손쉽게 사용할 수 있게 되었지만, 이는 어떻게 코드를 concurrent 하게 모델링할 수 있을지에 대한 또 다른 고민을 낳았다. 이 문제를 잘 해결한 것이 있으니, 바로 web scale이다.
- 만약 소프트웨어에 web scale을 적용할 수 있다면 인스턴스 수를 늘려 쉽게 문제를 해결할 수 있을 것이다.
- 멀티 코어, cloud conputing, web scale 그리고 여러 문제와 함께하는 이 세상에서 우리는 어떻게 동시성 문제를 해결할 수 있는가.
Why Is Concurrency Hard?
동시성 코드에서 흔히 발견되는 문제를 살펴보자.
Race conditions
- Race consitions은 순서가 보장되어야 할 작업의 작동 순서가 그렇지 못한 경우 발생한다.
- 대표적으로 data race에서 발생하는 문제이다.
- 예) 한 작업이 값을 읽음과 동시에 다른 작업이 같은 값에 쓰기 작업을 하는 경우
문제 예시 코드
1 2 3 4 5 6 7
var data int go func() { data++ }() if data == 0 { fmt.Printf("the value is %v.\n", data) }
- 위 코드에선 세 가지의 각기 다른 결과가 나올 수 있다.
- 아무것도 출력하지 않기
the value is 0
the value is 1
- 이를 해결하기 위해 goroutine과 if 문 사이에
time.Sleep()
을 넣을 생각은 하지 말자. 애초에 논리적으로 틀린 코드다.
- 위 코드에선 세 가지의 각기 다른 결과가 나올 수 있다.
Atomicity
- Atomic 작업은 어떠한 context 안에서 indivisible 하거나 uninterruptible 하다는 것이다.
- Context의 정의는 중요하다. 어떤 process의 context 내에서 atomic한 작업이 OS의 context 내에서는 atomic하지 않을 수 있고, 어떤 OS의 context 내에서는 atomic한 작업이 application의 context 내에서는 atomic하지 않을 수 있다.
따라서 atomicity를 생각할 때는 먼저 어느 context, 어느 scope 내에서 이 작업이 atomic하다는 건지 정의해야 한다. - Atomic한 작업 여러 개를 묶어놓는다고 더 큰 atomic한 작업이 되지는 않는다.
- 동시성 프로그래밍에서 어떤 작업이 atomic하다면, concurrent context 내에서 해당 작업은 안전해진다.
- 동시성 작업이 없는 context를 갖는 코드는 그 context 내에서 atomic하다. 만약
i++
코드가 작성되어 있지만i
가 외부에 노출되지 않은 goroutine이라면, 이 또한 그 context 내에서 atomic하다.
- 동시성 작업이 없는 context를 갖는 코드는 그 context 내에서 atomic하다. 만약
Memory Access Synchronization
Data race가 발생하는 예시 코드를 통해 알아보도록 한다.
1
2
3
4
5
6
7
var data int
go func() { data++ }()
if data == 0 {
fmt.Println("the value is 0.")
} else {
fmt.Printf("the value is %v.\n", data)
}
- 공유된 자원에 독점적인 접근 권한이 필요한 부분을 critical section이라 한다.
- 위 코드에는 세 critical section이 존재한다.
data
를 증가시키는 gorountinedata
가0
인지 확인하는 if 문data
를 가져와 출력하는fmt.Printf
문
- 위 코드에는 세 critical section이 존재한다.
Critical section을 안전하게 만들기 위해 위 코드를 다음과 같이 수정할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var memoryAccess sync.Mutex
var value int
go func() {
memoryAccess.Lock()
value++
memoryAccess.Unlock()
}()
memoryAccess.Lock()
if value == 0 {
fmt.Printf("the value is %v.\n", value)
} else {
fmt.Printf("the value is %v.\n", value)
}
memoryAccess.Unlock()
- Memory access synchronization이 적용되었다.
- Data race는 해결하였지만, 여전히 race condition 문제가 남아있다. Goroutine이 먼저 실행될지 if-else block이 먼저 실행될지 아직도 알 수가 없다.
- 또한 이는 유지보수, 성능 문제 등을 만들 수도 있다. Critical section에 계속 입장/퇴장을 반복해야 하는지, critical section의 크기는 얼만큼이 적당한지에 대한 문제도 있다.
Deadlocks, Livelocks, and Starvation
Deadlock
- 모든 concurrent process가 서로를 기다리는 상태
- The Coffman Conditions: Deadlock이 발생하기 위해 필요한 조건
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type value struct {
mu sync.Mutex
value int
}
var wg sync.WaitGroup
printSum := func(v1, v2 *value) {
defer wg.Done()
v1.mu.Lock()
defer v1.mu.Unlock()
time.Sleep(2 * time.Second)
v2.mu.Lock()
defer v2.mu.Unlock()
fmt.Printf("sum=%v\n", v1.value+v2.value)
}
var a, b value
wg.Add(2)
go printSum(&a, &b)
go printSum(&b, &a)
wg.Wait()
go printSum(&a, &b)
에서 a가 잠기고go printSum(&b, &a)
에서 b가 잠긴 뒤 서로가 서로를 풀어주기를 기다리게 된다.- The Coffman Conditions를 적용하면 다음과 같다.
- 상호 배제:
printSum
함수는 자원a
와b
의 독점적인 권한을 요구한다. - 점유 대기: 첫 번째
printSum
프로세스는 자원a
를 가진 상태에서 자원b
를 기다리고 있다. 두 번째printSum
프로세스는 자원b
를 가진 상태에서 자원a
를 기다리고 있다. - 비선점: 코드의 gorountine은 자원을 뺏을 방법이 없다.
- 순환 대기: 첫 번째
printSum
프로세스는 두 번째printSum
프로세스를 기다리고, 두 번째printSum
프로세스는 첫 번째printSum
프로세스를 기다린다.
- 상호 배제:
- Deadlock은 네 조건 중 하나라도 만족하지 않으면 방지할 수 있지만 실제로는 이 조건을 찾아내기가 어렵다.
Livelock
- Concurrent 작업을 막 수행 중이지만 실제로 프로그램이 진행되지는 않는 경우
- 마치 서로가 서로에게 무한히 길을 비켜주는 상황과 같다.
Deadlock을 피하고자 되는대로 비켜주는 경우 자주 발생하는 문제다.
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
cadence := sync.NewCond(&sync.Mutex{}) go func() { for range time.Tick(1 * time.Millisecond) { cadence.Broadcast() } }() takeStep := func() { cadence.L.Lock() cadence.Wait() cadence.L.Unlock() } tryDir := func(dirName string, dir *int32, out *bytes.Buffer) bool { fmt.Fprintf(out, " %v", dirName) atomic.AddInt32(dir, 1) takeStep() if atomic.LoadInt32(dir) == 1 { fmt.Fprint(out, ". Success!") return true } takeStep() atomic.AddInt32(dir, -1) return false } var left, right int32 tryLeft := func(out *bytes.Buffer) bool { return tryDir("left", &left, out) } tryRight := func(out *bytes.Buffer) bool { return tryDir("right", &right, out) } walk := func(walking *sync.WaitGroup, name string) { var out bytes.Buffer defer func() { fmt.Println(out.String()) }() defer walking.Done() fmt.Fprintf(&out, "%v is trying to scoot:", name) for i := 0; i < 5; i++ { if tryLeft(&out) || tryRight(&out) { return } } fmt.Fprintf(&out, "\n%v tosses her hands up in exasperation!", name) } var peopleInHallway sync.WaitGroup peopleInHallway.Add(2) go walk(&peopleInHallway, "Alice") go walk(&peopleInHallway, "Barbara") peopleInHallway.Wait() // Barbara is trying to scoot: left right left right left right left right left right // Barbara tosses her hands up in exasperation! // Alice is trying to scoot: left right left right left right left right left right // Alice tosses her hands up in exasperation! // Alice와 Barbara는 먼저 takeStep()을 해도 cadence를 기다려야 한다. // 따라서 atomic.LoadInt32(dir) 값은 항상 2가 된다.
- Livelock은 starvation의 하위 집합이다.
Starvation
- Concurrent 작업이 필요한 자원을 얻지 못하는 상황
Livelock은 모든 concurrent 작업이 공평하게 필요한 작업을 얻지 못해 결국 어떤 작업도 끝내지 못하는 상황을 의미하지만 보통 starvation은 한 개 이상의 작업이 더 많은 자원을 가져가려 하므로 나머지 작업이 공평하게 최대 효율을 내지 못하는 상황을 의미한다.
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 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
var wg sync.WaitGroup var sharedLock sync.Mutex const runtime = 1 * time.Second greedyWorker := func() { defer wg.Done() var count int for begin := time.Now(); time.Since(begin) <= runtime; { sharedLock.Lock() time.Sleep(3 * time.Nanosecond) sharedLock.Unlock() count++ } fmt.Printf("Greedy worker was able to execute %v work loops\n", count) } politeWorker := func() { defer wg.Done() var count int for begin := time.Now(); time.Since(begin) <= runtime; { sharedLock.Lock() time.Sleep(1 * time.Nanosecond) sharedLock.Unlock() sharedLock.Lock() time.Sleep(1 * time.Nanosecond) sharedLock.Unlock() sharedLock.Lock() time.Sleep(1 * time.Nanosecond) sharedLock.Unlock() count++ } fmt.Printf("Polite worker was able to execute %v work loops.\n", count) } wg.Add(2) go greedyWorker() go politeWorker() wg.Wait() // Polite worker was able to execute 289777 work loops. // Greedy worker was able to execute 471287 work loops.
- 공유된
sharedLock
을greedyWorker
가 더 많이 점유하고 더 높은 성능을 냈다.
- 공유된
- Starvation은 CPU, 메모리, 파일, 데이터베이스 등 공유될 수 있는 자원만 있다면 발생할 수 있다.
- 위 코드의
politeWorker
처럼 memory access synchronization을 적용하는 건 비용이 많이 든다. 그렇다고 비용 감소를 위해greedyWorker
처럼 critical section을 확장시키면 starvation이 생긴다.
따라서 둘 사이에 균형을 잡아야 하며, 일반적으로 작은 critical section을 이후 확장하는 것이 그 반대 작업보다 쉽기 때문에 저자는 critical section에만 memory access synchronization을 적용하는 것을 추천한다.
Determining Concurrency Safety
1
2
3
// CalculatePi calculates digits of Pi between the begin and end
// place.
func CalculatePi(begin, end int64, pi *Pi)
위 코드를 보고 세 가지 질문을 할 수 있다.
- 어떻게 쓰라는 거지?
- 내가 알아서 concurrent하게 이 함수를 써야 하나?
Pi
에 대해서 내가 memory access synchronization을 적용해야 하나? 아니면Pi
내부에서 처리되는 건가?
이러한 질문이 들지 않도록 주석을 잘 달아주자.
1
2
3
4
5
6
7
// CalculatePi calculates digits of Pi between the begin and end
// place.
//
// Internally, CalculatePi will create FLOOR((end-begin)/2) concurrent
// processes which recursively call CalculatePi. Synchronization of
// writes to pi are handled internally by the Pi struct.
func CalculatePi(begin, end int64, pi *Pi)
동시성이 포함된 코드를 작성할 때에는 자세하게 주석을 달아 다음과 같은 질문이 나오지 않는 게 좋다.
- 누가 동시성을 담당하는 거지?
- Concurrency primitives가 어떻게 구성된 거지?
- Memory access synchronization은 누가 담당하고 있지?
아니면 함수형 프로그래밍 방식을 채택해 side effect를 제거하여 함수를 더 분명하게 만들 수도 있다.
1
func CalculatePi(begin, end int64) []uint
Memory access synchronization 문제는 해결되었지만 아직 함수가 동시성을 가졌는지는 분명하지 않다.
채널을 사용하여 이 함수가 동시성을 갖는다는 사실을 더 명확하게 할 수 있다.
1
func CalculatePi(begin, end int64) <-chan uint
하지만 함수가 더 명확해질수록 성능은 더 떨어질 수 있다. 둘 사이의 균형을 잘 맞추어야 한다.
Simplicity in the Face of Complexity
Go의 concurrency primitives를 통해 우리는 좋은 성능과 명확성을 가질 수 있다.
This post is licensed under CC BY 4.0 by the author.