初识Go语言

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

其实严格来讲也不算初识,大概在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代码和相应的汇编。 ```c int foo(int a, int b) { int e = a / b; return a * b * e; } ``` ```asm 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交互的部分。 先看一小段代码: ```go type slice struct { array unsafe.Pointer len int cap int } func foo() []int { a := make([]int, 5) b := a[3:4] return b } ``` 在这段代码中,我把[slice的数据结构](https://go.dev/src/runtime/slice.go)和示例代码放在一起了。 可以从go的任意一本参考书上可知,上面代码约等于下面这段C代码: ```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, 这一点我挺意外的。 更意外的是,他的接口机制还有一种很奇特的机制。 下面展示一段代码看看效果: ```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) } /*输出结果: 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编程的。 同样,先来看一小段代码: ```go 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语言中,对象是否分配在栈上,规则稍有不同。他取决于你是否向接口转换,或者这个变量的作用域是否超出的定义他域。 下面看一段很有意思的代码: ```go package main func main() { m := make(map[int]int, 5) m[3] = 5 } ``` 如果按照C#的经验,这个m变量肯定要分配到堆上的,因为map/dictionary是一个引用类型。 但是Go可以通过逃逸分析发现,这个m变量只在当前作用域使用,所以分配到栈上就足够了。 这不得不说是一个很大的优化。

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

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

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