Go语言的逃逸分析机制

阅读前请悉知:本文是一篇翻译文章,出于对原文的喜爱与敬畏,所以需要强调:如果读者英文阅读能力好,请直接移步文末原文链接;如果对这篇翻译所述知识感兴趣,也请一定要再看下英文原文,加深理解。翻译中为了表达的需要,加入了自己的一些理解,不过因为知识有限,翻译过程难免纰漏,如有问题,欢迎留言指正。

前言

在这个由四部分组成的系列的第一篇文章中,我使用了一个例子来介绍指针机制的基础知识,在这个例子中,一个值被共享到goroutine的栈中。我没有向你们展示的是当你在栈上共享一个值时会发生什么。要理解这一点,你需要了解另一个内存区域:。有了这些知识,你就可以开始学习逃逸分析了。

逃逸分析是编译器用来确定由程序创建的值所处位置的过程。具体来说,编译器执行静态代码分析,以确定是否可以将值放在构造函数的栈(帧)上,或者该值是否必须“逃逸”到堆上。在Go中,没有关键字或函数可以用于在此决策中指导编译器。只有通过你写的代码来分析这一点。

堆是除栈之外的第二个内存区域,用于存储值。堆不像栈那样是自清理的,因此使用这个内存的成本更大。首先,成本与垃圾收集器(GC)有关,垃圾收集器必须参与进来以保持该区域的清洁。当GC运行时,它将使用25%的可用CPU资源。此外,它可能会产生微秒级的“stop the world”延迟。拥有GC的好处是你不需要担心内存的管理问题,因为内存管理是相当复杂、也容易出错的。

堆上的值构成Go中的内存分配。这些分配对GC造成压力,因为堆中不再被指针引用的每个值都需要删除。需要检查和删除的值越多,GC每次运行时必须执行的工作就越多。因此,GC算法一直在努力在堆的大小分配和运行速度之间寻求平衡。

共享栈

在Go中,不允许goroutine拥有指向另一个goroutine栈上的内存的指针。这是因为当栈必须增长或收缩时,goroutine的栈内存可能被一个新的内存块替换。如果运行时必须跟踪指向其他goroutine栈的指针,那么管理起来就太困难了,而在这些上更新指针的“stop the world”延迟将会非常困难。

下面是一个由于增长而多次被替换的栈示例。查看第2行和第6行的输出。你将在main的栈(帧)中看到字符串值的地址更改了两次。(字符串s的内存地址本来应该是在main的帧内的,为何会发生这种变化呢?没搞懂)

// Sample program to show how stacks grow/change.
package main

// Number of elements to grow each stack frame.
// Run with 10 and then with 1024
const size = 1024

// main is the entry point for the application.
func main() {
    s := "HELLO"
    stackCopy(&s, 0, [size]int{})
}

// stackCopy recursively runs increasing the size
// of the stack.
func stackCopy(s *string, c int, a [size]int) {
    println(c, s, *s)

    c++
    if c == 10 {
        return
    }

    stackCopy(s, c, a)
}

输出

0 0x1044dfa0 HELLO
1 0x1044dfa0 HELLO
2 0x10455fa0 HELLO
3 0x10455fa0 HELLO
4 0x10455fa0 HELLO
5 0x10455fa0 HELLO
6 0x10465fa0 HELLO
7 0x10465fa0 HELLO
8 0x10465fa0 HELLO
9 0x10465fa0 HELLO

逃逸机制

在函数的栈(帧)之外共享一个值时,它将被放置(或分配)在堆上。逃逸分析算法的工作是找到这些情况,并在程序中保持一定的完整性。完整性在于确保对任何值的访问总是准确、一致和高效的。

Listing 1

01 package main
02
03 type user struct {
04     name  string
05     email string
06 }
07
08 func main() {
09     u1 := createUserV1()
10     u2 := createUserV2()
11
12     println("u1", &u1, "u2", &u2)
13 }
14
15 //go:noinline
16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }
25
26 //go:noinline
27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

我正在使用go:noinline指令,以防止编译器直接内联这些函数的代码。内联将删除函数调用并使这个示例复杂化。我将在下一篇文章中介绍内联的副作用。

在清单1中,你将看到一个具有两个不同函数的程序,它们创建用户值并将值返回给调用者。createUserV1在返回时使用了值语义。

Listing 2

16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }

我说过函数在返回时使用值语义,因为这个函数创建的用户值正在被复制并传递给调用栈。这意味着调用函数正在接收值本身的副本。

你可以看到在第17行到第20行执行了用户值的构造。然后在第23行,用户值的副本被传递到调用栈并返回给调用者。函数返回后,栈是这样的。

Figure 1

image.png

在图1中可以看到,在调用createUserV1之后,两个帧中都存在一个用户值。在函数的createUserV2中,在返回时使用指针语义。

Listing 3

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

我说过,函数在返回时使用指针语义,因为这个函数创建的用户值在调用栈中被共享。这意味着调用函数正在接收该值的地址副本。

你可以看到,在第28到31行中使用了相同的struct文字构造用户值,但是在第34行中返回的值不同。不是将用户值的副本传递回调用栈,而是传递用户值的地址副本。基于此,你可能认为调用之后栈是这样的。

Figure 2

image.png

如果你在图2中看到的真的发生了,那么你就会遇到完整性问题。指针向下指向不再有效的调用栈。在main的下一个函数调用中,所指向的内存将被重新构造并重新初始化。

这就是逃逸分析开始维护完整性的地方。在这种情况下,编译器将确定在createUserV2的栈桢内构造用户值是不安全的,因此它将在堆上构造值。这将由第28行初始化完成。

可读性

正如你在上一篇文章中学到的,在所属帧内,函数可以通过指针直接访问桢内的内存,但是访问帧外的内存需要间接访问。这意味着对转义到堆的值的访问也必须通过指针间接完成。

记住createUserV2的代码是什么样子的。

Listing 4

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

语法隐藏了代码中真正发生的事情。第28行声明的变量u表示user类型的值。Go中的构造不会告诉你一个值在内存中的位置,所以直到第34行上的return语句,你才知道这个值需要逃逸。这意味着,即使u表示的是user类型的值,访问这个user值也必须通过封面下面的指针进行。

你可以在函数调用之后将内存布局形象化。

Figure 3

image.png

createUserV2的栈(帧)上的u变量表示堆上的值,而不是栈上的值。这意味着使用u访问值,需要指针访问,而不是语法建议的直接访问。你可能会想,既然访问它所代表的值需要使用指针,那么为什么不让u成为指针呢?

Listing 5

27 func createUserV2() *user {
28     u := &user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", u)
34     return u
35 }

如果你这样做,会牺牲代码的�可读性。暂时离开整个函数,只关注返回值。

Listing 6

34     return u
35 }

这还告诉你什么?它说的是一个u的副本被传递到调用栈上。然而,当你使用&操作符时,返回告诉你什么?

Listing 7

34     return &u
35 }

多亏了&运算符,返回现在告诉因为u需要共享到调用栈中,由此逃逸到堆中。记住,指针是用于共享的,并在读取代码时替换“共享”一词的&操作符。这在可读性方面非常强大,这是你不想失去的。

下面是另一个例子,使用指针语义构造值会损害可读性。

Listing 8

01 var u *user
02 err := json.Unmarshal([]byte(r), &u)
03 return u, err

你必须与json共享指针变量。在第02行调用Unmarshal,让这段代码工作。json.Unmarshal调用将创建用户值并将其地址分配给指针变量。

这段代码说了什么:
01:创建一个类型为user的指针变量。
02:与json.Unmarshal函数共享u
03:给调用方返回u的副本。

user的值是否由json.Unmarshal函数创建并与调用者共享尚不清楚。

在构造过程中使用值语义时可读性如何变化?

Listing 9

01 var u user
02 err := json.Unmarshal([]byte(r), &u)
03 return &u, err

这段代码说了什么:
01:创建一个类型为user的指针变量。
02:与json.Unmarshal函数共享u
03:与调用方共享u

一切都很清楚。第02行将调用栈中的user值共享给json.Unmarshal函数以及第03行将user值从调用栈上共享给调用者。此共享将导致用户值转义。

在构造值时使用值语义,并利用&运算符的可读性来明确值是如何被共享的。

编译器报告

要查看编译器正在做出的决策,你可以要求编译器提供一个报告。你所需要做的就是在go build调用中使用带有-m选项的-gcflags开关。

实际上有4个级别的-m可以使用,但是超过2个级别的信息就会让人不知所措。我将使用-m的两个级别。

Listing 10

$ go build -gcflags "-m -m"
./main.go:16: cannot inline createUserV1: marked go:noinline
./main.go:27: cannot inline createUserV2: marked go:noinline
./main.go:8: cannot inline main: non-leaf function
./main.go:22: createUserV1 &u does not escape
./main.go:34: &u escapes to heap
./main.go:34:   from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape
./main.go:12: main &u1 does not escape
./main.go:12: main &u2 does not escape

你可以看到编译器正在报告逃逸情况。编译器在说什么?首先再次查看createUserV1createUserV2函数以供参考。

Listing 13

16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

从报告中的这一行开始。

Listing 14

./main.go:22: createUserV1 &u does not escape

这就是说,在createUserV1函数中对println的函数调用不会导致用户值转义到堆中。必须检查它,因为它正在与println函数共享。

接下来看看报告中的这些行。

Listing 15

./main.go:34: &u escapes to heap
./main.go:34:   from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape

这些行表示,与u变量关联的user值(它是命名类型user,在第31行分配)因为在第34行返回而转义。最后一行和前面一样,第33行上的println调用不会导致用户值转义。

阅读这些报告可能会让人感到困惑,并可能会根据所涉及的变量类型是基于命名类型还是基于文字类型而略有变化。

u更改为文字类型*user,而不是之前的命名类型user

Listing 16

27 func createUserV2() *user {
28     u := &user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", u)
34     return u
35 }

再回头看报告

Listing 17

./main.go:30: &user literal escapes to heap
./main.go:30:   from u (assigned) at ./main.go:28
./main.go:30:   from ~r0 (return) at ./main.go:34

现在,报告说,由于在第34行返回,由u变量引用的用户值正在转义,该变量是文本类型*user,在第28行赋值。

结论

一个值的构造并不决定它的位置。只有如何共享一个值才能决定编译器将如何处理该值。任何时候你在调用栈上共享一个值,它都会被转义。在下一篇文章中,你将探讨其他原因来解释值的转义。

这些帖子试图引导你为任何给定类型选择值或指针语义的指导原则。每种语义都有其优点和代价。值语义将值保存在栈上,从而减少对GC的压力。但是,任何给定值都有不同的副本,必须存储、跟踪和维护。指针语义将值放在堆上,这会对GC造成压力。但是,它们是高效的,因为只有一个值需要存储、跟踪和维护。关键是正确、一致和平衡地使用每个语义。


版权声明:

任何个人或机构如需转载本文,无须再获得作者书面授权,但是转载者必须保留作者署名,并注明出处。

作者保留对本文的修改权。他人未经作者许可,不得擅自修改,破坏作品的完整性。

作者保留对本文的其他各项著作权权利。

原文阅读:
Language Mechanics On Escape Analysis

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342

推荐阅读更多精彩内容

  • 阅读前请悉知:本文是一篇翻译文章,出于对原文的喜爱与敬畏,所以需要强调:如果读者英文阅读能力好,请直接移步文末原文...
    wu_sphinx阅读 942评论 0 0
  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,714评论 0 38
  • 一个项目的开始,首先要确定要做的项目是什么,还要经过市场调研,看这个项目是否有做的必要,以及自己做的项目的优势是什...
    eff7af6c2f06阅读 145评论 0 2
  • 误会我谈恋爱了 从同学她妈那儿听来的 呵呵 我和她都很久没有联系了好么 哈 下次这种无事生非的舆论八卦 还是一笑置...
    梦小飞阅读 138评论 0 0