Chapter 24. goroutine
24.1 스레드란?
고루틴: 경량 스레드
- 함수나 명령을 동시에 실행할 때 사용
💡 컨텍스트 스위칭 비용 X
원래 CPU는 한 번에 하나의 스레드만 처리할 수 있어서, 효율성을 위해 여러 스레드를 전환하면 ‘컨텍스트 스위칭’ 비용이 발생한다. 적정 개수를 넘어 한 번에 너무 많은 스레드를 수행하게 되면 성능이 저하되어 주의해야 한다.
하지만 Go 언어에서는 CPU 코어마다 OS 스레드를 하나만 할당해서 사용하므로, 컨텍스트 스위칭 비용이 발생하지 않는다.
이를 가능하게 해주는 것이 고루틴이다.
24.2 고루틴 (goroutine)
모든 프로그램은 고루틴을 최소 하나 가짐 (→ 메인 루틴)
go 함수_호출
//메인 루틴 외에 고루틴 추가 생성
package main
import (
"fmt"
"time"
)
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()
time.Sleep(3 * time.Second)
}
- 결과
가 1 나 2 다 라 3 마 4 바 5 사
PrintHangul()
과PrintNumbers()
함수는 서로 다른 고루틴에서 실행 → 동시에 실행- 메인루틴에서 3초간 대기하는 이유? (
time.Sleep(3 * time.Second)
)→ 이를 해결할 수 있는 방법: sync 패키지의 WaitGroup 객체 사용 - → 메인 함수가 종료되면 아무리 많은 고루틴이 있더라도 프로그램이 즉시 종료되므로,
기다려줘야 한다.
📌 서브 고루틴이 끝날 때까지 대기하기: var wg sync.WaitGroup
var wg sync.WaitGroup
wg.Add(3) //작업 개수 설정
wg.Done() //작업 완료될 때마다 호출
wg.Wait() //모든 작업이 완료될 때까지 대기
Add() 메서드를 통해 완료해야 하는 작업 개수를 설정하고,
각 작업이 완료될 때마다 Done() 메서드를 호출하여 남은 작업 개수를 하나씩 줄여준다.
Wait()는 전체 작업이 완료될 때까지 대기한다.
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup // WaitGroup 객체 wg
func SumAtoB(a, b int) {
sum := 0
for i := a; i <= b; i++ { // a부터 b까지의 합을 구하는 함수
sum += i
}
fmt.Printf("%d부터 %d까지의 합계는 %d입니다.\n", a, b, sum)
wg.Done() //작업이 완료됨을 표시
}
func main() {
wg.Add(10)
for i := 0; i < 10; i++ { //총 작업의 개수 설정 (10개)
go SumAtoB(1, 1000000000) //고루틴 생성
}
wg.Wait() //모든 작업이 완료되길 기다림
fmt.Println("모든 계산이 완료됐습니다.")
}
- 결과
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
1부터 100000000까지의 합계는 500000000500000000입니다.
모든 계산이 완료됐습니다.
- 각 루틴에서 SumAtoB() 함수를 완료할 때, wg.Done()이 호출되어,
wg의 남은 작업 개수를 1씩 감소시킴. - wg.Wait()을 하면 남은 작업 개수가 0이 될 때까지 종료하지 않고 대기함.
24.3 고루틴의 동작 방법
- OS 스레드, 코어, 고루틴??? 뭐가 다른가?
CPU 코어 ← OS 스레드 ← 고루틴 순서로 프로그램이 전달되어 실행됨.
즉 GO 프로그램이 실행되려면,
고루틴과 CPU 코어가 OS 스레드로 연결되어 OS 스레드에서 고루틴이 실행됨.
2개의 코어를 가진 컴퓨터에서 고루틴의 동작을 살펴보자.
- 고루틴이 1개일 때
(=main 루틴만 존재하는 경우임)
OS 스레드를 1개 만들어서 첫 번째 코어와 바로 연결한다.
→ OS 스레드에서 고루틴을 바로 실행
- 고루틴이 2개일 때
- 두 번째 코어가 남아있기 때문에 두번째 OS 스레드를 생성하여 바로 두번째 고루틴도 실행.
- 고루틴이 3개일 때
세 번째 고루틴은 남는 코어가 생길 때까지 대기. 이후 코어가 비게 되면 그제서야 실행.
24.4 동시성 프로그래밍 주의점
동시성 프로그래밍의 문제점은, 동일한 메모리 자원에 여러 고루틴이 접근할 때 발생한다.
서로 다른 고루틴이 같은 메모리 공간에 접근하여 값을 변경시킨다면, 동시성 문제가 발생.
- 예제: 여러 고루틴에서 동시에 통장에 접근해 1000원을 입금, 다시 출금하는 상황
- 각 고루틴은 DepositAndWithdraw() 함수를 무한히 호출,
- 생성된 고루틴은 입금과 출금을 무한 반복함.
- 잔고가 0 이상이면 1000원을 입금, 1ms 쉬고, 1000원을 다시 출금
- 잔고가 0 이하이면 패닉을 발생시켜 종료함.
- 이론상으로는 잔고가 0 이하가 되지 않아야 하지만, 실제로는 패닉 발생.
package main
import (
"fmt"
"sync"
"time"
)
type Account struct {
Balance int
}
func main() {
var wg sync.WaitGroup
account := &Account{0}
wg.Add(10)
for i := 0; i < 10; i++ {
go func() { //고루틴 생성
for {
DepositAndWithdraw(account)
}
wg.Done()
}()
}
wg.Wait()
}
func DepositAndWithdraw(account *Account) {
if account.Balance < 0 {
panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
}
account.Balance += 1000
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
- 결과
panic: Balance should not be negative value: -2000
goroutine 15 [running]:
main.DepositAndWithdraw(0xc0000140b0)
...
24.5 뮤텍스를 이용한 동시성 문제 해결
뮤텍스(mutual exclusion) 이용 → 고루틴에서 값을 변경할 때 다른 고루틴이 건들지 못하도록 통제
뮤텍스를 이용한 자원 접근 권한 통제 방식
Lock()
메서드 호출 → 획득 or 대기Unlock()
메서드 호출 → 반납
뮤텍스를 이용하여 위 예제의 동시성 문제를 해결해보자.
package main
import (
"fmt"
"sync"
"time"
)
var mutex sync.Mutex //패키지 전역 변수 뮤텍스
type Account struct {
Balance int
}
func DepositAndWithdraw(account *Account) {
mutex.Lock() //뮤텍스 획득
defer mutex.Unlock()
if account.Balance < 0 {
panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
}
account.Balance += 1000
time.Sleep(time.Millisecond)
account.Balance -= 1000
}
func main() {
var wg sync.WaitGroup
account := &Account{0}
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
for {
DepositAndWithdraw(account)
}
wg.Done()
}()
}
wg.Wait()
}
- 출력이나 패닉 없이 무한 대기
mutex.Lock()
: 뮤텍스 획득defer mutex.Unlock()
: 반납- 잔고는 절대 0원 이하로 내려가지 않게 된다.
- 한 번 획득한 뮤텍스는 반드시 Unlock()을 호출해서 반납해야 한다! 는 것을 기억하자.
func DepositAndWithdraw(account *Account){ mutex.Lock() // 뮤텍스를 확보할 때까지 대기 defer mutex.Unlock() // 반납 account.Balance += 1000 // 뮤텍스를 확보한 고루틴이 실행할 로직 time.Sleep(time.Millisecond) //단 하나의 고루틴만 실행하게 된다. }
24.6 뮤텍스와 데드락
뮤텍스 사용 시의 문제점
- 동시성 프로그래밍의 의미가 없어짐 → 어차피 하나만 사용하므로.. 성능 향상 X
- 데드락 발생 가능 (프로그램을 완전히 멈춰버리는 문제)
예제: 수저와 포크를 둘 다를 집어야 식사를 할 수 있는 상황
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
var wg sync.WaitGroup
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()
}
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()
}
- A는 포크, B를 수저를 획득했지만, 서로 두 번째 뮤텍스를 갖지 못해 무한히 대기 → Go에서 데드락을 감지하고 에러 반환
- 데드락 문제는 여전히 해결하기 힘든 난제. 그러나 데드락을 조심하며 좁은 범위에서 뮤텍스가 꼬이지 않도록 철저히 확인해서 사용하면 유용하게 사용할 수 있음.
👉 요약 정리
- 멀티코어 컴퓨터에서는 여러 고루틴을 사용하여 성능을 향상시킬 수 있다.
- 같은 메모리를 여러 고루틴이 접근하면 프로그램이 꼬일 수 있다. (=동시성 문제)
- 뮤텍스를 이용하면 순서가 꼬이는 문제를 막을 수 있다.
- 그러나 뮤텍스를 잘못 사용하면 데드락이라는 심각한 문제가 생길 수 있다.
24.7 또다른 자원 관리 기법
→ 25장 채널과 컨텍스트에서 이어집니다.
Chapter 25. Channel & Context
25.1 채널 사용하기
채널이란? 고루틴끼리 메시지를 전달할 수 있는 메시지 큐
📌 채널 인스턴스 생성
- 채널 키워드 :
chan
- 생성 방법:
make(chan 자료형)
var messages chan string = make(chan string)
📌 채널에 데이터 넣기: 변수 ←
- 좌변: 채널 인스턴스 / 우변: 넣을 데이터
messages <- "This is message"
📌 채널에서 데이터 빼기: ← 변수
- 좌변: 빼낸 데이터를 담을 변수 / 우변: 채널 인스턴스
var msg string = <- messages
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup //wg 변수 생성
ch := make(chan int) //채널 ch 생성
wg.Add(1) //작업 개수는 1개
go square(&wg, ch) //고루틴 생성
ch <- 9 //채널에 데이터 넣기
wg.Wait() //작업이 완료되길 기다림
}
func square(wg *sync.WaitGroup, ch chan int) {
n := <-ch // 채널에서 데이터 빼기 (9)
time.Sleep(time.Second) //1초 대기
fmt.Printf("Square: %d\n", n*n) // 9*9
wg.Done()
}
- 결과:
Square: 81
📌 채널 크기
- 일반적으로 채널 생성 시 크기= 0
- → 데이터를 넣을 때 보관할 곳이 없어서, 데이터를 빼갈 때까지 대기 (그 전엔 프로그램 실행 X)
package main
import "fmt"
func main() {
ch := make(chan int) //크기 0인 채널
ch <- 9
fmt.Println("Never print") //실행되지 않는다
}
- 결과
fatal error: all goroutines are asleep - deadlock!
- 채널에 데이터를 넣었지만 크기가 0이라 보관할 곳이 없기 때문에 다른 고루틴이 데이터를 빼가기를 기다림.
- 하지만 어떤 고루틴도 데이터를 빼는 로직이 없기 때문에 영원히 대기하게 됨
- → 데드락 강제종료
📌 버퍼를 가진 채널
- 버퍼: 내부에 데이터를 보관할 수 있는 메모리 영역
- 버퍼를 가진 채널 = 보관함을 가진 채널
var chan string messages = make(chan string, 2) //버퍼 2개인 채널 생성
📌 채널에서 데이터 대기
package main
import (
"fmt"
"sync"
"time"
)
func square(wg *sync.WaitGroup, ch chan int) {
for n := range ch {
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
wg.Done()
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
wg.Wait()
}
- 결과
Square: 0
Square: 4
Square: 16
Square: 36
Square: 64
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
...
wg.Wait()
메서드로 작업 완료될 때까지 기다림 → for range 구문은 채널에 데이터가 들어오기를 기다림 → 모든 고루틴이 멈춤 (deadlock)- 채널을 다 사용하면 close(ch) 호출 → 채널이 모두 빈 상태에서 닫혔으면 for range문을 빠져나감
package main
import (
"fmt"
"sync"
"time"
)
func square(wg *sync.WaitGroup, ch chan int) {
for n := range ch {
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
wg.Done()
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
close(ch)
wg.Wait()
}
- 결과
Square: 0
Square: 4
Square: 16
Square: 36
Square: 64
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
📌 select문
select문은 언제 쓸까?
- 채널에서 데이터를 대기하는 상황에서, 만약 데이터가 들어오지 않으면 다른 작업을 하고 싶을 때
- 여러 채널을 동시에 대기하고 싶을 때
select{
case n := <-ch1:
... //ch1 채널에서 데이터를 빼낼 수 있을 때 실행
case n := <-ch2:
... //ch2 채널에서 데이터를 빼낼 수 있을 때 실행
case ...
}
예제
package main
import (
"fmt"
"sync"
"time"
)
func square(wg *sync.WaitGroup, ch chan int, quit chan bool) {
for {
select {
case n := <-ch:
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
case <-quit:
wg.Done()
return
}
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
quit := make(chan bool)
wg.Add(1)
go square(&wg, ch, quit)
for i := 0; i < 10; i++ {
ch <- i * 2
}
quit <- true
wg.Wait()
}
- 결과
Square: 0
Square: 4
Square: 16
Square: 36
Square: 64
Square: 100
Square: 144
Square: 196
Square: 256
Square: 324
- quit 종료 채널 : square() 루틴을 만들 때 알려줌
- select문 : ch와 quit 채널 모두 기다림 (ch 채널에서 데이터를 읽을 수 있다면 계속 읽음)
📌 일정 간격으로 실행
메시지 있다면 빼와서 실행, 없다면 1초 간격으로 다른 일을 수행한다고 가정
time 패키지의 Tick()
함수로 원하는 시간 간격마다 신호를 보내주는 채널 생성 가능
package main
import (
"fmt"
"sync"
"time"
)
func square(wg *sync.WaitGroup, ch chan int) {
tick := time.Tick(time.Second)
terminate := time.After(10 * time.Second)
for {
select {
case <-tick:
fmt.Println("Tick")
case <-terminate:
fmt.Println("Terminated!")
wg.Done()
return
case n := <-ch:
fmt.Printf("Square: %d\n", n*n)
time.Sleep(time.Second)
}
}
}
func main() {
var wg sync.WaitGroup
ch := make(chan int)
wg.Add(1)
go square(&wg, ch)
for i := 0; i < 10; i++ {
ch <- i * 2
}
wg.Wait()
}
- 결과
Square: 0
Square: 4
Tick
Square: 16
Square: 36
Tick
Square: 64
Square: 100
Square: 144
Tick
Square: 196
Square: 256
Square: 324
Tick
Terminated!
- select문을 이용해서 tick, terminate, ch 순서로 채널에서 데이터 읽기 시도
- tick은 1초 간격으로 신호 전송, 10초 이후에는 terminate 신호 (함수 종료)
25.2 컨텍스트 사용하기
컨텍스트(Context)란? context 패키지에서 제공하는 기능.
작업을 지시할 때 작업 가능 시간, 작업 취소 등의 조건을 지시할 수 있는 작업 명세서 역할
📌 작업 취소 컨텍스트
지시자가 원할 때 작업 취소를 알릴 수 있다. ← WithCancel()
함수
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx, cancel := context.WithCancel(context.Background())
go PrintEverySecond(ctx)
time.Sleep(5 * time.Second)
cancel()
wg.Wait()
}
func PrintEverySecond(ctx context.Context) {
tick := time.Tick(time.Second)
for {
select {
case <-ctx.Done():
wg.Done()
return
case <-tick:
fmt.Println("Tick")
}
}
}
- 결과
Tick
Tick
Tick
Tick
Tick
📌 작업 시간 설정 컨텍스트
일정한 시간 동안만 작업을 지시할 수 있는 컨텍스트
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
context 패키지의 WithTimeOut()
함수 → 작업 시간 설정
(시간이 지난 뒤 컨텍스트의 Done() 채널에 시그널 전송 = 작업 종료 요청)
📌 특정 값을 설정한 컨텍스트
작업자에게 작업을 지시할 때 별도 지시사항을 추가하고 싶을 때 사용
package main
import (
"context"
"fmt"
"sync"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
ctx := context.WithValue(context.Background(), "number", 9)
go square(ctx)
wg.Wait()
}
func square(ctx context.Context) {
if v := ctx.Value("number"); v != nil {
n := v.(int)
fmt.Printf("Square:%d", n*n)
}
wg.Done()
}
- 결과
Square:81
- “number”를 키로 값을 9로 설정한 컨텍스트 생성
- 컨텍스트의
Value()
메서드로 값을 읽어옴
컨텍스트에 값을 설정해서 다른 고루틴으로 작업을 지시할 때 외부 지시사항으로 설정 가능, 지시자와 작업자 사이에 어떤 키로 어떤값이 들어올지 약속
'3-1기 스터디 > Golang' 카테고리의 다른 글
[10주차] 단어 검색 프로그램 만들기, 객체지향 설계 원칙 SOLID (0) | 2022.01.22 |
---|---|
[6주차] 숫자 맞추기 게임 만들기, Go 언어의 슬라이스 (0) | 2021.12.01 |
[5주차] Chapter 15~16. 문자열, 패키지 (0) | 2021.11.30 |
[4주차] Go 언어의 배열, 구조체, 포인터 (0) | 2021.11.22 |
[3주차] Go언어의 if, switch, for (0) | 2021.11.11 |
댓글