以C视角来理解Go内存逃逸

音風の部屋 · · 1816 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

前言

相信很多有过 clang 开发经验的 gopher 都会很奇怪,go 里面到处都充斥着看似静态分配的局部变量却还能通过指针传递正常游走于各种函数的返回之间,这要在 clang 里面就是个教科书般典型的指针错误使用范例,这到 golang 这边就成了语言特性之一还被开发团队所推崇,这是为什么?

我们都知道在 clang 中所有静态内存的分配都是在 stack 上进行,函数体在执行结束出栈后所有在栈上分配的内存都将得到释放,如果此时直接返回当前作用域变量的指针,这在下层函数的寻址行为就会因为出栈的内存释放而造成空指针异常。这个时候我们就得需要用 malloc 在 heap 动态分配内存,自己管理内存的生命周期,自己手动释放才是安全保险 memory safety 的。

escape analysis 的存在让 go 完美规避了这些问题,编译器在编译时聪明的对代码做了分析,当发现当前作用域的变量没有跑出函数范围,则会自动分配在 stack 上,反之则分配在 heap 上。这种骚操作既模糊了堆栈的使用边界,直观上的表现就是在代码层面上完全不需要关心内存分配的走向,把底层要考虑的问题交给编译器,同时也减小了 gc 回收的压力。

C

实践出真理,以下代码片段就很好的说明了这个问题,看代码。


#include <stdio.h>
#include <unistd.h>

void f(int **q){
    int p = 666;
    *q = &p;
}
int main(void){
    int *p;
    printf("p=%p\n", p);           // Output: p=0x7fffe37d4360
    f(&p);
    sleep(1);
    printf("p=%p *p=%d\n", p, *p); // Output: p=0x7fffe37d4254 *p=0
    return 0;
}

f() 修改了 *p 所指向的物理内存地址为 0x7fffe37d4254 ,这个地址是在 stack 上分配的,当 f() 执行结束,OS 回收了 f() 分配出去所有栈内存,导致下方的 printf() 对一个不存在内存地址取值,从而得出的结果当然只有 0,这里我故意 sleep() 一秒留给 gc 回收内存,不然下方的读取快于回收速度时将看不出差别。

要解决这一问题,很简单只要手动在堆上 malloc 动态分配一块内存区域,讲 *p 指向它,只要 OS 不回收那我们的目的也达到了。


#include <stdio.h>
#include <malloc.h>
#include <unistd.h>

void f(int **q){
    *q = (int*)malloc(sizeof(int));
    **q = 666;
}
int main(void){
    int *p;
    printf("p=%p\n", p);           // Output: p=0x7ffff69a29d0
    f(&p);
    sleep(1);
    printf("p=%p *p=%d\n", p, *p); // Output: p=0x123a220 *p=666
    return 0;
}

以上可以看到在通过 malloc 动态分配后 *p 指向的内存地址有了很大的变化,这就是堆内存的地址,最后的输出结果也符合我们的预期。

Go

得益于逃逸检测的存在,在 golang 中我们什么都不需要关心,编译器自动为我们做好了这一切事情。

注意一件事: 之前听到不少有关 new(struct{}) 和 &struct{}{} 两者在内存分配上的缪误,这两种方式都会进行内存逃逸检测,内部处理是一模一样的,不存在 new(struct{}) 在堆内存上分配,&struct{}{} 在栈内存上分配这一说法。


package main

import (
	"fmt"
	"time"
)

func f(q **int) {
	var p = 10
	*q = &p
}
func main() {
	var p *int
	fmt.Printf("p=%p\n", p)           // Output: p=0x0
	f(&p)
	time.Sleep(1 * time.Second)
	fmt.Printf("p=%p *p=%d\n", p, *p) // Output: p=0xc42000e248 *p=666
	return
}

只要在编译的时候加上 -gcflags '-m' ,你就能见证这整一个过程。最后的返回结果同 C 一模一样,还是不放心?直接上汇编!


	0x0000 00000 (main.go:7)	TEXT	"".f(SB), $24-8
	0x0000 00000 (main.go:7)	MOVQ	(TLS), CX
	0x0009 00009 (main.go:7)	CMPQ	SP, 16(CX)
	0x000d 00013 (main.go:7)	JLS	103
	0x000f 00015 (main.go:7)	SUBQ	$24, SP
	0x0013 00019 (main.go:7)	MOVQ	BP, 16(SP)
	0x0018 00024 (main.go:7)	LEAQ	16(SP), BP
	0x001d 00029 (main.go:7)	FUNCDATA	$0, gclocals·1a65e721a2ccc325b382662e7ffee780(SB)
	0x001d 00029 (main.go:7)	FUNCDATA	$1, gclocals·69c1753bd5f81501d95132d08af04464(SB)
	0x001d 00029 (main.go:8)	LEAQ	type.int(SB), AX
	0x0024 00036 (main.go:8)	MOVQ	AX, (SP)
	0x0028 00040 (main.go:8)	PCDATA	$0, $0
	0x0028 00040 (main.go:8)	CALL	runtime.newobject(SB)

汇编本身不具备直接分配动态内存的能力,一般都是通过 Call 系统 API 来实现,在这里 runtime.newobject(SB) 调用了运行库 newobject 方法实现了动态内存的分配。

我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=209jzb1hnk4ks


有疑问加站长微信联系(非本文作者)

本文来自:音風の部屋

感谢作者:音風の部屋

查看原文:以C视角来理解Go内存逃逸

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1816 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传