這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
翻譯原文連結 轉帖/轉載請註明出處
英文原文連結 發表於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語言運行環境的細節,可以讀以下這個郵件清單裡的文章。