Go 協(xié)程堆棧設(shè)計進化之旅

- 后端早讀課翻譯計劃 第四篇-

- 翻譯自: a-journey-with-go

歡迎關(guān)注微信公眾號: 后端早讀課

本文詳細講述了 Golang 中蜗巧,堆棧設(shè)計理念以及演變過程壮池。描述了從 Segment Stack 到 Contiguous Stack 淀歇、初始堆棧大小從 8Kb 到 2Kb 的原因百揭。

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

?? 文章基于 Go 1.12.

Go 提供了一個輕量且智能的協(xié)程管理機制聋迎。輕量是因為協(xié)程堆棧初始化只有 2Kb飞涂,智能是因為協(xié)程堆椣油剩可以根據(jù)我們的需要自動增加 / 減少呀枢。

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

// The minimum size of stack used by Go code
_StackMin = 2048

我們需要注意的是笼痛,它曾經(jīng)在以下版本的時間里進行過優(yōu)化:

  • Go 1.2: 協(xié)程堆棧從 4Kb 增長到 8Kb.

  • Go 1.4: 協(xié)程堆棧從 8Kb 減少到 2Kb.

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

默認的堆棧大小有的時候并不能滿足我們運行的程序缨伊。這時候 Go 就會自動的調(diào)整堆棧大小摘刑。

動態(tài)堆棧大小

如果 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
}

這個例子只是計算了兩個數(shù)字中最大的一個紧唱。為了了解 Go 是如何管理協(xié)程堆棧分配的活尊,我們可以看下 Go 的編譯流程代碼, 通過命令: go build -gcflags -S main.go. 輸出 —— 我只保留了與堆棧有關(guān)的一些行 —— 它給我們一些有趣的信息漏益,這些內(nèi)容展示了 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 铜犬,他會調(diào)用 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)
}

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


type stack struct {
   lo uintptr
   hi uintptr
}

然后堆棧大小被乘以 2 倍癣猾,如果它沒有達到最大值的話 —— 最大值與系統(tǒng)架構(gòu)有關(guān)敛劝。


// 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
}

現(xiàn)在我們已經(jīng)了解了運行機制,我們來寫個簡單的例子來驗證以上的內(nèi)容纷宇。為了 debug夸盟,我們需要設(shè)置 stackDebug 常量,它在上面 newstack 的方法里會打印一些 debug 信息像捶,運行:


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

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

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

//go:noinline
func c(x [1000]int) {
   println(`func c`)
}

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

下面是一部分的 debug 日志:

runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]
stack grow done
func a
runtime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]
stack grow done
runtime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]
stack grow done
runtime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]
stack grow done
func b
runtime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]
func c

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

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

我們可以看到在編譯時 Goroutine 的棾辽荆空間初始大小為 2Kb 渐尿,在函數(shù)起始的地方增長到它所需要的大小醉途,直到大小已經(jīng)滿足運行條件或者達到了系統(tǒng)限制矾瑰。

堆棧分配管理

動態(tài)堆棧分配系統(tǒng)并不是唯一影響我們應(yīng)用原因。不過隘擎,堆棧分配方式也可能會對應(yīng)用產(chǎn)生很大的影響殴穴。通過兩個完整的日志跟蹤讓我們試著理解它是如何管理堆棧的。讓我們嘗試從前兩個堆棧增長的跟蹤中了解 Go 是如何進行堆棧管理的:

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

第一條指令顯示了當前堆棧的地址货葬, stack=[0xc00002e000, 0xc00002e800] 采幌, 并把他復(fù)制到新的堆棧里,并且是之前的二倍大小震桶, copystack [0xc00002e000 [...] 0xc00002e800] -> [0xc000076000 [...] 0xc000077000] 休傍,4096 字節(jié)的長度和我們上面看到的一樣。然后之前的堆棧將被釋放: stackfree 0xc00002e000 蹲姐。我們畫了個圖可以幫助理解上面的邏輯:

Golang stack growth with contiguous stack

copystack 指令復(fù)制了整個堆棧磨取,并把所有的地址都移向新的堆棧。我們可以通過一段簡短的代碼來很容易的發(fā)現(xiàn)這個現(xiàn)象:

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

打印出來的地址為

0xc00002e738
[...]
0xc000089f38

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

另外逢净,有趣的是,當垃圾回收被觸發(fā)時,堆棧會縮械痢(譯者注:一點也不 interesting)甥雕。

在我們的例子中,在函數(shù)調(diào)用之后胀茵,堆棧中除了主函數(shù)外沒有其他的有效函數(shù)調(diào)用犀农,所以在垃圾回收啟動的時候,系統(tǒng)會將堆棧進行縮減宰掉。為了證明這個問題呵哨,我們可以強制進行垃圾回收:

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

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

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

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

oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2

連續(xù)堆棧 VS 分段堆棧

將堆棧復(fù)制到更大的堆棸の瘢空間中的策略稱之為 連續(xù)堆棧(contiguous stack),與 分段堆棧(segmented stack)正好相反玉组。Go 在 1.3 版本中遷移到了連續(xù)堆棧的策略谎柄。為了看看他們的不同,我們可以在 Go 1.2 版本中跑相同的例子看看惯雳。同樣朝巫,我們需要修改 stackDebug 變量來展示 Debug 跟蹤信息。為此石景,由于 Go 1.2 的 runtime 是用 C 語言寫的劈猿,所以我們只能重新編譯源代碼.。這里是例子的運行結(jié)果:

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

當前的堆棧 stack=[0x7f8875952000, 0x7f8875953fa0] 大小是 8Kb (8192 字節(jié) + 堆棧頂部的大谐蹦酢)揪荣,同時新的堆棧創(chuàng)建大小為 18864 字節(jié) ( 18768 字節(jié) + 堆棧頂部的大小)往史。(譯者注:這里比較難理解
0x7f8875953fa0 - 0x7f8875952000 并不到 8Kb仗颈,應(yīng)該是筆誤,應(yīng)該是 8096 字節(jié))

內(nèi)存大小分配的邏輯如下:

// 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 都有可能(譯者注:根據(jù)平臺來判斷的)

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

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

當前分段堆棧機制有一個 “熱分離( hot split)”的問題 —— 如果堆椈嗣蓿快滿了,那么函數(shù)調(diào)用會引起一個新的堆棧塊被分配陌粹。當所有的函>數(shù)調(diào)用返回時撒犀,新的堆棧塊也被回收了。如果同樣的調(diào)用方式密集地重復(fù)發(fā)生,分配 / 回收 將會導(dǎo)致大量的開銷或舞。

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

因為這個問題荆姆,Go 1.2 將最小堆棧大小增長到了 8Kb。之后因為實現(xiàn)了連續(xù)堆棧映凳,則將堆棧大小縮減回了 2Kb胆筒。

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

Golang stack growth with segmented stack

總結(jié)

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

如果你想了解更深入的堆棧內(nèi)容矫渔,可以閱讀 Dave Cheney 的博客文章彤蔽,該文章討論了 redzone ,還有 Bill Kennedy 的文章解釋了堆棧中的 frames庙洼。

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

擴展閱讀:

contiguous stack

contiguous stack in Go 1.3

StackExtra

StackMin

StackSystem

talks about the redzone

frames in the stack

歡迎關(guān)注微信公眾號: 后端早讀課

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末顿痪,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子油够,更是在濱河造成了極大的恐慌蚁袭,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,042評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件石咬,死亡現(xiàn)場離奇詭異揩悄,居然都是意外死亡,警方通過查閱死者的電腦和手機碌补,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,996評論 2 384
  • 文/潘曉璐 我一進店門虏束,熙熙樓的掌柜王于貴愁眉苦臉地迎上來棉饶,“玉大人厦章,你說我怎么就攤上這事≌赵澹” “怎么了袜啃?”我有些...
    開封第一講書人閱讀 156,674評論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長幸缕。 經(jīng)常有香客問我群发,道長,這世上最難降的妖魔是什么发乔? 我笑而不...
    開封第一講書人閱讀 56,340評論 1 283
  • 正文 為了忘掉前任熟妓,我火速辦了婚禮,結(jié)果婚禮上栏尚,老公的妹妹穿的比我還像新娘起愈。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 65,404評論 5 384
  • 文/花漫 我一把揭開白布抬虽。 她就那樣靜靜地躺著官觅,像睡著了一般。 火紅的嫁衣襯著肌膚如雪阐污。 梳的紋絲不亂的頭發(fā)上休涤,一...
    開封第一講書人閱讀 49,749評論 1 289
  • 那天,我揣著相機與錄音笛辟,去河邊找鬼功氨。 笑死,一個胖子當著我的面吹牛手幢,可吹牛的內(nèi)容都是我干的疑故。 我是一名探鬼主播,決...
    沈念sama閱讀 38,902評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼弯菊,長吁一口氣:“原來是場噩夢啊……” “哼纵势!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起管钳,我...
    開封第一講書人閱讀 37,662評論 0 266
  • 序言:老撾萬榮一對情侶失蹤钦铁,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后才漆,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體牛曹,經(jīng)...
    沈念sama閱讀 44,110評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,451評論 2 325
  • 正文 我和宋清朗相戀三年醇滥,在試婚紗的時候發(fā)現(xiàn)自己被綠了黎比。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,577評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡鸳玩,死狀恐怖阅虫,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情不跟,我是刑警寧澤颓帝,帶...
    沈念sama閱讀 34,258評論 4 328
  • 正文 年R本政府宣布,位于F島的核電站窝革,受9級特大地震影響购城,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜虐译,卻給世界環(huán)境...
    茶點故事閱讀 39,848評論 3 312
  • 文/蒙蒙 一瘪板、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧漆诽,春花似錦侮攀、人聲如沸史侣。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,726評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽惊橱。三九已至,卻和暖如春箭昵,著一層夾襖步出監(jiān)牢的瞬間税朴,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,952評論 1 264
  • 我被黑心中介騙來泰國打工家制, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留正林,地道東北人。 一個月前我還...
    沈念sama閱讀 46,271評論 2 360
  • 正文 我出身青樓颤殴,卻偏偏與公主長得像觅廓,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子涵但,可洞房花燭夜當晚...
    茶點故事閱讀 43,452評論 2 348