Engineering · 1 min read
從 19 個 goroutine 到 exit 137:Go 並發 OOM 全紀錄
19 個 goroutine、一個共用瀏覽器、exit 137。從 Go 並發、GMP 排程、記憶體三層模型到 semaphore 修法的全紀錄。
19 個 goroutine、一個共用瀏覽器、exit 137。當 Go runtime 看不見 process 外面的上限,你得自己加上限。
故事
我們的截圖服務出了 exit 137。這個服務透過瀏覽器 sidecar 製作網頁截圖,之前每個請求有自己的瀏覽器實例,後來為了省資源改成共用一個。
當時我對 goroutine 底層運作不太熟。我知道 GOMAXPROCS=1,以為這代表「一次只跑一個」。結果不是。19 個 goroutine 同時開了 19 個瀏覽器分頁,瀏覽器記憶體爆掉,exit 137。所有正在截圖的請求全部失敗。
為了搞懂為什麼 GOMAXPROCS=1 擋不住 19 個並發,我從 process、thread 這些基礎開始往下挖。這系列有這麼多名詞解釋,每一個都是我在追根究底的過程中補上的。
路線圖
Foundations (0) Mechanism (1) Memory (1.5) Trap (2) Allocator (2.5) Fix (3) Pitfalls (4)
process, thread GMP scheduler cgroup accounting shared browser latency/size-cls semaphore wrong target
kernel, syscall netpoller/epoll cAdvisor metrics → correlated OOM fragmentation TryAcquire→429 GOMEMLIMIT ≠
socket, container goroutine-per-req allocator concept backpressure gap return policy keep-alive off browser mem
stack, heap, cgo GOMAXPROCS ≠ cap heap metrics fan-in on shared shed, not queue git archeology
按數字順序讀,每篇是我在過程中搞懂的一個觀念:
- OS 基礎詞彙 - process / thread / kernel / syscall
- 執行環境 - socket / container / sidecar / stack / heap / cgo
- GMP 排程器與 netpoller - goroutine 怎麼跑、I/O 怎麼等
- GOMAXPROCS 限制平行不限並發 - cpu:1 為什麼擋不住 19 個
- 記憶體三層模型 - cgroup → allocator → heap 的三層真相
- OOM 診斷決策樹 - 從 pprof 往下鑽的決策樹
- 共享資源的影響範圍 - 為什麼共用一個瀏覽器 = 全部一起死
- 分配器內部 - 分配器如何運作、碎片從哪來
- 用 TryAcquire 做 load shedding - semaphore + 429 的設計
- keep-alive 讓重試卡在同一台 - 為什麼 429 重試打同一台
- 錯誤的修法 - 調錯 process 的教訓
為什麼爆?三個條件湊在一起
net/http (goroutine-per-request) 每個請求自動開 goroutine
× GOMAXPROCS 只限平行 I/O-parked 的不受限
× 共用瀏覽器無並發上限 19 個分頁同時存在
= 瀏覽器 OOM (exit 137) 所有請求全部一起掛
- 隱含的並發:
net/http為每個請求開 goroutine,handler 裡再開一個,你沒寫go,並發就已經存在(GOMAXPROCS 與並發)。 - GOMAXPROCS 是假天花板:它限制同時「執行 Go 程式碼」的 goroutine 數量,不限制同時「等在 I/O 上」的。Parked goroutine 佔 0 CPU、0 P、0 M(GMP 排程器)。
- 共用 = 單一影響範圍:一個瀏覽器死了,所有正在用它的請求全死。Go runtime 看不見 process 外面的記憶體上限,backpressure不會自動回傳(影響範圍)。
怎麼修
- 在截圖服務加上 semaphore(
TryAcquire):超過 6 個同時截圖的請求,直接回 HTTP 429,不排隊(TryAcquire load shedding)。 - 呼叫端服務收到 429 時,關掉 keep-alive 重試,否則 L4 負載均衡會讓重試卡在同一個 pod(keep-alive 讓重試卡在同一台)。
錯誤示範
有一次嘗試用 GOMEMLIMIT 修 OOM,但 GOMEMLIMIT 控制的是 Go heap,不是瀏覽器 sidecar 的記憶體,fix-locus(修的地方)≠ failure-locus(壞的地方)。git log 回溯才發現,是一步步累積的架構變更才讓 OOM 有機會發生(錯誤的修法)。
集中化省了資源,也集中了風險。加回上限,才不會 19 個請求陪一個瀏覽器一起死。
參考來源: