3

初识Go语言 - Go语言中文网 - Golang中文社区

 1 year ago
source link: https://studygolang.com/articles/35931
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小时之前 · 116 次点击 · 预计阅读时间 6 分钟 · 大约8小时之前 开始浏览    

其实严格来讲也不算初识,大概在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想要的也说不定,可能他不希望你做这么底层的优化:grinning:


然后就是汇编。

是的,当我知道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变量只在当前作用域使用,所以分配到栈上就足够了。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK