Go語言的??臻g管理

翻譯原文鏈接 轉(zhuǎn)帖/轉(zhuǎn)載請注明出處

英文原文鏈接 發(fā)表于2014/09/15

在CloudFlare,我們使用Go語言搭建各種服務(wù)和應(yīng)用。在這篇博文里,我們將對Go語言的技術(shù)特點(diǎn)進(jìn)行深度分析。Go語言里最重要的一個特性就是goroutine。它們的開銷比較小,相互協(xié)作地調(diào)度線程來運(yùn)行。它們有廣泛的用途,比如實(shí)現(xiàn)超時控制(timeouts),生成器(generators),以及在多個后臺應(yīng)用之間實(shí)現(xiàn)相互競爭(racing)。為了使goroutine能夠適應(yīng)更多的任務(wù),我們必須保證每個goroutine占用很少的內(nèi)存。同時,人們應(yīng)該可以很方便地創(chuàng)建goroutine。

為了達(dá)到這些目標(biāo),Go語言管理的棧的方式看起來和其它很多語言一樣,但是它的實(shí)現(xiàn)確實(shí)非常不同。

線程棧介紹

在我們開始討論Go語言的棧之前,讓我們來看看C語言是怎么管理?xiàng)5摹?/p>

當(dāng)你在C語言里啟動一個線程的時候,標(biāo)準(zhǔn)庫(standard library)會負(fù)責(zé)分配一塊內(nèi)存來用作線程的??臻g。它首先分配一塊內(nèi)存,告訴內(nèi)核它的地址,然后讓內(nèi)核來控制線程的運(yùn)行。如果這塊分配的內(nèi)存空間不夠大的話,問題就變得復(fù)雜起來了。

我們來看看下面這個函數(shù):

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));
    }
}

這是個遞歸函數(shù)。調(diào)用a(4,5)會耗盡所有的棧內(nèi)存。為了避免這個問題,我們可以調(diào)整標(biāo)準(zhǔn)庫分配給棧的內(nèi)存空間的大小。但是增大這個參數(shù)會導(dǎo)致所有的線程都占用那么多的??臻g,即使這些函數(shù)并不需要遞歸調(diào)用。在這種情況下,雖然你的程序沒有用到分配的棧,它還是會耗盡所有的內(nèi)存。

另外一個解決辦法是給每個線程分配不同大小的棧。這樣你就需要給每個線程配置棧的大小,從而使得創(chuàng)建線程變得更加麻煩。想要決定一個線程會使用多少內(nèi)存通常是非常困難的。

Go語言的解決辦法

Go語言的運(yùn)行環(huán)境(runtime)嘗試在goroutine需要的時候動態(tài)地分配棧空間,而不是給每個goroutine分配固定大小的內(nèi)存空間。這樣就避免了需要程序員來決定棧的大小。Go的開發(fā)小組正嘗試從一種解決方案切換到另外一種解決方案。接下來將會討論老的解決方案和它的缺點(diǎn),然后介紹新的方案以及選擇它的原因。

分塊式的棧(Segmented stacks)

分塊式的棧是最初Go語言組織棧的方式。當(dāng)創(chuàng)建一個goroutine的時候,它會分配一個8KB的內(nèi)存空間來給goroutine的棧使用。

我們最感興趣的是當(dāng)這8KB的??臻g被用完的時候。為了處理這種情況,每個Go函數(shù)的開頭都有一小段檢測代碼。這段代碼會檢查我們是否已經(jīng)用完了分配的??臻g。如果是的話,它會調(diào)用morestack函數(shù)。morestack函數(shù)分配一塊新的內(nèi)存作為??臻g,并且在這塊??臻g的底部填入各種信息(包括之前的那塊棧地址)。在分配了這塊新的??臻g之后,它會重試剛才造成??臻g不足的函數(shù)。這個過程叫做棧分裂(stack split)。當(dāng)經(jīng)過棧分裂之后,棧結(jié)構(gòu)如下圖所示。

在新分配的棧底部,還插入了一個叫做lessstack的函數(shù)指針。這個函數(shù)還沒有被調(diào)用。這樣設(shè)置是為了從剛才造成棧空間不足的那個函數(shù)返回時做準(zhǔn)備的。當(dāng)我們從那個函數(shù)返回時,它會跳轉(zhuǎn)到lessstack。lessstack函數(shù)會查看在棧底部存放的數(shù)據(jù)結(jié)構(gòu)里的信息,然后調(diào)整棧指針(stack pointer)。這樣就完成了從新的棧塊到老的棧塊的跳轉(zhuǎn)。接下來,新分配的這個塊??臻g就可以被釋放掉了。

分塊式的棧的問題

分塊式的棧讓我們能夠按照需求來擴(kuò)展和收縮棧的大小。程序員不需要花精力去估計goroutine會用到多大的棧。創(chuàng)建一個新的goroutine的開銷也不大。當(dāng)程序員不知道棧會擴(kuò)展到多少大時,它也能很好的處理這種情況。

這一直是之前Go語言管理?xiàng)5牡姆椒?。但這個方法有一個問題??s減棧空間是一個開銷相對較大的操作。如果在一個循環(huán)里有棧分裂,那么它的開銷就變得不可忽略了。一個函數(shù)會擴(kuò)展,然后分裂棧。當(dāng)它返回的時候又會釋放之前分配的內(nèi)存塊。如果這些都發(fā)生在一個循環(huán)里的話,代價是相當(dāng)大的。

這就是所謂的熱分裂問題(hot split problem)。它是Go語言開發(fā)者選擇新的棧管理方法的主要原因。新的方法叫做棧復(fù)制法(stack copying)。

棧復(fù)制法(stack copying)

棧復(fù)制法一開始和分塊式的棧很像。當(dāng)goroutine運(yùn)行并用完棧空間的時候,與之前的方法一樣,棧溢出檢查會被觸發(fā)。但是,不像之前的方法那樣分配一個新的內(nèi)存塊并鏈接到老的棧內(nèi)存塊,新的方法會分配一個兩倍大的內(nèi)存塊并把老的內(nèi)存塊內(nèi)容復(fù)制到新的內(nèi)存塊里。這樣做意味著當(dāng)??s減回之前大小時,我們不需要做任何事情。棧的縮減沒有任何代價。而且,當(dāng)棧再次擴(kuò)展時,運(yùn)行環(huán)境也不需要再做任何事。它可以重用之前分配的空間。

棧是如何被復(fù)制的?

棧的復(fù)制聽起來很容易,但實(shí)際操作并非那么簡單。存儲在棧上的變量的地址可能已經(jīng)被使用到。也就是說程序使用到了一些指向棧的指針。當(dāng)移動棧的時候,所有指向棧里內(nèi)容的指針都會變得無效。幸運(yùn)的是,指向棧內(nèi)容的指針自身也必定是保存在棧上的。這是為了保證內(nèi)存安全的必要條件。否則一個程序就有可能訪問一段已經(jīng)無效的棧空間了。

因?yàn)槔厥盏男枰?,我們必須知道棧的哪些部分是被用作指針了。?dāng)我們移動棧的時候,我們可以更新棧里的指針讓它們指向新的地址。所有相關(guān)的指針都會被更新。我們使用了垃圾回收的信息來復(fù)制棧,但并不是任何使用棧的函數(shù)都有這些信息。因?yàn)楹艽笠徊糠诌\(yùn)行環(huán)境是用C語言寫的,很多被調(diào)用的運(yùn)行環(huán)境里的函數(shù)并沒有指針的信息,所以也就不能夠被復(fù)制了。當(dāng)遇到這種情況時,我們只能退回到分塊式的棧并支付相應(yīng)的開銷。(注:這部分信息有點(diǎn)過時了,但還是值得一讀!)

這也是為什么現(xiàn)在運(yùn)行環(huán)境的開發(fā)者正在用Go語言重寫運(yùn)行環(huán)境的大部分代碼。無法用Go語言重寫的部分(比如調(diào)度器的核心代碼和垃圾回收器)會在特殊的棧上運(yùn)行。這個特殊棧的大小由運(yùn)行環(huán)境的開發(fā)者設(shè)置。

這些改變除了使棧復(fù)制成為可能,它也允許我們在將來實(shí)現(xiàn)并行垃圾回收。

再說一下虛擬內(nèi)存

還有一種處理?xiàng)?臻g的辦法是分配很大一塊虛擬內(nèi)存。因?yàn)橹挥性趦?nèi)存地址被訪問到的時候才會真正分配物理內(nèi)存,似乎我們可以簡單地分配一塊很大的虛擬內(nèi)存然后讓操作系統(tǒng)來完成剩下的工作。但是這個方法有幾個問題。

首先,32位的系統(tǒng)只有4GB的虛擬內(nèi)存,而通常只有其中的3GB可以被應(yīng)用程序使用。創(chuàng)建上百萬的goroutine也不是不常見,這時你很可能會用完所有的虛擬內(nèi)存(即使我們假設(shè)棧只用到8KB的空間)。

其次,即使我們可以在64位的系統(tǒng)里分配大量的虛擬內(nèi)存,它依賴過量使用(overcommitting)內(nèi)存。過量使用是指我們分配比實(shí)際物理內(nèi)存空間更多的虛擬內(nèi)存,并且依賴操作系統(tǒng)來確保能夠分配到需要的物理內(nèi)存。但是過量使用虛擬內(nèi)存是存在一定風(fēng)險的。因?yàn)橐粋€進(jìn)程真的使用了比實(shí)際物理內(nèi)存更大的內(nèi)存空間時,它需要開始為新的需求騰出可用的物理空間。它通常會把一塊內(nèi)存里的內(nèi)容保存到磁盤上。這樣會導(dǎo)致延遲不可預(yù)測。因?yàn)檫@個原因,我們通常不在系統(tǒng)里過量使用內(nèi)存。

結(jié)束語

為了讓goroutine輕量化,快速,并且適用于大部分任務(wù),開發(fā)者們做了很多努力。棧的管理只是其中很小的一部分。如果你想了解更多關(guān)于棧復(fù)制的技術(shù),這份設(shè)計文檔提供了更多的細(xì)節(jié)。

如果你想了解更多關(guān)于重寫Go語言運(yùn)行環(huán)境的細(xì)節(jié),可以讀以下這個郵件列表里的文章。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容