Go符号表简介

参考资料

https://medium.com/a-journey-with-go/go-how-to-take-advantage-of-the-symbols-table-360dd52269e5

在参考资料的基础上,尝试复写已存在的变量,并更新Go版本到1.16。

符号表由编译器创建维护,用于存储程序相关的信息,比如函数或全局变量。

符号表

每个由Go编译的二进制程序默认包含符号表。举个例子

1
2
3
4
5
6
7
8
9
package main

import "fmt"

var AppVersion string

func main() {
fmt.Println(`Version: ` + AppVersion)
}
1
2
go build . -o main
nm main

符号表输出非常大,截取其中几行,如下

1
2
3
4
000000000114b000 s _main..inittask
000000000115fc10 b _main.AppVersion
00000000010a3120 t _main.main
0000000001158d10 d _runtime.buildVersion

符号前面用b标识是未初始化的数据。我们之前的变量AppVersion的确也没有初始化。d代表已初始化的数据。t代表文字符号,由这两者组成函数。

go也包装了nm命令,使用go tool nm可以得到相同的结果。

自定义变量

运行命令go build时,将会执行两个阶段:编译和链接。链接阶段从编译阶段的产物中派生出二进制文件。为了实现这一目标,链接器使用符号表将符号重新定位到最终的二进制文件。

Go允许我们通过-X命令来复写符号表。这是例子

1
go build -o ex -ldflags="-X main.AppVersion=v1.0.0"

重新运行程序,程序打印

1
Version: v1.0.0

重新运行nm命令,发现变量已经是初始化状态了

1
0000000001158c80 d _main.AppVersion

该方式不仅可以复写未初始化的变量,同时也可以复现已初始化的变量

1
go build -o ex -ldflags="-X main.AppVersion=v1.0.0,main.AppName=k8s"

感兴趣的朋友可以用这个程序试一下输出是什么

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

var AppVersion string
var AppName = "app"

func main()
fmt.Println(`appName: ` + AppName)
fmt.Println(`Version: ` + AppVersion)
}

Debug

符号表的目的是确保使用的标识符在使用之前已经得到了很好地声明。这意味着一旦程序构建完成,它就不再需要该表了。但是,默认情况下,出于调试目的,符号表嵌入在Go二进制文件中。在了解如何从二进制文件中删除它之前,让我们了解如何利用它。使用go build构建应用程序

1
go build -gcflags "-N -l" -ldflags="-compressdwarf=false -X main.AppVersion=v1.0.0 -X main.AppName=k8s" main.go

上述参数解释

  • Go 1.11 之后,为了压缩二进制文件的大小,debug信息被压缩了,OSX的gdb不能识别压缩的DWARF信息
  • -N代表禁止优化,不要在生产环境上开启,此处仅为演示使用
  • -l参数代表禁止内联,也建议不要在生产环境上开启,此处仅为演示使用
1
gdb main

image-20210715104937454

我们可以通过-s去掉符号表信息

1
go build -gcflags "-N -l" -ldflags="-s -X main.AppVersion=v1.0.0 -X main.AppName=k8s" main.go

gdb就看不到其他信息了

image-20210715105230494

二进制文件大小

去掉符号表,会使debug变得更加困难,但可以有效降低文件大小。MAC上两个文件的差异是

image-20210715105637847

1
2
go build -gcflags "-N -l" -ldflags="-compressdwarf=false -X main.AppVersion=v1.0.0 -X main.AppName=k8s" main.go
go build -gcflags "-N -l" -ldflags="-s -X main.AppVersion=v1.0.0 -X main.AppName=k8s" -o main-s main.go

如果在linux上,可以开启压缩dwarf特性,差距差不多在

image-20210715110105760

百分之20~百分之30之间

Varint编码

一个简单的消息

假设你有如下的protobuf定义

1
2
3
4
5
6
7
syntax = "proto3";
package test;
option go_package = "test";

message Test1 {
optional int32 a = 1;
}

在应用中,你创建了一个TestMessage实例,将a设置为150。接下来将消息序列化。检查序列化后的消息,你会发现是这么一个字节数组

1
0x08 0x96 0x01

Base 128 Varints

这是一个varints编码。varints是一种使用1个或多个字节来序列化整数的方式。更小地数字占更少的字节。

varint 中的每个字节,除了最后一个字节,都设置了最高有效位 (msb)——这表明还有更多的字节需要处理。 每个字节的低 7 位用于以 7 位为一组存储数字的二进制补码表示,采用小端字节存储。

举个例子,对于1来说,由于只有1个字节,所以没有设置msb

1
0000 0001

这是300,有一些复杂

1
1010 1100 0000 0010

怎么发现这是300的?首先,先把每个字节的msb去掉,变换为

1
010 1100 000 0010

再进行小端转换,变为

1
000 0010 010 1100

组合为

1
100101100

即就是300

使用Delve对Go程序进行Debug

翻译自

https://medium.com/a-journey-with-go/go-debugging-with-delve-core-dumps-384145b2e8d9

CoreDump是异常退出程序的内存快照。可以用来死后debug来找出crash发生的原因以及牵连的变量。通过GOTRACEBACKGo提供了控制程序崩溃时的输出。变量还可以强制生成CoreDump,使得debug成为可能。

GOTRACEBACK

GOTRACEBACK控制着程序崩溃时的输出,可以取以下的值

  • none 不展示任何协程堆栈追踪
  • single 默认选项,打印当前协程堆栈
  • all 展示所有用户创建的协程堆栈
  • system 展示所有协程堆栈,包括go运行时
  • crashsystem相似,不过同时会生成core dump文件

最后一个选项给了我们在crash的时候,debug程序的能力。如果您没有获得足够的日志,或者崩溃不可重现,这可能是一个不错的选择。 让我们以下面的程序为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "math/rand"

func main() {
for {
var sum int
n := rand.Intn(1e6)
sum += n
if sum % 42 == 0 {
panic(":(")
}
}
}

程序将很快crash

image-20210712180555586

我们不能从堆栈看出来什么值导致了crash。添加日志可能是一个解决方案,但是我们不是所有情况都知道在哪里添加日志。当一个问题无法重现时,添加日志只是一个后知后觉,不断寻找的思路。

让我们加上环境变量GOTRACEBACK=crash。由于所有协程和runtime堆栈都会输出,输出更加详细。并且我们还有了coredump文件

1
2
3
4
5
go build -gcflags=all="-N -l" .
export GOTRACEBACK=crash
./core_dump
# 如果您的core_dump没有生成,可能是coredump size配置为0,如下命令将coredump配置为1MB大小
ulimit -c 1048576

我们有

image-20210712183024428

Core-dump文件可被Go delve或GDB分析

Delve

安装Delve

1
go install github.com/go-delve/delve/cmd/dlv@latest

Delve 是用 Go 编写的 Go 程序的调试器。 它允许通过在用户代码和运行时的任何位置添加断点来逐步调试,甚至可以使用将二进制文件和CoreDump作为参数的命令 dlv core 调试CoreDump。

通过dlv core core_dump core.2716来调试coredump。然后通过bt命令打印堆栈,并且展示程序造成的panic

image-20210712183456364

我们看到7发生了panic,然后通过frame 9查看第9个堆栈

image-20210712185150712

这展示了panic时的代码,然后通过命令locals打印本地变量,帮助我们搞明白什么变量参与了crash

image-20210712185206700

随机生成的n的值是203300。sum呢?通过vars main来查看main包里的sum的值

image-20210712185307402

通过这个就可以推理出,sum的和刚好整除42,导致程序panic

Go中的指针

翻译自

https://go101.org/article/pointer.html

尽管Go吸收了很多其他语言的特性,但Go总体来说是一个C家族语言。其中一个证据就是Go也支持指针。Go指针和C指针在许多方面非常相似,但其中也有一些不同。本文将会列举Go指针的概念和细节。

Memory Address 内存地址

内存地址指的是整个系统管理(通常由操作系统管理)的内存空间中的偏移量(byte的个数)。

通常,内存地址被存储成一个无符号(整型)字。字长在32位机器上是4字节,在64位机器上是8字节。所以理论最大寻址范围,在32位机器上是
2^32,也就是4GB。在64位机器上是2^64,也就是16EB(1EB=1024PB,1PB=1024TB,1TB=1024GB)。

内存地址通常用Hex表达模式书写,如0x1234CDEF

Value Address 值地址

值的地址代表这个值在内存段中的起始地址

什么是指针?

指针是Go的一种类型。指针用来存储内存地址。跟C语言不同,为了安全的原因,Go指针上有一些限制。

Go指针类型和值

在Go中,未定义的指针类型可以被写成*TT可以是任意类型。T*T的基础类型。

我们也可以自定义指针类型,通常由于未定义指针类型拥有更好的可读性,不推荐使用自定义指针类型。

如果一个定义的指针类型的底层类型(underlying type)是*T,那么该指针的基础类型是T。这个基础类型相同的指针也是同一种类型。样例

1
2
3
4
5
6
7
8
9
// 未定义的指针类型,基础类型是int
*int
// 未定义的指针类型,基础类型是*int
**int

// Ptr是一个定义指针类型,基础类型是int
type Ptr *int
// PP是一个定义指针类型,基础类型是Ptr
type PP *Ptr

指针的零值是nil,不存储任何地址。

基础类型为T的指针仅能存储T类型值的地址

如何获得指针值、什么是可寻址值

有两种方式可用于获取非nil的指针值

  • go内置的new函数,可以分配任何类型的内存。new(T)在内存中分配一个T值的空间,然后返回T值的地址。分配的值是T
    类型的零值。返回的地址就是一个T类型的指针
  • 我们还可以直接获取可寻址值的地址。对于一个可寻址类型T的值t,我们可以使用&t来获取t的地址,`&操作符用来获取值地址。

通常上来说,可寻址值意味着该值存放在内存中的某处。现在,我们只需要知道任何变量都是可寻址的,同时常数、函数调用和显示转换的结果是不可寻址的。变量声明的时候,Go运行时将会为这个变量分配一片内存,这片内存的开始地址就是这个变量的地址。

Pointer Derefernece 指针解引用

对于一个基础类型为T的指针类型p,你如何获取指针中存储的值(或者说,被指针指向的值)?只需要使用表达式*p*
被称为解引用操作符。指针的解引用是取地址的逆运算。*p的返回值是T类型,也就是p的基础类型。

nil指针进行解引用会导致运行时异常。

这个程序展示了地址获取和解引用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import "fmt"

func main() {
// p0指向int的零值
p0 := new(int)
// hex表达的地址
fmt.Println(p0)
// 0
fmt.Println(*p0)

// x是p0指向值的拷贝
x := *p0
// 都取得x的地址
// x, *p1, *p2 的值相同
p1, p2 := &x, &x
// true
fmt.Println(p1 == p2)
// false
fmt.Println(p0 == p1)
// p3 和 p0 也存储相同的地址
p3 := &*p0
fmt.Println(p0 == p3)
*p0, *p1 = 123, 789
// 789 789 123
fmt.Println(*p2, x, *p3)

// int, int
fmt.Printf("%T, %T \n", *p0, x)
// *int, *int
fmt.Printf("%T, %T \n", p0, p1)
}

下图揭示了上面程序存储的值间关系

image-20210712093648121

为什么我们需要指针

让我们先来看一个样例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func double(x int) {
x += x
}

func main() {
var a = 3
double(a)
fmt.Println(a) // 3
}

上例中的double函数预期对输入值进行双倍处理。但是它失败了。为什么?因为所有值的分配,包括函数参数的传递,都是值拷贝。double
函数操作的x只是a变量的拷贝,而不是a变量。

修复上例的一种方式是让double函数返回一个新值。但这并不是所有场景都适用。下例展示了另一种使用指针的方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func double(x *int) {
*x += *x
// 这行只为了解释用途
x = nil
}

func main() {
var a = 3
double(&a)
fmt.Println(a) // 6
p := &a
double(p)
fmt.Println(a, p == nil) // 12 false
}

我们可以发现,通过将参数改为指针类型,传递的指针参数&a和它的拷贝x都指向相同的值,所以在*x上进行的修改,在a
上也体现了出来。同时,因为参数传递都是值拷贝,上面将x赋值为nil,在p上也不生效。

简而言之,指针提供了操作值的间接方式。大部分语言没有指针的概念。然而,指针的概念只是隐藏在了语言的其他概念中。

返回局部变量指针在Go是安全的

和C语言不通,Go支持垃圾回收,所以返回局部变量的指针在Go中是绝对安全的

1
2
3
4
func newInt() *int {
a := 3
return &a
}

Go指针的限制

为了安全原因,相比C语言来说,Go在指针上做了一些限制。通过这些限制,Go保持了指针带来的收益,并且避免了危险的指针使用。

Go指针不支持算术操作

在Go中,指针不能进行算术运算。对于指针pp++p-2都是非法的。

如果指针p指向一个数值,编译器会将*p++识别为一个合法的语句,并解析为(*p)++。换句话说,*p操作的优先级高于++--操作符。样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

func main() {
a := int64(5)
p := &a

// 下面的语句无法编译
/*
p++
p = (&a) + 8
*/

*p++
fmt.Println(*p, a) // 6 6
fmt.Println(p == &a) // true

*&a++
*&*&a++
**&p++
*&*p++
fmt.Println(*p, a) // 10 10
}

指针值不能转换为任意的指针类型

在Go中,T1的指针值可以被隐式或是显示地转换为T2,需要满足如下两个条件

  • T1T2的底层类型相同(忽略结构体Tag)。特别地,如果T1T2都是未定义类型并且它们的底层类型相同(考虑结构体Tag),可以进行隐式转换。
  • T1T2都是未定义指针类型,并且它们的基础类型的底层类型相同(忽略结构体Tag)

举个例子,有如下类型

1
2
3
type MyInt int64
type Ta *int64
type Tb *MyInt

有如下事实

  • *int64类型的值可以被隐式转换为Ta类型,反过来也是可以的。因为它们的底层类型都是*int64
  • *MyInt类型的值可以被隐式转换为Tb类型,反过来也行。因为它们的底层类型都是*MyInt
  • *MyInt类型的值可以被限制转换为*int64,反之亦然。因为它们的基础类型的底层类型都是int64
  • 即使是显示转换,Ta类型的值也不能直接转换为Tb。因为Ta和底层类型和Tb不同,并且都是定义指针类型。不过可以进行连续几次转换,将
    Ta类型的pa间接转化为Tb类型。 先将pa转化为*int64类型(因为基础类型的底层类型都是int64),再将*int64类型转换为
    *MyInt类型,再将*MyInt类型转化为*Tb类型。Tb((*MyInt)((*int64)(pa)))

上面的值通过任何安全的手段,都不能转化为类型*uint64

任意两个指针的值不能比较

在Go中,指针可以通过==!=符号比较。如果满足如下任意一个条件,那么两个Go指针的值可以进行比较

  • 两个Go指针类型一致
  • 指针值可以隐式转换为另一类型
  • 两个指针中的一个且仅一个用无类型 nil 标识符表示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

func main() {
type MyInt int64
type Ta *int64
type Tb *MyInt

// 4个不同类型的指针零值
var pa0 Ta
var pa1 *int64
var pb0 Tb
var pb1 *MyInt

// 下面这6行都可以正常编译
// 比较结果都为true
// 指针可以隐式转换
_ = pa0 == pa1
// 指针类型一致
_ = pb0 == pb1
_ = pa0 == nil
_ = pa1 == nil
_ = pb0 == nil
_ = pb1 == nil

// 这三行都不能正常编译
/*
_ = pa0 == pb0
_ = pa1 == pb1
_ = pa0 == Tb(nil)
*/
}

指针值不能赋值给其他指针类型

指针值互相赋值的条件和比较的条件一样

有手段打破Go对指针的限制

unsafe 标准包提供的机制(特别是 unsafe.Pointer 类型)可以用来打破 Go 中对指针的限制。 unsafe.Pointer 类型类似于 C 中的
void*。一般不推荐使用unsafe

翻译自

https://medium.com/a-journey-with-go/go-what-is-the-unsafe-package-d2443da36350

正文

包的名称指引着我们的使用。了解此包可能不安全的原因,让我们首先查看如下文档

1
2
unsafe包包含可以绕过Go程序类型安全的操作
引用了unsafe的包,可能会无法跨平台、跨设备,并且Go1 兼容性指南不保证这些包的兼容

因此,该名称被用作对为 Go 提供类型的安全性的反面。 现在让我们深入研究文档中提到的这两点。

类型安全

在Go中,每个变量都拥有一个类型,在赋值给另外一个变量之前,可以转换为另外的类型。在此转换期间,Go 执行此数据的转换以适应所请求的类型。 这是一个例子

1
2
3
4
5
6
7
8
9
10
11
package unsafe_test

import "testing"

func TestConvert(t *testing.T) {
// -1 二进制表达 11111111
var i int8 = -1
// -1 二进制表达 11111111 11111111
var j = int16(i)
println(i, j)
}

输出

1
-1 -1

unsafe包让我们直接操作这个变量的内存地址,直接获取里面的二进制值。在绕过类型约束时,我们可以随意使用它。

1
2
3
var k uint8 = *(*uint8)(unsafe.Pointer(&i))
println(k)
// 255 is the uint8 value for the binary 11111111

Go1 兼容性指南

unsafe包可能会依赖于Go的底层实现。Go可能会做出破坏性的改动。

Go 反射包中使用Unsafe

reflect包是使用最多的一个包。反射基于空interface包含的数据。为了读取数据,Go只是将我们的变量转化为一个空接口,并通过映射一个结构体来读取它们,该结构体匹配空接口的内部表示与指针地址处的内存。

1
2
3
4
5
6
7
8
9
func ValueOf(i interface{}) Value {
[...]
return unpackEface(i)
}
// unpackEface converts the empty interface i to a Value.
func unpackEface(i interface{}) Value {
e := (*emptyInterface)(unsafe.Pointer(&i))
[...]
}

变量 e 现在包含有关该值的所有信息,例如类型或该值是否已导出。 反射还使用 unsafe 包通过直接在内存中更新值来修改反射变量的值。

Go Sync包使用Unsafe

sync.Pool 这些池通过一段go协程都能访问的内存片段来共享数据。这访问模式和C语言数组的访问方式很像

1
2
3
4
func indexLocal(l unsafe.Pointer, i int) *poolLocal {
lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))
return (*poolLocal)(lp)
}

l是内存片段,i是数字。函数 indexLocal 只是读取这个内存段——它包含 X个poolLocal 结构——与它读取的索引相关的偏移量。 存储指向完整内存段的单个指针是实现共享池的一种非常轻量的方式。

在Go runtime包中的使用

Go 在runtime包也大量使用 unsafe 包,因为它必须处理内存操作,如栈分配或释放栈内存。 栈由其结构中的两个边界表示:

1
2
3
4
type stack struct {
lo uintptr
hi uintptr
}

unsafe包可以完成这个操作

1
2
3
4
5
6
7
func stackfree(stk stack) {
[...]
v := unsafe.Pointer(stk.lo)
n := stk.hi - stk.lo
// 内存基于栈上的指针释放
[...]
}

开发平时使用

unsafe包的一个很好的用法是转换具有相同底层数据结构的不同结构体,这是转换器无法实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package unsafe

import (
"testing"
"unsafe"
)

type A struct {
A int8
B string
C float32
}

type B struct {
D int8
E string
F float32
}

func TestStructConvert(t *testing.T) {
a := A{A: 1, B: `foo`, C: 1.23}
b := *(*B)(unsafe.Pointer(&a))
println(b.D, b.E, b.F) // 1 foo 1.23
}

不使用三方库

协程Sleep方式

1
2
3
4
5
6
go func() {
for true {
fmt.Println("Hello !!")
time.Sleep(1 * time.Second)
}
}()

使用ticker方式1

1
2
3
4
5
6
7
8
9
10
ticker := time.NewTicker(1 * time.Second)
go func() {
for range ticker.C {
fmt.Println("Hello !!")
}
}()

// wait for 10 seconds
time.Sleep(10 *time.Second)
ticker.Stop()

使用ticker方式2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
done := make(chan bool)
ticker := time.NewTicker(1 * time.Second)

go func() {
for {
select {
case <-done:
ticker.Stop()
return
case <-ticker.C:
fmt.Println("Hello !!")
}
}
}()

// wait for 10 seconds
time.Sleep(10 *time.Second)
done <- true

参考

https://stackoverflow.com/questions/53057237/how-to-schedule-a-task-at-go-periodically

从简单通信协议开始

最近工作中又需要处理协议解析,我对协议解析和网络抓包其实还是小有研究,17年刚毕业的时候,就用Netty手写过SMPP协议的对接。(其实做协议解析是一个很枯燥的工作,如果协议解析可以像antlr那样子写grammar自动解析应该会很酷?)本文总结一下协议在tcp下编码拆包粘包的三种解决方案。

网上有一些人对拆包粘包的说法不是很认可,但是我觉得这个术语还是挺形象的。

首先,让我们来设计一个简单地通信协议,Sorry,客户端一直对服务器发送I am Sorry,服务端回复That's ok。如下图所示

image-20210704104926698

让我们来写个demo程序实现这个协议

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"fmt"
"net"
)

func main() {
listen, err := net.Listen("tcp", "localhost:1997")
if err != nil {
panic(err)
}
defer listen.Close()
for {
conn, err := listen.Accept()
if err != nil {
panic(err)
}
go handleRequest(conn)
}
}

// handle incoming requests
func handleRequest(conn net.Conn) {
// make a buffer to hold incoming data
buf := make([]byte, 1024)
// Read the incoming connection into the buffer
reqLen, err := conn.Read(buf)
if err != nil {
fmt.Println("error reading: ", err.Error())
}
if reqLen != 10 {
fmt.Println("invalid request size ", reqLen)
}
_, err = conn.Write([]byte("That's ok"))
if err != nil {
fmt.Println("error sending: ", err.Error())
}
}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use std::io::{Read, Write};
use std::net::TcpStream;
use std::str::from_utf8;

fn main() {
match TcpStream::connect("localhost:1997") {
Ok(mut stream) => {
println!("success connect to 1997");
let msg = b"I am Sorry";
let expect_resp = b"That's ok";
stream.write(msg);
println!("Send hello, awaiting reply");
// use 9 byte buffer
let mut data = [0 as u8; 9];
match stream.read_exact(&mut data) {
Ok(_) => {
if &data == expect_resp {
println!("Reply is ok")
} else {
let text = from_utf8(&data).unwrap();
println!("Unexpected reply: {}", text);
}
},
Err(e) => {
println!("Failed to receive data: {}", e);
}
}
}
Err(e) => {
println!("Failed to connect: {}", e)
}
}

}

注意上面在服务端的实现中,我们校验了请求体的大小。

运行成功,我们在Wireshark上可以看到

image-20210704115955993

目标端口为1997,这是客户端发出的报文。当然也能看到响应的报文

image-20210704120027704

那么,如果客户端是个十分礼貌的人,他如果连续发送10个I am Sorry呢?我们将代码修改为

1
2
3
for _ in 0..10 {
stream.write(msg);
}

服务端报错了,服务端收到了一个请求,大小为100。并不是新手预期的10个大小为10的消息,

image-20210704120639637

那么实际在网络中是如何传输的呢?一定是1个大小为100的消息吗?答案是否定的。在我的这次测试中,在TCP层,分成了两组消息,第一个大小为10,包含一个I am Sorry

image-20210704120759769

另一个大小为90,包含9个

image-20210704120818834

揭秘时刻

TCP协议

TCPUDP不同,它是一个基于流的协议,TCP并不识别你定义的协议规则,只负责将这些报文打包发送,它可以基于TCP_NODELAYNagle算法等,任意的对你的报文进行切分发送。有两个典型的场景:第一个像上文中的例子,两个及以上的包在一个TCP数据包发送了,有个很形象的名字叫粘包。还有一个,因为报文过大,拆分成两个TCP报文发送,这叫拆包。

应用层读取

常见API,应用层读取也不保证单次操作一定仅仅读取一个tcp数据包,会根据你提供的buffer大小,尽量提供数据。你读取到的可能是上一个TCP包的末尾和下一个TCP包的开头部分。

总结

TCP是基于流的协议,并非基于报文。TCP提供了保序的语义保证,这要求应用程序,尤其是接收者,需要能够从报文流中提取出协议信息,TCP决不保证读取到的报文恰好是发送者一次write写入的报文,即使能在测试环境通过case,那也只不过是你运气好而已。

像我们上面,读取到100大小的消息。根据协议大小请求固定为10,我们就可以将100消息分割为10条协议报文。如果读取到的大小为96,那就先处理前90个字节,剩下6个字节,待后面4个字节到达之后再合并处理。下一节我们详细介绍一下几种常见方式。

常见TCP协议定义方式

定长编码

就像我们例子中的那样一样,定义一个定长宽度,然后切分

使用Go的gnet库的Server例子

1
2
3
4
5
6
7
8
9
10
import "github.com/panjf2000/gnet"

type ExampleServer struct {
*gnet.EventServer
}

func main() {
codec := gnet.NewFixedLengthFrameCodec(10)
gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(codec))
}

基于分隔符

基于分隔符的编码也十分容易理解,双方约定好一个字符,并在正常报文中不出现这个字符(出现则需要转义),比较类似的是以太网的7d7d?这个计算机网络链路层相关的知乎,学太久了,忘记了。

1
2
3
4
5
6
7
8
9
10
import "github.com/panjf2000/gnet"

type ExampleServer struct {
*gnet.EventServer
}

func main() {
codec := gnet.NewDelimiterBasedFrameCodec(0x11)
gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(codec))
}

基于固定行数的编码

这个也很简单,协议内容不换行,发送完再发送一个换行符,比较类似的有HTTP的\r\n

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "github.com/panjf2000/gnet"

type ExampleServer struct {
*gnet.EventServer
}

func main() {
gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(&gnet.LineBasedFrameCodec{}))
}

长度编码

长度编码是使用最多的,最流行的一种编码方式。最简单的一种工作方式是,在报文的最开始数个字节(常见为4个字节,足以编码4个G长度,相比之下两个字节仅能存放64K消息),声明报文剩余内容的长度。以Kafka协议举例

image-20210704125652379

Kafka这条消息,在TCP层占据的总长度为87字节,其中前4个字节00 00 00 53声明为83长度,为其余报文的长度。

这一模式还有很多变体,如

  • 声明的长度包括其长度字段本身的长度
  • 长度字段并不是打头的字段
  • 长度字段的长度

等等。这也就是下面解码器,拥有的参数非常多的原因,都是为了适配这些变体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import (
"encoding/binary"
"github.com/panjf2000/gnet"
)

type ExampleServer struct {
*gnet.EventServer
}

func main() {
encoderConfig := gnet.EncoderConfig{
ByteOrder: binary.BigEndian,
LengthFieldLength: 4,
LengthAdjustment: 0,
LengthIncludesLengthFieldLength: true,
}
decoderConfig := gnet.DecoderConfig{
ByteOrder: binary.BigEndian,
LengthFieldOffset: 0,
LengthFieldLength: 4,
LengthAdjustment: -4,
InitialBytesToStrip: 4,
}
codec := gnet.NewLengthFieldBasedFrameCodec(encoderConfig, decoderConfig)
gnet.Serve(&ExampleServer{}, "tcp://localhost:1998", gnet.WithCodec(codec))
}

事实上,长度字段编码格式是我见过开源代码使用最多的格式,像MQTT、KAFKA、SMPP等都使用这种格式。其中原因,个人觉得在于声明长度之后,buffer申请及释放,可以简化很多,性能最好。

其他网络协议使用的编码方式

MQTT

使用长度字段编码格式

image-20210704131034560

AMQP

AMQP的解析较为麻烦,它根据协议目前的状态,同时使用定长编码和长度字段两种编码方式。这就要求解码器不仅仅要处理报文,还要处理当前协议交互到那个状态了。

定长场景

image-20210704131231757

长度字段模式

image-20210704131317098

代码地址

开发一个filebeat的websocket插件, 代码仓地址: https://github.com/hezhangjian/beats_output_websocket

引入对beat的依赖

1
go get github.com/elastic/beats/v7

定义在filebeat中的配置文件

filebeat通常以配置文件的方式加载插件。让我们定义一下必须的配置,就像elasticsearch中的连接地址等等一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
output.websocket:
# worker
# 用于工作的websocket客户端数量
workers: 1
# 日志批量的最大大小
batch_size: 1
# 重试的最大次数,0代表不重试
retry_limit: 1
# conn
# ws/wss
schema: "ws"
# websocket连接地址
addr: "localhost:8080"
# websocket路径
path: "/echo"
# websocket心跳间隔,用于保活
ping_interval: 30

go文件中的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type clientConfig struct {
// Number of worker goroutines publishing log events
Workers int `config:"workers" validate:"min=1"`
// Max number of events in a batch to send to a single client
BatchSize int `config:"batch_size" validate:"min=1"`
// Max number of retries for single batch of events
RetryLimit int `config:"retry_limit"`
// Schema WebSocket Schema
Schema string `config:"schema"`
// Addr WebSocket Addr
Addr string `config:"addr"`
// Path WebSocket Path
Path string `config:"path"`
// PingInterval WebSocket PingInterval
PingInterval int `config:"ping_interval"`
}

初始化加载插件

加载插件

在某个init函数中注册插件

1
2
3
func init() {
outputs.RegisterType("websocket", newWsOutput)
}

newWsOutput中卸载配置,并提供配置给WebSocket客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func newWsOutput(_ outputs.IndexManager, _ beat.Info, stats outputs.Observer, cfg *common.Config) (outputs.Group, error) {
config := clientConfig{}
// 卸载配置,将配置用于初始化WebSocket客户端
if err := cfg.Unpack(&config); err != nil {
return outputs.Fail(err)
}
clients := make([]outputs.NetworkClient, config.Workers)
for i := 0; i < config.Workers; i++ {
clients[i] = &wsClient{
stats: stats,
Schema: config.Schema,
Host: config.Addr,
Path: config.Path,
PingInterval: config.PingInterval,
}
}

return outputs.SuccessNet(true, config.BatchSize, config.RetryLimit, clients)
}

初始化WebSocket客户端

WebSocket客户端不仅仅是一个WebSocket客户端,而且还需要实现filebeat中的NetworkClient接口,接下来,让我们来关注接口中的每一个方法的作用及实现

String()接口

String作为客户端的名字,用来标识日志以及指标。是最简单的一个接口

1
2
3
func (w *wsClient) String() string {
return "websocket"
}

Connect()接口

Connect用来初始化客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (w *wsClient) Connect() error {
u := url.URL{Scheme: w.Schema, Host: w.Host, Path: w.Path}
dial, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err == nil {
w.conn = dial
ticker := time.NewTicker(time.Duration(w.PingInterval) * time.Second)
go func() {
for range ticker.C {
w.conn.WriteMessage(websocket.PingMessage, nil)
}
}()
} else {
time.Sleep(10 * time.Second)
}
return err
}

注意,这里初始化失败,需要Sleep一段时间,否则,filebeat会一直重试。这绝非是你想要的。或许对于场景来说,退避重试可能会更好

Close()接口

关闭客户端,也是很简单的接口

1
2
3
func (w *wsClient) Close() error {
return w.conn.Close()
}

Publish()接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func (w *wsClient) Publish(_ context.Context, batch publisher.Batch) error {
events := batch.Events()
// 记录这批日志
w.stats.NewBatch(len(events))
failEvents, err := w.PublishEvents(events)
if err != nil {
// 如果发送正常,则ACK
batch.ACK()
} else {
// 发送失败,则重试。受RetryLimit的限制
batch.RetryEvents(failEvents)
}
return err
}

func (w *wsClient) PublishEvents(events []publisher.Event) ([]publisher.Event, error) {
for i, event := range events {
err := w.publishEvent(&event)
if err != nil {
// 如果单条消息发送失败,则将剩余的消息直接重试
return events[i:], err
}
}
return nil, nil
}

func (w *wsClient) publishEvent(event *publisher.Event) error {
bytes, err := encode(&event.Content)
if err != nil {
// 如果编码失败,就不重试了,重试也不会成功
// encode error, don't retry.
// consider being success
return nil
}
err = w.conn.WriteMessage(websocket.TextMessage, bytes)
if err != nil {
// 写入WebSocket Server失败
return err
}
return nil
}

编码

编码的逻辑因人而异,事实上,这可能是大家最大的差异所在。这里只是做一个简单地例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type LogOutput struct {
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
}

func encode(event *beat.Event) ([]byte, error) {
logOutput := &LogOutput{}
value, err := event.Fields.GetValue("message")
if err != nil {
return nil, err
}
logOutput.Timestamp = event.Timestamp
logOutput.Message = value.(string)
return json.Marshal(logOutput)
}

最后是我们的wsclient

1
2
3
4
5
6
7
8
9
10
type wsClient struct {
// construct field
Schema string
Host string
Path string
PingInterval int

stats outputs.Observer
conn *websocket.Conn
}

添加额外的功能:大包丢弃

你可能会想保护你的WebSocket服务器,避免接收到超级大的日志。我们可以在配置项中添加一个配置

maxLen用来限制日志长度,超过maxLen的日志直接丢弃。为什么不使用filebeat中的max_bytes

因为filebeatmax_bytes的默认行为是截断,截断的日志在某些场景下不如丢弃。(比如,日志是json格式,截断后格式无法解析)

配置中添加maxLen

1
max_len: 1024

省略掉那些重复的添加结构体,读取max_len在encode的时候忽略掉

1
2
3
4
s := value.(string)
if len(s) >= w.MaxLen {
return nil, err
}

参考资料

https://golang.org/ref/mem

TLDR

  • 协程之间的数据可见性满足HappensBefore法则,并具有传递性
  • 如果包 p 导入包 q,则 q 的 init 函数的完成发生在任何 p 的操作开始之前
  • main.main 函数的启动发生在所有 init 函数完成之后
  • go语句启动新的协程发生在新协程启动开始之前
  • go协程的退出并不保证发生在任何事件之前
  • channel上的发送发生在对应channel接收之前
  • 无bufferchannel的接收发生在发送操作完成之前
  • 对于容量为C的buffer channel来说,第k次从channel中接收,发生在第k + C次发送完成之前。
  • 对于任何的sync.Mutex或者sync.RWMutex变量,且有n<m,第n个调用UnLock一定发生在mLock`之前。
  • 从 once.Do(f) 对 f() 的单个调用返回在任何一个 once.Do(f) 返回之前。
  • 如果两个动作不满足HappensBefore,则顺序无法预测

介绍

Go内存模型指定了在何种条件下可以保证在一个 goroutine 中读取变量时观察到不同 goroutine 中写入该变量的值。

建议

通过多个协程并发修改数据的程序必须将操作序列化。为了序列化访问,通过channel操作或者其他同步原语(syncsync/atomic)来保护数据。

如果你必须要阅读本文的其他部分才能理解你程序的行为,请尽量不要这样…

Happens Before

在单个 goroutine 中,读取和写入的行为必须像按照程序指定的顺序执行一样。 也就是说,只有当重新排序不会改变语言规范定义的 goroutine 中的行为时,编译器和处理器才可以重新排序在单个 goroutine 中执行的读取和写入。 由于这种重新排序,一个 goroutine 观察到的执行顺序可能与另一个 goroutine 感知的顺序不同。 例如,如果一个 goroutine 执行 a = 1; b = 2;,另一个可能会在 a 的更新值之前观察到 b 的更新值。

为了满足读写的需求,我们定义了happens before,Go程序中内存操作的局部顺序。如果事件e1e2之前发生,我们说e2e1之后发生。还有,如果e1不在e2之前发生、e2也不在e1之前发生,那么我们说e1e2并发happen。

在单个goroutine中,happens-before顺序由程序指定。

当下面两个条件满足时,变量v的阅读操作r可能观察到写入操作w

  • r不在w之前发生
  • 没有其他的请求w2发生在w之后,r之前

为了保证r一定能阅读到v,保证wr能观测到的唯一的写操作。当下面两个条件满足时,r保证可以读取到w

  • wr之前发生
  • 任何其他对共享变量v的操作,要么在w之前发生,要么在r之后发生

这一对条件比上一对条件更强;这要求无论是w还是r,都没有相应的并发操作。

在单个goroutine中,没有并发。所以这两个定义等价:读操作r能读到最近一次w写入v的值。但是当多个goroutine访问共享变量时,它们必须使用同步事件来建立happens-before关系。

使用变量v类型的0值初始化变量v的行为类似于内存模型中的写入。

对于大于单个机器字长的值的读取和写入表现为未指定顺序的对多个机器字长的操作。

同步

初始化

程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建其他并发运行的 goroutine。

如果包 p 导入包 q,则 q 的 init 函数的完成发生在任何 p 的操作开始之前。

main.main 函数的启动发生在所有 init 函数完成之后。

Go协程的创建

go语句启动新的协程发生在新协程启动开始之前。

举个例子

1
2
3
4
5
6
7
8
9
10
var a string

func f() {
print(a)
}

func hello() {
a = "hello, world"
go f()
}

调用hello将会打印hello, world。当然,这个时候hello可能已经返回了。

Go协程的销毁

go协程的退出并不保证发生在任何事件之前

1
2
3
4
5
6
var a string

func hello() {
go func() { a = "hello" }()
print(a)
}

对 a 的赋值之后没有任何同步事件,因此不能保证任何其他 goroutine 都会观察到它。 事实上,激进的编译器可能会删除整个 go 语句。

如果一个 goroutine 的效果必须被另一个 goroutine 观察到,请使用同步机制,例如锁或通道通信来建立相对顺序。

通道通信

通道通信是在go协程之间传输数据的主要手段。在特定通道上的发送总有一个对应的channel的接收,通常是在另外一个协程。

channel上的发送发生在对应channel接收之前

1
2
3
4
5
6
7
8
9
10
11
12
13
var c = make(chan int, 10)
var a string

func f() {
a = "hello, world"
c <- 0
}

func main() {
go f()
<-c
print(a)
}

程序能保证输出hello, world。对a的写入发生在往c发送数据之前,往c发送数据又发生在从c接收数据之前,它又发生在print之前。

channel的关闭发生在从channel中获取到0值之前

在之前的例子中,将c<-0替换为close(c),程序还是能保证输出hello, world

无bufferchannel的接收发生在发送操作完成之前
这个程序,和之前一样,但是调换发送和接收操作,并且使用无buffer的channel

1
2
3
4
5
6
7
8
9
10
11
12
13
var c = make(chan int)
var a string

func f() {
a = "hello, world"
<-c
}

func main() {
go f()
c <- 0
print(a)
}

也保证能够输出hello, world。对a的写入发生在c的接收之前,继而发生在c的写入操作完成之前,继而发生在print之前。

如果该channel是bufferchannel(例如:c=make(chan int, 1)),那么程序就不能保证输出hello, world。可能会打印空字符串、崩溃等等。从而,我们得到一个相对通用的推论:

对于容量为C的buffer channel来说,第k次从channel中接收,发生在第k + C次发送完成之前。

此规则将先前的规则推广到缓冲通道。 它允许通过buffer channel 来模拟信号量:通道中的条数对应活跃的数量,通道的容量对应于最大并发数。向channel发送数据相当于获取信号量,从channel中接收数据相当于释放信号量。 这是限制并发的常用习惯用法。

该程序为工作列表中的每个条目启动一个 goroutine,但是 goroutine 使用limitchannel进行协调,以确保一次最多三个work函数正在运行。

1
2
3
4
5
6
7
8
9
10
11
12
var limit = make(chan int, 3)

func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}

sync包中实现了两种锁类型:sync.Mutexsync.RWMutex

对于任何的sync.Mutex或者sync.RWMutex变量,且有n<m,第n个调用UnLock一定发生在mLock`之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var l sync.Mutex
var a string

func f() {
a = "hello, world"
l.Unlock()
}

func main() {
l.Lock()
go f()
l.Lock()
print(a)
}

这个程序也保证输出hello,world。第一次调用unLock一定发生在第二次Lock调用之前

对于任何sync.RWMutexRLock方法调用,存在变量n,满足RLock方法发生在第nUnLock调用之后,并且对应的RUnlock发生在第n+1Lock方法之前。

Once

在存在多个 goroutine 时,sync包通过once提供了一种安全的初始化机制。对于特定的f,多个线程可以执行once.Do(f),但是只有一个会运行f(),另一个调用会阻塞,直到f()返回

从 once.Do(f) 对 f() 的单个调用返回在任何一个 once.Do(f) 返回之前。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a string
var once sync.Once

func setup() {
a = "hello, world"
}

func doprint() {
once.Do(setup)
print(a)
}

func twoprint() {
go doprint()
go doprint()
}

调用 twoprint 将只调用一次 setup。 setup函数将在任一打印调用之前完成。 结果将是hello, world打印两次。

不正确的同步

注意,读取r有可能观察到了由写入w并发写入的值。尽管观察到了这个值,也并不意味着r后续的读取可以读取到w之前的写入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a, b int

func f() {
a = 1
b = 2
}

func g() {
print(b)
print(a)
}

func main() {
go f()
g()
}

有可能g会接连打印2和0两个值。

双检查锁是为了降低同步造成的开销。举个例子,twoprint方法可能会被误写成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var a string
var done bool

func setup() {
a = "hello, world"
done = true
}

func doprint() {
if !done {
once.Do(setup)
}
print(a)
}

func twoprint() {
go doprint()
go doprint()
}

因为没有任何机制保证,协程观察到done为true的同时可以观测到a为hello, world,其中有一个doprint可能会输出空字符。

另外一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a string
var done bool

func setup() {
a = "hello, world"
done = true
}

func main() {
go setup()
for !done {
}
print(a)
}

和以前一样,不能保证在 main 中,观察对 done 的写入意味着观察对 a 的写入,因此该程序也可以打印一个空字符串。 更糟糕的情况下,由于两个线程之间没有同步事件,因此无法保证 main 会观察到对 done 的写入。 main 中的循环会一直死循环。

下面是该例子的一个更微妙的变体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type T struct {
msg string
}

var g *T

func setup() {
t := new(T)
t.msg = "hello, world"
g = t
}

func main() {
go setup()
for g == nil {
}
print(g.msg)
}

尽管main观测到g不为nil,但是也没有任何机制保证可以读取到t.msg。

在上述例子中,解决方案都是相同的:请使用显式的同步机制。

参考资料

阶段

Go编译器由四个阶段组成,可以分为两类

  • frontend前端:这一阶段对源码进行语法解析,并生成AST
  • backend后端:这一阶段将把transform the representation of the source code into machine code, 并进行数项优化

为了更好地理解每个阶段,让我们使用如下的示例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

func main() {
a := 1
b := 2
if true {
add(a, b)
}
}

func add(a, b int) {
println(a + b)
}

P1 解析

  • cmd/compile/internal/syntax 词法,解析器,语法树

第一个阶段非常简单直接:

第一阶段,源码经过词法分析、语法解析,对每个源码文件,都构造出相应的语法树

Lexer首先运行,把源代码转化为词法单元。我们可以通过这个程序来自己模拟运行Lexer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"fmt"
"go/scanner"
"go/token"
"io/ioutil"
)

func main() {
// src is the input that we want to tokenize.
src, _ := ioutil.ReadFile(`main.go`)

// Initialize the scanner
var s scanner.Scanner
// positions are relative to fSet
fSet := token.NewFileSet()
file := fSet.AddFile("", fSet.Base(), len(src))
// nil means no error handler
s.Init(file, src, nil, scanner.ScanComments)

// Repeated calls to Scan yield the token sequence found in the input
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break
}
fmt.Printf("%s\t%s\t%q\n", fSet.Position(pos), tok, lit)
}
}

截选输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1:1	package	"package"
1:9 IDENT "main"
1:13 ; "\n"
3:1 func "func"
3:6 IDENT "main"
3:10 ( ""
3:11 ) ""
3:13 { ""
4:2 IDENT "a"
4:4 := ""
4:7 INT "1"
4:8 ; "\n"
5:2 IDENT "b"
5:4 := ""
5:7 INT "2"
5:8 ; "\n"
6:2 if "if"
6:5 IDENT "true"
6:10 { ""
7:3 IDENT "add"
7:6 ( ""
7:7 IDENT "a"

一旦经过词法化,源码被解析构造成语法树。

语法树还包含了代码位置信息,该信息可用于debug或错误报告。

P2 类型检查和AST转化

  • cmd/compile/internal/gc 创建编译器AST,类型检查,AST转换

AST是类型检查的。第一个步骤就是名字解析和类型推断,确定对象和标识符的对应关系,表达式是何种类型。Type-checking这一阶段还引入了额外的确定性步骤,例如,“声明未使用”、函数是否终止等。

还有一些确定的转换也在AST阶段完成。一些节点会根据类型信息进行细化,比如字符串加法从算术加法节点中分离出来。其他一些示例是不可达代码清除、内联函数调用、逃逸分析。

转化到AST的步骤可以通过命令go tool compile -w来展示出来,如果加上-l,则可以禁用内联。在我们的样例代码中,如果不禁用内联,add方法会被内联掉。我们可以分别使用go tool compile -w example.ogo tool compile -w -l example.o进行对比

禁用了内联的命令,会输出这样的AST

image-20210701110847543

没禁用内联的命令则不会生成,这里可以看出来,编译器做了内联的优化。

SSA 生成

SSA 概念

  • cmd/compile/internal/gc AST转化到SSA
  • cmd/compile/internal/ssa SSA阶段和规则

在这个阶段,AST转化为SSA的格式,这是一种具有特定属性的更底层的IR,可以更轻松地在上面进行优化并最终生成机器码。阶段应用了内联函数。这些是编译器被教导要根据具体情况用高度优化的代码替换的特殊函数。在AST到SSA的转换期间,某些确定的节点也被降低为更简单的组件,使得编译器的其余部分可以使用它们。例如,内置的copy函数被内存移动取代、范围循环被重写为for循环。由于历史原因,其中一些目前在SSA转换之前发生,但长期计划是将它们全部移到这里。

然后,应用一系列的、机器无关的阶段和规则。这些不涉及任何的计算机架构,因此可以在任何GOARCH变体上运行。

这些通用的阶段包括:不可达代码清除、删除不需要的nil检查、移除无用的分支。

通用的重写规则主要涉及表达式,包括表达式替换为常量、优化乘法和浮点运算等。

SSA code可以用这个命令dump并展示出来

1
GOSSAFUNC=main go tool compile main.go && open ssa.html

SSA阶段

Go编译流程

SSA优化解析

start Tab上生成了最开始的SSA

image-20210701101016958

变量 a 和 b 与 if 条件一起在此处突出显示,以便我们稍后查看这些行是如何更改的。 代码还向我们展示了编译器如何管理 println
函数,它被分解为 4 个步骤:printlockprintintprintnlprintunlock。 编译器会自动为我们加锁,并根据参数的类型调用相关方法正确打印。
在我们的示例中,由于 a 和 b 在编译时已知,编译器可以计算最终结果并将变量标记为不再需要。 opt阶段 会优化这部分:

image-20210701101445933

这个阶段v7被优化计算成了3。并且接下来,因为v4和v5已经没有人声明使用,在opt deadcode阶段,v4和v5也会被清除掉

image-20210701101729496

等待所有阶段完成之后,Go编译器将会生成中间汇编语言

image-20210701102735403

下一阶段会将汇编语言转换为二进制文件

机器代码生成

  • cmd/compile/internal/ssa SSA “lowering” 和 特定arch的阶段
  • cmd/internal/obj 机器语言生成

机器相关的编译阶段从”lowering”阶段开始,它将通用的值替换成机器特定的变体。例如,在 amd64 内存操作数上是可能的,因此可以组合许多加载-存储操作。

注意这些底层阶段执行了所有机器特定的规则,所以也应用了很多优化。

一旦SSA被”lowered”到更特定的目标架构,就开始执行最终的代码优化。这包括另一个不可达代码清除阶段、将值更靠近它们的使用者、移除从未使用的本地变量、寄存器分配。

还有一部分重要工作包括堆栈帧布局,它将堆栈偏移分配给局部变量,以及指针存活分析,它计算每个 GC 安全点上哪些堆栈上指针是活跃的。

在 SSA 生成阶段结束时,Go 函数已转换为一系列 obj.Prog 指令。 这些被传递给汇编器(cmd/internal/obj),汇编器将它们转换成机器代码并写出最终的目标文件。
目标文件还将包含反射数据、导出数据和调试信息。

我们可以使用go tool objdump $binary来查看汇编代码。当compile的.o文件生成之后,可以通过go tool link来生成二进制可运行文件。

0%