跳至主要內容
技術

GOMAXPROCS 這個講了五年的坑,Go 1.25 已經修了——但只有 go.mod 寫對才生效

GOMAXPROCS 這個講了五年的坑,Go 1.25 已經修了——但只有 go.mod 寫對才生效
公有雲替你處理掉的那些事 第 1 / 1 篇 ,前往系列總覽

本篇是「公有雲替你處理掉的那些事」系列的第 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
容器 runtimeOrbStack 2.2.1 / Docker Engine 29.4.0
建置用 imagegolang:1.25Go 1.25.12Go 1.25 於 2025-08 釋出
執行用 imagedebian:bookworm-slim
JVM 對照組eclipse-temurin:21Java 21.0.11

本文實測日期:2026-08-07。

先講結果:同一份原始碼,go.mod 差一行,throttle 差 18 倍

固定總工作量 1600 輪,均分給 GOMAXPROCS 條 goroutine,兩邊都跑在 --cpus=1.5 裡。

go.mod 寫 go 1.25go.mod 寫 go 1.24倍數
GOMAXPROCS(0)28
wall time5.758s8.254s+43%
p504.131ms9.009ms2.2×
p9939.463ms177.594ms4.5×
nr_throttled56821.5×
throttled_usec2,831,31552,440,76118.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 算完,之後不管配額怎麼變都不再重算。ConcGCThreadsCICompilerCount 也是同樣的道理,{ergonomic} 這個標記的意思就是「啟動時由 JVM 自己決定」。

所以兩邊的分歧不在「知不知道」,在「知道之後改不改」:

Go 1.25JVM 21
讀到的核心數會跟著 cgroup 變GOMAXPROCSavailableProcessors()
--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.statthrottled_usec 持續上升 → 看 go.modgo 指令是不是 < 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 一動,應用就得整個停下來等——它又非常合理。

那些「明明可以更精確卻選擇不精確」的地方,通常藏著設計者真正在怕的東西。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →