5

Go语言之内存篇 - Go语言中文网 - Golang中文社区

 1 year ago
source link: https://studygolang.com/articles/35932
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Go语言之内存篇

findstr · 大约6小时之前 · 109 次点击 · 预计阅读时间 7 分钟 · 大约8小时之前 开始浏览    

TL;DR:本文不讨论三色垃圾回收,不讨论读写屏障,不讨论内存分配策略。仅仅从内存视角抽象出一个简单的屏障。以便可以在写Go语言时,知道语言的边界,可以把之前C/C++的经验复用。

上一篇文章中,我提到了一个疑问,就是两个Slice分别引用一个Array的不同部分,GC是如何保证在Mark时,可以Mark到那个被引用的Array。

在这里,我陷入了一个很大的误区。

根据Lua和C#的经验,GC在Mark一个对象时,实际上是Mark一块内存,当这个内存被Mark之后,他就不会被释放。从malloc这个函数也很容易知道,释放一个内存块同样需要内存块的首地址。

这也是为什么很多带GC的语言都不允许做指针运算的原因。

我当时看过的Go语言书籍都说,Go语言虽然有指针,但是不允许做指针运算。

经验主义让我认为,GC系统的主流设计思想都差不多,无非就是算法的不同。

然后,我就有了一种Go语言的指针和C#的引用其实是一个东西的错觉

然而,这种错觉无法解释上一篇文章中有关Slice的GC问题。

事实上,由于潜意识的限制,我甚至忽略了一种更为普遍的情况。

来看一段代码(只是为了演示问题,因为这么做毫无道理):

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节“内存分配器的实现原理”找到线索。

Go的内存分配器在1.11版本前后实现是不一样的,《Go语言设计与实现》花了大量笔墨来介绍1.11版本之后的实现细节。

两个版本对上层的抽象是一致的,但是1.11之后的版本稍嫌复杂了,1.11版之前的“线性分配器”版本,更能帮助我建立简单直观的印象。

于是,我找到另一篇文章,这篇文章详细介绍了"线性分配器"的设计思路。

这篇文章中,我们可以得到几个很重要的提示:

  • 内存分配的最小单位是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语言”这种说法了。


上一篇文章中我炫技似的留下了一段关于接口相关的代码,如下:

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语言写出上面代码的等价代码。

//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语言时代我们所有人都写过下面这种代码:

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语言如下:

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,再进行编译(注:这里是指源码级,由于是值嵌入,在编译时,可以直接算出地址偏移量,在汇编层面优化不优化都没有任何区别,如果是指针嵌入效果又不一样)。

下面让我们来证明一下这个结论:

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来找到蛛丝马迹。

func main() {
    fA := (*A).foo
    fB := (*B).foo
    println(fA)
    println(fB)
}

至此,Go语言的所有内存布局相关的细节,我们基本上都和C语言对上了。

ps. 有人说研究这些没有用。但是不搞清语言的边界,怎么才能发挥出一个语言的最大威力呢 ^_^!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK