
시작하며
Go의 GoRoutine은 OS 스레드보다 훨씬 가벼운 경량 스레드로, 동시성 프로그래밍을 손쉽게 구현할 수 있게 해준다. 이 글에서는 고루틴의 기본 사용법부터 WaitGroup을 이용한 종료 보증, Mutex와 DeadLock 문제, 그리고 영역 분할을 통한 자원 충돌 방지 방법을 정리한다.
Go Routine
GoRoutine은 Go언어에서 관리하는 lightweight thread이다. 여러 GoRoutine을 가지는 프로그램을 동시성 프로그래밍(Concurrent Programming)이라고 한다. 아래와 같이 선언하면 PrintHangul(), PrintNumbers(), main() 총 3개의 고루틴이 생성된다.
// 예시
// go function_call()
func PrintHangul() {
hanguls := []rune{ '가', '나', '다', '라', '마', '바', '사' }
for _, v := range hanguls {
time.Sleep(300 * time.Millisecond)
fmt.Printf("%c ", v)
}
}
func PrintNumbers() {
for i := 1; i <= 5; i++ {
time.Sleep(400 * time.Millisecond)
fmt.Printf("%d ", i)
}
}
func main() {
go PrintHangul()
go PrintNumbers()
// 이 main() 이 종료되면 프로그램이 종료다!
// 다른 Go Routine을 기다리지 않는다!
time.Sleep(3 * time.Second)
}Sub GoRoutine과 WaitGroup
Go에서는 기존 OS thread에서 발생하는 Context-Switching 비용이 발생하지 않아 동시성 프로그래밍에 유리하다. CPU 코어당 OS thread 하나만을 할당하기 때문에 thread Context(thread instruction pointer, stack memory)를 저장해 둘 필요가 없기 때문이다. CPU 코어 수를 초과해 들어오는 GoRoutine은 대기 상태로 들어간다.
단순히 time.Sleep()으로는 고루틴의 종료를 보증할 수 없다. 따라서 고루틴이 종료될 때까지 대기하기 위해 WaitGroup 객체를 사용한다.
var wg sync.WaitGroup
wg.Add(3) // 작업 개수 설정
wg.Done() // 작업이 완료될 때마다 호출
wg.Wait() // 모든 작업이 완료될 때까지 대기한다.실제 사용 방법은 다음과 같다.
var wg sync.WaitGroup
func SumAtoB(a, b int) {
sum := 0
for i := a; i <= b; i++ {
sum += i
}
fmt.Printf("%d부터 %d까지 합계는 %d입니다.\n", a, b, sum)
wg.Done() // 함수가 완료되고 나서는 남은작업 개수 -1개
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ {
go SumAtoB(1, 1000000)
}
wg.Wait() // 남은 함수 개수가 0이 되는 순간 Wait() 메서드가 종료
fmt.Println("모든 계산이 완료되었습니다.")
} 동시성 프로그래밍의 문제
동시성 프로그램의 문제는 동일 자원에 여러 고루틴이 접근할 때 발생한다. 이를 막기 위한 방법으로 다음 세 가지가 있다.
- Mutex(뮤텍스, Mutual Exclusion)
- 영역 분할
- 역할 분할
Mutex
뮤텍스는 Lock() 메소드를 통해 획득하며, 한 번 획득한 뮤텍스는 반드시 Unlock() 메소드를 호출해 반납해야 한다. 이 방식으로 동일 자원 접근 문제는 해결했지만, 동시성 프로그래밍으로 얻는 성능 향상을 잃게 된다. 오직 하나의 고루틴만 공유 자원에 접근 가능하다면 한 번에 하나의 고루틴이 돌아가는 것과 동일하기 때문이다.
Mutex와 DeadLock
고루틴들 중 누구도 원하는 만큼의 뮤텍스를 획득하지 못해 종료되지 않으면 무한히 대기 상태가 유지될 수 있다. 이를 DeadLock이라고 한다. 아래 코드는 이를 구현한 예시이다.
var wg sync.WaitGroup
func diningProblem(name string, first, second *sync.Mutex, firstName, secondName string) {
for i := 0; i < 100; 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.Seed(time.Now().UnixNano())
wg.Add(2)
fork := &sync.Mutex{}
spoon := &sync.Mutex{}
go diningProblem("A", fork, spoon, "포크", "수저")
go diningProblem("B", spoon, fork, "포크", "수저")
wg.Wait()
}영역 분할과 역할 분할
DeadLock 상황을 막기 위해 자원의 영역을 나누어 고루틴들에게 골고루 나눠주는 방법이 있다.
type Job interface {
Do()
}
type SquareJob struct {
index int
}
func (j *SquareJob) Do() {
fmt.Printf("%d 작업 시작\n", j.index)
time.Sleep(1 * time.Second)
fmt.Printf("%d 작업 완료 - 결과: %d\n", j.index, j.index*j.index)
}
func main() {
var jobList [10]Job
for i := 0; i < 10; i++ {
jobList[i] = &SquareJob{i}
}
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
job := jobList[i]
go func() {
job.Do()
wg.Done()
}()
}
wg.Wait()
}정리하며
Go의 고루틴은 go 키워드 하나로 생성되는 경량 스레드이며, OS 스레드와 달리 Context-Switching 비용이 없다. sync.WaitGroup으로 고루틴의 종료를 보증하고, 공유 자원 경쟁 문제는 Mutex, 영역 분할, 역할 분할로 해결한다. Mutex는 편리하지만 DeadLock 위험이 있으므로 채널을 이용한 역할 분할 방식이 더 안전한 경우가 많다.