Go 協程堆棧設計進化之旅

2020-10-26 閃念基因

本文詳細講述了 Golang 中,堆棧設計理念以及演變過程。描述了從 Segment Stack 到 Contiguous Stack 、初始堆棧大小從 8Kb 到 2Kb 的原因。

Illustration created for 「A Journey With Go」, made from the original Go Gopher, created by Renee French.

:information_source: 文章基於 Go 1.12.

Go 提供了一個輕量且智能的協程管理機制。輕量是因為協程堆棧初始化只有 2Kb,智能是因為協程堆棧可以根據我們的需要自動增加 / 減少。

堆棧的大小定義,我們可以在這裡找到 runtime/stack.go:

// The minimum size of stack used by Go code_StackMin = 2048

我們需要注意的是,它曾經在以下版本的時間裡進行過優化:

  • Go 1.2: 協程堆棧從 4Kb 增長到 8Kb.
  • Go 1.4: 協程堆棧從 8Kb 減少到 2Kb.

協程堆棧大小的變化主要是因為堆棧分配策略的變化。在文章後面我們一會兒將會提到這個問題。

默認的堆棧大小有的時候並不能滿足我們運行的程序。這時候 Go 就會自動的調整堆棧大小。

動態堆棧大小

如果 Go 可以自動的增長棧空間大小,那麼也意味著可以決定堆棧大小到底有沒有必要需要修改。讓我們看一個例子,分析一下它是怎麼工作的:

func main() { a := 1 b := 2 r := max(a, b) println(`max: `+strconv.Itoa(r))}func max(a int, b int) int { if a >= b { return a } return b}

這個例子只是計算了兩個數字中最大的一個。為了了解 Go 是如何管理協程堆棧分配的,我們可以看下 Go 的編譯流程代碼, 通過命令: go build -gcflags -S main.go . 輸出 —— 我只保留了與堆棧有關的一些行 —— 它給我們一些有趣的信息,這些內容展示了 Go 都做了什麼:

"".main STEXT size=186 args=0x0 locals=0x70 0x0000 00000 (/go/src/main.go:5) TEXT "".main(SB), ABIInternal, $112-0 [...] 0x00b0 00176 (/go/src/main.go:5) CALL runtime.morestack_noctxt(SB)[...]0x0000 00000 (/go/src/main.go:13) TEXT "".max(SB), NOSPLIT|ABIInternal, $0-24有兩條指令涉及到棧大小的更改:- CALL runtime.morestack_noctxt: 這個方法會在需要的時候增加堆棧大小。-NOSPLIT: 這條指令的意味著堆棧不需要溢出檢測,他與指令 //go:nosplit .比較相似。

我們看到這個方法: runtime.morestack_noctxt ,他會調用 runtime/stack.go 中的 newstack 方法:

func newstack() { [...] // Allocate a bigger segment and move the stack. oldsize := gp.stack.hi - gp.stack.lo newsize := oldsize * 2 if newsize > maxstacksize { print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n") throw("stack overflow") } // The goroutine must be executing in order to call newstack, // so it must be Grunning (or Gscanrunning). casgstatus(gp, _Grunning, _Gcopystack) // The concurrent GC will not scan the stack while we are doing the copy since // the gp is in a Gcopystack status. copystack(gp, newsize, true) if stackDebug >= 1 { print("stack grow done\n") } casgstatus(gp, _Gcopystack, _Grunning)}

首先根據 gp.stack.higp.stack.lo 的邊界來計算堆棧的大小,他們是指向堆棧頭部和尾部的指針。

type stack struct { lo uintptr hi uintptr}

然後堆棧大小被乘以 2 倍,如果它沒有達到最大值的話 —— 最大值與系統架構有關。

// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.// Using decimal instead of binary GB and MB because// they look nicer in the stack overflow failure message.if sys.PtrSize == 8 { maxstacksize = 1000000000} else { maxstacksize = 250000000}

現在我們已經了解了運行機制,我們來寫個簡單的例子來驗證以上的內容。為了 debug,我們需要設置 stackDebug 常量,它在上面 newstack 的方法裡會列印一些 debug 信息,運行:

func main() { var x [10]int a(x)}//go:noinlinefunc a(x [10]int) { println(`func a`) var y [100]int b(y)}//go:noinlinefunc b(x [100]int) { println(`func b`) var y [1000]int c(y)}//go:noinlinefunc c(x [1000]int) { println(`func c`)}

//go:noinline 指令是為了避免編譯時把所有的方法都放到一行。如果都放到一行的話,我們將看不到每個方法開始時候的堆棧動態增長。

下面是一部分的 debug 日誌:

runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]stack grow donefunc aruntime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]stack grow doneruntime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]stack grow doneruntime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]stack grow donefunc bruntime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]func c

我們可以看到堆棧一共有 4 次增長。其實,方法開始會將堆棧增長到它需要的大小。就像我們在代碼中看到的,堆棧的邊界定義了堆棧的大小,所以我們可以計算每一個新的堆棧的大小 —— newstack stack=[...] 指令提供了當前堆棧邊界的指針:

runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]0xc00002e800 - 0xc00002e000 = 2048runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]0xc000077000 - 0xc000076000 = 4096runtime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]0xc000040000 - 0xc00003e000 = 8192runtime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]0xc000082000 - 0xc00007e000 = 16384runtime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]0xc00008a000 - 0xc000082000 = 32768

我們可以看到在編譯時 Goroutine 的棧空間初始大小為 2Kb ,在函數起始的地方增長到它所需要的大小,直到大小已經滿足運行條件或者達到了系統限制。

堆棧分配管理

動態堆棧分配系統並不是唯一影響我們應用原因。不過,堆棧分配方式也可能會對應用產生很大的影響。通過兩個完整的日誌跟蹤讓我們試著理解它是如何管理堆棧的。讓我們嘗試從前兩個堆棧增長的跟蹤中了解 Go 是如何進行堆棧管理的:

runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]copystack gp=0xc000000300 [0xc00002e000 0xc00002e6e0 0xc00002e800] -> [0xc000076000 0xc000076ee0 0xc000077000]/4096stackfree 0xc00002e000 2048stack grow doneruntime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]copystack gp=0xc000000300 [0xc000076000 0xc000076890 0xc000077000] -> [0xc00003e000 0xc00003f890 0xc000040000]/8192stackfree 0xc000076000 4096stack grow done

第一條指令顯示了當前堆棧的地址, stack=[0xc00002e000, 0xc00002e800] , 並把他複製到新的堆棧裡,並且是之前的二倍大小, copystack [0xc00002e000 [...] 0xc00002e800] -> [0xc000076000 [...] 0xc000077000] ,4096 字節的長度和我們上面看到的一樣。然後之前的堆棧將被釋放: stackfree 0xc00002e000 。我們畫了個圖可以幫助理解上面的邏輯:

Golang stack growth with contiguous stack

copystack 指令複製了整個堆棧,並把所有的地址都移向新的堆棧。我們可以通過一段簡短的代碼來很容易的發現這個現象:

func main() { var x [10]int println(&x) a(x) println(&x)}

列印出來的地址為

0xc00002e738[...]0xc000089f38

地址 0xc00002e738 是被包含在我們之前看到的堆棧地址之中 stack=[0xc00002e000, 0xc00002e800] ,同樣的 0xc000089f38 這個地址也是包含在後一個堆棧之中 stack=[0xc000082000, 0xc00008a000] ,這兩個 stack 地址是我們上面通過 debug 模式追蹤到的。這也證明了確實所有的值都已經從老的堆棧移到了新的堆棧裡。

另外,有趣的是,當垃圾回收被觸發時,堆棧會縮小(譯者註:一點也不 interesting)。

在我們的例子中,在函數調用之後,堆棧中除了主函數外沒有其他的有效函數調用,所以在垃圾回收啟動的時候,系統會將堆棧進行縮減。為了證明這個問題,我們可以強制進行垃圾回收:

func main() { var x [10]int println(&x) a(x) runtime.GC() println(&x)}

Debug 程序會展示出堆棧縮減的日誌:

func cshrinking stack 32768->16384copystack gp=0xc000000300 [0xc000082000 0xc000089e60 0xc00008a000] -> [0xc00007e000 0xc000081e60 0xc000082000]/16384

正如我們看到的這樣,堆棧大小被縮減為原來的一半,並重用了之前的堆棧地址 stack=[0xc00007e000, 0xc000082000] ,同樣在 runtime/stack.go — shrinkstack() 中我們可以看到,縮減函數默認就是將當前堆棧大小除以 2:

oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize / 2

連續堆棧 VS 分段堆棧

將堆棧複製到更大的堆棧空間中的策略稱之為 連續堆棧(contiguous stack),與 分段堆棧(segmented stack)正好相反。Go 在 1.3 版本中遷移到了連續堆棧的策略。為了看看他們的不同,我們可以在 Go 1.2 版本中跑相同的例子看看。同樣,我們需要修改 stackDebug 變量來展示 Debug 跟蹤信息。為此,由於 Go 1.2 的 runtime 是用 C 語言寫的,所以我們只能重新編譯原始碼.。這裡是例子的運行結果:

func aruntime: newstack framesize=0x3e90 argsize=0x320 sp=0x7f8875953848 stack=[0x7f8875952000, 0x7f8875953fa0] -> new stack [0xc21001d000, 0xc210021950]func bfunc cruntime: oldstack gobuf={pc:0x400cff sp:0x7f8875953858 lr:0x0} cret=0x1 argsize=0x320

當前的堆棧 stack=[0x7f8875952000, 0x7f8875953fa0] 大小是 8Kb (8192 字節 + 堆棧頂部的大小),同時新的堆棧創建大小為 18864 字節 ( 18768 字節 + 堆棧頂部的大小)。(譯者註:這裡比較難理解

0x7f8875953fa0 - 0x7f8875952000 並不到 8Kb,應該是筆誤,應該是 8096 字節)

內存大小分配的邏輯如下:

// allocate new segment.framesize += argsize;framesize += StackExtra; // room for more functions, Stktop.if(framesize < StackMin) framesize = StackMin;framesize += StackSystem;

其中常量 StackExtra 是 2048 , StackMin 是 8192 , StackSystem 從 0 到 512 都有可能(譯者註:根據平臺來判斷的)

所以我們新的堆棧包括了 : 16016 (frame size) + 800 (arguments) + 2048 (StackExtra) + 0 (StackSystem)

一旦所有的函數都調用完畢,新的堆棧將被釋放(log runtime: oldstack )。這個行為是迫使 Golang 團隊轉移到連續堆棧的原因之一:

當前分段堆棧機制有一個 「熱分離( hot split)」的問題 —— 如果堆棧快滿了,那麼函數調用會引起一個新的堆棧塊被分配。當所有的函>數調用返回時,新的堆棧塊也被回收了。如果同樣的調用方式密集地重複發生,分配 / 回收 將會導致大量的開銷。

https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ/pub

因為這個問題,Go 1.2 將最小堆棧大小增長到了 8Kb。之後因為實現了連續堆棧,則將堆棧大小縮減回了 2Kb。

下圖是分段堆棧的演示圖:

Golang stack growth with segmented stack

總結

Go 的堆棧管理是非常高效的,而且容易理解。Golang 不是唯一一個沒有選擇分段堆棧的語言, Rust 語言因為同樣的原因而沒有選擇這個方案。

如果你想了解更深入的堆棧內容,可以閱讀 Dave Cheney 的博客文章,該文章討論了 redzone ,還有 Bill Kennedy 的文章解釋了堆棧中的 frames。

閱讀原文: https://medium.com/a-journey-with-go/go-how-does-the-goroutine-stack-size-evolve-447fc02085e5

相關焦點

  • GO語言:協程——Goroutine
    協程協程是一種用戶態的輕量級線程,又稱微線程,英文名Coroutine,協程的調度完全由用戶控制。人們通常將協程和子程序(函數)比較著理解。 子程序調用總是一個入口,一次返回,一旦退出即完成了子程序的執行。
  • Swoole協程與Go協程的區別,很詳細,很牛逼
    協程是輕量級線程, 協程的創建、切換、掛起、銷毀全部為內存操作,消耗是非常低的。協程是屬於線程,協程是在線程裡執行的。協程的調度是用戶手動切換的,所以又叫用戶空間線程。協程的調度策略是:協作式調度。Swoole 協程Swoole 的協程客戶端必須在協程的上下文環境中使用。Swoole 的協程是基於單線程的, 無法利用多核CPU,同一時間只有一個在調度。
  • 一文詳解,PHP 協程:Go + Chan + Defer
    Golang:靜態語言,嚴謹強大性能好,PHP+Swoole:動態語言,靈活簡單易用本文基於Swoole-4.2.9和PHP-7.2.9版本關鍵詞go :創建一個協程chan :創建一個通道協程並發使用go函數可以讓一個函數並發地去執行。在編程過程中,如果某一段邏輯可以並發執行,就可以將它放置到go協程中執行。順序執行<?
  • Go之旅:Goroutine 的開啟和退出
    由於協程會有自己的運行時間,因此 Go 通知運行時配置一個新協程,意味著:創建棧收集當前程序計數器或調用方數據的信息更新協程內部數據,如 ID 或 狀態然而,協程沒有立即獲取運行時狀態。新創建的協程被加入到了本地隊列的最前端,會在 Go 調度的下一周期運行。
  • 使用 go 協程+Channel,讓你的代碼執行快到起飛
    作者: horryhuang,騰訊 PCG 後臺開發工程師傳統的串行代碼執行,邏輯比較簡單,當數據量比較大時,執行效率低下,既然我們使用 go,那就利用 go 相對與其他語言的優勢,輕量化的協程以及 channel,接下來讓我們使用 go
  • Golang協程並發的流水線模型
    go語言精簡優雅,既有編譯型語言的嚴謹和高性能,又有解釋型語言的開發效率,出色的並發性能也是go區別於其他語言的一大特色。go的並發編程代碼雖然簡單,但重在其並發模型和流程的設計。所以這裡總結下golang協程並發常用的流水線模型。
  • golang 多協程的同步方法總結
    之前用 go 寫一個小工具的時候, 用到了多個協程之間的通信, 當時隨手查了查, 結果查出來一大坨, 簡單記錄一下. golang中多個協程之間是如何進行通信及數據同步的嘞.共享變量一個最簡單, 最容易想到的, 就是通過全局變量的方式, 多個協程讀寫同一個變量. 但對同一個變量的更改, 就不得不加鎖了, 否則極易引發數據問題. 一般系統庫都提供基本的鎖, go 也提供了.
  • 說說協程coroutine
    協程,coroutine,是最近幾年出現的一種異步改同步的編程機制,被go、python等廣泛支持,c也有第三方的協程庫。在協程出現之前,伺服器端的高並發程序,都是直接使用OS的異步事件機制。這類程序還容易出錯,到處是函數指針的調用,一下子看不出來到底調用的是哪個指針:(然後,就出現了協程,把異步編程嵌到了語言的底層機制裡了。只要async/await,go就行了。協程,類似用戶態的線程、或進程。
  • Python乾貨整理:python與golang的協程區別
    Go語言裡,go function很容易啟動一個goroutine,goroutine可以在多核上運行,從而實現協程並行當協程中發生channel讀寫的阻塞或者系統調用時,就會切換到其他協程。以上的G任務執行是按照隊列順序(也就是go調用的順序)執行的。
  • 硬核系列—深入剖析 Java 協程
    一個協程代表一個具體的任務,一個線程內部可包含一組協程隊列,換句話說,協程運行在線程之上,線程是協程的運行環境。剛才提及過,協程非常適用於處理I/O密集型任務,這是因為協程的上下文切換無需由內核調度介入,同時也不會發生系統調用,因此,任務可獲得大量的CPU時間。
  • Go goroutine理解
    為了更好理解Goroutine,先講一下線程和協程的概念線程(Thread):有時被稱為輕量級進程(Lightweight Process,LWP),是程序執行流的最小單元。一個標準的線程由線程ID,當前指令指針(PC),寄存器集合和堆棧組成。
  • 面試官:實現協程同步有哪些方式?
    假設現在有多個協程並發訪問操作同一塊內存中的數據,那麼可能上一納秒第一個協程剛把數據從寄存器拷貝到內存,第二個協程馬上又把此數據用它修改的值給覆蓋了,這樣共享數據變量會亂套。gt; go run test.go1014184dashu@dashu > /data1/htdocs/go_practice > go run test.go1026029dashu@dashu > /data1/htdocs/go_practice > go run test.go19630...
  • Go語言潛力有目共睹,但它的Goroutine機制底層原理你了解嗎?
    協程的提出者梅爾文·愛德華·康威是一位計算機科學家,除了協程之外他還創造了Conway's Law康威定律,他基於社會學觀察提出了系統設計的一些觀點,本文就不展開了,感興趣的可以看下作者的論文How Do Committees Invent?
  • 詳解Python中的協程,為什麼說它的底層是生成器?
    go語言由於天然支持協程,並且支持得非常好,使得它廣受好評,短短幾年時間就迅速流行起來。對於Python來說,本身就有著一個GIL這個巨大的先天問題。GIL是Python的全局鎖,在它的限制下一個Python進程同一時間只能同時執行一個線程,即使是在多核心的機器當中。這就大大影響了Python的性能,尤其是在CPU密集型的工作上。
  • 特性完成:VS2019 v16.8全面支持C++協程
    提供一個嚴格遵循C++標準的協程實現,使得用戶可以編寫和使用可移植代碼。2. 確保那些使用實驗版本協程的用戶可以毫不費力地升級到v16.8而無需改動他們的代碼。對於對稱轉移,協程可以指示協程句柄,以便另一個協程在掛起時立即恢復。
  • Pokemon go精靈怎麼進化 口袋妖怪Go精靈進化方法
    Pokemon go精靈怎麼進化?精靈進化需要什麼材料呢?在pokemon go中,精靈可以通過二段三段進化提高CP和HP,也是提高戰力的重要途徑,那麼精靈怎麼進化,進化材料需要什麼呢?一起來看看吧!安趣網口袋妖怪GO8群:254653161
  • PHP協程:並發 shell_exec
    在Swoole4協程環境下可以用Co::exec並發地執行很多命令。本文基於Swoole-4.2.9和PHP-7.2.9版本協程示例<?php$c = 10;while($c--) { go(function () { //這裡使用 sleep 5 來模擬一個很長的命令 co::exec(&34;); });}返回值Co::exec執行完成後會恢復掛起的協程,並返回命令的輸出和退出的狀態碼。
  • 你必須了解的 Go 歷史:Go 的設計思想和每個版本的優劣
    了解幾年來每個發行版本的主要變化,有助於理解 Go 的設計思想和每個版本的優勢/弱點。想了解特定版本的更詳細信息,可以點擊每個版本號的連結來查看修改記錄。M 是一個 OS 線程,P 表示一個處理器(P 的數量不能大於 GOMAXPROCS),每個 P 作為一個本地協程隊列。
  • Go語言的魅力
    給周末無事,想充電的朋友分享一下我最近迷戀上了Go語言的理由[偷笑],起初讓我了解它,是它那幾位殿堂級的創始人,後來去年B站原始碼洩露,幾乎整站全是go語言架構,讓我映像深刻,而近年國內很多巨頭阿里,頭條,小米,360,美團,螞蟻,騰訊等的開源項目可見,go就是一門未來具有絕對魅力的語言,我很多朋友公司裡,無歷史包袱的新項目都在優先考慮golang架構。
  • 理解真實世界中 Go 的並發 BUG
    go的RWMutex實現與C中的pthread_rwlock_t不同,go中的寫鎖請求優先級高於讀鎖。go中還有一些新特性,Once保證一個函數隻執行一次:使用 Once.Do(f) 方法,即使這一語句被多個協程調用了多次,也只有第一次的時候,函數f會被執行。和C中的pthread_join類似,go使用WaitGroup來實現等待協程對其他協程的等待。