美文网首页Go语言实践Go
go-内存机制(1)

go-内存机制(1)

作者: GGBond_8488 | 来源:发表于2020-03-27 16:40 被阅读0次

    逃逸分析

    堆与栈

    在go语言中,变量可以存储在栈或者堆之上。如果变量存储在栈之上,那么当这个栈被清理时,对应的栈内的变量也随之清理。如果变量存储在堆上,那么就需要GC来清理这个变量

    逃逸机制

    任何时候,一个值被分享到函数栈帧范围之外,它都会在堆上被重新分配。这是逃逸分析算法发现这些情况和管控这一层的工作。(内存的)完整性在于确保对任何值的访问始终是准确、一致和高效的。

    package main
    
    type user struct {
        name  string
        email string
    }
    
    func main() {
        u1 := createUserV1()
        u2 := createUserV2()
    
        println("u1", &u1, "u2", &u2)
    }
    
    func createUserV1() user {
        u := user{
            name:  "Bill",
            email: "bill@ardanlabs.com",
        }
    
        println("V1", &u)
        return u
    }
    
    func createUserV2() *user {
        u := user{
            name:  "Bill",
            email: "bill@ardanlabs.com",
        }
    
        println("V2", &u)
        return &u
    }
    

    上面这一段程序可以看到创建 user 值,并返回给调用者的两个不同的函数。在v1中返回值。

    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 }
    

    这个函数返回的是值是因为这个被函数创建的 user 值被拷贝并传递到调用栈上。这意味着调用函数接收到的是这个值的拷贝。

    你可以看下第 17 行到 20 行 user 值被构造的过程。然后在第 23 行,user 值的副本被传递到调用栈并返回给调用者。函数返回后,栈看起来如下所示:


    图1

    可以看到图 1 中,当调用完 createUserV1 ,一个 user 值同时存在(两个函数的)栈帧中。而在v2 中,返回得时user的指针。

    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 }
    

    这个函数返回的是指针是因为这个被函数创建的 user 值通过调用栈被共享了。这意味着调用函数接收到一个值的地址拷贝。(所以go语言中不存在引用传递,slice,map,channel等等都是因为内部带有共享底层数据结构的指针)

    你可以看到在第 28 行到 31 行使用相同的字段值来构造 user 值,但在第 34 行返回时却是不同的。不是将 user 值的副本传递到调用栈,而是将 user 值的地址传递到调用栈。基于此,你也许会认为栈在调用之后是这个样子。


    图2

    如果看到的图 2 真的发生的话,你将遇到一个问题。指针指向了栈下的无效地址空间。当 main 函数调用下一个函数,指向的内存将重新映射并将被重新初始化。

    这就是逃逸分析将开始保持完整性的地方。在这种情况下,编译器将检查到,在 createUserV2 的(函数)栈中构造 user 值是不安全的,因此,替代地,会在堆中构造相应的值。这个分析并处理的过程将在第 28 行构造时立即发生。

    我们知道一个函数只能直接访问它的(函数栈)空间,或者通过(函数栈空间内的)指针,通过跳转访问(函数栈空间外的)外部内存。这意味着访问逃逸到堆上的值也需要通过指针跳转。

    记住 createUserV2 的代码的样子:

    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 行返回类型时,你才知道值需要逃逸(处理)。这意味着,虽然 u 代表类型 user 的一个值,但对该值的访问必须通过指针进行。

    你可以在函数调用之后,看到堆栈就像(图 3)这样。


    图3

    在 createUserV2 函数栈中,变量 u 代表的值存在于堆中,而不是栈。这意味着用 u 访问值时,使用指针访问而不是直接访问。
    为什么不让 u 成为指针,毕竟访问它代表的值需要使用指针?

    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 }
    

    retuen u告诉你什么了呢?它说明了返回 u 值的副本给调用栈。然而,当你使用 & 操作符,return 又告诉你什么了呢?
    return &u多亏了 & 操作符,return 告诉你 u 被分享给调用者,因此,已经逃逸到堆中。记住,当你读代码的时候,指针是为了共享,& 操作符对应单词 "sharing"。这在提高可读性的时候非常有用。

    编译器逃逸分析

    想查看编译器(关于逃逸分析)的决定,你可以让编译器提供一份报告。你只需要在调用 go build 的时候,打开 -gcflags 开关,并带上 -m 选项。

    实际上总共可以使用 4 个 -m,(但)超过 2 个级别的信息就已经太多了。我将使用 2 个 -m 的级别。

    D:\GoProject\GoStudy\mem>go build -gcflags "-m -m"
    # _/D_/GoProject/GoStudy/mem
    .\demo1.go:15:6: can inline createUserV1 as: func() user { u := user literal; println("V1", &u); return u }
    .\demo1.go:25:6: can inline createUserV2 as: func() *user { u := user literal; println("V2", &u); return &u }
    .\demo1.go:8:6: can inline main as: func() { u1 := createUserV1(); u2 := createUserV2(); println("u1", &u1, "u2", &u2) }
    .\demo1.go:9:20: inlining call to createUserV1 func() user { u := user literal; println("V1", &u); return u }
    .\demo1.go:10:20: inlining call to createUserV2 func() *user { u := user literal; println("V2", &u); return &u }
    .\demo1.go:26:2: u escapes to heap:
    .\demo1.go:26:2:   flow: ~r0 = &u:
    .\demo1.go:26:2:     from &u (address-of) at .\demo1.go:32:9
    .\demo1.go:26:2:     from return &u (return) at .\demo1.go:32:2
    .\demo1.go:26:2: moved to heap: u
    
    .\demo1.go:26:2: u escapes to heap:
    .\demo1.go:26:2:   flow: ~r0 = &u:
    .\demo1.go:26:2:     from &u (address-of) at .\demo1.go:32:9
    .\demo1.go:26:2:     from return &u (return) at .\demo1.go:32:2
    .\demo1.go:26:2: moved to heap: u
    

    这几行是说,类型为 user,并在第 26行被赋值的 u 的值,因为第 32 行的 return 逃逸。

    总结

    值在构建时并不能决定它将存在于哪里。只有当一个值被共享,编译器才能决定如何处理这个值。当你在调用时,共享了栈上的一个值时,它就会逃逸。

    每种方式都有(对应的)好处和(额外的)开销。保持在栈上的值,减少了 GC 的压力。但是需要存储,跟踪和维护不同的副本。将值放在堆上的指针,会增加 GC 的压力。然而,也有它的好处,只有一个值需要存储,跟踪和维护。(其实,)最关键的是如何保持正确地、一致地以及均衡(开销)地使用。

    附:Go中常见的逃逸类型
    指针逃逸
    典型的逃逸case,函数返回局部变量的指针。
    栈空间不足逃逸
    当对象大小超过的栈帧大小时,变量对象发生逃逸被分配到堆上。
    闭包引用逃逸
    动态类型逃逸
    当对象不确定大小或者被作为不确定大小的参数时发生逃逸。(常见于interface,动态分配slice大小)
    切片或map赋值
    在给切片或者map赋值对象指针(与对象共享内存地址时),对象会逃逸到堆上。但赋值对象值或者返回对象值切片是不会发生逃逸的。

    参考:

    Go 语言机制之栈与指针
    Go 语言机制之逃逸分析
    Go 语言机制之内存剖析

    相关文章

      网友评论

        本文标题:go-内存机制(1)

        本文链接:https://www.haomeiwen.com/subject/vuoluhtx.html