Go語言的棧空間管理

翻譯原文連接 轉帖/轉載請註明出處程序員

英文原文連接 發表於2014/09/15golang

在CloudFlare,咱們使用Go語言搭建各類服務和應用。在這篇博文裏,咱們將對Go語言的技術特色進行深度分析。Go語言裏最重要的一個特性就是goroutine。它們的開銷比較小,相互協做地調度線程來運行。它們有普遍的用途,好比實現超時控制(timeouts),生成器(generators),以及在多個後臺應用之間實現相互競爭(racing)。爲了使goroutine可以適應更多的任務,咱們必須保證每一個goroutine佔用不多的內存。同時,人們應該能夠很方便地建立goroutine。安全

爲了達到這些目標,Go語言管理的棧的方式看起來和其它不少語言同樣,可是它的實現確實很是不一樣。數據結構

線程棧介紹

在咱們開始討論Go語言的棧以前,讓咱們來看看C語言是怎麼管理棧的。less

當你在C語言裏啓動一個線程的時候,標準庫(standard library)會負責分配一塊內存來用做線程的棧空間。它首先分配一塊內存,告訴內核它的地址,而後讓內核來控制線程的運行。若是這塊分配的內存空間不夠大的話,問題就變得複雜起來了。函數

咱們來看看下面這個函數:post

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)會耗盡全部的棧內存。爲了不這個問題,咱們能夠調整標準庫分配給棧的內存空間的大小。可是增大這個參數會致使全部的線程都佔用那麼多的棧空間,即便這些函數並不須要遞歸調用。在這種狀況下,雖然你的程序沒有用到分配的棧,它仍是會耗盡全部的內存。google

另一個解決辦法是給每一個線程分配不一樣大小的棧。這樣你就須要給每一個線程配置棧的大小,從而使得建立線程變得更加麻煩。想要決定一個線程會使用多少內存一般是很是困難的。操作系統

Go語言的解決辦法

Go語言的運行環境(runtime)嘗試在goroutine須要的時候動態地分配棧空間,而不是給每一個goroutine分配固定大小的內存空間。這樣就避免了須要程序員來決定棧的大小。Go的開發小組正嘗試從一種解決方案切換到另一種解決方案。接下來將會討論老的解決方案和它的缺點,而後介紹新的方案以及選擇它的緣由。線程

分塊式的棧(Segmented stacks)

分塊式的棧是最初Go語言組織棧的方式。當建立一個goroutine的時候,它會分配一個8KB的內存空間來給goroutine的棧使用。

咱們最感興趣的是當這8KB的棧空間被用完的時候。爲了處理這種狀況,每一個Go函數的開頭都有一小段檢測代碼。這段代碼會檢查咱們是否已經用完了分配的棧空間。若是是的話,它會調用morestack函數。morestack函數分配一塊新的內存做爲棧空間,而且在這塊棧空間的底部填入各類信息(包括以前的那塊棧地址)。在分配了這塊新的棧空間以後,它會重試剛纔形成棧空間不足的函數。這個過程叫作棧分裂(stack split)。當通過棧分裂以後,棧結構以下圖所示。

在新分配的棧底部,還插入了一個叫作lessstack的函數指針。這個函數尚未被調用。這樣設置是爲了從剛纔形成棧空間不足的那個函數返回時作準備的。當咱們從那個函數返回時,它會跳轉到lessstacklessstack函數會查看在棧底部存放的數據結構裏的信息,而後調整棧指針(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語言運行環境的細節,能夠讀如下這個郵件列表裏的文章

相關文章
相關標籤/搜索