Go语言之内存篇

findstr · · 925 次点击 · · 开始浏览    

TL;DR:本文不讨论三色垃圾回收,不讨论读写屏障,不讨论内存分配策略。仅仅从内存视角抽象出一个简单的屏障。以便可以在写Go语言时,知道语言的边界,可以把之前C/C++的经验复用。 在[上一篇文章中](https://blog.gotocoding.com/archives/1767 ""),我提到了一个疑问,就是两个Slice分别引用一个Array的不同部分,GC是如何保证在Mark时,可以Mark到那个被引用的Array。 在这里,我陷入了一个很大的误区。 根据Lua和C#的经验,GC在Mark一个对象时,实际上是Mark一块内存,当这个内存被Mark之后,他就不会被释放。从malloc这个函数也很容易知道,释放一个内存块同样需要内存块的首地址。 这也是为什么很多带GC的语言都不允许做指针运算的原因。 我当时看过的Go语言书籍都说,Go语言虽然有指针,但是不允许做指针运算。 经验主义让我认为,GC系统的主流设计思想都差不多,无非就是算法的不同。 然后,我就有了一种Go语言的指针和C#的引用其实是一个东西的**错觉**。 然而,这种**错觉**无法解释[上一篇文章](https://blog.gotocoding.com/archives/1767 "")中有关Slice的GC问题。 事实上,由于潜意识的限制,我甚至忽略了一种更为普遍的情况。 来看一段代码(只是为了演示问题,因为这么做毫无道理): ```go func foo() *int { a := make([]int, 3) return &a[1] } ``` 是的,我甚至弄错了,Go语言的指针是真的指针这一事实。 Go不能做指针运算,指的是我们不能将一个指针加上或减去任意一个偏移量。 Go的指针可以是指向任意一块合法内存的地址。 以上面的代码为例。 当一个函数`bar`调用`foo`之后并持有这个int指针,即使Slice变量a被销毁,a所指向的Array也不会被回收。 那么我之前对Go的GC理解必然是错的。 几经辗转,终于在[《Go语言设计与实现》中的7.1节“内存分配器的实现原理”](https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%9C%B0%E5%9D%80%E7%A9%BA%E9%97%B4)找到线索。 Go的内存分配器在1.11版本前后实现是不一样的,[《Go语言设计与实现》](https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/#%E5%9C%B0%E5%9D%80%E7%A9%BA%E9%97%B4)花了大量笔墨来介绍1.11版本之后的实现细节。 两个版本对上层的抽象是一致的,但是1.11之后的版本稍嫌复杂了,1.11版之前的“线性分配器”版本,更能帮助我建立简单直观的印象。 于是,我找到[另一篇文章](https://www.sobyte.net/post/2022-04/golang-memory-allocation/),这篇文章详细介绍了"线性分配器"的设计思路。 在[这篇文章](https://www.sobyte.net/post/2022-04/golang-memory-allocation/)中,我们可以得到几个很重要的提示: - 内存分配的最小单位是Page - 分配出去的内存块是一个称之为mspan的结构,每一个mpan结构一定持整数个Page - 任意一个Page都会有与之对应mspan结构的指针,当一个mspan持有多个Page时,多个Page会有相同的mspan结构。 上面提示,已经足够解释前面所有的问题了。 由于每个Page都是同样大小,可以根据内存地址以O(1)的时间复杂度得到Page的索引。 再根据Page的索引,以O(1)的时间复杂度得到mspan的指针。 一个mspan内存块中,所有对象都占用同样大小的内存,使用spanClass来表示对象的大小(spanClass==0例外)。 这样,再根据从mspan得到的对象大小信息,算出指针指向对象的首地址在何处。 当我搞明白这种思路之后,简直都惊呆了。 Go语言通过将内存分配器和GC系统融合之后,提供了几乎90%的指针功能,此时我有点明白“云时代的C语言”这种说法了。 ----------- 在[上一篇文章中](https://blog.gotocoding.com/archives/1767 "")我炫技似的留下了一段关于接口相关的代码,如下: ```go package main import "fmt" type FooBar interface { foo() bar() } type st1 struct { FooBar n int } type st2 struct { FooBar m int } func (s *st1) foo() { fmt.Println("st1.foo", s.n) } func (s *st1) bar() { fmt.Println("st1.bar", s.n) } func (s *st2) foo() { fmt.Println("st2.foo", s.m) } func test(fb FooBar) { fb.foo() fb.bar() } func main() { v1 := &st1{n: 1} v3 := &st2{ m: 3, FooBar: v1, } test(v1) test(v3) } ``` 当时,由于Plan9汇编的阻碍,我对于底层的实现和机制没太明白,更没有明白这种用法的边界是什么。 最近终于有一个自洽的推测了。 是的,因为我目前为止依然看不太懂Plan9汇编,以下全是推测,只有部分佐证。 我先尝试使用C语言写出上面代码的等价代码。 ```c //a.c #include <stdio.h> #include <string.h> #include <stdlib.h> typedef void (*foo_t)(void *); typedef void (*bar_t)(void *); struct FooBarFn { foo_t foo; bar_t bar; }; struct FooBar { void *data; struct FooBarFn *itab; }; struct st1 { struct FooBar _foobar; int n; }; struct st2 { struct FooBar _foobar; int m; }; void st1_foo(struct st1 *s) { printf("st1.foo:%d\n", s->n); } void st1_bar(struct st1 *s) { printf("st1.bar:%d\n", s->n); } void st2_foo(struct st2 *s) { printf("st2.foo:%d\n", s->m); } void st2_bar(struct st2 *s) { s->_foobar.itab->bar(s->_foobar.data); } struct FooBar st1_interface(struct st1 *s) { struct FooBar i; i.data = (void *)s; i.itab = malloc(sizeof(struct FooBarFn)); i.itab->foo = (foo_t)st1_foo; i.itab->bar = (bar_t)st1_bar; return i; } struct FooBar st2_interface(struct st2 *s) { struct FooBar i; i.data = (void *)s; i.itab = malloc(sizeof(struct FooBarFn)); i.itab->foo = (foo_t)st2_foo; i.itab->bar = (bar_t)st2_bar; return i; } void test(struct FooBar bar) { bar.itab->foo(bar.data); bar.itab->bar(bar.data); } int main() { struct FooBar i1, i2; struct st1 *v1 = malloc(sizeof(*v1)); struct st2 *v3 = malloc(sizeof(*v3)); memset(v1, 0, sizeof(*v1)); memset(v3, 0, sizeof(*v3)); v1->n = 1; v3->m = 3; v3->_foobar = st1_interface(v1); i1 = st1_interface(v1); i2 = st2_interface(v3); test(i1); test(i2); return 0; } //gcc -o a a.c ``` 上面这段代码是可以被编译通过的,而且和各种Go语言书中披露的interface实现,非常接近,我几乎可以认定Go语言就是这么实现的。 这段代码主要想解释**“结构/接口内嵌”**,编译器到底做了什么,他的规则是什么,以便我可以更好的利用这种规则。 Go的整个嵌入结构其实非常酷炫,但是也难以理解。 但是如果按上面的C代码去分析,其实整个规则非常简单,只是两个语法糖而已。 先来单纯看struct的内存布局。 在C语言时代我们所有人都写过下面这种代码: ```c struct A { int f1; int f2; }; struct B { struct A a; int f3; }; void foo() { struct B b; b.a.f1 = 3; b.a.f2 = 4; b.f3 = 5; } ``` 对应的Go语言如下: ```go type A struct { f1 int f2 int } type B struct { A f3 int } type D struct { A a f3 int } func foo() { b := new(B) b.f1 = 3 b.f2 = 4 b.f3 = 5 d := new(D) d.a.f1 = 3 d.a.f2 = 4 d.f3 = 5 } ``` 可以看到,内嵌结构体的字段访问,其实就是个语法糖。 Go编译器在编译阶段, 会将结构B转换为结构D,再进行编译(注:这里是指源码级,由于是值嵌入,在编译时,可以直接算出地址偏移量,在汇编层面优化不优化都没有任何区别,如果是指针嵌入效果又不一样)。 下面让我们来证明一下这个结论: ```go package main import ( "fmt" "unsafe" ) type A struct { f1 int8 f2 int8 } type B struct { A f3 int8 } func (*A) foo() {} func main() { var a A var b B fmt.Println(unsafe.Sizeof(a)) fmt.Println(unsafe.Sizeof(b)) } ``` 上面的代码可以证明,关于struct结构布局并没有什么魔法,B结构的大小就是A结构的大小+int8的大小。 同理,`type B struct {*A}` 和`type B struct {a *A}`也并没有任何区别。 再来看函数,当一个B嵌入A时,他就有了A的所有函数, 如`foo`函数。 其实,这也是一个很甜的语法糖,甜到都像是魔法了。 当B嵌入了A之后,他会帮B生成一套A的所有函数,这样B就有了自己的foo函数。 而B.foo函数的函数体,其实只干一件事,就是再调用A.foo函数。 之所以会这样,是因为调用A.foo时,需要传入A对象的内存地址。 这一切都是优化前的思路。 如果你直接去反汇编,可能会得到不同的结论。 为了少生成一条call指令,编译器通常会在调用B.foo时,直接生成B.A.foo代码。 但是我们可以通过println来找到蛛丝马迹。 ```go func main() { fA := (*A).foo fB := (*B).foo println(fA) println(fB) } ``` 至此,Go语言的所有内存布局相关的细节,我们基本上都和C语言对上了。 ps. 有人说研究这些没有用。但是不搞清语言的边界,怎么才能发挥出一个语言的最大威力呢 ^_^!

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

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

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