고루틴 시작하기
함수 호출 앞에 go를 붙이면 그 호출이 동시에 실행됩니다. 문장은 즉시 반환되며, 호출한 쪽은 기다리지 않습니다.
고루틴 네 개가 동시에 shout을 실행합니다. 끝나는 순서는 정해져 있지 않지만, 각 고루틴이 자기 인덱스에 쓰고 main은 wg.Wait()가 반환된 뒤에만 슬라이스를 읽으므로 출력은 항상 원래 순서대로입니다.
이 예제에는 거의 모든 고루틴 프로그램에 필요한 세 가지가 이미 들어 있습니다. 작업을 시작하는 방법(go), 작업을 기다리는 방법(sync.WaitGroup), 그리고 두 고루틴이 같은 메모리를 건드리지 않고 결과를 돌려받는 방법(각자 슬라이스 요소 하나)입니다.
main은 기다리지 않는다
main이 반환되면 프로그램이 종료됩니다. 아직 실행 중인 고루틴은 어디에 있든 그 자리에서 멈춥니다. 아무것도 그것들을 기다리지 않습니다.
이 코드는 보통 from main만 출력합니다. 가끔 고루틴이 제때 스케줄링되어 두 줄이 모두 보이기도 합니다. 그 "보통"이 문제입니다. 내 컴퓨터에서는 되고 부하가 걸린 서버에서는 실패하는 코드가 되니까요.
main 끝에 time.Sleep을 넣으면 데모는 두 줄을 모두 출력하지만, 잘못된 해결책입니다. 작업이 얼마나 걸릴지 추측하는 것이기 때문입니다. WaitGroup(WaitGroup 페이지에서 자세히 다룹니다)이나 채널로 작업 자체를 기다리세요.
결과 돌려받기
go 문은 함수의 반환 값을 버립니다. x := go f()는 컴파일되지 않습니다. 데이터를 돌려받는 표준 방법은 두 가지입니다.
고루틴마다 자리 하나. 첫 번째 예제처럼 슬라이스 크기를 미리 정하고, 각 고루틴에 인덱스를 주고, Wait 후에 읽습니다. 두 고루틴이 같은 요소에 쓰지 않으므로 순서가 유지되고 잠금이 필요 없습니다.
채널. 각 고루틴이 결과를 보내고, 받는 쪽이 모읍니다. 결과는 시작 순서가 아니라 완료 순서대로 도착합니다.
정확히 len(nums)개의 값을 받는 것이 기다리는 역할도 합니다. 모든 고루틴이 보내기 전까지 main은 반복문을 지나갈 수 없습니다. 도착 순서는 실행할 때마다 바뀌므로, 프로그램은 순서에 의존하는 것을 출력하기 전에 정렬합니다. 버퍼 있는 채널, 닫기, 채널에 대한 range는 채널 페이지에서 다룹니다.
반복 변수와 클로저 (Go 1.22 변경)
Go 1.22부터 for 반복문은 반복마다 반복 변수의 새 복사본을 가집니다. 고루틴에서 시작한 클로저는 그 반복의 값을 캡처하므로 다음 코드는 올바릅니다:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
Go 1.22 이전에는 모든 반복이 i 하나와 w 하나를 공유했고, 모든 고루틴이 마지막 값을 보기 쉬웠습니다. 오래된 코드는 값을 인자로 넘기거나(go func(i int, w string) { ... }(i, w)) 섀도잉(i := i)으로 이를 우회합니다. 둘 다 Go 1.22 이상에서는 해가 없고, 기존 코드에서 여전히 보게 될 것입니다. 새 동작은 모듈의 go.mod에 go 1.22 이상이 적혀 있을 때 적용됩니다.
고루틴은 저렴하다
고루틴은 작은 스택(몇 킬로바이트)으로 시작하며, 런타임이 필요에 따라 늘리고 줄입니다. Go 스케줄러는 OS 스레드 풀에서 고루틴을 실행하며, 한 번에 Go 코드를 실행하는 스레드는 최대 GOMAXPROCS개이고 기본값은 CPU 수입니다. 채널, 뮤텍스, sleep, 네트워크 입출력에서 블록되면 고루틴은 잠시 멈추고 스레드를 다른 고루틴에게 내줍니다.
그래서 작업마다 고루틴을 하나씩 시작해도 개수가 많을 때조차 괜찮습니다:
고루틴 십만 개가 1초도 안 되어 끝납니다. atomic.Int64가 각 덧셈을 쪼갤 수 없게 만들므로 합계는 항상 4999950000입니다. 하지만 저렴하다고 공짜는 아닙니다. 아직 블록되어 있는 고루틴은 자기 스택과 그것이 참조하는 모든 것을 살려 둡니다.
| OS 스레드 | 고루틴 | |
|---|---|---|
| 만드는 주체 | 커널 | Go 런타임 |
| 초기 스택 | 고정, 보통 1MB 이상 | 몇 KB, 필요할 때 늘어남 |
| 전환 | 커널 컨텍스트 스위치 | 사용자 공간의 Go 스케줄러 |
| 식별자 | 스레드 ID가 있음 | 설계상 읽을 수 있는 ID가 없음 |
| 일반적인 개수 | 수백 개 | 수천에서 수백만 개 |
데이터 레이스
두 고루틴이 같은 변수에 동시에 접근하고, 그중 적어도 하나가 쓴다면 데이터 레이스입니다. 결과는 "조금 틀린" 정도가 아니라 예측할 수 없습니다. 갱신이 사라지고, 문자열, 슬라이스, 맵, 인터페이스 값에 대한 레이스는 프로그램을 죽이거나 메모리를 망가뜨릴 수 있습니다.
멀티코어 머신에서는 대부분의 실행에서 10000보다 작은 다른 숫자를 출력합니다. 두 고루틴이 같은 이전 값을 읽고 둘 다 그 값에 1을 더해 쓰기 때문입니다. 단일 코어에서는 10000을 출력할 수도 있는데, 그게 더 나쁩니다. 버그가 테스트를 통과하고 운영 환경에서 나타나기 때문입니다.
Go에는 레이스 탐지기가 들어 있습니다. 프로그램이나 테스트를 -race와 함께 실행하세요:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
정확한 줄(counter++)과 두 고루틴을 가리켜 줍니다. 실행 중에 실제로 일어난 레이스만 보고하므로, 동시성 경로를 실행하는 테스트에서 돌리세요. 프로그램을 몇 배 느리게 만들므로 운영 환경이 아니라 테스트와 스테이징용입니다.
해결책을 가장 단순한 것부터 가장 일반적인 것 순서로 보면 다음과 같습니다:
- 공유하지 마세요. 각 고루틴에 자기 데이터를 주고 마지막에 합치세요(고루틴마다 자리 하나 패턴).
- 카운터나 플래그 하나에는
sync/atomic을 쓰세요:var n atomic.Int64; n.Add(1). - 맵이나 필드가 여러 개인 구조체처럼 더 큰 것에는
sync.Mutex를 쓰세요. 뮤텍스 페이지에서RWMutex와sync.Once도 다룹니다. - 한 번에 한 고루틴만 소유하도록 데이터를 채널로 보내세요.
고루틴의 패닉은 프로그램을 죽인다
고루틴이 패닉을 일으켰는데 그 고루틴 안에서 아무것도 복구하지 않으면, main과 다른 모든 고루틴을 포함해 프로그램 전체가 죽습니다. recover는 자기 고루틴의 패닉만 잡으므로 main의 recover는 도움이 되지 않습니다.
이렇게 복구하는 것은 오래 실행되는 서버의 가장자리에서 의미가 있습니다. 잘못된 요청 하나가 나머지를 쓰러뜨리면 안 되기 때문입니다. 평범한 코드 안에서 패닉은 보통 버그를 뜻하며, 요란하게 죽는 것이 올바른 결과입니다.
고루틴 누수
영원히 블록된 고루틴은 절대 끝나지 않고 메모리도 해제하지 않습니다. 전형적인 원인은 아무도 받지 않을 송신입니다:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
호출할 때마다 고루틴 len(urls) - 1개가 누수됩니다. 이 요청을 수천 번 처리하는 서버라면 프로세스가 죽을 때까지 메모리가 늘어납니다. 해결책은 두 가지입니다. 모든 송신자가 끝날 수 있을 만큼 채널을 크게 만들거나(make(chan string, len(urls))), 고루틴에 포기할 방법을 주세요. 보통 context.Context와 ctx.Done()에 대한 select입니다. 테스트에서 runtime.NumGoroutine()으로 누수를 감시할 수 있습니다.
동시에 실행되는 개수 제한하기
"항목마다 고루틴 하나"는 가벼운 계산 10,000개라면 괜찮습니다. 같은 서버에 대한 HTTP 요청 10,000개나 열린 파일 10,000개라면 괜찮지 않습니다. 버퍼 있는 채널을 세마포어로 써서 동시성에 상한을 두세요:
버퍼 있는 채널은 토큰을 최대 3개 담으므로, 어느 순간이든 sem <- 줄을 지난 고루틴은 최대 3개입니다. 최댓값은 절대 3을 넘지 않으며, 각각 sleep하는 작업 열두 개라면 실제로 3에 도달합니다. 작업 채널에서 읽는 고정된 워커 고루틴 풀도 흔한 모양이며, WaitGroup 페이지에서 하나를 만들어 봅니다.
표준 라이브러리 밖에서는 golang.org/x/sync/errgroup이 WaitGroup, 첫 번째 오류, 컨텍스트 취소, 동시성 제한(g.SetLimit(n))을 한 타입에 합쳐 줍니다. 네 가지가 모두 필요한 운영 코드에서 흔히 고르는 도구입니다.
흔한 실수
- 기다리는 것을 잊음.
main이 반환되고 작업은 조용히 일어나지 않습니다. 모든go문에는 끝났음을 알 수 있는 짝이 필요합니다. - 고루틴 안에서
wg.Add를 호출함.Wait가Add보다 먼저 실행되어 카운터를 0으로 보고 일찍 반환될 수 있습니다.go문 전에Add를 호출하세요. - 동기화 없이 변수를 공유함. 맵이 흔한 경우입니다. 맵에 대한 동시 쓰기는 대개 런타임이 감지해서
fatal error: concurrent map writes로 프로그램을 죽이며, 이는recover로 잡을 수 없습니다. - 순서를 가정함. 고루틴은 스케줄러가 고른 순서대로 실행됩니다. 출력에 순서가 있어야 한다면 모아서 정렬하거나 인덱스 자리에 쓰세요.
- 동기화에
time.Sleep을 씀. 테스트를 느리게 만들면서도 여전히 불안정합니다. 추측이 아니라 이벤트를 기다리세요. - 멈출 방법 없이 고루틴을 시작함. 반복하거나 입출력을 기다리는 것은 호출자가 취소할 수 있도록
context.Context를 받아야 합니다.
자주 묻는 질문
Go에서 고루틴이란 무엇인가요?
고루틴은 프로그램의 나머지 부분과 동시에 실행되는 함수 호출입니다. 호출 앞에 go를 붙여 시작합니다: go work(). 고루틴은 운영체제가 아니라 Go 런타임이 관리하며, 런타임이 많은 고루틴을 적은 수의 OS 스레드에 다중화하므로 수천 개를 시작하는 것도 평범한 일입니다.
Go에서 고루틴이 끝나기를 기다리려면 어떻게 하나요?
sync.WaitGroup을 씁니다. 각 go 문 전에 wg.Add(1)을 호출하고, 고루틴 맨 위에서 defer wg.Done()을 쓰고, 모두 끝나야 하는 곳에서 wg.Wait()를 호출하세요. 고루틴이 값을 만든다면 채널에서 고루틴마다 값 하나씩을 받는 것도 기다리는 방법이 됩니다.
고루틴과 스레드의 차이는 무엇인가요?
OS 스레드는 고정된 스택(보통 1MB 이상)을 가지며 커널이 스케줄링합니다. 고루틴은 몇 킬로바이트의 스택으로 시작해서 필요에 따라 늘어나고, Go 스케줄러가 사용자 공간에서 고루틴 사이를 전환합니다. 런타임은 한 번에 최대 GOMAXPROCS개(기본값은 CPU 수)의 스레드에서 고루틴을 실행합니다.
고루틴에서 반환 값은 어떻게 받나요?
go 문은 함수의 반환 값을 버립니다. 결과를 채널로 보내거나(results <- compute(x)) 미리 크기를 정한 슬라이스의 자기 자리에 쓰고(out[i] = compute(x)) wg.Wait() 뒤에 읽으세요.
고루틴이 아무것도 출력하기 전에 Go 프로그램이 끝나는 이유는 무엇인가요?
main이 반환되면 프로그램이 끝나고, 다른 모든 고루틴은 남은 코드를 실행하지 못한 채 멈춥니다. 고루틴을 자동으로 기다려 주는 것은 없습니다. WaitGroup이나 채널 수신으로 작업이 끝날 때까지 main을 블록하세요. time.Sleep을 추가하면 문제를 숨길 뿐입니다.