고루틴 시작하기 — go 키워드 뒤에서 벌어지는 일
go 키워드가 실제로 하는 일
go f()를 실행하면 무슨 일이 벌어질까. 가장 흔한 오해는 "새 OS 스레드가 뜬다"는 것이다.
아니다. Go 런타임은 고루틴을 자체 자료구조(g — goroutine)로 만들어서 자신이 관리하는
큐에 넣을 뿐이다. 실제로 그 고루틴이 CPU 위에서 실행되는지는 런타임 스케줄러가 나중에
결정한다. 이 스케줄러를 이해하려면 세 가지 문자로 요약되는 GMP 모델을 알아야 한다.
- G (Goroutine): 실행할 함수와 그 스택, 실행 상태를 담은 구조체
- M (Machine): 실제 OS 스레드. 커널이 스케줄링하는 단위는 이거다
- P (Processor): M이 G를 실행하려면 반드시 거쳐야 하는 논리적 자원.
GOMAXPROCS개수만큼 존재
flowchart TD P1["P (Processor)"] --> M1["M (OS 스레드)"] P1 --> Q1["로컬 큐: G1, G2, G3"] M1 --> CPU["CPU 코어"] GQ["전역 큐"] -.->|"로컬 큐 비면 훔쳐옴"| P1
P는 기본적으로 GOMAXPROCS(보통 CPU 코어 수) 개만큼 만들어진다. 각 P는 자신만의 로컬
고루틴 큐를 갖고 있고, M이 P를 붙잡아야만 그 큐에 있는 G를 실행할 수 있다. 즉 실제
병렬로 실행되는 고루틴 수의 상한은 코어 수가 아니라 GOMAXPROCS다 — 코어가 32개여도
GOMAXPROCS=4면 동시에 4개까지만 진짜 병렬로 돈다. 이건 자주 헷갈리는 지점이라
한 번 더 강조한다: 동시성(concurrency)과 병렬성(parallelism)은 다르다. 수만 개의
고루틴을 만들 수 있다는 것과, 그것들이 동시에 CPU를 점유한다는 것은 별개의 이야기다.
왜 M보다 P가 많으면 안 되고, G는 몇 개든 괜찮은가
M(OS 스레드)은 비싸다. 스택도 크고 커널이 스케줄링하는 비용도 있다. 그래서 Go 런타임은 M을 무한정 늘리지 않는다(블로킹 시스템 콜이 많아지면 예외적으로 늘어나긴 한다 — 뒤에서 설명한다). 반면 G는 만들기 거의 공짜에 가깝다. 초기 스택이 몇 KB 수준이고, 필요하면 런타임이 스택을 복사해서 늘린다(고정 크기 스택이었다면 재귀가 깊어질 때마다 스택 오버플로를 걱정해야 했을 것이다). 이 비대칭성 때문에 "고루틴 10만 개를 띄운다"는 문장이 실무에서 실제로 성립한다 — 스레드 10만 개를 띄운다는 문장은 대부분의 시스템에서 성립하지 않는다.
func main() {
var wg sync.WaitGroup
for i := 0; i < 100_000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 가벼운 작업
}(i)
}
wg.Wait()
}이 코드는 보통 몇백 밀리초 안에 끝난다. 같은 걸 OS 스레드 10만 개로 하면 대부분의 환경에서 메모리 부족으로 죽는다.
블로킹 시스템 콜을 만나면 M이 갈라진다
여기서부터가 GMP 모델의 진짜 핵심이다. 고루틴 하나가 파일 읽기 같은 블로킹 시스템 콜에 들어가면 그 고루틴을 실행 중이던 M도 커널에 의해 블로킹된다. 이대로면 같은 P에 연결된 다른 고루틴들도 전부 멈춰야 한다 — P 하나에 M 하나만 붙을 수 있으니까. Go 런타임은 이걸 막기 위해 블로킹이 감지되면 그 P를 새 M(또는 유휴 M)에 즉시 넘긴다. 원래 M은 블로킹된 시스템 콜이 끝날 때까지 혼자 대기하고, P는 새 M을 데리고 계속 다른 G들을 실행한다.
이게 바로 "M이 P보다 많아질 수 있는" 유일한 이유다. 반대로 네트워크 I/O(소켓 읽기/쓰기
같은)는 사정이 다르다. Go 런타임은 자체 네트워크 폴러(netpoller, 리눅스에서는 epoll 기반)를
갖고 있어서, 소켓이 준비 안 됐을 때 고루틴을 진짜로 블로킹시키지 않고 M을 반납한 채 대기
상태로 등록해 둔다. 그래서 net/http 서버가 고루틴 수만 개를 처리해도 M이 수만 개로
불어나지 않는다. 파일 시스템 콜과 네트워크 I/O를 Go 런타임이 다르게 취급한다는 걸
알아두면, 나중에 "왜 이 코드는 고루틴을 이렇게 많이 띄워도 괜찮지?"라는 질문에 답할 수
있다.
결과를 버리는 고루틴은 거의 항상 버그다
앞 챕터 예제에서 go fetchUser(1)을 쓰고 결과를 버렸다. 실전에서 이런 코드를 짜면 안
된다 — 이유가 두 가지다.
첫째, main 함수가 끝나면 프로그램 전체가 종료되면서 아직 실행 중인 고루틴도 그대로
죽는다. defer도, recover도 실행을 보장하지 않는다. 둘째, 그 고루틴 안에서 패닉이
나면 프로그램 전체가 죽는다 — 고루틴의 패닉은 그 고루틴을 띄운 쪽으로 전파되지 않는다.
main이 모르는 사이에 어디선가 조용히 크래시가 난다.
// 이렇게 쓰면 fetchUser의 결과도, 에러도 알 방법이 없다
go fetchUser(1)
// 최소한 이 정도는 해야 한다 — 완료를 기다리고 에러를 회수한다
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("fetchUser panicked: %v", r)
}
}()
user := fetchUser(1)
_ = user
}()
wg.Wait()go 키워드를 쓸 때마다 스스로에게 물어야 할 질문은 하나다 — "이 고루틴이 끝나는 걸
누가, 어떻게 아는가?" 답이 없다면 그 고루틴은 만들지 않는 게 낫다. 다음 챕터에서 다룰
채널이 바로 이 질문에 대한 표준적인 답이다.