Go 런타임 깊이 보기: G/M/P 스케줄러, 채널 구현, 인터페이스 레이아웃, 삼색 GC
이 글의 핵심
고루틴이 계속 늘어나거나 GC 때문에 지연 시간이 튀는 문제는 문법만 알아서는 원인을 짚기 어렵습니다. 이 글은 문법 요약 대신 런타임 내부 구조를 pprof와 트레이스로 확인할 수 있는 수준에서 설명하고, 값이 인터페이스에 담길 때의 할당, STW 구간, 메모리와 CPU 사이의 트레이드오프를 운영 패턴과 연결합니다.
이 글의 핵심
이 글은 Go 문법보다 런타임이 고루틴·채널·인터페이스·GC를 어떻게 구현하는지에 초점을 둡니다. 고루틴 스케줄러의 G/M/P 모델과 작업 훔치기, 채널의 대기열과 버퍼 동작, 인터페이스 값의 두 워드 레이아웃, 삼색 마킹 GC와 쓰기 장벽을 차례로 보고, 마지막에 context 전파·그레이스풀 셧다운·동시성 상한 같은 운영 패턴과 연결합니다. 런타임 세부 구현은 버전마다 바뀌므로 여기서는 Go 1.2x 기준의 큰 구조를 다룹니다.
문법과 기초 동시성은 Go 2주 완성 시리즈와 고루틴·채널을 참고하세요.
고루틴 스케줄러: G/M/P 모델
OS 스레드는 생성할 때마다 큰 고정 스택과 커널 스케줄링 비용이 듭니다. Go는 수만 개의 고루틴을 적은 수의 OS 스레드 위에서 돌리는 M:N 스케줄링을 하며, 고루틴은 작은 스택(현재 2KB)으로 시작해 필요할 때 늘어납니다. 이를 위해 런타임은 세 가지 단위를 둡니다.
| 기호 | 의미 |
|---|---|
| G | Goroutine. 스택, 프로그램 카운터, 상태를 가진 실행 단위 |
| M | Machine. 실제로 코드를 실행하는 OS 스레드 |
| P | Processor. Go 코드를 실행할 권한과 로컬 실행 큐(LRQ), 메모리 할당 캐시 등을 가진 논리적 컨텍스트. M은 P를 잡아야 Go 코드를 실행할 수 있음 |
P의 개수는 GOMAXPROCS로 정해지며 기본값은 논리 CPU 수입니다. Go 1.25부터는 컨테이너의 cgroup CPU 한도가 더 작으면 그 값을 반영합니다. 동시에 Go 코드를 실행하는 스레드 수는 P의 수를 넘지 않으므로, 실행 가능한 고루틴이 그보다 많으면 큐에서 차례를 기다립니다.
실행 흐름
go f()로 만든 새 G는 현재 P의 runnext 슬롯에 들어가 다음 차례로 실행될 가능성이 높고, 원래 그 자리에 있던 G는 로컬 큐로 밀려납니다. 로컬 큐가 가득 차면 절반을 전역 큐(global queue)로 옮깁니다.
M이 다음에 실행할 G를 찾는 순서는 대략 이렇습니다. 공정성을 위해 일정 횟수(스케줄링 61번)마다 전역 큐를 먼저 확인하고, 평소에는 자기 P의 runnext와 로컬 큐를 봅니다. 비어 있으면 전역 큐, 네트워크 폴러(I/O가 준비된 고루틴)를 차례로 확인하고, 그래도 없으면 다른 P의 로컬 큐에서 절반을 훔쳐 옵니다(work stealing). 끝까지 일이 없으면 M은 P를 반납하고 잠듭니다.
고루틴이 시스템 콜로 블록되면 그 M도 함께 블록됩니다. 백그라운드 감시 스레드인 sysmon은 시스템 콜이 짧은 시간(약 20µs) 이상 이어지면 그 M에서 P를 떼어 다른 M에 넘기므로, 블로킹 시스템 콜 하나가 다른 고루틴의 실행을 막지 않습니다. 네트워크 I/O는 시스템 콜로 블록하지 않고 네트워크 폴러(epoll, kqueue, IOCP)에 등록한 뒤 고루틴만 대기 상태로 바꿉니다.
sysmon은 한 고루틴이 10ms 이상 계속 실행되면 선점도 요청합니다. Go 1.14부터는 신호를 이용한 비동기 선점이 도입되어, 함수 호출이 없는 촘촘한 루프도 선점할 수 있게 되었습니다.
이 모델이 운영에 주는 의미
고루틴이 가벼운 이유는 고루틴마다 커널 스레드를 붙이지 않고 런타임 내부의 큐와 작은 스택만 늘리기 때문입니다. 그러나 CPU 바운드 작업은 고루틴 수를 늘려도 P의 수 이상으로 병렬화되지 않습니다. I/O 대기가 많은 경우 고루틴은 대기 상태로 빠졌다가 이벤트 후 다시 큐에 들어가는데, 이때도 고루틴을 무한정 만들면 스택 메모리와 다운스트림(DB, 외부 API) 부하가 함께 늘어나므로 세마포나 워커 풀로 상한을 두어야 합니다.
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 0을 넘기면 값을 바꾸지 않고 조회만 함
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
}
runtime.Gosched나 runtime.LockOSThread 같은 함수는 스케줄러와 직접 상호작용하지만, 일반 애플리케이션 코드에는 거의 필요하지 않습니다. LockOSThread는 스레드 로컬 상태에 의존하는 C 라이브러리나 OS API(예: 일부 GUI, 네임스페이스 전환)를 호출할 때 쓰입니다.
채널: 구현과 버퍼링
chan T는 런타임에서 hchan 구조체로 표현됩니다. 뮤텍스, 버퍼로 쓰는 원형 큐, 그리고 송신 대기 큐(sendq)와 수신 대기 큐(recvq)를 가집니다. 대기 큐의 원소는 대기 중인 고루틴과 주고받을 값의 위치를 담은 sudog입니다.
언버퍼드 채널 (용량 0)
송신과 수신이 만나야 둘 다 진행되는 동기적(rendezvous) 동작입니다. 수신자가 먼저 와서 기다리고 있다면, 송신자는 값을 수신자의 스택 위치로 직접 복사하고 수신자를 깨운 뒤 진행합니다. 송신자가 먼저 오면 sendq에 sudog로 매달려 잠들고, 나중에 온 수신자가 값을 가져가며 송신자를 깨웁니다. 그래서 언버퍼드 채널의 송신이 반환됐다면 수신자가 값을 받았다는 것이 보장됩니다.
버퍼드 채널 (용량 N)
송신 시 대기 중인 수신자가 있으면 버퍼를 거치지 않고 수신자에게 바로 넘깁니다. 대기자가 없고 버퍼에 빈 슬롯이 있으면 값을 버퍼에 넣고 바로 반환합니다. 버퍼가 가득 차면 송신 고루틴은 sendq에서 대기합니다. 수신도 대칭적으로, 버퍼가 비어 있으면 recvq에서 대기합니다.
용량을 키우면 일시적인 생산·소비 속도 차를 흡수할 여지가 생기지만, 소비자가 근본적으로 느리다면 버퍼는 결국 가득 차고 그동안 메모리와 지연만 늘어납니다.
닫기(close)와 수신 패턴
닫힌 채널에 송신하거나, 이미 닫힌 채널을 다시 닫으면 패닉이 발생합니다. 닫힌 채널에서 수신하면 버퍼에 남은 값을 먼저 받고, 그 뒤로는 즉시 제로 값과 ok == false(v, ok := <-ch)를 돌려받습니다. for v := range ch는 채널이 닫히고 버퍼가 빌 때까지 순회합니다. nil 채널에 대한 송신과 수신은 영원히 블록되는데, select에서 특정 case를 끄는 용도로 이 성질을 쓰기도 합니다.
닫기는 “더 이상 보낼 값이 없다”는 송신 측의 신호이므로, 생산자 쪽(생산자가 여럿이면 그들을 조율하는 쪽) 한 곳에서만 닫도록 소유권을 정해 두어야 이중 close 패닉을 피할 수 있습니다.
select
select는 여러 채널 연산 중 준비된 것을 하나 고르며, 여러 개가 동시에 준비되어 있으면 무작위로 선택해 특정 case가 굶지 않게 합니다. 준비된 것이 없으면 default가 있으면 그것을 실행하고, 없으면 하나가 준비될 때까지 블록합니다. 취소는 ctx.Done()을 case로 넣어 처리하는 것이 보통입니다. 루프 안에서 time.After를 쓰면 반복마다 타이머가 만들어지는데, Go 1.23 이전에는 만료되기 전까지 회수되지 않아 메모리가 쌓였습니다. 1.23부터는 참조가 없어진 타이머가 GC로 회수되지만, 긴 타임아웃을 루프에서 반복한다면 time.NewTimer를 재사용하는 편이 여전히 효율적입니다.
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
for v := range ch {
fmt.Println(v) // 1, 2를 출력한 뒤 루프 종료
}
}
인터페이스 값 레이아웃
인터페이스 값은 런타임에서 두 워드로 표현됩니다.
메서드가 있는 인터페이스는 iface로, 첫 워드가 itab을 가리킵니다. itab은 인터페이스 타입과 구체 타입의 조합마다 하나씩 만들어져 캐시되며, 구체 타입 정보(_type)와 인터페이스 메서드 순서대로 정렬된 함수 포인터 테이블을 담습니다. 메서드 호출은 이 테이블을 통한 간접 호출입니다. 두 번째 워드는 데이터 포인터입니다.
빈 인터페이스(interface{}/any)는 메서드 테이블이 필요 없으므로 eface로, (_type 포인터, 데이터 포인터) 쌍입니다.
값이 인터페이스에 담길 때의 할당
데이터 워드는 항상 포인터입니다(Go 1.4 이후). 그래서 값을 인터페이스로 변환하면 일반적으로 그 값의 복사본을 어딘가에 두고 그 주소를 저장합니다. 예외는 몇 가지입니다. 포인터, 맵, 채널, 함수처럼 값 자체가 포인터 하나인 타입은 데이터 워드에 직접 들어갑니다. 크기가 0인 값과 0~255 범위의 정수 값 등은 런타임의 정적 배열을 가리키게 해 할당을 피합니다. 컴파일 타임 상수도 읽기 전용 데이터를 가리킬 수 있습니다. 또 이스케이프 분석 결과 인터페이스 값이 함수 밖으로 나가지 않으면 복사본을 스택에 둘 수 있습니다.
이 경우들을 제외하면 복사본은 힙에 할당됩니다. 예를 들어 fmt.Println(x)에 큰 구조체나 정수를 넘기면 ...any로 변환되면서 할당이 생기는 일이 흔합니다. 핫 루프에서 불필요한 any 변환을 피하고, go build -gcflags=-m의 출력이나 할당 프로파일로 실제 할당 여부를 확인하는 것이 좋습니다.
타입 단언
package main
import "fmt"
type Stringer interface{ String() string }
type MyInt int
func (m MyInt) String() string { return fmt.Sprintf("v=%d", m) }
func main() {
var s Stringer = MyInt(42)
if v, ok := s.(MyInt); ok {
fmt.Println("assert:", v)
}
}
구체 타입으로의 단언(s.(MyInt))은 itab 안의 타입 포인터를 비교하는 것으로 끝나므로 빠릅니다. 다른 인터페이스로의 단언(s.(io.Writer))은 해당 조합의 itab을 찾거나 만들어야 하며, 결과는 전역 해시 테이블에 캐시됩니다. reflect 패키지는 같은 타입 메타데이터를 일반적인 API로 노출합니다.
가비지 컬렉터: 삼색 마킹과 동시 GC
Go의 GC는 세대 구분이 없고(non-generational), 객체를 옮기지 않으며(non-moving), 애플리케이션과 동시에(concurrent) 마킹하는 마크-스윕 방식입니다. 도달 가능성은 삼색 마킹으로 판별합니다.
| 색 | 의미 |
|---|---|
| 흰색(white) | 아직 방문하지 않은 객체. 마킹이 끝날 때 흰색이면 회수 대상 |
| 회색(grey) | 도달 가능하다고 표시했지만, 그 객체가 가리키는 객체들은 아직 스캔하지 않음 |
| 검은색(black) | 객체와 그 객체가 직접 가리키는 포인터까지 스캔 완료 |
GC는 루트(고루틴 스택, 전역 변수 등)에서 시작해 회색 객체를 하나씩 꺼내 검은색으로 바꾸고, 그 객체가 가리키는 흰색 객체를 회색으로 만듭니다. 회색이 더 이상 없으면 남은 흰색 객체가 회수 대상입니다.
쓰기 장벽(write barrier)이 필요한 이유
마킹이 진행되는 동안에도 애플리케이션은 포인터를 바꿉니다. 이미 스캔이 끝난 검은 객체에 흰 객체를 가리키는 포인터를 새로 쓰고, 그 흰 객체를 가리키던 원래 경로를 지우면, GC는 그 객체를 다시 방문할 기회가 없어 살아 있는 객체를 회수하게 됩니다. 이를 막기 위해 마킹 중에는 포인터 쓰기마다 쓰기 장벽이 실행되어 관련 객체를 회색으로 표시합니다. Go 1.8부터는 삭제 장벽(Yuasa)과 삽입 장벽(Dijkstra)을 합친 하이브리드 쓰기 장벽을 써서, 마킹 종료 시 고루틴 스택을 다시 스캔할 필요를 없앴습니다.
STW와 지연 시간
동시 GC라도 짧은 stop-the-world 구간은 남습니다. 한 GC 사이클에는 마킹을 시작하기 전(sweep termination, 쓰기 장벽 활성화)과 마킹을 끝낼 때(mark termination) 두 번의 STW가 있고, 보통 각각 수십~수백 마이크로초 수준입니다. 스윕은 STW 없이 백그라운드와 할당 경로에서 점진적으로 진행됩니다. 지연 시간에 GC가 더 크게 영향을 주는 경로는 STW보다 마크 어시스트입니다. 할당 속도가 GC 마킹 속도보다 빠르면 할당하는 고루틴이 마킹 작업을 일부 떠맡게 되어, 그 요청의 지연이 늘어납니다.
GOGC, GOMEMLIMIT와 메모리·CPU 트레이드오프
GOGC(기본 100)는 다음 GC를 시작할 힙 목표를 정합니다. 직전 GC 후 살아남은 힙(과 GC 루트)의 크기에 대해 GOGC% 만큼 더 늘어나면 다음 사이클을 시작하므로, 기본값에서는 라이브 힙의 약 두 배가 목표가 됩니다. 값을 크게 하면 GC 빈도와 CPU 사용은 줄지만 메모리 사용량이 늘고, 작게 하면 반대가 됩니다.
Go 1.19에 추가된 GOMEMLIMIT은 런타임이 쓰는 전체 메모리의 소프트 한도입니다. 사용량이 이 한도에 가까워지면 GOGC와 관계없이 GC를 더 자주 돌립니다. 컨테이너 메모리 limit보다 조금 낮게 설정하면 평소에는 GC를 덜 돌리고, 한도 근처에서만 적극적으로 회수하게 할 수 있습니다. 다만 라이브 힙 자체가 한도를 넘으면 GC가 계속 돌면서 CPU만 소모하는 상황이 될 수 있으므로, 한도는 실제 라이브 힙보다 충분히 여유 있게 잡아야 합니다.
# GC가 돌 때마다 힙 크기, STW 시간, CPU 사용률 요약을 stderr에 출력
GODEBUG=gctrace=1 ./server
# 실행 트레이스로 GC 구간과 고루틴 스케줄링을 시각화
curl -o trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out
운영 환경에서는 runtime/metrics 패키지나 Prometheus Go 클라이언트가 노출하는 메트릭으로 할당 속도, GC 주기, 힙 크기를 지속적으로 모니터링합니다.
프로덕션 Go 패턴
context 전파와 취소
HTTP 핸들러, gRPC 핸들러, 백그라운드 작업에는 요청 단위 context.Context를 첫 인자로 넘깁니다. 상위가 취소되면 하위 고루틴이 ctx.Done()을 보고 빠져나와야 고아 작업과 리소스 누수가 생기지 않습니다. 타임아웃은 context.WithTimeout, 명시적 취소는 context.WithCancel로 만들고, 반환된 cancel 함수는 defer로 반드시 호출해 내부 타이머와 자원을 해제합니다. 자세한 내용은 컨텍스트 타임아웃·취소를 참고하세요.
그레이스풀 셧다운
signal.NotifyContext(또는 signal.Notify)로 SIGINT/SIGTERM을 받고, http.Server.Shutdown(ctx)을 호출하면 새 연결을 받지 않고 진행 중인 요청이 끝날 때까지 기다립니다. Shutdown은 하이재킹된 연결(WebSocket 등)은 기다리지 않으므로 그런 연결은 따로 닫아야 합니다. HTTP 서버를 먼저 멈춘 뒤 메시지 컨슈머, 그다음 DB 커넥션 풀 순으로 닫아야 처리 중인 작업이 중간에 끊기지 않습니다.
동시성 상한: 워커 풀과 세마포
고루틴은 가볍지만 요청이나 작업마다 제한 없이 만들면 다운스트림이 먼저 무너집니다. 고정된 수의 워커가 작업 채널을 소비하게 하거나, golang.org/x/sync/semaphore의 Weighted, 또는 errgroup.Group의 SetLimit으로 동시 실행 수를 제한합니다.
에러 처리와 관측
에러를 그대로 return err로 올리기만 하면 최종 로그에서 어디서 실패했는지 알기 어렵습니다. fmt.Errorf("load user %d: %w", id, err)처럼 맥락을 붙여 감싸고, 호출자는 errors.Is/errors.As로 원인을 판별합니다. 관측은 OpenTelemetry나 Prometheus 메트릭, log/slog 구조화 로깅으로 트레이스·메트릭·로그를 연결하고, 병목은 net/http/pprof로 CPU, 힙, 고루틴, 뮤텍스, 블록 프로파일을 받아 확인합니다. 뮤텍스와 블록 프로파일은 기본으로 꺼져 있어 runtime.SetMutexProfileFraction, runtime.SetBlockProfileRate로 켜야 합니다.
예시: 취소 가능한 워커 풀
package main
import (
"context"
"sync"
)
// jobs를 닫으면 워커가 남은 작업을 마친 뒤 종료합니다. ctx 취소 시에도 빠져나옵니다.
func workerPool(ctx context.Context, jobs <-chan int, n int, fn func(context.Context, int) error) error {
var wg sync.WaitGroup
var errOnce sync.Once
var firstErr error
setErr := func(e error) {
if e == nil {
return
}
errOnce.Do(func() { firstErr = e })
}
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
if err := fn(ctx, j); err != nil {
setErr(err)
}
}
}
}()
}
wg.Wait()
return firstErr
}
작업 채널을 닫으면 워커는 남은 작업을 처리한 뒤 종료하고, 컨텍스트가 취소되면 대기 중이던 워커도 빠져나옵니다. 이 예시는 첫 오류만 기록하고 나머지 작업은 계속 처리합니다. 첫 오류에서 전체를 중단하고 싶다면 errgroup.WithContext를 쓰면 됩니다. 한 고루틴이 에러를 반환하는 순간 파생 컨텍스트가 취소되어 나머지 작업이 ctx.Done()을 보고 멈춥니다. 한편 wg.Wait()가 반환된 뒤 firstErr를 읽으므로 firstErr에 대한 데이터 경쟁은 없습니다.