Go语言之闭包篇

在有GC和闭包实现的语言中,我最熟悉的是Lua语言。所以在使用Go语言时,碰到不熟悉的细节,总是会以Lua的机制来对比。

然而由于动态语言和静态语言的区别(静态语言总是有更多优化的机制), 以至于很多时候会得出错误的结论。

比如下面代码:

package main
import "os"
func exist(list []int, f func(n int)bool) bool {
    for _, n := range list {
        if f(n) == true {
            return true
        }
    }
    return false
}
func main() {
    count := len(os.Args)
    a := make([]int, 0)
    for i := 0; i < count; i++ {
        a = append(a, i)
    }
    exist(a, func(n int) bool {
        return n == (count - 1)
    })
}

这段代码定义了一个闭包,然后作为参数传给exist函数。

按照Lua的经验,定义闭包肯定是需要malloc内存。然而Go语言反手就教我做人。

使用go run -gcflags="-m -l" a.go可以发现,这个闭包并没有被分配在堆上。

再使用go tool compile -N -l -S a.go来看一下与闭包相关的Plan9汇编代码。

"".exist STEXT size=234 args=0x28 locals=0x58
    ................
    0x0085 00133 (a.go:5)   MOVQ    "".f+120(SP), DX
    0x008a 00138 (a.go:5)   MOVQ    AX, (SP)
    0x008e 00142 (a.go:5)   MOVQ    (DX), AX
    0x0091 00145 (a.go:5)   PCDATA  $1, $1
    0x0091 00145 (a.go:5)   CALL    AX
    0x0093 00147 (a.go:5)   MOVBLZX 8(SP), AX
    0x0098 00152 (a.go:5)   MOVB    AL, ""..autotmp_5+23(SP)
    0x009c 00156 (a.go:5)   NOP
    0x00a0 00160 (a.go:5)   TESTB   AL, AL
    0x00a2 00162 (a.go:5)   JNE     166
    0x00a4 00164 (a.go:5)   JMP     184
    0x00a6 00166 (a.go:6)   MOVB    $1, "".~r2+128(SP)
    0x00ae 00174 (a.go:6)   MOVQ    80(SP), BP
    0x00b3 00179 (a.go:6)   ADDQ    $88, SP
    0x00b7 00183 (a.go:6)   RET
    0x00b8 00184 (a.go:5)   PCDATA  $1, $-1
    0x00b8 00184 (a.go:5)   JMP     186
    0x00ba 00186 (a.go:5)   JMP     188

"".main STEXT size=372 args=0x0 locals=0x90
    ................
    0x00ff 00255 (a.go:17)  XORPS   X0, X0
    0x0102 00258 (a.go:17)  MOVUPS  X0, ""..autotmp_4+88(SP)
    0x0107 00263 (a.go:17)  LEAQ    ""..autotmp_4+88(SP), AX
    0x010c 00268 (a.go:17)  MOVQ    AX, ""..autotmp_6+104(SP)
    0x0111 00273 (a.go:17)  TESTB   AL, (AX)
    0x0113 00275 (a.go:17)  LEAQ    "".main.func1(SB), CX
    0x011a 00282 (a.go:17)  MOVQ    CX, ""..autotmp_4+88(SP)
    0x011f 00287 (a.go:17)  TESTB   AL, (AX)
    0x0121 00289 (a.go:17)  MOVQ    "".count+72(SP), AX
    0x0126 00294 (a.go:17)  MOVQ    AX, ""..autotmp_4+96(SP)
    0x012b 00299 (a.go:17)  MOVQ    "".a+112(SP), AX
    0x0130 00304 (a.go:17)  MOVQ    "".a+120(SP), CX
    0x0135 00309 (a.go:17)  MOVQ    "".a+128(SP), DX
    0x013d 00317 (a.go:17)  MOVQ    AX, (SP)
    0x0141 00321 (a.go:17)  MOVQ    CX, 8(SP)
    0x0146 00326 (a.go:17)  MOVQ    DX, 16(SP)
    0x014b 00331 (a.go:17)  MOVQ    ""..autotmp_6+104(SP), AX
    0x0150 00336 (a.go:17)  MOVQ    AX, 24(SP)
    0x0155 00341 (a.go:17)  CALL    "".exist(SB)

"".main.func1 STEXT nosplit size=54 args=0x10 locals=0x10
    0x0000 00000 (a.go:17)  TEXT    "".main.func1(SB), NOSPLIT|NEEDCTXT|ABIInternal, $16-16
    0x0000 00000 (a.go:17)  SUBQ    $16, SP
    0x0004 00004 (a.go:17)  MOVQ    BP, 8(SP)
    0x0009 00009 (a.go:17)  LEAQ    8(SP), BP
    0x000e 00014 (a.go:17)  FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    0x000e 00014 (a.go:17)  FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    0x000e 00014 (a.go:17)  MOVQ    8(DX), AX
    0x0012 00018 (a.go:17)  MOVQ    AX, "".count(SP)
    0x0016 00022 (a.go:17)  MOVB    $0, "".~r1+32(SP)
    0x001b 00027 (a.go:18)  MOVQ    "".count(SP), AX
    0x001f 00031 (a.go:18)  DECQ    AX
    0x0022 00034 (a.go:18)  CMPQ    "".n+24(SP), AX
    0x0027 00039 (a.go:18)  SETEQ   "".~r1+32(SP)
    0x002c 00044 (a.go:18)  MOVQ    8(SP), BP
    0x0031 00049 (a.go:18)  ADDQ    $16, SP
    0x0035 00053 (a.go:18)  RET

上面的代码并不算太复杂,我们大致可以翻译出他的等价Go语言(翻译出来的代码是可以被编译运行的)。

package main
import "os"
type Closure1 struct {
    F func(int) bool
    n int
}

var DX *Closure1

func func1(n int) bool {
    x := DX.n - 1
    return x == n
}
func exist(list []int, f *Closure1) bool {
    for _, n := range list {
        DX = f
        if f.F(n) == true {
            return true
        }
    }
    return false
}
func main() {
    count := len(os.Args)
    a := make([]int, 0)
    for i := 0; i < count; i++ {
        a = append(a, i)
    }
    c := &Closure1{
        F: func1,
        n: count,
    }
    exist(a, c)
}

从上面的Go代码可以很清楚的看到,其实一个闭包到底分配不分配内存,关键就在于Closure1在栈上还是在堆上。

当Closure1结构暴露出来之后,一切都是那么的显然。

即然闭包是一个struct对象,那么Go当然可以和一般的自定义struct一样进行逃逸分析,而根据逃逸规则,这里的c对象显然不需要逃逸。

一切都很完美,只是还有一个问题没有解决。

exist在调用f函数时,是如何区分调用的是闭包还是非闭包,比如下面代码:

package main
import "os"
func exist(list []int, f func(n int)bool) bool {
        for _, n := range list {
                if f(n) == true {
                        return true
                }
        }
        return false
}
func foo(n int) bool {
        return n == 3
}
func main() {
        count := len(os.Args)
        a := make([]int, 0)
        for i := 0; i < count; i++ {
                a = append(a, i)
        }
        exist(a, foo)
}

再来看一下对应的汇编代码:

"".exist STEXT size=234 args=0x28 locals=0x58
    .......
    0x0085 00133 (a.go:5)   MOVQ    "".f+120(SP), DX
    0x008a 00138 (a.go:5)   MOVQ    AX, (SP)
    0x008e 00142 (a.go:5)   MOVQ    (DX), AX
    0x0091 00145 (a.go:5)   PCDATA  $1, $1
    0x0091 00145 (a.go:5)   CALL    AX
    0x0093 00147 (a.go:5)   MOVBLZX 8(SP), AX
    0x0098 00152 (a.go:5)   MOVB    AL, ""..autotmp_5+23(SP)
    0x009c 00156 (a.go:5)   NOP
    0x00a0 00160 (a.go:5)   TESTB   AL, AL
    0x00a2 00162 (a.go:5)   JNE     166
    0x00a4 00164 (a.go:5)   JMP     184
    .......

"".main STEXT size=300 args=0x0 locals=0x78
    0x00ea 00234 (a.go:20)  MOVQ    "".a+88(SP), AX
    0x00ef 00239 (a.go:20)  MOVQ    "".a+96(SP), CX
    0x00f4 00244 (a.go:20)  MOVQ    "".a+104(SP), DX
    0x00f9 00249 (a.go:20)  MOVQ    AX, (SP)
    0x00fd 00253 (a.go:20)  MOVQ    CX, 8(SP)
    0x0102 00258 (a.go:20)  MOVQ    DX, 16(SP)
    0x0107 00263 (a.go:20)  LEAQ    "".foo·f(SB), AX
    0x010e 00270 (a.go:20)  MOVQ    AX, 24(SP)
    0x0113 00275 (a.go:20)  CALL    "".exist(SB)

通过对比可以发现,其实exist函数的代码并没有任何变化,有变化的代码是a.go第20行。

再来将汇编翻译成Go语言:

package main
import "os"
type Closure1 struct {
    F func(int) bool
}
var DX *Closure1
func foo(n int) bool {
    return n == 3
}
func exist(list []int, f *Closure1) bool {
    for _, n := range list {
        DX = f
        if f.F(n) == true {
            return true
        }
    }
    return false
}
func main() {
    count := len(os.Args)
    a := make([]int, 0)
    for i := 0; i < count; i++ {
        a = append(a, i)
    }
    c := &Closure1{
        F: foo,
    }
    exist(a, c)
}

通过对比两次翻译后的Go语言,可以发现一件很有意思的事。

Go语言其实把所有函数都抽象成闭包,这一点倒是与Lua有颇多相似之处。

只是没有任何值捕获的闭包,在逃逸分析时可以做更多的优化。

一例误用unsafe包引起的内存问题

Go语言的指针真的很灵活,远非其他带GC的语言可以比拟的。

比如下面这段代码,并不会产生GC问题。

而在其他带GC的语言中,是不太可能写出类似这种代码的。

var b *int
{
    a := make([]int, 3)
    b = &a[1]
}
fmt.Println(*b)

在写Go代码时,常常会让人有种在写C语言的错觉,他的指针几乎实现了90%的功能,剩下没有实现的10%则是与GC有关。

而unsafe包则可以让我们做到剩下的10%, 但是内存安全性需要我们自己保证。

比如下面代码就是完全正确的,只要c还活着,a就不可能被回收。

var b unsafe.Pointer
{
    a := make([]int, 3)
    a[1] = 0x01020304
    b = unsafe.Pointer(&a[1])
}
c := (*byte)(b)
fmt.Printf("%04x\n", *c)

在一些性能敏感的地方,比如在一场回合制战斗或者一个复杂对象的反序列化中,内存分配可能会吃掉相当一部分算力。

这些场景都有一个共同的特别,有一大批小对象同生共死。如果能够将这些小对象分配进行批量化,就可以显著提高性能。

比如下面代码,性能会有将近300%的差别。

package main

import (
    "os"
    "unsafe"
)

type Foo struct {
    a uint64
}
var pool []uint64
//go:noinline
func Alloc(direct bool) *Foo {
    if direct {
        return &Foo{}
    } else {
        var f *Foo
        size := unsafe.Sizeof(*f)
        need := (size + 7) / 8
        if len(pool) < int(need) {
            pool = make([]uint64, 512)
        }
        p := unsafe.Pointer(&pool[0])
        pool = pool[need:]
        return (*Foo)(p)
    }
}

func main() {
    direct := len(os.Args) == 1
    for i := 0; i < 64*1024*1024; i++ {
        Alloc(direct)
    }
}
/*
$ time ./a
./a  0.77s user 0.01s system 110% cpu 0.710 total
$ time ./a x
./a x  0.26s user 0.06s system 129% cpu 0.250 total
*/

不用怀疑,上面的代码一定是对的。

因为所有使用Alloc分配出来的指针一定是8字节对齐的,而所有的Foo指针也必将引用pool对象的内存,使他不被回收。

然而,有朝一日,代码被迭代成了下面的样子,GC就会开始紊乱了。

package main

import (
    "os"
    "unsafe"
)

type Bar struct {
    b uint64
}

type Foo struct {
    a uint64
    b *Bar
}

var pool []uint64

//go:noinline
func Alloc(direct bool) *Foo {
    if direct {
        return &Foo{}
    } else {
        var f *Foo
        size := unsafe.Sizeof(*f)
        need := (size + 7) / 8
        if len(pool) < int(need) {
            pool = make([]uint64, 512)
        }
        p := unsafe.Pointer(&pool[0])
        pool = pool[need:]
        return (*Foo)(p)
    }
}

func main() {
    direct := len(os.Args) == 1
    for i := 0; i < 64*1024*1024; i++ {
        f := Alloc(direct)
        f.b = &Bar{}
        //do something with f
    }
}

让我们来写段代码测试一下:

package main

import (
    "fmt"
    "runtime"
    "time"
    "unsafe"
)

type Bar struct {
    b [64]int
}

type Foo struct {
    a uint64
    b *Bar
}

var pool []uint64

//go:noinline
func Alloc(direct bool) *Foo {
    if direct {
        return &Foo{}
    } else {
        var f *Foo
        size := unsafe.Sizeof(*f)
        need := (size + 7) / 8
        if len(pool) < int(need) {
            pool = make([]uint64, 512)
        }
        p := unsafe.Pointer(&pool[0])
        pool = pool[need:]
        return (*Foo)(p)
    }
}

func fin(r *Bar) {
    fmt.Printf("fin %p\n", r)
}

func main() {
    f := Alloc(false)
    f.b = &Bar{}
    runtime.SetFinalizer(f.b, fin)
    fmt.Printf("%p %d\n", f.b, unsafe.Sizeof(*f.b))
    time.Sleep(time.Second)
    runtime.GC()
    time.Sleep(time.Second)
    fmt.Printf("%p %d\n", f.b, f.b.b[0])
}

在第50行代码,我特意加上了fmt.Print来引用f对象,甚至引用了f.b对象。

但是我们可以从log看出,在48行GC时,f.b指向的内存已经被回收了。

之所以会出现这种情况,本质上和Go语言的GC机制有关系。

在上一篇文章中,我找到了一些资料来解释为什么Go语言的指针可以有这么大的自由度。

现在这篇文章同样能解释这次的Bug。

本质上在Go语言代码层面,所有的指针和unsafe.Pointer的功能并无两样,仅用于指明这个变量是指针,在进行GC Mark时,需要Mark这个变量所指向的内存块。

至于这块内存中指针变量的位置,是在new这块内存时,Go编译器会根据这块内存的类型来标记的。

我们上面的Alloc池,在分配内存时给GC的信息是,我们要分配一个512大小的uint64类型的slice。

编译器生成代码时就会标明,这块内存中没有指针成员,在GC Mark时不需要继续Mark子元素。

虽然我们将其中的某块内存使用unsafe包强转成Foo对象的指针,但这也仅能保证pool对象内存的安全,并不能保证Foo对象中指针变量指向内存的安全。

GC系统从Foo的指针最终是Mark的是pool对象,当然也就不能Mark到Foo.b所指向的内存。

在这段代码中,还有一个有意思的现象,即使第50行我们引用了f.b对象。但是依然不能阻止GC对他的回收。

但是我们在47行之后,如果插入一行b := f.b就可以阻止f.b对象被回收。

这是因为,虽然Go的最新版GC使用了混合写屏障,但是在一个GC循环中,每个goroutine的栈至少会被扫描一遍的。

GC不在运行中,代码44行的混合写屏障没能开启,所以f.b没能被mark。

GC运行时扫描栈对象时,又识别不出f.b变量是个指针,更不可能Mark内存块f.b。

Go语言之内存篇

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

初识Go语言

其实严格来讲也不算初识,大概在15年时,就学过一次Go语言的语法。

由于当时Go语言GC的名声不太好,也就没太认真研究,只是大致把语法学习了一下。

对Go的印象除了语法有点怪,也就没有其他特别的印象了。

这一次,我仔细学习了一下Go语言(到目前为止已经学习了4周了)。

有了一些不太一样的感受,还发现了一些令人耳目一新的点。


首先就是GC。

我仔细回忆了一下,Go竟然是我知道的第一门编译型带GC的语言(IL2CPP不算),这里的编译不是将代码编译成字节码然后解释的那种,是真正编译成能在CPU上执行的native code。

编译成native代码运行肯定会更快,但同时也会有一些潜在的问题。

Go编译器在编译代码时,会在代码的各处插入GC相关的代码。

在进行源码级调试时,一般不会有太大的问题,调试器会智能跳过编译器插入的代码。

但是,当想看某一行代码在汇编级是怎么执行时(这是从C语言时代就养成的习惯,一般写一行C语法,基本上都能预测出生成的非优化汇编代码), 我发现代码中到处充斥着Go插入的代码,让代码的可读性差很多。

而一些使用虚拟机的语言如Lua,Java等。OpCode和逻辑代码是一一对应的,GC相关的细节被封装在虚拟机内部。

这种分层会让底层的OpCode非常清晰,对底层调优很有帮助。

当然,这也许正是Go想要的也说不定,可能他不希望你做这么底层的优化:D


然后就是汇编。

是的,当我知道Go反汇编出来的是Plan9汇编时,我震惊了。

这就意味着,即使我能突破编译器插入代码这个障碍,我依然看不到最终执行的X86指令,我依然不知道代码最终在CPU上是如何执行的。

举个最简单的例子,所有人都说goroutine的切换开销比线程小,其实我一直对这个观点保持怀疑态度。

按照我X86汇编的经验,在编译器的优化阶段,总是尽可能的将栈上变量,优化到寄存器上去,甚至前几个参数都是通过寄存器来传递的。

来随便看段简单的C代码和相应的汇编。

int foo(int a, int b)  {
    int e = a / b;
    return a * b * e;
}
foo:
.LFB0:
    .cfi_startproc
    mov eax, edi
    cdq
    idiv    esi
    imul    edi, esi
    imul    eax, edi
    ret
    .cfi_endproc

可以看到foo函数中的e变量并没有在栈上,而是直接分配了一个寄存器。

这就导致一个问题,当一个线程被抢占时,他当前的整个callstack的上下文中,被使用的寄存器是不确定的。

因此在linux中的,Thread被换出时,需要保存全套的寄存器(EAX,EBX….)。

但是所有的Go文章都说goroutine切换代价很小,他需要保存更少的寄存器,有些人甚至说他只需要保存3个寄存器。

我对这个说法最开始是相信的,如果goroutine的切换点总是在函数调用时进行,他完全可以做到把ABI的"callee saved registers"的个数减少到3个。

但是,后来我看到了goroutine是可以在任意时机被抢占的。

这我就不太能理解了,不管是不是Plan9汇编,最终只要跑在x86指令集的机器上,他们的优化思路都应该是尽可能多的使用寄存器,而不是栈。

那么,只要我整个函数使用的寄存器超过3个,想要在for {}语句中抢占一个goroutine,就势必要保存整套寄存器,那所谓的轻量切换也就不存在了,最多就是栈的空间消耗会少一些。

当我想进一步寻找答案时,Plan9成了阻碍。

我很难确定,是不是在Plan9的ABI中,每个函数只有三个寄存器可用。

在从Plan9生成X86汇编时,会把栈上的变量尽可能多地转移到x86寄存器上。

除非我将最终的二进制文件反汇编成x86, 显然我还没有对go熟悉到这种程度,这个问题就只能暂时搁置了。

而且我不得不说,相关的资料真的很少,不管是中文的还是英文的。


Go的slice是一个很有意思的数据结构。

多个slice,有时会共享内存,有时不会。会不会共享取决于当时的代码执行情况,但结果可以预测。

我理解下来,这基本上是对性能妥协的结果。

总的来讲我认为这个妥协是正向的,因为共享不共享是有明确规则的,只要留心一点,一般问题不大。

我比较好奇的是,slice和GC交互的部分。

先看一小段代码:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
func foo() []int {
    a := make([]int, 5)
    b := a[3:4]
    return b
}

在这段代码中,我把slice的数据结构和示例代码放在一起了。

可以从go的任意一本参考书上可知,上面代码约等于下面这段C代码:

struct slice {
    int *array;
    int len;
    int cap;
}

func foo() slice {
    struct slice a, b;
    a.array = malloc(5 * sizeof(int));
    a.cap = 5;
    a.len = 5;
    b.array = &a.array[3];
    b.cap = a.cap - 3;
    b.len = 4 - 3;
    return b;
}

所有的资料都提到,Go语言的GC是并发三色垃圾回收。

现在问题来了,由于b.array做了指针计算(所有带垃圾回收功能的语言,都会避免支持指针运算,因为这会让GC变得很难)。

当GC模块去Mark变量b时,它该如何找到这块内存的首地址呢,这一点我一直没有想通。

相关的文档没有找到,而且似乎大家也不是很关心这个事情 ^_^!


上面都是一些实现细节,下面谈谈语言层面上的设计。

Go语言的接口机制和CSP同步机制,着实让人耳目一新。

Go语言作为一门静态语言,竟然实现了DuckType, 这一点我挺意外的。

更意外的是,他的接口机制还有一种很奇特的机制。

下面展示一段代码看看效果:

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)
}
/*输出结果:
st1.foo 1
st1.bar 1
st2.foo 3
st1.bar 1
*/

对于前两行的输入,其实在我知道了Go支持DuckType时,就已经可以预见了。

但是后两行的输出,真的是让人惊艳。

这种组合方式,不仅粘合了两个struct, 还粘合了两个变量。

如果用得好,也许会有出其不意的威力

当然,天下没有白吃的午餐。

整个interface机制是有运行时开销的,这个开销会发生在由具体的struct到相应的interface对象转换时。

具体的开销,可能要等我熟悉了Plan9汇编和runtime库之后,才能破解谜题了。


再来看看Go的CSP编程,Go是通过channel来实现CSP编程的。

同样,先来看一小段代码:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go func() {
        n := <-ch1
        fmt.Println(n)
        ch2 <- (n + 1)
    }()
    go func() {
        fmt.Println("0")
        ch1 <- 1
        n := <-ch2
        fmt.Println(n)
    }()
    time.Sleep(1 * time.Second)
}

无论执行多少次,这段代码都会严格按照“0,1,2”的顺序打印。

如果在C语言中,用线程和一般的消息队列来写类似的代码,并不会有此效果。

每次程序运行都有可能会输出不一样的结果。

我认为这就是CSP(Communicating Sequential Process)的本质。

channel不仅仅是用来通信的,它还是一种同步手段。

channel会协调两端的goroutine在某一个点进行对接,然后再各自并发。

在这个对接点上,channel两端的goroutine是同步的。

用Go语言文档上的话说,在channel的一端没有取走数据之前,发送端的goroutine是不会被唤醒的。

当然Go语言还提供一种有缓冲的channel, 这种就更像是一个消息队列。

我理解下来,有缓冲的channel更适合于一些非常规场合,CSP则推荐使用无缓冲channel。

几乎所有的Go的参考书都会给我们强调说:并发属于代码;井行属于一个运行中的程序

这句话结合CSP的概念,让我有了一种不一样的感觉。

仍以上面的代码为例,当13行的fmt.Println被换成更具体而繁重的任务时,两个goroutine不可能有机会并行执行。

并发属于代码;井行属于一个运行中的程序这句话似乎在隐隐告诉我:不要害怕CSP导致并行度下降,只要你开足够多的goroutine,并行度在运行时很快就上去了,这也是为什么Go语言一直不停的鼓励我们写并发结构程序的原因。

想象一下,我们有64个CPU核心,有1W个goroutine。

就算每156个goroutine被channel粘合到一起,不得不串行执行,64个CPU核心依然会被跑满。

在CSP的模式下,整个系统的负载会更加均衡,不会出现生产者撑爆内存,或者消费者饿死的情况。

同时,理论上,由于隐式同步的存在,并发的Bug也会更少。


最后提一下Go的逃逸分析。

Go在堆上分配内存的机制,和一般的带GC的面向对象语言稍有不同。

以C#为例,他把对象分为值类型和引用类型。struct对象就是值类型,class就是引用类型。

因此,C#在new struct时会直接在栈上分配,在new class时会直接在堆上分配。

在Go语言中,对象是否分配在栈上,规则稍有不同。他取决于你是否向接口转换,或者这个变量的作用域是否超出的定义他域。

下面看一段很有意思的代码:

package main

func main() {
    m := make(map[int]int, 5)
    m[3] = 5
}

如果按照C#的经验,这个m变量肯定要分配到堆上的,因为map/dictionary是一个引用类型。

但是Go可以通过逃逸分析发现,这个m变量只在当前作用域使用,所以分配到栈上就足够了。

这不得不说是一个很大的优化。