<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>公有雲替你處理掉的那些事 - Bobo 的學思山丘</title><description>我在公有雲上做了二十年後端，卻從沒碰過一次容量水位——這一系列是我把 BGP、Ceph、KubeVirt 真的跑起來的紀錄。</description><link>https://bobochen.dev/</link><item><title>GOMAXPROCS 這個講了五年的坑，Go 1.25 已經修了——但只有 go.mod 寫對才生效</title><link>https://bobochen.dev/blog/private-cloud-05-gomaxprocs-cgroup-go125/</link><guid isPermaLink="true">https://bobochen.dev/blog/private-cloud-05-gomaxprocs-cgroup-go125/</guid><description>實測同一顆 toolchain、同一份原始碼、同一個 --cpus=1.5，只把 go.mod 的 go 指令從 1.25 改成 1.24，throttled_usec 就從 2.83 秒變成 52.44 秒、p99 從 39ms 變成 178ms。另外量到兩件沒什麼人寫的事：--cpus=1 給的 GOMAXPROCS 是 2 不是 1，以及 Go 1.25 會在跑的時候自己跟著 cgroup 改。</description><pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate><content:encoded>## 我差點照抄了自己的筆記

這篇的起點是一條我抄了很多年的筆記：

&gt; 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 &apos;CPUs={{.NCPU}} Cgroup={{.CgroupVersion}}/{{.CgroupDriver}}&apos;
CPUs=8 Cgroup=2/cgroupfs

$ docker version --format &apos;{{.Server.Version}} / {{.Server.KernelVersion}}&apos;
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         : &quot;containermaxprocs=0&quot;
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](https://github.com/golang/go/issues/73193)）裡，方向接近、但更具體：**GOMAXPROCS=1 會關掉 scheduler 的所有平行性**，GC worker 跟應用 goroutine 只能輪流跑，看起來就像 GC 週期性地「暫停」了你的程式（原文：GC workers temporarily &quot;pausing&quot; 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**凍結到下一個週期開始。兩層是獨立的，所以上面那層設對只是減少浪費，不是免疫。

```mermaid
flowchart TB
    subgraph Go[&quot;Go runtime（user space）&quot;]
        direction LR
        CM[&quot;cpu.max&lt;br/&gt;150000 100000&quot;] --&gt;|&quot;max(2, ceil(1.5))&quot;| GMP[&quot;GOMAXPROCS = 2&quot;]
        GMP --&gt; P1[&quot;P1&quot;] &amp; P2[&quot;P2&quot;]
        P1 --&gt; G1[&quot;goroutines&quot;]
        P2 --&gt; G2[&quot;goroutines&quot;]
    end
    subgraph Kern[&quot;Linux CFS（kernel）&quot;]
        direction LR
        Q[&quot;每 100ms 發 150ms 配額&quot;] --&gt; Used{&quot;用完了？&quot;}
        Used --&gt;|&quot;否&quot;| Run[&quot;繼續跑&quot;]
        Used --&gt;|&quot;是&quot;| Frz[&quot;整個 cgroup 凍結&lt;br/&gt;到下一個 period&lt;br/&gt;nr_throttled++&quot;]
    end
    Go -.-&gt;|&quot;兩條 P 全速 → 75ms 用完&quot;| 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，三行就知道。

```bash
# 1. 進到你的容器裡，看 runtime 認為自己有幾核
kubectl exec -it &lt;pod&gt; -- sh -c &apos;cat /sys/fs/cgroup/cpu.max&apos;
# 輸出 &quot;150000 100000&quot; 代表配額 1.5 核

# 2. 看有沒有在被 throttle（跑一陣子之後再看一次）
kubectl exec -it &lt;pod&gt; -- sh -c &apos;grep -E &quot;nr_throttled|throttled_usec&quot; /sys/fs/cgroup/cpu.stat&apos;

# 3. 看你的 module 綁在哪個語言版本
head -3 go.mod
```

**最小可用流程**：`cpu.stat` 的 `throttled_usec` 持續上升 → 看 `go.mod` 的 `go` 指令是不是 &lt; 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()`）。語言版本 &lt; 1.25 的話，`updatemaxprocs` 本來就預設關著，沒有東西會被關掉，這段可以跳過。

下面這張決策圖把上面幾條路串起來。左邊那條分岔是這篇的核心——**語言版本才是開關，不是 toolchain 版本**。右邊兩條是你確定要自己控制時才走的路：明確設環境變數，或是在不能動 `go.mod` 的情況下退回 `automaxprocs`。中間那個「還在 throttle」的迴圈是坑三，它提醒你設對 `GOMAXPROCS` 之後仍然要回去看 `cpu.stat`，因為 CFS 那一層的行為沒有被改變。

```mermaid
flowchart TD
    S([&quot;容器裡的 Go 服務 p99 有週期性尖峰&quot;]) --&gt; C1{&quot;go.mod 的 go 指令&lt;br/&gt;≥ 1.25 ?&quot;}
    C1 --&gt;|&quot;否&quot;| A1{&quot;能不能提&lt;br/&gt;語言版本？&quot;}
    A1 --&gt;|&quot;可以&quot;| FIX[&quot;改 go.mod → 重編&lt;br/&gt;（最乾淨）&quot;]
    A1 --&gt;|&quot;不行&quot;| AMP[&quot;引入 automaxprocs&lt;br/&gt;⚠️ 無下限 2，取整規則不同&quot;]
    C1 --&gt;|&quot;是&quot;| C2{&quot;有沒有設&lt;br/&gt;GODEBUG=containermaxprocs=0&lt;br/&gt;或 GOMAXPROCS 環境變數 ?&quot;}
    C2 --&gt;|&quot;有&quot;| UNSET[&quot;拿掉它&lt;br/&gt;（多半是舊部署留下的）&quot;]
    C2 --&gt;|&quot;沒有&quot;| OK[&quot;GOMAXPROCS 已經對齊配額&quot;]
    FIX --&gt; CHK
    AMP --&gt; CHK
    UNSET --&gt; CHK
    OK --&gt; CHK{&quot;再看一次&lt;br/&gt;cpu.stat 的 throttled_usec&quot;}
    CHK --&gt;|&quot;仍在快速上升&quot;| RAISE[&quot;這不是 GOMAXPROCS 的問題&lt;br/&gt;→ 配額本身不夠，或有突發負載&quot;]
    CHK --&gt;|&quot;降到背景值&quot;| DONE([&quot;結案&quot;])
```

## 這件事在雲上是誰做的

我在公有雲上寫了二十年後端，從來沒有量過 `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 一動，應用就得整個停下來等——它又非常合理。

那些「明明可以更精確卻選擇不精確」的地方，通常藏著設計者真正在怕的東西。</content:encoded><media:content url="https://bobochen.dev/_astro/cover.Cj-qJSaj.webp" medium="image"/><category>Go</category><category>Kubernetes</category><category>容器</category><category>cgroup</category><category>效能</category><category>JVM</category><enclosure url="https://bobochen.dev/_astro/cover.Cj-qJSaj.webp" length="0" type="image/png"/></item></channel></rss>