前序(Prelude)
本系列文章總共四篇,主要幫助大家理解 Go 語言中一些語法結(jié)構(gòu)和其背后的設(shè)計原則,包括指針、棧、堆、逃逸分析和值/指針傳遞。這是第二篇,主要介紹堆和逃逸分析。
以下是本系列文章的索引:
- 「GCTT 出品」Go 語言機制之棧和指針
- Go 語言機制之逃逸分析
- Go 語言機制之內(nèi)存剖析
- Go 語言機制之數(shù)據(jù)和語法的設(shè)計哲學
介紹(Introduction)
在四部分系列的第一部分,我用一個將值共享給 goroutine 棧的例子介紹了指針結(jié)構(gòu)的基礎(chǔ)。而我沒有說的是值存在棧之上的情況。為了理解這個,你需要學習值存儲的另外一個位置:堆。有這個基礎(chǔ),就可以開始學習逃逸分析。
逃逸分析是編譯器用來決定你的程序中值的位置的過程。特別地,編譯器執(zhí)行靜態(tài)代碼分析,以確定一個構(gòu)造體的實例化值是否會逃逸到堆。在 Go 語言中,你沒有可用的關(guān)鍵字或者函數(shù),能夠直接讓編譯器做這個決定。只能夠通過你寫代碼的方式來作出這個決定。
堆(Heaps)
堆是內(nèi)存的第二區(qū)域,除了棧之外,用來存儲值的地方。堆無法像棧一樣能自清理,所以使用這部分內(nèi)存會造成很大的開銷(相比于使用棧)。重要的是,開銷跟 GC(垃圾收集),即被牽扯進來保證這部分區(qū)域干凈的程序,有很大的關(guān)系。當垃圾收集程序運行時,它會占用你的可用 CPU 容量的 25%。更有甚者,它會造成微秒級的 “stop the world” 的延時。擁有 GC 的好處是你可以不再關(guān)注堆內(nèi)存的管理,這部分很復雜,是歷史上容易出錯的地方。
在 Go 中,會將一部分值分配到堆上。這些分配給 GC 帶來了壓力,因為堆上沒有被指針索引的值都需要被刪除。越多需要被檢查和刪除的值,會給每次運行 GC 時帶來越多的工作。所以,分配算法不斷地工作,以平衡堆的大小和它運行的速度。
共享棧(Sharing Stacks)
在 Go 語言中,不允許 goroutine 中的指針指向另外一個 goroutine 的棧。這是因為當棧增長或者收縮時,goroutine 中的棧內(nèi)存會被一塊新的內(nèi)存替換。如果運行時需要追蹤指針指向其他的 goroutine 的棧,就會造成非常多需要管理的內(nèi)存,以至于更新指向那些棧的指針將使 “stop the world” 問題更嚴重。
這里有一個棧被替換好幾次的例子。看輸出的第 2 和第 6 行。你會看到 main 函數(shù)中的棧的字符串地址值改變了兩次。https://play.golang.org/p/pxn5u4EBSI
逃逸機制(Escape Mechanics)
任何時候,一個值被分享到函數(shù)棧幀范圍之外,它都會在堆上被重新分配。這是逃逸分析算法發(fā)現(xiàn)這些情況和管控這一層的工作。(內(nèi)存的)完整性在于確保對任何值的訪問始終是準確、一致和高效的。
通過查看這個語言機制了解逃逸分析。https://play.golang.org/p/Y_VZxYteKO
清單 1

我使用 go:noinline 指令,阻止在 main 函數(shù)中,編譯器使用內(nèi)聯(lián)代碼替代函數(shù)調(diào)用。內(nèi)聯(lián)(優(yōu)化)會使函數(shù)調(diào)用消失,并使例子復雜化。我將在下一篇博文介紹內(nèi)聯(lián)造成的副作用。
在表 1 中,你可以看到創(chuàng)建 user 值,并返回給調(diào)用者的兩個不同的函數(shù)。在函數(shù)版本 1 中,返回值。
清單 2

我說這個函數(shù)返回的是值是因為這個被函數(shù)創(chuàng)建的 user 值被拷貝并傳遞到調(diào)用棧上。這意味著調(diào)用函數(shù)接收到的是這個值的拷貝。
你可以看下第 17 行到 20 行 user 值被構(gòu)造的過程。然后在第 23 行,user 值的副本被傳遞到調(diào)用棧并返回給調(diào)用者。函數(shù)返回后,棧看起來如下所示。
圖 1

你可以看到圖 1 中,當調(diào)用完 createUserV1 ,一個 user 值同時存在(兩個函數(shù)的)棧幀中。在函數(shù)版本 2 中,返回指針。
清單 3

我說這個函數(shù)返回的是指針是因為這個被函數(shù)創(chuàng)建的 user 值通過調(diào)用棧被共享了。這意味著調(diào)用函數(shù)接收到一個值的地址拷貝。
你可以看到在第 28 行到 31 行使用相同的字段值來構(gòu)造 user 值,但在第 34 行返回時卻是不同的。不是將 user 值的副本傳遞到調(diào)用棧,而是將 user 值的地址傳遞到調(diào)用棧。基于此,你也許會認為棧在調(diào)用之后是這個樣子。
圖 2

如果看到的圖 2 真的發(fā)生的話,你將遇到一個問題。指針指向了棧下的無效地址空間。當 main 函數(shù)調(diào)用下一個函數(shù),指向的內(nèi)存將重新映射并將被重新初始化。
這就是逃逸分析將開始保持完整性的地方。在這種情況下,編譯器將檢查到,在 createUserV2 的(函數(shù))棧中構(gòu)造 user 值是不安全的,因此,替代地,會在堆中構(gòu)造(相應的)值。這(個分析并處理的過程)將在第 28 行構(gòu)造時立即發(fā)生。

可讀性(Readability)
在上一篇博文中,我們知道一個函數(shù)只能直接訪問它的(函數(shù)棧)空間,或者通過(函數(shù)棧空間內(nèi)的)指針,通過跳轉(zhuǎn)訪問(函數(shù)棧空間外的)外部內(nèi)存。這意味著訪問逃逸到堆上的值也需要通過指針跳轉(zhuǎn)。
記住 createUserV2 的代碼的樣子:
清單 4

語法隱藏了代碼中真正發(fā)生的事情。第 28 行聲明的變量 u 代表一個 user 類型的值。Go 代碼中的類型構(gòu)造不會告訴你值在內(nèi)存中的位置。所以直到第 34 行返回類型時,你才知道值需要逃逸(處理)。這意味著,雖然 u 代表類型 user 的一個值,但對該值的訪問必須通過指針進行。
你可以在函數(shù)調(diào)用之后,看到堆棧就像(圖 3)這樣。
圖 3

在 createUserV2 函數(shù)棧中,變量 u 代表的值存在于堆中,而不是棧。這意味著用 u 訪問值時,使用指針訪問而不是直接訪問。你可能想,為什么不讓 u 成為指針,畢竟訪問它代表的值需要使用指針?
清單 5

如果你這樣做,將使你的代碼缺乏重要的可讀性。(讓我們)離開整個函數(shù)一秒,只關(guān)注 return。
清單 6
34 return u 35 }
這個 return 告訴你什么了呢?它說明了返回 u 值的副本給調(diào)用棧。然而,當你使用 & 操作符,return 又告訴你什么了呢?
清單 7
34 return &u 35 }
多虧了 & 操作符,return 告訴你 u 被分享給調(diào)用者,因此,已經(jīng)逃逸到堆中。記住,當你讀代碼的時候,指針是為了共享,& 操作符對應單詞 "sharing"。這在提高可讀性的時候非常有用,這(也)是你不想失去的部分。
清單 8

為了讓其可以工作,你一定要通過共享指針變量(的方式)給(函數(shù)) json.Unmarshal。json.Unmarshal 調(diào)用時會創(chuàng)建 user 值并將其地址賦值給指針變量。https://play.golang.org/p/koI8EjpeIx
代碼解釋:
01:創(chuàng)建一個類型為 user,值為空的指針。
02:跟函數(shù) json.Unmarshal 函數(shù)共享指針。
03:返回 u 的副本給調(diào)用者。
這里并不是很好理解,user值被 json.Unmarshal 函數(shù)創(chuàng)建,并被共享給調(diào)用者。
如何在構(gòu)造過程中使用語法語義來改變可讀性?
清單 9

代碼解釋:
01:創(chuàng)建一個類型為 user,值為空的變量。
02:跟函數(shù) json.Unmarshal 函數(shù)共享 u。
03:跟調(diào)用者共享 u。
這里非常好理解。第 02 行共享 user 值到調(diào)用棧中的 json.Unmarshal,在第 03 行 user 值共享給調(diào)用者。這個共享過程將會導致 user 值逃逸。
在構(gòu)建一個值時,使用值語義,并利用 & 操作符的可讀性來明確值是如何被共享的。
編譯器報告(Compiler Reporting)
想查看編譯器(關(guān)于逃逸分析)的決定,你可以讓編譯器提供一份報告。你只需要在調(diào)用 go build 的時候,打開 -gcflags 開關(guān),并帶上 -m 選項。
實際上總共可以使用 4 個 -m,(但)超過 2 個級別的信息就已經(jīng)太多了。我將使用 2 個 -m 的級別。
清單 10

你可以看到編譯器報告是否需要逃逸處理的決定。編譯器都說了什么呢?請再看一下引用的 createUserV1 和 createUserV2 函數(shù)。
清單 13

從報告中的這一行開始。
清單 14
./main.go:22: createUserV1 &u does not escape
這是說在函數(shù) createUserV1 調(diào)用 println 不會造成 user 值逃逸到堆。這是必須檢查的,因為它將會跟函數(shù) println 共享(u)。
接下來看報告中的這幾行。
清單 15

這幾行是說,類型為 user,并在第 31 行被賦值的 u 的值,因為第 34 行的 return 逃逸。最后一行是說,跟之前一樣,在 33 行調(diào)用 println 不會造成 user 值逃逸。
閱讀這些報告可能讓人感到困惑,(編譯器)會根據(jù)所討論的變量的類型是基于值類型還是指針類型而略有變化。
將 u 改為指針類型的 *user,而不是之前的命名類型 user。
清單 16

再次生成報告。
清單 17

現(xiàn)在報告說在 28 行賦值的指針類型 *user,u 引用的 user 值,因為 34 行的 return 逃逸。
結(jié)論
值在構(gòu)建時并不能決定它將存在于哪里。只有當一個值被共享,編譯器才能決定如何處理這個值。當你在調(diào)用時,共享了棧上的一個值時,它就會逃逸。在下一篇中你將探索一個值逃逸的其他原因。
這些文章試圖引導你選擇給定類型的值或指針的指導原則。每種方式都有(對應的)好處和(額外的)開銷。保持在棧上的值,減少了 GC 的壓力。但是需要存儲,跟蹤和維護不同的副本。將值放在堆上的指針,會增加 GC 的壓力。然而,也有它的好處,只有一個值需要存儲,跟蹤和維護。(其實,)最關(guān)鍵的是如何保持正確地、一致地以及均衡(開銷)地使用。
via: https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html
作者:William Kennedy 譯者:gogeof 校對:polaris1119
期待該系列后續(xù)文章,歡迎關(guān)注我哦!