『每週譯 Go』Go 如何做逃逸分析

原文地址:http://www.agardner.me/golang/garbage/collection/gc/escape/analysis/2015/10/18/go-escape-analysis.html

原文作者:

本文永久鏈接:https://github.com/gocn/translator/blob/master/2021/w15_golang_escape_analysis.md

譯者:cuua

校對:gocn

垃圾回收是 Go - 自動內存管理的一個便利功能, 使代碼更整潔,內存泄漏的可能性更小。但是,GC 還會增加間接性能消耗,因爲程序需要定期停止並收集未使用的對象。Go 編譯器足夠智能,可以自動決定是否應在堆上分配變量,之後需要在堆上收集垃圾,或者是否可以將其分配爲該變量的函數的棧的一部分。棧與堆分配變量不同,棧分配變量不會產生任何 GC 開銷,因爲它們在棧的其餘部分(當功能返回時)被銷燬。

例如,Go 的逃生分析比 HotSpot JVM 更基本。基本規則是,如果從申報的函數返回對變量的引用,則會 "逃逸" - 函數返回後可以引用該變量,因此必須將其堆分配。這是比較複雜的,因爲:

爲了執行逃生分析,Go 在編譯時構建一個函數調用圖,並跟蹤輸入參數和返回值的流。函數可能引用其中一個參數,但如果該引用未返回,變量不會逃逸。函數也可以返回引用,但在申明變量返回的函數之前,該引用可能由棧中的另一個函數取消引用或未返回。爲了說明一些簡單的案例,我們可以運行編譯器,這將打印詳細的逃生分析信息:-gcflags '-m'

package main

type S struct {}

func main() {
  var x S
  _ = identity(x)
}

func identity(x S) S {
  return x
}

你必須用 go run -gcflags '-m -l' '-l'標籤阻止功能被內聯 (這是另一個時間的主題) 來構建這個功能。輸出是:什麼都沒有!Go 使用值傳遞,因此始終將變量複製到棧中。在沒有引用的一般代碼中,總是很少使用棧分配。沒有逃生分析可做。再看下面一個例子:

package main

type S struct {}

func main() {
  var x S
  y := &x
  _ = *identity(y)
}

func identity(z *S) *S {
  return z
}

輸出:

$ go run -gcflags '-m -l' main.go
# command-line-arguments
.\main.go:11:15: leaking param: z to result ~r1 level=0

第一行顯示變量 "流過":輸入變量返回爲輸出。但不採取參考,所以變量不會逃逸。不在 main 返回之後沒有對 x 的引用存在,因此 x 分配在 main 的堆上。第三個實驗:

package main

type S struct {}

func main() {
  var x S
  _ = *ref(x)
}

func ref(z S) *S {
  return &z
}

輸出:

$ go run -gcflags '-m -l' main.go
# command-line-arguments
.\main.go:10:10: moved to heap: z

現在有一些逃避正在發生。請記住,go 是值傳遞,所以 z 是 main 中 x 變量的副本。返回 z 的引用,所以 z 不能是棧的一部分 - 返回時的參考點在哪裏?取而代之的是它逃到堆。儘管 Go 在不取消計算參考值的情況下會立即扔掉引用,但 Go 的逃逸分析不夠精密,無法找出這一點 - 它只查看輸入和返回變量的流。值得注意的是,在這種情況下,如果我們不阻止它,編譯器就會強調這一點。

如果將引用分配給結構成員,該怎麼辦?

package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(i)
}

func refStruct(y int) (z S) {
  z.M = &y
  return z
}

輸出:

$ go run -gcflags '-m -l' main.go
# command-line-arguments
.\main.go:13:16: moved to heap: y

在這種情況下,Go 仍然可以跟蹤引用流,即使引用是結構體的成員。既然 refStruct 做了引用並返回它,y 就必須逃逸。與本案例相比:

package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(&i)
}

func refStruct(y *int) (z S) {
  z.M = y
  return z
}

輸出:

$ go run -gcflags '-m -l' main.go
# command-line-arguments
.\main.go:13:16: leaking param: y to result z level=0

由於 main 做了引用並傳遞 refStruct,引用永遠不會超過申報引用變量的棧。這和前面的程序有稍微不同的語義,但如果第二個程序足夠的話,它會更有效率:在第一個例子 i 必須分配在 main 的棧上,然後在堆上重新分配並將其複製爲 refStruct 的參數。在第二個示例中 i 只分配一次,並傳遞引用。

一個更深入的例子:

package main

type S struct {
  M *int
}

func main() {
  var x S
  var i int
  ref(&i, &x)
}

func ref(y *int, z *S) {
  z.M = y
}

輸出:

$ go run -gcflags '-m -l' main.go
# command-line-arguments
.\main.go:14:10: leaking param: y
.\main.go:14:18: z does not escape
.\main.go:10:6: moved to heap: i

這裏的問題是 y 是分配給輸入結構體的成員。Go 無法跟蹤該關係 - 輸入僅允許流到輸出 - 因此逃逸分析失敗,必須對變量進行堆分配。有許多有據可查的案例(as of Go 1.5),由於 go 逃逸分析的限制,必須堆分配變量 - 請參閱此鏈接 (https://docs.google.com/document/d/1CxgUBPlx9iJzkz9JWkb6tIpTe5q32QDmz8l0BouG0Cw/preview) 。

最後,maps 和切片呢?請記住,maps 和切片實際上只是使用指針構建到堆分配的內存:切片結構暴露在包中(SliceHeader : https://golang.org/pkg/reflect/#SliceHeader)中。map 結構是更難找到的,但它存在:hmap 。如果這些結構無法逃逸,它們將被棧分配,但備份數組或哈希存儲桶中的數據本身將每次都堆分配。避免這種情況的唯一方法是分配一個固定大小的數組(如 [10000]int)。

如果您已經看過分析程序的堆使用情況 ,並且需要減少 GC 時間,則可能會從堆中移動頻繁分配的變量而獲得一些收穫。這也只是一個引人入勝的話題:要進一步閱讀 HotSpot JVM 如何處理逃逸分析,請查看這篇文章 (https://www.cc.gatech.edu/~harrold/6340/cs6340_fall2009/Readings/choi99escape.pdf) ,其中涉及堆棧分配,以及檢測何時可以消除同步。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/V-gyEohnGoKJmKCHCmIoIw