Go GMP·GOMAXPROCS — 컨테이너 CPU limit과 고루틴
이 글의 순서8개 절
go를 붙이면 함수가 동시에 진행된다. 그렇다면 고루틴을 10만 개 만들면 스레드도 10만 개가 생길까. GOMAXPROCS를 1로 두면 한 번에 한 작업만 처리할까. 실행할 일과 실행 자원을 나눠 보면 답이 보이고, 여기에 컨테이너의 CPU 시간 예산을 더하면 설정 숫자 하나가 지연으로 이어지는 길도 따라갈 수 있다.
런타임은 내 코드와 커널 사이의 한 층이다
Go 기본 시리즈가 내 코드가 무엇을 뜻하는지를 다뤘다면, 이 시리즈는 그 코드가 실제 기계 위에서 어떻게 돌아가는지를 본다. 내 코드와 CPU·메모리 사이에는 Go 런타임이라는 층이 하나 있다. 런타임이 하는 큰일은 둘이다. 고루틴을 스레드에 태우는 스케줄러가 있고, 힙을 청소하는 GC가 있다.
런타임은 기계 전체를 기준으로 판단하고, 커널은 컨테이너 단위로 제한한다. 두 기준이 어긋나는 곳이 CPU에 하나, 메모리에 하나 있다. 이번 글은 CPU 쪽, 스케줄러다. 고루틴이 왜 싼지를 구조로 먼저 보고, 그 구조가 컨테이너 안에서 어떻게 어긋날 수 있는지로 넘어간다. 런타임 전체를 넓게 훑은 글은 정적 타입·컴파일 언어가 런타임 성능에 미치는 영향에 있다.
고루틴 10만 개면 스레드도 10만 개인가
그렇지 않다. 고루틴은 실행할 일이고, 스레드는 그 일을 하는 자원이다. 고루틴 10만 개를 띄워 기다리게 했을 때 OS 스레드는 4개였다(GOMAXPROCS=2, 아래 실습). 둘 사이를 잇는 것이 런타임 스케줄러이고, 그 가운데에 P라는 자원이 있다.
주방에 주문서가 천 장 쌓였다고 요리사가 천 명 필요한 것은 아니다. 주문서는 해야 할 일이고 요리사는 실제로 일하는 자원이다. Go도 이렇게 나눠 보면 출발이 쉽다.
| 기호 | 실제 의미 | 비유 |
|---|---|---|
| G | 고루틴. 실행 상태와 스택을 가진 작업 | 주문서와 그 진행 상태 |
| M | 운영체제가 스케줄링하는 스레드 | 실제로 일하는 요리사 |
| P | 실행 큐, 할당기 상태처럼 Go 코드 실행에 필요한 런타임 자원 | 일할 수 있게 준비된 작업대 |
P는 물리 CPU 코어가 아니다. 작업대 비유는 역할을 설명하려는 것이고, P가 실제 코어와 1:1로 묶이지는 않는다.
M이 Go 코드를 실행하려면 P가 필요하다. 런타임이 실행할 G와 M, P를 짝지어 준다. GOMAXPROCS가 2라면 P가 두 개이므로 Go 코드를 동시에 실행할 자원이 두 벌이고, 나머지 실행 가능한 G는 큐에서 차례를 기다린다.
G와 M만으로는 안 되는지 물을 수 있다. 작업이 전역 큐 하나에만 몰리면 여러 스레드가 그 큐를 두고 경합한다. Go는 P마다 로컬 실행 큐를 두고, 일이 모자란 쪽이 다른 쪽의 일을 가져오는 work stealing으로 작업을 나눈다. 큐 길이나 내부 상수까지 외울 필요는 없다. P가 일을 나누는 데 필요한 상태를 갖고 있다는 것만 잡고 가면 된다.1
🧪 직접 해보기
P가 하나여도 네 작업은 함께 진행된다
동시에 진행 중인 작업 수와 동시에 CPU에서 Go 코드를 실행하는 수가 다르다는 걸 숫자로 확인해 보자. 아래 프로그램은 작업 네 개를 고루틴으로 띄운다. 기본 모드는 CPU 계산을 하고, -wait 모드는 작업마다 200ms를 기다린다.
package main
import ( "flag" "fmt" "runtime" "sync" "time")
func spin(seed uint64) uint64 { x := seed for i := 0; i < 40_000_000; i++ { x ^= x << 13 x ^= x >> 7 x ^= x << 17 } return x}
func main() { wait := flag.Bool("wait", false, "계산 대신 200ms 대기") flag.Parse() fmt.Println("Go:", runtime.Version(), "NumCPU:", runtime.NumCPU(), "GOMAXPROCS:", runtime.GOMAXPROCS(0))
start := time.Now() results := make([]uint64, 4) var wg sync.WaitGroup for i := range results { wg.Add(1) go func(idx int) { defer wg.Done() if *wait { time.Sleep(200 * time.Millisecond) results[idx] = uint64(idx + 1) return } results[idx] = spin(uint64(idx + 1)) }(i) } wg.Wait() // 각 고루틴이 서로 다른 원소에만 쓰고, 읽기는 Wait 뒤에 한다
var sum uint64 for _, v := range results { sum ^= v } fmt.Println("완료:", len(results), "체크섬:", sum, "경과:", time.Since(start).Round(time.Millisecond))}$ go build -o sched sched/main.go$ GOMAXPROCS=1 ./sched완료: 4 체크섬: 6185594040096594035 경과: 321ms$ GOMAXPROCS=2 ./sched완료: 4 체크섬: 6185594040096594035 경과: 171ms$ GOMAXPROCS=1 ./sched -wait완료: 4 체크섬: 4 경과: 200ms$ GOMAXPROCS=2 ./sched -wait완료: 4 체크섬: 4 경과: 201ms네 실행 모두 작업 넷이 끝났고, CPU 모드의 체크섬은 P를 바꿔도 같다. P는 병렬성을 정할 뿐 결과나 진행 여부를 바꾸지 않는다.
CPU 모드는 P를 늘리자 빨라졌다. 정확히 두 배가 되어야 하는 건 아니다. 실제로 쓸 수 있는 CPU, 코어 성능, 그때의 다른 부하가 모두 끼어든다. 이 측정은 2코어 환경이라 P를 더 늘려도 의미가 없었다.
가장 중요한 건 대기 모드다. P가 1이어도 대기 모드는 200ms 근처에서 끝났다. 하나씩 처리했다면 800ms가 걸렸을 것이다. 기다리는 작업은 실행 자리를 계속 차지하지 않아서 네 작업의 대기가 겹친다. GOMAXPROCS를 1로 두면 한 번에 한 요청만 처리한다는 말이 틀린 이유가 이 줄에 있다.
이 맥(darwin/arm64, 10코어, Go 1.23.11)에서 세 번씩 다시 돌려도 경향은 같았다.
코어가 넉넉하니 P를 4까지 늘리면 CPU 모드가 57ms까지 줄었고, 대기 모드는 P를 1로 둬도 201ms였다. 이 실험의 대기는 타이머 대기이고 실제 네트워크 통신은 아니다. 한 번의 숫자보다 여러 번 돌린 경향을 보고, 차이가 흐려지면 계산 반복 횟수를 늘리면 된다.
🧪 직접 해보기
고루틴 10만 개에 스택 205MB, 스레드는 4개였다
고루틴은 작은 스택에서 시작해 필요하면 늘리고, 전환의 많은 부분을 사용자 공간에서 처리한다. 그래서 많은 작업을 비교적 싸게 관리할 수 있다. 얼마나 싼지 재 보자.
package main
import ( "fmt" "os" "regexp" "runtime" "time")
func main() { var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before)
block := make(chan struct{}) for i := 0; i < 100_000; i++ { go func() { <-block }() // 전부 블록된 채 대기시킨다 } time.Sleep(500 * time.Millisecond) runtime.ReadMemStats(&after)
b, _ := os.ReadFile("/proc/self/status") // 리눅스에서 OS 스레드 수 threads := regexp.MustCompile(`Threads:\s+(\d+)`).FindStringSubmatch(string(b))[1]
fmt.Println("goroutines :", runtime.NumGoroutine()) fmt.Printf("StackInuse : %.1f MB (%.2f KB/G)\n", float64(after.StackInuse)/1e6, float64(after.StackInuse)/100_000/1024) fmt.Println("OS threads :", threads, "/ GOMAXPROCS:", runtime.GOMAXPROCS(0)) close(block)}$ go run gor/main.gogoroutines : 100001StackInuse : 205.1 MB (2.00 KB/G)OS threads : 4 / GOMAXPROCS: 2이 환경에서 고루틴 하나가 스택 2KB를 썼고, 10만 개를 띄웠는데 OS 스레드는 4개였다. 스레드는 대략 P 개수에 syscall이나 런타임 일에 묶인 몇 개가 더해진 정도다.
macOS에는 /proc이 없어서, 스레드 수를 runtime/pprof의 threadcreate 프로필로 세도록 한 줄 바꿔 이 맥에서도 돌렸다. GOMAXPROCS=2로 두면 205.1MB, 2.00KB/G, 스레드 4개로 원문과 같았고, 기본값인 10으로 두면 스레드가 11개였다.
그렇다고 과장하지는 말자. 시작 스택이 모든 환경에서 정확히 2KB라거나, OS 스레드 스택이 늘 몇 MB라고 못 박아 비교하면 틀린다. 스레드 스택은 보통 주소 공간을 예약해 두는 것이고, 실제로 쓴 만큼만 물리 메모리를 차지한다. 고루틴도 스택과 참조 데이터를 들고 있으니 무한히 만들어도 되는 자원은 아니다. 205MB는 공짜가 아니다.
고루틴이 싼 데에는 작게 시작해 필요할 때 늘어나는 스택, 커널을 거치지 않는 사용자 공간 전환, 네트워크 대기를 대신 맡는 netpoller가 함께 작용한다.
막히면 작업대를 넘긴다: handoff와 netpoller
채널에서 값을 기다리는 G는 지금 실행해도 할 일이 없다. 런타임은 그 G를 대기 상태로 두고 다른 G를 실행한다. 곤란한 건 OS 스레드 자체가 붙잡히는 경우다. 블로킹 시스템 호출이나 cgo 호출이 그렇다.
그래서 GOMAXPROCS=2라고 전체 OS 스레드가 정확히 두 개인 것은 아니다. 막힌 M은 P 없이 커널에서 기다리고, P를 넘겨받을 M이 따로 있어야 하기 때문이다.
네트워크 I/O는 한 단계 더 낫다. 런타임의 netpoller(리눅스 epoll, macOS kqueue)가 지원하는 I/O라면 소켓을 netpoller에 등록하고 G만 대기시킨 뒤, 준비가 되면 G를 다시 깨운다. M은 그동안 다른 G를 잡는다. 내 코드는 conn.Read() 한 줄인데 스레드가 막히지 않는 이유가 이것이다.
🧪 직접 해보기
Go 1.14부터는 함수 호출이 없는 루프도 선점된다
P 개수가 정해져 있으면 위험이 하나 생긴다. 어떤 G가 작업대를 절대 내놓지 않으면 어떻게 될까. Go 1.13까지의 선점은 협조적이어서 함수 호출 같은 선점 지점이 있어야 양보가 일어났다. 그래서 함수 호출이 드문 연산 루프가 P를 독차지하는 문제가 있었고, 심하면 GC의 STW까지 늦췄다. Go 1.14에 들어온 비동기 선점이 이 문제를 줄였다.
package main
import ( "fmt" "runtime" "time")
func main() { runtime.GOMAXPROCS(1) // 작업대를 하나로 제한한다 start := time.Now() var n int go func() { for { // 함수 호출이 없는 연산 루프 n++ } }() time.Sleep(10 * time.Millisecond) fmt.Println("main이 깨어나기까지:", time.Since(start).Round(time.Millisecond))}$ go run preempt/main.gomain이 깨어나기까지: 10ms$ GODEBUG=asyncpreemptoff=1 go run preempt/main.go(출력 없이 멈춘 채 5초 timeout으로 종료)두 번째 실행에서는 Sleep(10ms)이 끝나도 main이 돌아갈 작업대가 없다. 루프 고루틴이 하나뿐인 P를 쥔 채 선점 지점을 한 번도 만들지 않기 때문이다. 이 맥(darwin/arm64, Go 1.23.11)에서도 첫 실행은 14ms에 깨어났고, asyncpreemptoff=1로는 5초가 지나도 아무것도 찍히지 않았다.
구현은 플랫폼마다 다르고, 모든 고루틴의 실행 간격이나 엄격한 공정성을 보장하는 실시간 스케줄러가 생긴 것도 아니다.
컨테이너의 CPU 1개는 전용 코어가 아니다
여기서부터 실무의 함정이다. 리눅스 컨테이너의 CPU limit은 보통 cgroup이 정한 주기별 CPU 시간 예산으로 동작한다. CPU 하나를 이 프로세스만 쓰도록 떼어 준다는 뜻과 다르다. 여러 CPU에서 동시에 실행하면 그 시간이 합산되어 예산을 깎는다.
두 경우의 총 CPU 사용량은 같다. 다른 건 일이 주기에 걸쳐 고르게 되느냐, 앞쪽에 몰렸다가 나머지를 기다리느냐다. 그래서 평균 CPU 사용률만 보고는 판단하기 어렵다. 평균에는 실행한 시간과 예산을 다 써서 기다린 시간이 함께 들어 있다. 몰아서 실행한 뒤 기다리는 패턴은 요청의 꼬리 지연에 영향을 줄 수 있다.
다만 p99가 튈 때마다 원인을 CPU quota로 단정하면 안 된다. GC, 락 경합, 외부 I/O도 따로 봐야 한다.
쿠버네티스를 쓴다면 CPU request와 limit도 구분한다. request는 스케줄링과 경합 때의 자원 배분에 관여하고, 여기서 말한 quota 기반 상한은 limit 쪽이다. limit이 없는 배포에 이 이야기를 그대로 가져가면 해석이 어긋난다.
Go 1.25 이후에도 limit과 P가 항상 같지는 않다
Go 1.24까지는 기본값을 정할 때 컨테이너의 CPU quota를 보지 않았다. 보이는 논리 CPU 수로 P를 잡으니, 64코어 노드에 limit: 1짜리 파드를 띄우면 허용된 CPU 시간과 실행 병렬성이 크게 어긋날 수 있었다. Go 1.25부터는 리눅스에서 quota를 반영하는 기본 동작이 들어왔다.
그렇다고 limit이 1이면 P도 1이라고 줄여 생각하면 틀린다. 지금 기본값은 논리 CPU 수, affinity로 허용된 CPU 수, quota를 함께 본다. 소수 quota는 올림하고, 논리 CPU나 affinity가 2 이상이면 quota 때문에 기본 P를 2 아래로 내리지 않는다. 그래서 CPU limit이 1이어도 P가 2일 수 있고, 이것만 보고 설정이 실패했다고 판단하면 안 된다.
툴체인 버전만 올렸다고 기본 동작이 다 바뀌는 것도 아니다. 배포에서 확인할 값은 이렇다.
| 항목 | 확인할 이유 |
|---|---|
툴체인 · go.mod 버전 |
런타임 버전뿐 아니라 언어 버전에 따른 호환 기본값도 함께 작용한다 |
GODEBUG |
containermaxprocs가 quota 반영을, updatemaxprocs가 자동 갱신을 켜고 끈다. go.mod의 go 버전이 1.24 이하면 호환 기본값이 0일 수 있다 |
GOMAXPROCS 직접 설정 |
환경변수에 양의 정수를 주거나 코드에서 GOMAXPROCS(n)을 부르면 자동 기본값과 자동 갱신이 함께 꺼진다 |
| CPU quota · affinity | quota는 시간 예산이고 affinity는 실행할 수 있는 CPU의 범위다. 서로 다른 제약이다 |
cgroup v1 노드라면 하나가 더 있다. Go 1.25는 컨테이너가 호스트와 cgroup 네임스페이스를 같이 쓰면 quota 파일 경로를 잘못 만들어 limit을 읽지 못하고, 논리 CPU 수를 그대로 쓴다. cgroup v1에서는 Docker와 podman이 기본으로 그렇게 띄우고, 확인해 본 RKE2 v1.28.2 노드의 파드도 그랬다. 이 버그는 Go 이슈 #76390으로 보고돼 Go 1.26에서 고쳐졌다. 4 vCPU 노드에서 CPU limit 1로 돌려 보니 Go 1.25.8은 GOMAXPROCS가 4, Go 1.26.10은 2였다. 실험 과정은 requests·limits 글에 적었다.
그러니 값은 짐작하지 말고 실행 중에 읽는다. 앞의 실습 코드처럼 runtime.GOMAXPROCS(0)과 runtime.NumCPU()를 시작 로그에 남겨 두면, 버전을 올리거나 매니페스트를 고칠 때마다 실제 값이 어떻게 변했는지 바로 비교할 수 있다.
구버전에서는 명시 설정이나 automaxprocs 같은 라이브러리를 쓰는 방법도 있다. 새 런타임에 무조건 덧붙이기보다 기존 배포 설정이 이미 무엇을 고정하고 있는지부터 확인하는 편이 낫다.
CPU throttling은 어디서 읽나
throttling 기록은 cgroup 쪽에 남는다. 리눅스 cgroup v2라면 cpu.max에서 quota를, cpu.stat의 nr_throttled와 throttled_usec에서 예산을 다 써서 멈춘 횟수와 시간을 읽는다. Go 쪽 스케줄러 추적은 그 옆에서 실행 큐가 얼마나 쌓였는지를 보여 준다.
🧪 직접 해보기
schedtrace로 P 위에서 도는 G와 줄 선 G를 나눠 본다
로컬 예제에 GODEBUG=schedtrace를 주면 실행 큐와 스케줄러 상태를 주기적으로 볼 수 있다. 짧은 간격은 실험에서만 쓰고, 운영에서는 출력량과 목적에 맞춘다. 줄이 여러 개 찍히도록 sched/main.go의 spin 반복을 4억 번으로 늘려 돌렸다.
$ GODEBUG=schedtrace=200 GOMAXPROCS=2 ./schedSCHED 0ms: gomaxprocs=2 idleprocs=1 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 [0 0]Go: go1.23.11 NumCPU: 10 GOMAXPROCS: 2SCHED 212ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=1 [0 1]SCHED 412ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=1 [0 1]SCHED 622ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=2 [0 0]SCHED 829ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=2 [0 0]SCHED 1036ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0 needspinning=1 idlethreads=1 runqueue=0 [1 1]완료: 4 체크섬: 11855647230730954544 경과: 1.082sgomaxprocs는 P 개수, idleprocs는 노는 P, threads는 M 개수, runqueue는 전역 큐 길이, 끝의 대괄호는 P별 로컬 큐 길이다. 212ms 이후의 줄은 어느 것이든 전역 큐와 로컬 큐를 더하면 2다. 작업 넷 가운데 둘은 P 위에서 돌고 둘은 줄을 서 있다는 뜻이다.
gomaxprocs=2인데 threads=4인 것도 보인다. 앞에서 본 대로 M은 P보다 많을 수 있다. 원문 환경(Go 1.24.7, linux/amd64 2코어)에서는 같은 자리에 threads=6이 찍혔다. 다만 스케줄러 추적만으로 CPU throttling을 확정할 수는 없다.
cat /sys/fs/cgroup/cpu.maxcat /sys/fs/cgroup/cpu.stat위 경로는 컨테이너에 자기 cgroup 루트가 마운트된 흔한 구성에서 통한다. 다른 마운트 구성이거나 호스트에서 볼 때는 /proc/self/cgroup과 마운트 정보로 실제 경로를 찾는다.
cpu.max가 100000 100000이면 100,000μs 주기마다 CPU 시간 100,000μs라는 뜻이고, max 100000이면 이 파일이 정한 quota 상한이 없다는 뜻이다. cpu.stat의 nr_throttled와 throttled_usec는 누적값이다. 같은 부하 구간의 전후 차이를 봐야 하고, 누적 총량 하나를 서비스 지연과 바로 비교하면 안 된다.
관측값마다 답하는 질문이 다르다.
| 관측값 | 알 수 있는 것 | 알 수 없는 것 |
|---|---|---|
NumGoroutine() |
진행 중인 작업 수 | 그중 몇 개가 지금 CPU를 쓰는지 |
schedtrace |
P 개수, 큐에 쌓인 정도 | 커널이 실제로 CPU를 얼마나 허용했는지 |
cpu.stat 증가량 |
그 구간에 예산을 다 써서 멈춘 횟수와 시간 | 그게 이번 p99 상승의 원인인지 |
P를 바꾸기 전후로 처리량, p95·p99, CPU 시간, throttling 증가량을 함께 비교한다. 실행 큐가 길다고 P를 무작정 늘리면 예산을 더 빨리 쓸 수 있고, 줄이면 CPU 병렬 처리량이 떨어질 수 있다. 이 글의 로컬 측정은 컨테이너 밖에서 했으므로 throttling을 재현한 것은 아니다.
정리
| 숫자 | 세는 것 | 이 글에서 본 값 |
|---|---|---|
| 고루틴 수 | 진행 중인 일 | 10만 개에 스택 205MB, OS 스레드 4개 |
P (GOMAXPROCS) |
Go 코드를 동시에 실행하는 수 | P가 1이어도 대기 작업 넷이 200ms에 끝났다 |
| CPU quota | 주기마다 받는 CPU 시간 | 네 스레드면 100ms 예산을 25ms에 다 쓴다 (규칙 그림) |
고루틴이 싸다는 건 알고 있었지만, 그 이유가 P라는 한 층에 모여 있다는 건 이번에 그림으로 그려 보고서야 정리됐다. 로컬 큐라서 경합이 줄고, 개수가 정해져 있어 스레드가 무한정 늘지 않고, 떼어 넘길 수 있어서 하나가 막혀도 나머지가 돈다.
그 정해진 개수가 컨테이너에서는 확인할 지점이 된다. 개수를 누가 무엇을 보고 정하느냐가 Go 버전마다 달라졌기 때문이다. 버전을 올리거나 매니페스트를 고친 뒤에는 시작 로그의 GOMAXPROCS(0)과 NumCPU() 두 값부터 본다.
참고 자료
- Go 1.25 Release Notes · Container-aware GOMAXPROCS: quota 반영과 자동 갱신, 끄는 방법
- runtime.GOMAXPROCS: 기본값을 고르는 규칙, 2 아래로 내리지 않는 조건, 올림
- Go 이슈 #76390: cgroup v1에서 마운트 루트가
/가 아닐 때 quota를 못 읽는 버그, Go 1.26에서 수정 - GODEBUG:
containermaxprocs,updatemaxprocs와go.mod버전별 기본값 - Go 1.14 Release Notes · Runtime: 비동기 선점
- Linux cgroup v2 · CPU Interface Files:
cpu.max,cpu.stat - runtime/proc.go: G·M·P 스케줄러 구현과 설명 주석
- runtime/pprof · Profile: macOS에서 스레드 수를 센
threadcreate프로필
각주
-
이 구조 설명은 Go 런타임 구현을 설명하는 모델이다. 언어 명세가 특정 큐 구조나 스케줄링 순서를 보장하지는 않는다. ↩