問任何一位 Go 開發者「一個 goroutine 的成本是多少」,你大概會得到同一個答案,而且連位元組數都講得很精準:2KB。FAQ 會寫「幾 KB」。Conference 演講會說「你可以開一百萬個」。而我也一直用那種懶得查證的方式相信它,直到我剛好有空、一台有 68GB RAM 的筆電,以及比把一百萬個 goroutine 全都掛起來看看更好的點子。

這個數字大致上是對的。一百萬個閒置 goroutine 大約吃掉 2.8GB:每個 2KB 的 stack,加上一些帳務資料。後來我只改了一件事。每個 goroutine 在掛起前,先呼叫一次會把一個 8KB 陣列放到 stack 上的函式,呼叫一次、只做一次。結果同樣的一百萬個 goroutine 變成吃掉 13GB。這個落差就是本文要談的重點;老實說,背後機制比數字本身更有意思。

2KB 這個數字是真的,而且它只指 stack

這個測試程式刻意寫得很小。它會啟動 N 個 goroutine,讓它們卡在 channel 上,等到全部都掛起後再繼續。接著它印出 runtime 的記憶體計數器,而且(這點後面很重要)會一次一次強制執行幾次 garbage collection,並在每次之後印出計數器。mode 參數決定每個 goroutine 在掛起前是否會碰到自己的 stack,以及碰多大。

main.go

package main

import (
    "fmt"
    "os"
    "runtime"
    "strconv"
    "sync"
    "time"
)

func stats(label string) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%-24s goroutines=%-8d StackInuse=%8.1fMB HeapInuse=%6.1fMB Sys=%8.1fMB\n",
        label, runtime.NumGoroutine(), float64(m.StackInuse)/1e6,
        float64(m.HeapInuse)/1e6, float64(m.Sys)/1e6)
}

//go:noinline
func sink(b []byte) byte { return b[0] + b[len(b)-1] }

// 一個函式,其 frame 裡有 8KB 的區域變數。這個 slice 會傳到
// noinline 的 sink,所以 compiler 無法把陣列省掉。
//go:noinline
func touch8() byte {
    var buf [8 << 10]byte
    buf[0], buf[len(buf)-1] = 1, 1
    return sink(buf[:])
}

// 同樣的寫法,但區域變數是 64KB。
//go:noinline
func touch64() byte {
    var buf [64 << 10]byte
    buf[0], buf[len(buf)-1] = 1, 1
    return sink(buf[:])
}

func main() {
    n, _ := strconv.Atoi(os.Args[1])
    mode := os.Args[2] // idle | 8k | 64k
    var wg sync.WaitGroup
    block := make(chan struct{})
    start := time.Now()
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            switch mode {
            case "8k":
                touch8()
            case "64k":
                touch64()
            }
            <-block
        }()
    }
    for runtime.NumGoroutine() < n {
        time.Sleep(10 * time.Millisecond)
    }
    fmt.Printf("spawned %d (%s) in %v\n", n, mode, time.Since(start).Round(time.Millisecond))
    stats("parked, before any GC")
    for i := 1; i <= 6; i++ {
        runtime.GC()
        stats(fmt.Sprintf("after GC #%d", i))
    }
    close(block)
    wg.Wait()
}

先說兩個前提,因為它們會影響數字。StackInuse 是 runtime 自己統計 stack span 內用了多少位元組,所以它是比較誠實的「目前有多少 stack 記憶體」指標。再來,我在啟動迴圈時設了 GOGC=off,這樣 collector 在我還沒建立完 goroutine 之前就不會偷偷幫我縮 stack。後面的六次 runtime.GC() 是唯一發生的 collection。環境是 Go 1.26.4、Apple silicon 的 Mac。

一百萬個什麼都不做的 goroutine:

GOGC=off ./gor 1000000 idle
# spawned 1000000 (idle) in 290ms
# parked, before any GC    goroutines=1000001  StackInuse=  2048.8MB HeapInuse= 724.8MB Sys=  2817.8MB
# after GC #6              goroutines=1000001  StackInuse=  2048.9MB HeapInuse= 639.1MB Sys=  2821.0MB
# maximum resident set size: 2812690432

1,000,001 個 goroutine 合計 2,048.8MB 的 stack,平均每個正好 2,048 bytes。這就是 runtime/stack.go 裡的 stackMin = 2048,也就是大家講 2KB 時引用的那個常數。旁邊還有 639MB 的 heap,而且做了六次 GC 後還在,所以那不是垃圾。那是每個 goroutine 的 g struct,加上 closure 和 deferred call,大約每個 640 bytes(我試過只留 go park(c)、拿掉 closure 和 defer,結果還是 639 bytes,所以大部分都是 g 本身)。總 resident memory 是 2.8GB。把它說成每個 goroutine 2.8KB,和坊間常講的數字其實只差一點四捨五入的誤差。

順帶一提,Linux 上的 OS thread 會依照 ulimit -s 保留 stack 空間,多數系統是 8MB(pthread_create 的 man page 有細節)。那是虛擬記憶體,而且大多還沒真的碰到,但一百萬個 thread 代表 8TB 的位址空間,kernel 早在那之前就會拒絕你。所以 FAQ 說「如果 goroutine 只是 thread 的話,系統資源會在更少的數量下耗盡」這句是對的。沒問題。FAQ 下一句大家比較少引用的那句才是重點:runtime 會自動「增加(以及縮小)用來儲存 stack 的記憶體」。

一個 8KB 的區域變數,會把 2KB 變成 16KB

「增加」在實際上是什麼意思?幾乎每個 Go 函式一開始都會先檢查:我的 goroutine stack 還有沒有足夠空間放這個 frame?如果沒有,就會跑 runtime.morestack。Go 1.4 之後,它會配置一個兩倍大的新 stack,把舊內容全部複製過去,然後修正所有指向舊 stack 的指標。Keith Randall 在 2013 年寫的 contiguous stacks 設計文件就是這樣描述的:「使用 2 的次方大小,然後每次重配置就直接加倍」,而今天 runtime 裡真的就是 newsize := oldsize * 2

加倍代表大小會走 2KB、4KB、8KB、16KB。帶有 8KB 區域變數的函式,沒辦法塞進 8KB 的 stack(frame 還要放 return address、呼叫者的 frame,以及 runtime 留在底部的 guard area),所以它最後會拿到 16KB。相同的 100,000 個 goroutine,每個在掛起前先呼叫一次 touch8

GOGC=off ./gor 100000 8k
# spawned 100000 (8k) in 151ms
# parked, before any GC    goroutines=100001   StackInuse=  1461.8MB HeapInuse=  72.2MB Sys=  1555.1MB
# maximum resident set size: 2567208960

100,001 個 goroutine 合計 1,461.8MB,平均每個 14.6KB,所以幾乎全部都是 16KB 的 stack(剩下的我猜是 runtime 的 per-P stack cache)。單單一次立刻返回的函式呼叫,就讓記憶體變成 idle case 的八倍。64KB 版本則是再多做一次加倍:

GOGC=off ./gor 100000 64k
# spawned 100000 (64k) in 619ms
# parked, before any GC    goroutines=100001   StackInuse= 12093.6MB HeapInuse=  72.8MB Sys= 12219.8MB
# maximum resident set size: 11055398912

stack 一共 12GB,每個 128KB,因為 64KB 再加上 frame 之後,已經塞不進 64KB。啟動時間則從 43ms 變成 619ms。這就是 stack growth:runtime 會先算出需要的大小(它會一直加倍到 frame 塞得下,然後只配置一次),所以每個 goroutine 都付出了一個 128KB stack、一次複製、一次指標修正,而整個 process 在這過程中觸碰了 12GB 的新記憶體。CockroachDB 在 2016 年也在他們的 gRPC handler 上遇到同樣的成本,那時候 growth 還是分階段發生的(下面會再提)。

這些都不是 leak,也不是 bug,而是設計如文件所說地正常運作。大家常跳過的一點是:goroutine 最後拿到多大的 stack,取決於它曾經呼叫過最深的那個東西,而不是它「現在」正在做什麼。

Stack 會在每次 GC 時砍半,一次只砍一半,而且最低停在 4KB

FAQ 會說 stack 會縮小,確實會,但規則比「縮小」更精確。這段邏輯在 runtime/stack.goshrinkstack:在 garbage collection 期間,如果某個 goroutine 使用的 stack 少於四分之一,runtime 就會配置一個只有一半大小的 stack,然後把內容往下複製。是砍半,不是「縮到剛好需要的大小」,而且絕不低於最小值。Randall 2013 年的設計文件也有同樣的計畫:「在 GC 時,如果某個 go routine 使用的 stack 不超過 1/4,就釋放 stack 的下半部。」

所以測試才會強制做六次 collection,並且每次都印出來。下面是 64KB 版本延續到第一行之後的結果:

# parked, before any GC    goroutines=100001   StackInuse= 12093.6MB
# after GC #1              goroutines=100001   StackInuse=  6554.6MB
# after GC #2              goroutines=100001   StackInuse=  3277.8MB
# after GC #3              goroutines=100001   StackInuse=  1639.4MB
# after GC #4              goroutines=100001   StackInuse=   820.3MB
# after GC #5              goroutines=100001   StackInuse=   410.8MB
# after GC #6              goroutines=100001   StackInuse=   410.8MB

128KB、64KB、32KB、16KB、8KB、4KB,然後停止。100,001 個 goroutine 共用 410.8MB,平均每個 4KB,不是 2KB。曾經呼叫過東西的 parked goroutine,用量會比 4KB stack 的四分之一稍微多一點(自己的 frame、defer 紀錄、runtime 的 guard space),所以 shrink 規則會把它留在那裡。就我看來,除非 goroutine 結束,不然它會一直維持下去。8KB 的版本最後也落在同一個地方,410.6MB,只是早了兩次 GC。

所以 goroutine 的記憶體其實有三個數字,不是一個。它一開始是多少(2KB)。第一次呼叫真正的函式之後會長到多少(足以容納最深 frame 的 2 的次方大小)。以及經過足夠多次 GC 之後會停在多少,對任何曾經長大過的 goroutine 來說是 4KB。在 GC 每隔幾秒就跑一次的伺服器裡,中間那個數字只會短暫出現。若是 GOGC=off 的批次工作,或是 heap 很大、GC 幾分鐘才跑一次的服務,你付的就是那個數字。

「每幾秒一次」這句其實有個我剛剛略過的陷阱,dev.to 留言區的 @vinhnguyenthanhdn 有指出來。collector 是由 heap allocation 觸發的。stack 成長不會在 heap 上分配,所以如果某個服務的 heap 已經平了,它就會停止 collection,而 stack 也就停在當時長到的大小。他用 Go 1.26.2、預設 GOGC、不手動呼叫 runtime.GC() 再跑一次 8KB 的案例:100,001 個 goroutine 停在 770MB 的 stack,NumGC 停在 4,而且閒置 18 秒後還是 770MB。最後真的能自己走出來的,是 runtime 每兩分鐘在沒有其他 collection 觸發時會強制執行的一次 collection(runtime/proc.go 裡的 forcegcperiod);而那是每兩分鐘只砍半一次,所以 128KB 砍到 4KB 大概要十分鐘什麼都不做。真正的解法是 GOMEMLIMIT:記憶體上限會把 stack 算進去,所以在上限之下 collector 會持續運作,最後到達 shrinkstack。他那次相同的 binary 在 GOMEMLIMIT=600MiB 下最後停在 448MB。

同一段輸出裡還有一行我花了一點時間才看懂:在第一次 GC 前,Sys 是 12.2GB;GC 後變成 19.4GB。stack 變小代表要先配置一個更小的新 stack 並把內容複製過去,而舊的 stack span 會回到 free list,不會立刻還給 OS。所以 process 先跟 kernel 要了更多記憶體,來用更少的部分。resident memory 一度衝到 11GB。這種圖表會讓 dashboard 看起來短暫「怪怪的」,過一下又恢復正常;如果沒親眼看過這些 counters,我也不確定自己會不會相信那條曲線。

一百萬個各做一件事的 goroutine,成本是 13GB

把前面的片段在 conference talks 最愛講的規模拼起來看。一百萬個 goroutine,每個都呼叫一次 touch8 然後掛起,使用一般的 GOGC,所以 collector 會在建立過程中一起運作:

./gor 1000000 8k
# spawned 1000000 (8k) in 1.861s
# parked, before any GC    goroutines=1000001  StackInuse=  8995.1MB HeapInuse= 658.9MB Sys= 13411.7MB
# after GC #6              goroutines=1000001  StackInuse=  4097.0MB HeapInuse= 639.3MB Sys= 13413.3MB
# maximum resident set size: 13407453184

resident memory 是 13.4GB,不是 2GB。collector 在整個過程中一直幫 stack 縮小,所以 StackInuse 只有 9GB,而不是 16GB。再做六次 collection 後,它降到 4.1GB,也就是一百萬個 goroutine 乘上 4KB 的地板值。但 process 已經從 OS 手上拿到 13.4GB,而且不會急著還回去。若在 spawn 期間設 GOGC=off,同樣的執行會峰值到 25.5GB;我提這個只是因為它很像一個「先大量分配、很少收集」工作負載的形狀。

同樣一百萬個完全閒置的版本只要 2.8GB。程式一樣、goroutine 也一樣,只是每個人的過去多了一次函式呼叫,整個 process 就變成 4.5 倍大。

好吧,那到底是什麼把 8KB 放到 goroutine 的 stack 上?

這個質疑很合理,因為 var buf [8 << 10]byte 並不是大多數 goroutine 的典型樣子。答案有兩個,而第二個讓我有點意外。

第一個是:你不需要一個超大的區域變數,你需要的是深度。handler 呼叫 router,router 呼叫 middleware,middleware 呼叫你的程式碼,你的程式碼呼叫 database driver,driver 再呼叫 encoder——這樣一層一層下去就是十幾個 frame,而它們會累積。據我所知,最好的公開數字來自 CockroachDB:2016 年 12 月,Peter Mattis 因為他們的 gRPC Server.Batch 入口需要 16 到 32KB 的 stack,而在 Go issue 18138 開 issue;他寫到自己可以「看到 stack 以 4 個步驟從 2KB 成長到 32KB」,而且「stack growth 稍微昂貴,因此提前誘導 runtime 提早成長 stack 會有幫助」。他們當時是手動預先把 stack 長大,好避免複製。一般服務裡的 HTTP handler 通常比那小,但顯然不是 2KB。

第二個答案是:Go 知道這件事,而且已經改了預設。從 Go 1.19 開始,runtime 會「根據 goroutine 的歷史平均 stack 使用量配置初始 goroutine stack」,release notes 這麼說,「代價是對低於平均值的 goroutine 最多浪費 2 倍空間。」stack.go 裡的程式會在每次 GC 後根據平均被掃描的 stack 重新計算 startingStackSize,並且把 issue 18138 當作理由。也就是說,在真實伺服器裡,如果大多數 goroutine 會長到 8KB 或 16KB,新 goroutine 已經不再從 2KB 開始,而是從 8KB 或 16KB 開始。runtime 已經判斷出,對大家一直拿來引用的那些工作負載來說,2KB 是個不太好的預設。(GODEBUG=adaptivestackstart=0 可以把它關掉;在我那些從來不長大的百萬 goroutine 測試裡,開不開都沒差,這也正是重點:平均值就是 2KB。)

還有一個上限,從 Go 1.2 就有了:單一 goroutine 的 stack 在 64 位元系統上最多可以長到 1GB,之後 runtime 會直接讓程式死掉,debug.SetMaxStack 可以調整它。這是為了讓失控的遞迴能快速失敗,而不是把整台機器吃光。

如果是我,我會怎麼看

StackInuse 除以 NumGoroutine,這才是該看的指標。這兩個都很好讀(如果你不想在 production 呼叫 ReadMemStatsruntime/metrics 提供 /memory/classes/heap/stacks:bytes/sched/goroutines:goroutines;後者會 stop the world)。如果平均值是 2 到 4KB,代表你的 goroutine 屬於便宜型,數量就是全部故事。如果平均值是 16KB 或 32KB,那數量的重要性會比你想的高四到十六倍,而你該看的不是 goroutine 有多少,而是它們到底呼叫了什麼。

把大型區域變數留在不會大量建立 goroutine 的地方。一個 per-connection goroutine 裡的 [64 << 10]byte 暫存 buffer,會讓每個 connection 在接下來幾次 GC 之前都各自吃掉一個 128KB stack;之後才降到 4KB。把它放到 sync.Pool 裡,或放到 heap 上,讓它按自己的節奏被計算與回收,而不是每次 stack 加倍都被複製。

如果你開始考慮 worker pool,老實說原因要說清楚。不是因為 goroutine 建立很貴(不是,100 萬個才 290ms)。而是因為有限數量的 goroutine,代表有限數量的成長後 stack,而成長過的 stack 才是有真實成本的那一部分。

所以 2KB 這件事是真的,只是那是「還沒做任何事的 goroutine」的成本。


感謝閱讀!英文不是我的母語,所以我會用 AI 幫忙潤飾文法。除此之外,這篇文章裡的想法、程式碼和觀點都來自我本人。

喜歡這篇嗎?歡迎保持聯絡——我在 LinkedIn,很樂意聊天、交換想法,或 պարզապես 打個招呼。👋


原文出處:https://dev.to/nazar-boyko/goroutines-are-cheap-their-stacks-arent-4ena


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬4  
159
🥈
我愛JS
💬1  
6
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登