개요
이번 포스팅은 Golang을 할 때 가장 큰 장점으로 꼽히는 부분인 동시성(고루틴)과 크게 관련된 내용입니다.
동시성 프로그래밍은 사실 쉽지 않습니다.
동시에 실행된다는 개념 때문에 프로그래머가 생각하는 순서와 실제 프로세스의 순서가 다를 수 있습니다.
이 문제를 레이스 컨디션이라 부릅니다.
레이스 컨디션 (Race Condition) : 개발자의 의도와 상관없이 프로세스의 순서가 바뀌어 결과가 일정하지 않은 문제
아래는 간단한 레이스 컨디션의 예제입니다.
var num int
go func() {
num++
}()
if num == 0 {
fmt.Printf("The value is %d.\n", num)
}
위 코드의 결과는 대부분 "The value is 0."로 출력될 겁니다.
요즘 사용하는 컴퓨터의 수준은 대부분 일정하게 보장 때문에 같은 순서로 실행되는 것이죠.
위 값에서 위 코드는 다음 순서로 코드가 실행됩니다.
- 고루틴으로 함수 스케줄링 -> 조건문 검사 -> print -> 고루틴에 스케줄링된 함수 실행
하지만 만약 조건문의 실행시간이 길어진다는 가정을 한다면 고루틴이 먼저 실행되어 기존의 값과 다른 값이 나올 수 있죠
즉 똑같이 "The value is 0."가 나오는 게 아닌 다른 값이 나올 수 있다는 겁니다
- ex) "The value is 1." 출력
만약 이 문제는 실제 서비스에 있다고 생각하다면
수년간 발생하지 않다가 갑자기 발생할 수 있는 문제입니다.
거기에 같은 상황을 재현하기도 힘들다는 문제는 덤이죠.
즉 매우 중요한 부분입니다.
하지만 개발자들이 그런걸 내버려 둘리가 없죠
레이스 컨디션 문제는 sync.Mutex를 통해 간단히 해결할 수 있습니다.
var memoryAccess sync.Mutex
var num int
go func() {
// memoryAccess.Lock()을 호출하면 Unlock()을 호출하기 전까지 다른 곳에서 Lock()을 호출할 수 없음
memoryAccess.Lock()
num++
memoryAccess.Unlock()
}()
// Unlock()을 호출할 때 까지 기다림
memoryAccess.Lock()
if num == 0 {
fmt.Printf("The value is %d.\n", num)
} else {
fmt.Printf("The value is %d.\n", num)
}
memoryAccess.Unlock()
위처럼 프로그래밍을 한다면 Race Condition문제는 쉽게 해결할 수 있습니다.
하지만 동시성에서는 그것 말고도 데드락, 라이브락, 기아상태와 같은 문제가 발생할 수 있습니다.
그래서 이번 포스팅은 데드락, 라이브락, 기아상태까지 알아보려 합니다.
데드락 (DeadLock)
데드락은 익숙할 겁니다. 정보처리기사에서 문제로 나오는 교착상태가 바로 데드락이죠,
데드락 (DeadLock) : 프로그램 내의 프로세스들이 타 프로세스의 자원을 필요로 해서 이를 얻기 위해 타 프로세스가 종료되기를 무한정 기다리는 상태를 같이 가질 때 생기는 문제, 즉 외부 개입 없이는 프로그램이 정상적으로 진행할 수 없음
이걸 코드로 풀어보자면 아래와 같습니다.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
type value struct {
mu sync.Mutex
resource int
}
var wg sync.WaitGroup
process := func(v1, v2 *value) {
defer func() {
wg.Done()
v1.mu.Unlock()
v2.mu.Unlock()
}()
v1.mu.Lock()
// 원할한 테스트를 위해 2초 대기
time.Sleep(2 * time.Second)
v2.mu.Lock()
fmt.Println("sum=", v1.resource+v2.resource)
}
var a, b, c value
wg.Add(3)
// Process A 실행
go process(&a, &c)
// Process B 실행
go process(&b, &a)
// Process C 실행
go process(&c, &b)
wg.Wait()
}
위 코드를 실행하면 deadlock 에러가 발생한 걸 볼 수 있습니다
fatal error: all goroutines are asleep - deadlock!
라이브락 (Livelock)
라이브락은 데드락과 비슷하지만 살짝 다릅니다, 어떻게 다르냐면 데드락은 프로세스들이 멈춰있지만
라이브락은 문제를 해결하기 위해 락과 락해지를 진행하지만 실제로는 문제가 해결되지 않고 이를 반복하는 현상입니다.
즉 프로세스들끼리 자원을 주고 받고 진행하려 하지만 실제로는 변화지 않는 상태이죠.
이런 문제는 대게 데드락을 피하려다가 발생하는 경우가 많습니다.
또한 CPU로 보기엔 정상적으로 실행되는 걸로 보이기에 데드락보다 훨씬 더 위험합니다.
라이브락 (Livelock) : 서로 다른 프로세스가 동시에 연산(진행)을 함으로써 전체적인 프로그램이 다음 단계로 나아가지 못하는 상황을 만들어져 계속 연산을 반복하는 상태
이를 코드로 하면 아래와 같습니다,
package main
import (
"bytes"
"fmt"
"sync"
"sync/atomic"
"time"
)
func main() {
fmt.Println("Livelock Start")
// 조건 변수 생성, 뮤텍스와 함께 사용
// 뮤텍스 : 공유 자원에 대한 접근을 제어하는 동기화 기법으로 하나의 고루틴만 접근 가능하게 함
cadence := sync.NewCond(&sync.Mutex{})
go func() {
// 1 밀리초마다 cadence broadcast => wait를 깨움
for range time.Tick(1 * time.Millisecond) {
cadence.Broadcast()
}
}()
// cadence lock
takeStep := func() {
cadence.L.Lock()
// cadence wait는 cadence broadcast가 호출될 때까지 대기
cadence.Wait()
cadence.L.Unlock()
}
// 어떤 사람이 특정 방향으로 움직이도록 시도하는 함수로 성공 여부를 반환
process := func(dirName string, dir *int32, out *bytes.Buffer) bool {
fmt.Fprintf(out, "%v ", dirName)
// 방향 값을 1증가 => atomic 패키지는 함수들의 연산이 원자적이다
atomic.AddInt32(dir, 1)
// 1밀리세컨드 기다리기
takeStep()
// atomic 연산을 했지만 고루틴으로 2번 실행되기 때문에 진전을 못함
if atomic.LoadInt32(dir) == 1 {
fmt.Fprint(out, ". Success!")
return true
}
takeStep()
// 해당 방향으로 움직일 수 없음을 알게되면 방향 값을 1 감소
atomic.AddInt32(dir, -1)
return false
}
var resource int32
start := func(wg *sync.WaitGroup, processName string) {
// 결과 값을 저장할 변수
var out bytes.Buffer
defer func() {
fmt.Println(out.String())
wg.Done()
}()
// 반복문에 제한을 둠 => 안그러면 계속 돔
for i := 0; i < 5; i++ {
if process("loading", &resource, &out) {
fmt.Fprintf(&out, "Process %v는 정상 실행되었습니다!.", processName)
return
}
}
fmt.Fprintf(&out, "Process %v에 라이브락이 발생했습니다!.", processName)
}
var wg sync.WaitGroup
wg.Add(2)
go start(&wg, "A")
go start(&wg, "B")
wg.Wait()
fmt.Println("Livelock End")
}
다음과 같은 결과물이 나옵니다
두 개의 고루틴은 resource 값을 같이 바꾸기 때문에 진전 없이 반복하게 됩니다.
Livelock Start
loading loading loading loading loading Process A에 라이브락이 발생했습니다!.
loading loading loading loading loading Process B에 라이브락이 발생했습니다!.
Livelock End
기아 상태 (Starvation)
어떤 프로세스가 동시에 수행되는데 이때 필요한 리소스가 있는 데 이를 다른 프로세스 때문에 받지 못하는 상태입니다.
라이브락을 기아 상태의 하위로 보는 경우도 있으나 라이브락은 서로 못하게 막힌다면
기아 상태는 한 프로세스로 의해 나머지 프로세스들이 자원을 얻지 못한다는 점에서 차이가 있습니다.
기아 상태 (Starvation) : 타 프로세스에게 자원을 뺏기게 되어 원래보다 더 적게 작동하거나 못하게 되는 상태
코드로 하면 아래와 같습니다. Resource의 역할을 하는 건 공유 메모리인 sharedLock으로 보시면 됩니다.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
var sharedLock sync.Mutex
const runtime = 1 * time.Second
greedWorker := func() {
defer wg.Done()
var count int
for begin := time.Now(); time.Since(begin) <= runtime; {
// greedWorker의 는 한번 돌 때 마다 sharedLock을 1번 잠그고 풀어줌
sharedLock.Lock()
time.Sleep(3 * time.Nanosecond)
sharedLock.Unlock()
count++
}
fmt.Printf("욕심쟁이 루프는 %v번 돌았음\n", count)
}
politeWorker := func() {
defer wg.Done()
var count int
for begin := time.Now(); time.Since(begin) <= runtime; {
// politeWorker의 루프는 한번 돌 때 마다 sharedLock을 3번 잠그고 풀어줌
// lock과 unlock 함수의 시간차이는 없음
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("공손한 루프는 %v번 돌았음\n", count)
}
wg.Add(2)
// greed가 많은 이유는
// - polite가 한번 돌때 거의 2~3배 더 돌기 때문
// - polite는 필요할 때만 사용한 반면 greed는 필요 없을 때도 사용하기 때문
// - greed는 polite의 unlock이 끝나자말자 바로 뺏기 때문
go greedWorker()
go politeWorker()
wg.Wait()
}
제 경우 다음과 같은 결과가 출력되었습니다.
욕심쟁이 루프는 34번 돌았음
공손한 루프는 12번 돌았음
'Go Lang > Study' 카테고리의 다른 글
[GoLang] 반복문에서 고루틴 돌릴 때 주의점 (0) | 2024.01.19 |
---|---|
[GoLang] Go에서 동시성이란 (1) | 2024.01.15 |
[GoLang] Context가 뭘까요? (2) | 2023.12.02 |
[GoLang] Markdown을 HTML로 변환하기 (고도화) (0) | 2023.11.12 |
[GoLang] Markdown을 HTML로 변환하기 (0) | 2023.11.05 |