GOMAXPROCS 這個講了五年的坑,Go 1.25 已經修了——但只有 go.mod 寫對才生效
本篇是「公有雲替你處理掉的那些事」系列的第 1 / 1 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。
我差點照抄了自己的筆記
這篇的起點是一條我抄了很多年的筆記:
Go 在容器裡會把
GOMAXPROCS設成宿主機的核心數,不是 cgroup 的 CPU limit。要嘛手動設,要嘛用 uber 的automaxprocs。
它整理自 2019 到 2024 年之間的一堆文章,每一篇都這麼講,看起來天經地義。
但下筆前我停了一下。Go 1.25 的 release note 說這件事修了——runtime 會自己去讀 cgroup 的 CPU 限制。如果是真的,這條筆記就是過期資訊;照抄,等於在 2026 年發表一個 2025 年 8 月就已經反過來的結論。
所以我把 docker run --cpus=1.5 真的打了下去,想花十分鐘驗證「修了」兩個字。
「修了」是真的。但它跟我想像的完全不一樣:開關不在 toolchain,在 go.mod 的一行字;它算出來的數字跟 --cpus 給的對不上;而且就算全部設對,kernel 那層照樣凍你。
十分鐘變成三個坑,外加一個把我自己打臉的 JVM 對照。
這篇的 lab 環境:一台 M2 上的 OrbStack,cgroup v2
所有數字都出自這一台,沒有引用別人的 benchmark。
$ docker info --format 'CPUs={{.NCPU}} Cgroup={{.CgroupVersion}}/{{.CgroupDriver}}'
CPUs=8 Cgroup=2/cgroupfs
$ docker version --format '{{.Server.Version}} / {{.Server.KernelVersion}}'
29.4.0 / 7.0.11-orbstack-00360-gc9bc4d96ac70
| 元件 | 版本 | 相關日期 |
|---|---|---|
| 宿主 | Apple M2(4 P-core + 4 E-core)、macOS 26.5.2 | — |
| 容器 runtime | OrbStack 2.2.1 / Docker Engine 29.4.0 | — |
| 建置用 image | golang:1.25 → Go 1.25.12 | Go 1.25 於 2025-08 釋出 |
| 執行用 image | debian:bookworm-slim | — |
| JVM 對照組 | eclipse-temurin:21 → Java 21.0.11 | — |
本文實測日期:2026-08-07。
先講結果:同一份原始碼,go.mod 差一行,throttle 差 18 倍
固定總工作量 1600 輪,均分給 GOMAXPROCS 條 goroutine,兩邊都跑在 --cpus=1.5 裡。
go.mod 寫 go 1.25 | go.mod 寫 go 1.24 | 倍數 | |
|---|---|---|---|
GOMAXPROCS(0) | 2 | 8 | |
| wall time | 5.758s | 8.254s | +43% |
| p50 | 4.131ms | 9.009ms | 2.2× |
| p99 | 39.463ms | 177.594ms | 4.5× |
nr_throttled | 56 | 82 | 1.5× |
throttled_usec | 2,831,315 | 52,440,761 | 18.5× |
兩個 binary 是同一顆 Go 1.25.12 toolchain 編出來的,原始碼一個字都沒改。
差別只有 go.mod 裡那一行 go 指令。
接下來照我踩坑的順序走:先找出這個開關到底在哪一層(坑一),再看它算出來的數字準不準(坑二),然後回答「全設對了為什麼還是被凍」(坑三)。後面是 Go 1.25 另一個沒什麼人量過的新行為,和一個原本要當反面教材、結果打臉我的 JVM 對照。
坑一:容器感知這件事,Go 綁在語言版本上不是綁在 toolchain 上
Go 1.25 的 release note 講得很清楚:runtime 會去看 cgroup 的 CPU bandwidth limit,如果比邏輯 CPU 數少,GOMAXPROCS 就取那個較低的值。
我原本以為「裝了 Go 1.25 就有」。
不是。
GODEBUG 的預設值是跟著 go.mod 的語言版本走的。把 go 1.25 改成 go 1.24,同一顆 toolchain 編出來的 binary 就退回舊行為,因為 containermaxprocs 的預設值來自語言版本而不是編譯器版本。
實測三組,全部 --cpus=1.5:
# A:go.mod 寫 go 1.25
runtime.NumCPU(): 8
GOMAXPROCS(0) : 2
cpu.max : 150000 100000
# B:go.mod 寫 go 1.24(同一顆 toolchain 編的)
runtime.NumCPU(): 8
GOMAXPROCS(0) : 8
# C:go.mod 寫 go 1.25,但 GODEBUG=containermaxprocs=0
GODEBUG : "containermaxprocs=0"
runtime.NumCPU(): 8
GOMAXPROCS(0) : 8
B 跟 C 的行為完全一樣,這反過來證明 B 的成因就是 GODEBUG 預設值。
這件事的殺傷力在於它很安靜。 你升級了 Go、CI 綠燈、測試全過、go version 印出 1.25,然後線上 p99 還是老樣子——因為那個 module 的 go.mod 從三年前就沒動過。
⚠️ 順帶一提,runtime.NumCPU() 在兩種情況下都回 8。它報的是啟動當下這個 process 可用的邏輯 CPU 數,不受 CPU bandwidth limit(cpu.max)影響——但 --cpuset-cpus 這種改 affinity 的設定它是會跟著變的。所以任何用 NumCPU() 去算 worker pool 大小的程式碼,Go 1.25 也救不了它。
坑二:--cpus=1 給的 GOMAXPROCS 是 2,不是 1
開關找到了。下一個問題:它到底會把 GOMAXPROCS 設成幾?
我本來以為公式就是 quota 除以 period。實測不是。
--cpus=0.25 → cpu.max: 25000 100000 → GOMAXPROCS = 2
--cpus=0.5 → cpu.max: 50000 100000 → GOMAXPROCS = 2
--cpus=1 → cpu.max: 100000 100000 → GOMAXPROCS = 2
--cpus=1.5 → cpu.max: 150000 100000 → GOMAXPROCS = 2
--cpus=2.7 → cpu.max: 270000 100000 → GOMAXPROCS = 3
公式是 max(2, ceil(quota / period))。
有一個下限 2。所以在 --cpus=1 甚至 --cpus=0.25 的容器裡,Go 仍然會開兩條 P,刻意超賣。
這跟 uber-go/automaxprocs 的行為不一樣——那套是無條件向下取整,--cpus=1.5 會給你 1。
我原本猜下限 2 是為了避免單一 P 被某個長時間不讓出的 goroutine 卡死整個 runtime。release note 確實沒解釋,但官方理由寫在 proposal(golang/go#73193)裡,方向接近、但更具體:GOMAXPROCS=1 會關掉 scheduler 的所有平行性,GC worker 跟應用 goroutine 只能輪流跑,看起來就像 GC 週期性地「暫停」了你的程式(原文:GC workers temporarily “pausing” the application)。
同一段還有一個判斷值得抄下來:quota ≤ 1 本身就被視為「這個 workload 是 bursty」的訊號——所以 runtime 選擇寧可刻意超賣,也不掉進 GOMAXPROCS=1 的陷阱。
實務上的意思是:如果你原本靠 automaxprocs 拿到 1,升上 Go 1.25 之後會變成 2。這是行為改變,不是 bug,但值得在升級 checklist 上寫一行。
坑三:GOMAXPROCS 對了,還是會被 throttle
到這裡我以為可以收工了——開關對了,數字也對了。
然後我看到 cpu.stat。這是我這次最意外的一格。
A 組的 GOMAXPROCS 是 2、配額是 1.5 核,看起來很合理。結果 nr_throttled 還是 56 次,throttled_usec 2.83 秒。
原因是 CFS 的配額是按 100ms 一個週期發的,兩條 P 全速跑的話 75ms 就把 150ms 的額度用完,剩下 25ms 整個 cgroup 被凍結。
順帶解釋一個看起來像造假的數字:B 組的 throttled_usec 是 52.44 秒,比 8.254 秒的 wall time 還大。那不是筆誤——這個計數器是每顆 CPU 的 cfs_rq 各自累加上去的,不是牆鐘時間。A 組 2 條 P × 每個週期凍 25ms × 56 個週期 ≈ 2.8 秒,B 組 8 條 P × 凍 81.25ms × 82 個週期 ≈ 53 秒,兩組都跟表格對得上。
下面這張圖把兩個決策點畫在一起。上半是 Go runtime 那一層:GOMAXPROCS 決定同時可以有幾條 P 在跑 goroutine,這個數字在 Go 1.25 之後由 cpu.max 推導。下半是 kernel 那一層:不管 runtime 開了幾條 P,CFS 都按 period 為單位發配額,用完就把整個 cgroup凍結到下一個週期開始。兩層是獨立的,所以上面那層設對只是減少浪費,不是免疫。
flowchart TB
subgraph Go["Go runtime(user space)"]
direction LR
CM["cpu.max<br/>150000 100000"] -->|"max(2, ceil(1.5))"| GMP["GOMAXPROCS = 2"]
GMP --> P1["P1"] & P2["P2"]
P1 --> G1["goroutines"]
P2 --> G2["goroutines"]
end
subgraph Kern["Linux CFS(kernel)"]
direction LR
Q["每 100ms 發 150ms 配額"] --> Used{"用完了?"}
Used -->|"否"| Run["繼續跑"]
Used -->|"是"| Frz["整個 cgroup 凍結<br/>到下一個 period<br/>nr_throttled++"]
end
Go -.->|"兩條 P 全速 → 75ms 用完"| Kern
所以「GOMAXPROCS 設對」不等於「不會被 throttle」,它只是讓同時搶那份配額的 P 從 8 條變成 2 條。
八條 P 搶 1.5 核的配額,每條平均只拿到 0.19 核,但每條都以為自己有一整顆——排程器把時間切得更碎,context switch 更多,配額也用得更快。
換句話說,它只是把 throttle 從災難級降到背景雜訊級。
18.5 倍的差距就是這個意思。
Go 1.25 會在程式跑的時候自己跟著 cgroup 改
release note 裡還有第二句話:這個值不只啟動時算一次,程式跑著也會跟著 cgroup 更新。這個行為我找不到什麼人實際量過,所以自己量。
我讓一支程式每秒印一次 GOMAXPROCS,中途用 docker update --cpus 改配額:
t=06s GOMAXPROCS=2 cpu.max=150000 100000
t=07s GOMAXPROCS=2 cpu.max=600000 100000 ← 配額變了,runtime 還沒跟上
t=08s GOMAXPROCS=6 cpu.max=600000 100000 ← 追上了
...
t=15s GOMAXPROCS=2 cpu.max=50000 100000 ← 砍到 0.5 核
...
t=23s GOMAXPROCS=4 cpu.max=400000 100000
GOMAXPROCS 真的跟著動了,三次都對。
追上的延遲在一個取樣點以內。 我的取樣是每秒一次,所以我只能說「≤1 秒」,不能說出更精確的數字——這是我這個實驗的解析度上限,不是 runtime 的實際延遲。
要關掉這個行為是 GODEBUG=updatemaxprocs=0,跟前面那個 containermaxprocs 是兩個獨立開關。
在 Kubernetes 上這件事有實際意義:VPA 改了 CPU limit 之後,Go 1.25 的服務不用重啟就會跟上。 這在 in-place pod resize 逐漸可用的環境裡是一個真的差別。
JVM 那半:查詢值會跟著變,但已經建好的執行緒池不會
我原本要拿 JVM 當「反面教材」,結果實測打臉了我自己。
先講靜態值,--cpus=1.5:
--- JVM ---
java.version : 21.0.11
availableProcessors() : 2
FJP commonPool par. : 1
--- GC / JIT thread ---
bool UseContainerSupport = true {product} {default}
uint ParallelGCThreads = 2 {product} {default}
uint ConcGCThreads = 1 {product} {ergonomic}
intx CICompilerCount = 2 {product} {ergonomic}
UseContainerSupport 預設就是開的,availableProcessors() 回 2 而不是 8。JVM 在這件事上比 Go 早了很多年。
然後是動態測試。同樣每秒印一次,中途改配額:
t=08s availableProcessors=2 FJP=1
t=09s availableProcessors=6 FJP=1 ← 改成 --cpus=6,查詢值跟上了
t=18s availableProcessors=6 FJP=1
t=19s availableProcessors=1 FJP=1 ← 改成 --cpus=0.5,又跟上了
availableProcessors() 是會動的。 我原本以為它啟動就定型,這個假設是錯的。
真正凍住的是下面那一欄。
ForkJoinPool.commonPool() 的 parallelism 從頭到尾都是 1——它在 JVM 啟動的那一刻用 availableProcessors - 1 算完,之後不管配額怎麼變都不再重算。ConcGCThreads 與 CICompilerCount 也是同樣的道理,{ergonomic} 這個標記的意思就是「啟動時由 JVM 自己決定」。
所以兩邊的分歧不在「知不知道」,在「知道之後改不改」:
| Go 1.25 | JVM 21 | |
|---|---|---|
| 讀到的核心數會跟著 cgroup 變 | ✅ GOMAXPROCS | ✅ availableProcessors() |
--cpus=0.5 給幾 | 2(有下限 2) | 1(單純 ceil) |
| 已建好的執行緒池跟著變 | ✅ P 的數量就是 GOMAXPROCS | ❌ FJP commonPool 凍在啟動值 |
| GC / JIT thread | 不適用 | ❌ 啟動時 ergonomic 定型 |
Go 之所以能跟著變,是因為 GOMAXPROCS 本身就是 scheduler 的參數;JVM 的執行緒池是應用層物件,改了查詢值不會回頭去重建它們。
3 分鐘快速上手:怎麼確認你的服務踩到了沒有
不用寫 benchmark,三行就知道。
# 1. 進到你的容器裡,看 runtime 認為自己有幾核
kubectl exec -it <pod> -- sh -c 'cat /sys/fs/cgroup/cpu.max'
# 輸出 "150000 100000" 代表配額 1.5 核
# 2. 看有沒有在被 throttle(跑一陣子之後再看一次)
kubectl exec -it <pod> -- sh -c 'grep -E "nr_throttled|throttled_usec" /sys/fs/cgroup/cpu.stat'
# 3. 看你的 module 綁在哪個語言版本
head -3 go.mod
最小可用流程:cpu.stat 的 throttled_usec 持續上升 → 看 go.mod 的 go 指令是不是 < 1.25 → 是的話把它提上去,重新編譯,再看一次同一個計數器。
三個要記的旋鈕:
GODEBUG=containermaxprocs=0— 關掉容器感知,退回讀宿主機核心數GODEBUG=updatemaxprocs=0— 關掉執行期動態更新,只在啟動時算一次GOMAXPROCS=N環境變數 — 明確指定,優先於上面兩者
如果你的 module 因為別的原因不能提語言版本,uber-go/automaxprocs 仍然可用。⚠️ 但要注意它跟 Go 1.25 的取整規則不同(無下限 2),而且兩個同時生效時是後設定的贏。還有一個只有在 go.mod 已經 ≥ 1.25、卻仍想用 automaxprocs 取整規則時才要在意的細節:它是在 init 裡呼叫 runtime.GOMAXPROCS(),而這個呼叫會一併關掉前面那節講的執行期自動更新(要救回來得自己呼叫 runtime.SetDefaultGOMAXPROCS())。語言版本 < 1.25 的話,updatemaxprocs 本來就預設關著,沒有東西會被關掉,這段可以跳過。
下面這張決策圖把上面幾條路串起來。左邊那條分岔是這篇的核心——語言版本才是開關,不是 toolchain 版本。右邊兩條是你確定要自己控制時才走的路:明確設環境變數,或是在不能動 go.mod 的情況下退回 automaxprocs。中間那個「還在 throttle」的迴圈是坑三,它提醒你設對 GOMAXPROCS 之後仍然要回去看 cpu.stat,因為 CFS 那一層的行為沒有被改變。
flowchart TD
S(["容器裡的 Go 服務 p99 有週期性尖峰"]) --> C1{"go.mod 的 go 指令<br/>≥ 1.25 ?"}
C1 -->|"否"| A1{"能不能提<br/>語言版本?"}
A1 -->|"可以"| FIX["改 go.mod → 重編<br/>(最乾淨)"]
A1 -->|"不行"| AMP["引入 automaxprocs<br/>⚠️ 無下限 2,取整規則不同"]
C1 -->|"是"| C2{"有沒有設<br/>GODEBUG=containermaxprocs=0<br/>或 GOMAXPROCS 環境變數 ?"}
C2 -->|"有"| UNSET["拿掉它<br/>(多半是舊部署留下的)"]
C2 -->|"沒有"| OK["GOMAXPROCS 已經對齊配額"]
FIX --> CHK
AMP --> CHK
UNSET --> CHK
OK --> CHK{"再看一次<br/>cpu.stat 的 throttled_usec"}
CHK -->|"仍在快速上升"| RAISE["這不是 GOMAXPROCS 的問題<br/>→ 配額本身不夠,或有突發負載"]
CHK -->|"降到背景值"| DONE(["結案"])
這件事在雲上是誰做的
我在公有雲上寫了二十年後端,從來沒有量過 cpu.stat。
不是因為我不在乎延遲,是因為那個旋鈕不在我手上。Cloud Run 給我的是「一個 instance 幾 vCPU」,App Engine 給我的是 instance class,我調的是那個數字,不是 CFS 的 quota 與 period。托管平台把 cgroup 這一層整個收走了。
代價是我也失去了診斷能力。服務 p99 有週期性尖峰的時候,我能看的只有 APM 的火焰圖,看不到「這一秒 kernel 把整個 cgroup 凍結了 25 毫秒」。
自建平台上這一層是打開的。打開的意思是兩件事同時成立:你查得到根因,而且這個根因現在歸你負責。
這篇我沒跑到的部分
- P99 的絕對值不能當生產數字。 M2 是 4 P-core + 4 E-core 的異質架構,中間還隔了 Virtualization.framework 的排程。同樣的 CFS 配額落在 P-core 跟 E-core 上算力差很多。可以用的是「有沒有週期性尖峰」與「兩組的比例關係」,不能用毫秒數去對照 x86 server。
- 「宿主機 8 核」是 OrbStack Linux VM 的 8 個 vCPU,不是 macOS 直接看到的 8 核。這裡數字剛好相同,容易誤讀。
- 動態更新的延遲我只量到「≤1 秒」,因為取樣就是每秒一次。要量出實際延遲需要改用高頻取樣或 runtime trace,我沒做。
- 沒測 Kubernetes 上的 in-place resize。我用的是
docker update,跟 kubelet 改 pod 資源的路徑不同。VPA 那段是從機制推論的,不是實測。 - 沒測多容器互相干擾。真實 node 上還有其他 pod 在搶同一顆 CPU,
throttled_usec的形狀會更亂。
反思
技術面
這題的教訓不是「Go 1.25 修好了」,是預設值會版本化。
GODEBUG 綁語言版本這個設計很合理——它保證舊 module 升 toolchain 不會行為突變。但它也意味著「我升級了」跟「我拿到新行為了」是兩件事,中間隔著一行你可能三年沒看過的 go.mod。
我以後看任何「某版本修好了」的 release note,都會多問一句:這個修正是綁編譯器,還是綁語言版本。
心態面
開頭那份筆記不是隨手抄的——我當時是真心相信的。而且如果這次沒有動手,我到今天都還會信。
更糟的是同一個實驗裡被打臉了兩次。第一次是筆記那條;第二次是 JVM——我原本要拿它當反面教材,寫「JVM 啟動就定型」,結果實測 availableProcessors() 明明會動。兩個假設,兩次被自己的 lab 打臉。
查資料查到的東西,跟自己跑出來的東西,是兩種不同強度的知識。我以前分不太清楚。
有趣發現
最反直覺的是那個下限 2。
--cpus=0.25 的容器,Go 給你兩條 P。也就是說在資源最緊的那一格,runtime 選擇的是刻意超賣而不是老實對齊。這跟整個 cgroup 感知的立意看起來是矛盾的,但如果你想過 GOMAXPROCS=1 的世界長什麼樣——GC worker 一動,應用就得整個停下來等——它又非常合理。
那些「明明可以更精確卻選擇不精確」的地方,通常藏著設計者真正在怕的東西。