고루틴과 실행 모델
이 챕터에서 다루는 것
go f() 한 줄이 무엇을 만들어 내는지, 그것이 OS 스레드와 어떻게 다른지, 그리고
그 고루틴이 언제 죽고 언제 죽지 않는지를 다룬다.
마지막 항목이 이 파트 전체에서 가장 중요하다. Go에서 동시성 버그의 대다수는 "고루틴이 안 도는 것"이 아니라 **"고루틴이 끝나지 않는 것"**이다.
문제 — 스레드는 비싸다
동시에 여러 일을 하려면 실행 흐름이 여러 개 필요하다. 전통적인 답은 OS 스레드다. 문제는 가격이다.
| OS 스레드 | 고루틴 | |
|---|---|---|
| 스택 | 고정 크기, 보통 1–8MB를 예약 | 2KB에서 시작해 필요하면 늘어나고 줄어든다 |
| 생성 비용 | 시스템 콜, 커널 자료구조 | 사용자 공간 할당 |
| 문맥 전환 | 커널 개입 | 런타임이 사용자 공간에서 처리 |
| 현실적 개수 | 수천 개 | 수십만 개 |
스택 크기가 결정적이다. 스레드 10,000개를 1MB 스택으로 만들면 주소 공간 10GB를
예약해야 한다. 그래서 다른 언어들은 스레드 풀, 이벤트 루프, async/await 같은
장치를 얹는다. Go는 실행 흐름 자체를 싸게 만드는 쪽을 택했다.
그 결과 Java나 Python에서 "요청마다 스레드를 만들면 안 된다"였던 규칙이 Go에서는
"요청마다 고루틴을 만들어도 된다"가 된다. 실제로 net/http 서버는 요청 하나당
고루틴 하나를 띄운다.
시작 스택 크기는 runtime/metrics로 직접 확인할 수 있다.
package main
import (
"fmt"
"os"
"runtime"
"runtime/metrics"
)
// readMetric은 runtime/metrics에서 uint64 값 하나를 읽는다.
func readMetric(name string) (uint64, bool) {
s := []metrics.Sample{{Name: name}}
metrics.Read(s)
if s[0].Value.Kind() != metrics.KindUint64 {
return 0, false
}
return s[0].Value.Uint64(), true
}
func main() {
// GOMAXPROCS(0)은 값을 바꾸지 않고 현재 값만 돌려준다.
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
if v, ok := os.LookupEnv("GOMAXPROCS"); ok {
fmt.Println("환경 변수 GOMAXPROCS =", v)
} else {
fmt.Println("환경 변수 GOMAXPROCS 없음 — 런타임이 스스로 정한다")
}
// 환경 변수나 이전 호출로 고정된 값을 런타임 기본값으로 되돌린다.
// 되돌린 뒤에는 CPU 제한이 바뀔 때 런타임이 다시 자동으로 따라간다.
runtime.SetDefaultGOMAXPROCS()
fmt.Println("SetDefaultGOMAXPROCS 이후:", runtime.GOMAXPROCS(0))
if n, ok := readMetric("/gc/stack/starting-size:bytes"); ok {
fmt.Println("새 고루틴의 시작 스택 크기(바이트):", n)
}
if n, ok := readMetric("/sched/goroutines:goroutines"); ok {
fmt.Println("현재 고루틴 수:", n)
}
}
cd examples/07-concurrency
go run ./01-gomaxprocs
출력 (10코어 맥에서 실행 — CPU 수는 기계마다 다르다):
NumCPU: 10
GOMAXPROCS: 10
환경 변수 GOMAXPROCS 없음 — 런타임이 스스로 정한다
SetDefaultGOMAXPROCS 이후: 10
새 고루틴의 시작 스택 크기(바이트): 2048
현재 고루틴 수: 6
GOMAXPROCS=2 go run ./01-gomaxprocs로 다시 돌리면 GOMAXPROCS: 2가 나오고,
SetDefaultGOMAXPROCS() 이후에는 다시 10으로 돌아온다.
:::note 고루틴 수가 왜 6인가
runtime.NumGoroutine()은 이 시점에 1을 돌려주지만
/sched/goroutines:goroutines는 6이다. 후자는 GC 워커나 스케줄러 보조 같은
런타임 내부 고루틴까지 센다. 누수를 추적할 때는 절대값이 아니라
증가 추세를 봐야 하는 이유다.
:::
go 문
문법은 하나뿐이다. 함수 호출 앞에 go를 붙인다.
go doWork(x, y)
go func() { doWork(x, y) }()
go 문이 하는 일은 정확히 이것이다.
- 인자를 지금 평가한다.
doWork(x, y)의x,y는go문을 실행하는 시점의 값이다. 2-7의defer와 같은 규칙이다. - 새 고루틴을 만들어 실행 큐에 넣는다.
- 즉시 다음 줄로 넘어간다. 반환값이 없고, 기다리지 않는다.
3번이 핵심이다. go 문에는 반환값도 에러도 없다. 고루틴에서 나온 결과와 에러를
바깥으로 가져오는 것은 전적으로 내가 설계해야 할 문제이고, 그게 이 파트의 나머지
전부다.
main이 끝나면 전부 죽는다
func main() {
go fmt.Println("안녕")
}
이 프로그램은 대개 아무것도 출력하지 않는다. main이 반환하면 프로세스가
종료되고, 실행 중이거나 대기 중인 고루틴은 정리 기회 없이 사라진다.
defer도 실행되지 않는다.
:::warning main 고루틴은 특별하다
다른 언어의 "메인 스레드가 끝나도 데몬 아닌 스레드는 계속 돈다"(Java) 같은 규칙이
Go에는 없다. main이 곧 프로세스다. 끝내기 전에 기다리는 것은 내 책임이다.
:::
기다리는 표준적인 방법이 sync.WaitGroup이다.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("시작 시점 고루틴 수:", runtime.NumGoroutine())
// 결과를 담을 자리를 미리 만들어 둔다.
// 고루틴마다 서로 다른 인덱스에만 쓰므로 공유 상태를 건드리지 않는다.
results := make([]string, 5)
var wg sync.WaitGroup
for i := range results {
// wg.Go가 고루틴을 띄우고 카운터 증감까지 맡는다.
wg.Go(func() {
// i는 반복마다 새로 만들어지는 변수다. Go 1.22 이전에는 그렇지 않았다.
results[i] = fmt.Sprintf("worker-%d 완료", i)
})
}
// Wait 직전에는 최소한 main + 방금 띄운 고루틴들이 살아 있다.
// 이미 끝난 고루틴이 있을 수 있으므로 이 숫자는 실행마다 다르다.
wg.Wait()
// 완료 순서는 매번 다르지만, 인덱스로 썼으므로 출력은 항상 같다.
for _, r := range results {
fmt.Println(r)
}
fmt.Println("Wait 이후 고루틴 수:", runtime.NumGoroutine())
}
go run ./01-goroutine-basics
시작 시점 고루틴 수: 1
worker-0 완료
worker-1 완료
worker-2 완료
worker-3 완료
worker-4 완료
Wait 이후 고루틴 수: 1
출력이 매번 같은 이유는 결과를 슬라이스 인덱스에 썼기 때문이다. 고루틴 5개의
실행 순서는 매번 다르지만, 각자 자기 칸에만 쓰고 wg.Wait() 뒤에 순서대로 읽으니
결과는 결정적이다. 이 파트에서 계속 쓸 기법이다 — 비결정적 실행에서 결정적
출력을 뽑아내려면 순서를 복원할 자리를 미리 잡아 둔다.
wg.Go는 고루틴 시작과 카운터 관리를 한 번에 한다. 기존 코드에서 훨씬 자주 보게 될
wg.Add(1) / defer wg.Done() 형태와 함께 7-5에서 제대로
다룬다. 여기서는 "기다리는 장치"로만 쓴다.
:::info 루프 변수 캡처 — Go 1.22가 바꾼 것
위 코드의 i는 클로저가 캡처한다. Go 1.21까지는 루프 변수가 루프 전체에서
하나뿐이어서, 고루틴들이 전부 같은 i를 보고 마지막 값(또는 아무 값)을 읽는
고전적인 버그가 났다. 그래서 이런 줄을 넣는 관용구가 있었다.
for i := 0; i < 5; i++ {
i := i // 예전에는 필수였다
go func() { fmt.Println(i) }()
}
Go 1.22부터 for 루프 변수는 반복마다 새로 만들어진다. 위의 i := i는 이제
불필요하다. 오래된 코드나 오래된 블로그 글에서 이 줄을 보면 "1.22 이전 코드"라는
표시로 읽으면 된다. go fix의 forvar 모더나이저가 이 줄을 지워 준다.
:::
GMP — 스케줄러의 골격
고루틴이 어떻게 실제 CPU에 올라가는지는 세 글자로 요약된다.
| 뜻 | 개수 | |
|---|---|---|
| G | goroutine. 실행 흐름 하나와 그 스택 | 수십만 개 가능 |
| M | machine. 실제 OS 스레드 | 필요한 만큼 (기본 상한 10,000) |
| P | processor. G를 실행할 권한(논리 프로세서) | GOMAXPROCS개 |
M이 G를 실행하려면 P를 하나 쥐어야 한다. P마다 실행 대기 중인 G들의 로컬 큐가 있고, 전역 큐도 하나 있다. P의 큐가 비면 다른 P의 큐에서 절반을 훔쳐 온다 (work stealing).
이 구조가 답하는 질문은 이것이다. "고루틴이 블로킹되면 스레드도 같이 멈추는가?"
- 채널 연산, 뮤텍스,
time.Sleep처럼 런타임이 아는 블로킹: 런타임이 G만 대기 상태로 빼고, M은 P를 계속 쥔 채 다음 G를 실행한다. 스레드는 놀지 않는다. - 시스템 콜처럼 런타임이 통제할 수 없는 블로킹: M이 커널에 갇힌다. 런타임은 그 M에게서 P를 회수해 다른 M에게 넘긴다. 그래서 파일 읽기 하나가 프로그램 전체를 세우지 않는다.
여기가 Go와 async/await 언어들의 갈림길이다. Python이나 JavaScript에서는
"이 함수가 블로킹인가 아닌가"를 호출부가 await로 표시해야 하고, 실수로 블로킹
함수를 부르면 이벤트 루프 전체가 멈춘다. 이른바 함수 색깔 문제다. Go에는
async 함수와 보통 함수의 구분이 없다. 모든 함수를 그냥 부르면 되고, 블로킹
처리는 런타임이 한다.
:::note 선점
Go 1.13까지 고루틴은 함수 호출 지점에서만 양보할 수 있었다. 그래서
for {} 같은 순수 계산 루프가 P를 영원히 붙들어 GC까지 세우는 일이 있었다.
Go 1.14부터 비동기 선점이 들어와 런타임이 시그널로 고루틴을 끊는다.
이제 for {}도 다른 고루틴을 굶기지 않는다.
:::
동시성과 병렬성은 다르다
Rob Pike의 유명한 구분이다.
동시성(concurrency)은 여러 일을 다루는 구조이고, 병렬성(parallelism)은 여러 일을 동시에 실행하는 것이다.
GOMAXPROCS=1이어도 고루틴 1,000개는 정상적으로 동작한다. 동시에 실행되지 않을
뿐이다. 고루틴은 병렬 실행 장치가 아니라 프로그램 구조를 표현하는 장치다.
CPU 코어가 하나뿐인 기계에서도 "요청을 받는 일"과 "로그를 쓰는 일"을 분리해서
쓰는 것은 여전히 가치가 있다.
GOMAXPROCS와 컨테이너
GOMAXPROCS는 동시에 Go 코드를 실행할 수 있는 P의 개수다. 기본값은 런타임이
정한다.
- 논리 CPU 개수
- 프로세스의 CPU 어피니티 마스크
- 리눅스에서는 cgroup CPU 쿼터로부터 계산한 평균 처리량 한계
세 번째가 최근에 들어온 것이고, 실무를 바꾼다. 예전에는 컨테이너에 --cpus=2를
줘도 Go 런타임은 호스트의 64코어를 보고 GOMAXPROCS=64로 잡았다. 그래서
uber-go/automaxprocs를 넣는 것이 컨테이너 배포의 필수 관례였다.
:::tip 이제 automaxprocs는 필요 없다
런타임이 cgroup 제한을 직접 읽고, 제한이 바뀌면 주기적으로 다시 맞춘다.
컨테이너에서 GOMAXPROCS를 손으로 설정하라는 조언은 낡은 것이다.
그래도 손을 대야 한다면:
GOMAXPROCS 환경 변수, runtime.GOMAXPROCS(n),
자동 추적으로 되돌리려면 runtime.SetDefaultGOMAXPROCS(),
자동 동작 자체를 끄려면 GODEBUG=containermaxprocs=0,updatemaxprocs=0.
:::
고루틴 누수
고루틴 누수란 영원히 끝나지 않는 고루틴이다. 메모리 누수와 같은 종류의 문제지만 더 나쁘다. 스택 메모리뿐 아니라 그 고루틴이 붙들고 있는 모든 것 — 채널, 버퍼, 커넥션 — 이 함께 살아남는다. GC는 도와줄 수 없다. 실행 중인(또는 실행 대기 중인) 고루틴은 정의상 도달 가능한 루트이기 때문이다.
package main
import (
"fmt"
"runtime"
"sync"
)
// leak은 고루틴을 하나 만들고, 그 고루틴이 영원히 깨어나지 못하게 방치한다.
// ch는 leak이 끝나면 바깥에서 참조할 수 없지만, 고루틴이 붙들고 있으므로
// 고루틴도 채널도 GC 대상이 아니다.
func leak() {
ch := make(chan int)
go func() {
v := <-ch // 아무도 송신하지 않는다. 영원히 블록된다.
fmt.Println("여기는 절대 실행되지 않는다:", v)
}()
}
// handled는 같은 모양이되, 고루틴이 반드시 빠져나가도록 보장한다.
func handled(wg *sync.WaitGroup) {
ch := make(chan int)
wg.Go(func() {
<-ch
})
ch <- 1 // 송신하는 쪽이 있으므로 고루틴은 종료한다.
}
func main() {
base := runtime.NumGoroutine()
fmt.Println("시작:", base)
for range 100 {
leak()
}
fmt.Println("leak 100번 후:", runtime.NumGoroutine())
var wg sync.WaitGroup
for range 100 {
handled(&wg)
}
wg.Wait()
fmt.Println("handled 100번 후 (Wait 완료):", runtime.NumGoroutine())
fmt.Println("누수된 고루틴:", runtime.NumGoroutine()-base)
}
go run ./01-goroutine-leak
시작: 1
leak 100번 후: 101
handled 100번 후 (Wait 완료): 101
누수된 고루틴: 100
handled를 100번 더 불렀는데도 숫자가 101에서 움직이지 않는다. 그쪽 고루틴은
전부 끝났고, 남은 100개는 leak이 만든 것이다. 프로세스가 죽을 때까지
사라지지 않는다.
이 프로그램은 runtime.NumGoroutine()을 정해진 지점에서만 읽고, wg.Wait() 뒤에
읽기 때문에 출력이 결정적이다.
누수를 만드는 세 가지 전형
- 받는 쪽이 사라진다. 고루틴이
resultCh <- v를 하는데 호출자가 먼저return해 버렸다. 버퍼 없는 채널이면 송신 고루틴이 영원히 대기한다. - 보내는 쪽이 사라진다. 위 예제의
leak. 또는 채널을 닫아 주기로 한 쪽이 에러 경로에서close를 빼먹은 경우. - 취소 신호가 없다. 무한 루프를 도는 워커에게 "그만"이라고 말할 방법을 안 만들어 뒀다.
세 경우의 공통 처방이 있고, 그게 7-4의 select와
7-7의 context다. 지금 기억할 규칙은 하나다.
고루틴을 띄울 때, 그 고루틴이 어떻게 끝나는지 같은 화면에서 보일 수 있어야 한다.
go 문을 쓰는 순간 "이건 어떤 조건에서 반환하는가?"를 답할 수 없다면 그것이 누수다.
진단하는 법
1. runtime.NumGoroutine()을 주기적으로 찍어 본다. 가장 싸다. 부하가
일정한데 숫자가 단조 증가하면 누수다.
2. 고루틴 덤프를 뜬다. 실행 중인 프로그램에 SIGQUIT(터미널에서 Ctrl+\)을
보내면 모든 고루틴의 스택이 찍히고 프로세스가 죽는다. 같은 함수의 같은 줄에서
블록된 고루틴이 1,000개 보이면 범인을 찾은 것이다. runtime/pprof의 goroutine
프로파일도 같은 정보를 준다.
3. 고루틴 누수 프로파일 (실험적).
GOEXPERIMENT=goroutineleakprofile go run ./01-goroutine-leak
goroutineleak이라는 프로파일 종류가 추가되고, GC의 도달성 분석을 이용해
채널·뮤텍스·조건 변수에 영구히 블록된 고루틴만 골라낸다. 위의 "숫자가 늘어난다"
같은 간접 신호가 아니라 직접적인 답이다. 다만 아직 실험 기능이라 기본으로
켜져 있지 않고, 이름과 동작이 바뀔 수 있다. 프로파일을 읽고 해석하는 것은
프로파일링을 본격적으로 다루는 파트(pprof)의 몫이다.
:::note 테스트로 잡는 방법은 파트 8에서
"이 테스트가 끝난 뒤 고루틴이 남아 있으면 실패"라는 검사를 테스트에 넣는 것이
실무에서 가장 효과적이다. testing 패키지를 아직 다루지 않았으므로 여기서는
main에서 세는 방식으로만 보여 준다.
:::
흔히 하는 실수
1. go 문의 반환값을 기대한다
result := go compute() // 컴파일 에러
go는 문(statement)이지 식(expression)이 아니다. 결과를 받으려면 채널이나
공유 변수를 써야 하고, 그게 7-2의 주제다.
2. time.Sleep으로 기다린다
go work()
time.Sleep(100 * time.Millisecond) // 끝났겠지?
느린 기계에서는 안 끝났고, 빠른 기계에서는 시간을 낭비한다. Sleep은 동기화
장치가 아니다. WaitGroup이나 채널을 쓴다.
3. 고루틴 안의 패닉을 복구할 수 있다고 생각한다
defer func() { recover() }() // main의 defer는 다른 고루틴을 구하지 못한다
go mightPanic()
recover는 같은 고루틴의 defer 안에서만 동작한다. 고루틴 하나가 패닉하면
프로그램 전체가 죽는다. 복구가 필요하면 그 고루틴 안에 defer/recover를 둬야
한다. 반대로, 라이브러리가 내부에서 띄운 고루틴이 패닉하면 사용자 코드가 막을 방법이
없다 — 함부로 고루틴을 띄우는 라이브러리를 경계할 이유이기도 하다.
4. init에서 고루틴을 띄운다
6-1에서 init은 자기 등록 용도로만
쓴다고 했다. 여기에 하나 더한다. init에서 띄운 고루틴은 아무도 멈출 수 없다.
import만 해도 시작되고, 종료 지점이 없고, 테스트에서 격리할 수 없다. 백그라운드
작업은 main이 명시적으로 부르는 함수에서 시작한다.
5. 고루틴 개수를 제한하지 않는다
고루틴이 싸다고 무한정 만들어도 된다는 뜻은 아니다. 입력 100만 건마다 고루틴을 띄우면 각각이 붙드는 커넥션과 버퍼가 먼저 터진다. 개수를 묶는 장치가 워커 풀이고 7-8에서 만든다.
정리
- 고루틴은 런타임이 관리하는 실행 흐름이다. 2KB 스택에서 시작해 자라고, 생성과 전환이 사용자 공간에서 일어난다. 그래서 수십만 개가 가능하다.
go문은 인자를 즉시 평가하고, 반환값이 없고, 기다리지 않는다.main이 반환하면 프로세스가 끝나고 고루틴은 정리 없이 사라진다. 기다리는 것은 내 책임이다.- GMP: M(스레드)이 P(실행 권한)를 쥐고 G(고루틴)를 돌린다. 채널 블로킹은 G만 재우고, 시스템 콜 블로킹은 P를 다른 M에게 넘긴다. 그래서 함수 색깔 문제가 없다.
- 동시성 ≠ 병렬성. 고루틴은 구조를 표현하는 장치다.
GOMAXPROCS는 컨테이너 CPU 제한을 자동으로 인식한다.automaxprocs는 이제 불필요하다.- 고루틴 누수는 끝나지 않는 고루틴이다. GC가 회수하지 못한다.
go문을 쓸 때 종료 조건을 같이 적을 수 없다면 이미 누수다. - 루프 변수 캡처는 Go 1.22부터 문제가 아니다.
i := i는 오래된 코드의 흔적이다.
연습문제
-
01-goroutine-basics에서results[i] = ...대신 지역 변수에 담고fmt.Println으로 바로 출력하도록 바꿔 보자. 열 번 돌려서 출력 순서를 기록해 보고, 순서가 몇 가지나 나오는지 세어 보자. 그다음GOMAXPROCS=1로도 돌려서 차이가 있는지 확인해 보자. -
01-goroutine-leak의leak()을 100번이 아니라 100,000번 부르도록 바꾸고runtime.NumGoroutine()과 함께runtime.ReadMemStats로 힙 크기를 찍어 보자. 고루틴 하나가 실제로 얼마나 쓰는가? 시작 스택 크기 2KB와 비교하면 어떤가? -
main에서defer fmt.Println("main 종료")를 선언하고, 고루틴 안에서도defer fmt.Println("고루틴 종료")를 선언한 뒤wg.Wait()를 일부러 빼고 실행해 보자. 어떤defer가 실행되고 어떤 것이 실행되지 않는가? 그 결과가 "프로세스 종료는 고루틴을 정리하지 않는다"와 어떻게 연결되는지 설명해 보자.