翻譯原文鏈接 轉帖/轉載請注明出處
英文原文鏈接 發表于2014/09/15
在CloudFlare,我們使用Go語言搭建各種服務和應用。在這篇博文里,我們將對Go語言的技術特點進行深度分析。Go語言里最重要的一個特性就是goroutine。它們的開銷比較小,相互協作地調度線程來運行。它們有廣泛的用途,比如實現超時控制(timeouts),生成器(generators),以及在多個后臺應用之間實現相互競爭(racing)。為了使goroutine能夠適應更多的任務,我們必須保證每個goroutine占用很少的內存。同時,人們應該可以很方便地創建goroutine。
為了達到這些目標,Go語言管理的棧的方式看起來和其它很多語言一樣,但是它的實現確實非常不同。
線程棧介紹
在我們開始討論Go語言的棧之前,讓我們來看看C語言是怎么管理棧的。
當你在C語言里啟動一個線程的時候,標準庫(standard library)會負責分配一塊內存來用作線程的棧空間。它首先分配一塊內存,告訴內核它的地址,然后讓內核來控制線程的運行。如果這塊分配的內存空間不夠大的話,問題就變得復雜起來了。
我們來看看下面這個函數:
int a(int m, int n) {
if (m == 0) {
return n + 1;
} else if (m > 0 && n == 0) {
return a(m - 1, 1);
} else {
return a(m - 1, a(m, n - 1));
}
}
這是個遞歸函數。調用a(4,5)
會耗盡所有的棧內存。為了避免這個問題,我們可以調整標準庫分配給棧的內存空間的大小。但是增大這個參數會導致所有的線程都占用那么多的棧空間,即使這些函數并不需要遞歸調用。在這種情況下,雖然你的程序沒有用到分配的棧,它還是會耗盡所有的內存。
另外一個解決辦法是給每個線程分配不同大小的棧。這樣你就需要給每個線程配置棧的大小,從而使得創建線程變得更加麻煩。想要決定一個線程會使用多少內存通常是非常困難的。
Go語言的解決辦法
Go語言的運行環境(runtime)嘗試在goroutine需要的時候動態地分配棧空間,而不是給每個goroutine分配固定大小的內存空間。這樣就避免了需要程序員來決定棧的大小。Go的開發小組正嘗試從一種解決方案切換到另外一種解決方案。接下來將會討論老的解決方案和它的缺點,然后介紹新的方案以及選擇它的原因。
分塊式的棧(Segmented stacks)
分塊式的棧是最初Go語言組織棧的方式。當創建一個goroutine的時候,它會分配一個8KB的內存空間來給goroutine的棧使用。
我們最感興趣的是當這8KB的棧空間被用完的時候。為了處理這種情況,每個Go函數的開頭都有一小段檢測代碼。這段代碼會檢查我們是否已經用完了分配的棧空間。如果是的話,它會調用morestack
函數。morestack
函數分配一塊新的內存作為棧空間,并且在這塊棧空間的底部填入各種信息(包括之前的那塊棧地址)。在分配了這塊新的棧空間之后,它會重試剛才造成棧空間不足的函數。這個過程叫做棧分裂(stack split)。當經過棧分裂之后,棧結構如下圖所示。
在新分配的棧底部,還插入了一個叫做lessstack的函數指針。這個函數還沒有被調用。這樣設置是為了從剛才造成棧空間不足的那個函數返回時做準備的。當我們從那個函數返回時,它會跳轉到lessstack
。lessstack
函數會查看在棧底部存放的數據結構里的信息,然后調整棧指針(stack pointer)。這樣就完成了從新的棧塊到老的棧塊的跳轉。接下來,新分配的這個塊棧空間就可以被釋放掉了。
分塊式的棧的問題
分塊式的棧讓我們能夠按照需求來擴展和收縮棧的大小。程序員不需要花精力去估計goroutine會用到多大的棧。創建一個新的goroutine的開銷也不大。當程序員不知道棧會擴展到多少大時,它也能很好的處理這種情況。
這一直是之前Go語言管理棧的的方法。但這個方法有一個問題。縮減棧空間是一個開銷相對較大的操作。如果在一個循環里有棧分裂,那么它的開銷就變得不可忽略了。一個函數會擴展,然后分裂棧。當它返回的時候又會釋放之前分配的內存塊。如果這些都發生在一個循環里的話,代價是相當大的。
這就是所謂的熱分裂問題(hot split problem)。它是Go語言開發者選擇新的棧管理方法的主要原因。新的方法叫做棧復制法(stack copying)。
棧復制法(stack copying)
棧復制法一開始和分塊式的棧很像。當goroutine運行并用完棧空間的時候,與之前的方法一樣,棧溢出檢查會被觸發。但是,不像之前的方法那樣分配一個新的內存塊并鏈接到老的棧內存塊,新的方法會分配一個兩倍大的內存塊并把老的內存塊內容復制到新的內存塊里。這樣做意味著當棧縮減回之前大小時,我們不需要做任何事情。棧的縮減沒有任何代價。而且,當棧再次擴展時,運行環境也不需要再做任何事。它可以重用之前分配的空間。
棧是如何被復制的?
棧的復制聽起來很容易,但實際操作并非那么簡單。存儲在棧上的變量的地址可能已經被使用到。也就是說程序使用到了一些指向棧的指針。當移動棧的時候,所有指向棧里內容的指針都會變得無效。幸運的是,指向棧內容的指針自身也必定是保存在棧上的。這是為了保證內存安全的必要條件。否則一個程序就有可能訪問一段已經無效的棧空間了。
因為垃圾回收的需要,我們必須知道棧的哪些部分是被用作指針了。當我們移動棧的時候,我們可以更新棧里的指針讓它們指向新的地址。所有相關的指針都會被更新。我們使用了垃圾回收的信息來復制棧,但并不是任何使用棧的函數都有這些信息。因為很大一部分運行環境是用C語言寫的,很多被調用的運行環境里的函數并沒有指針的信息,所以也就不能夠被復制了。當遇到這種情況時,我們只能退回到分塊式的棧并支付相應的開銷。(注:這部分信息有點過時了,但還是值得一讀!)
這也是為什么現在運行環境的開發者正在用Go語言重寫運行環境的大部分代碼。無法用Go語言重寫的部分(比如調度器的核心代碼和垃圾回收器)會在特殊的棧上運行。這個特殊棧的大小由運行環境的開發者設置。
這些改變除了使棧復制成為可能,它也允許我們在將來實現并行垃圾回收。
再說一下虛擬內存
還有一種處理棧空間的辦法是分配很大一塊虛擬內存。因為只有在內存地址被訪問到的時候才會真正分配物理內存,似乎我們可以簡單地分配一塊很大的虛擬內存然后讓操作系統來完成剩下的工作。但是這個方法有幾個問題。
首先,32位的系統只有4GB的虛擬內存,而通常只有其中的3GB可以被應用程序使用。創建上百萬的goroutine也不是不常見,這時你很可能會用完所有的虛擬內存(即使我們假設棧只用到8KB的空間)。
其次,即使我們可以在64位的系統里分配大量的虛擬內存,它依賴過量使用(overcommitting)內存。過量使用是指我們分配比實際物理內存空間更多的虛擬內存,并且依賴操作系統來確保能夠分配到需要的物理內存。但是過量使用虛擬內存是存在一定風險的。因為一個進程真的使用了比實際物理內存更大的內存空間時,它需要開始為新的需求騰出可用的物理空間。它通常會把一塊內存里的內容保存到磁盤上。這樣會導致延遲不可預測。因為這個原因,我們通常不在系統里過量使用內存。
結束語
為了讓goroutine輕量化,快速,并且適用于大部分任務,開發者們做了很多努力。棧的管理只是其中很小的一部分。如果你想了解更多關于棧復制的技術,這份設計文檔提供了更多的細節。
如果你想了解更多關于重寫Go語言運行環境的細節,可以讀以下這個郵件列表里的文章。