Go知识点杂记

Go语言的不足

我在使用 Go 语言编程开发时发现的一些问题,记录:

  1. 没有泛型,写工具库时很不方便
  2. Go 把错误当成一种函数返回值来处理自有其设计道理,但是一次只能只能处理一个错误则是语法上的不足
  3. 编译器不支持尾递归优化,这对于递归代码而言很不友好
  4. strings 库不像 Java 那样直接支持正则
  5. 没有三目运算符
  6. 官方库提供的锁不支持重入
  7. Go静态链接编译的做法会把二进制包撑的特别大,虽然这带来了较好的可移植性
  8. 使用首字母大小写这种隐式的做法来区分变量可见性很不直观,同时也会限制程序的命名风格,申明一个关键字则会好的很多
  9. 没有集合的支持,虽然用散列表实现也就是一行代码的事,但是可读性上来说有很大区别
  10. 协程只能主动退出,无法kill 或者 interrupt
  11. 不像Java有 swing,python 有 tkinter,go 语言就没有自带的 GUI 库,而且现在的第三方库也没有很成熟的实现,这在一定程度上限制了 go 在构建GUI应用的能力
  12. 协程 panic 没有 recover 住就会导致整个进程崩溃,缺乏从语法上兜底的手段

基本语法

for i 遍历和 for range 遍历有什么区别

后者是前者的语法糖,编译在编译的时候还是会转化成 for i 的方式。

for v1,v2 := range ha 遍历数组的实际运行格式:

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
    tmp := ha[hv1] 
    v1, v2 := hv1, tmp
    ...
}

for hv1 := range h1 遍历 channel 实际运行格式:

ha := a
hv1, hb := <-ha
for ; hb != false; hv1, hb = <-ha {
    v1 := hv1
    hv1 = nil
    ...
}

所以不难解释如下代码为什么不会永远运行了,因为这个遍历的次数一开始就根据切片的长度确定了:

func main() {
	arr := []int{1, 2, 3}
	for _, v := range arr {
		arr = append(arr, v)
	}
	fmt.Println(arr)
}

$ go run main.go
1 2 3 1 2 3

Go字符串实现原理

字符串的实现其实和切片比较像,只是没有容量而已:

type StringHeader struct {
	Data uintptr
	Len  int
}

字符串的底层是一个只读的字节数组指针:[]byte,所以其实也不用担心字符串在传递时的性能开销。

因为 string 是只读的,而字节数组是可以修改的,所以在和 []byte 相互转换时会通过:memmove 复制内存,因此我们最好尽量避免这两者之间的频繁转换,空间复杂度为 O(len(string)),开销还是比较大的。

Go 里的字符串是使用 UTF-8 编码的,相较于 Unicode,英文字符等跟 ASCII 一样只要占用一个字节,中文字符在 ASCII 下基本都要占用三个字节,在使用 for range 遍历 string 时,是按照字符而不是字节去遍历,值也是用 rune 类型承接的,不过每次的序号还是这个字符的第一个字节的下标。

我们在需要字符的序号时,可以把字符串转成 []rune 数组去遍历。

同时我们也可以转成 []byte 强制其按字节遍历。

func main() {
	str := "你好啊今天天气真不错"
	runeTotal, byteTotal := 0, 0
	for range str {
		runeTotal++
	}
	for range []byte(str) {
		byteTotal++
	}
	fmt.Printf("字符数:%d 字节数:%d 长度:%d", runeTotal, byteTotal, len(str))
}

输出:字符数:10 字节数:30 长度:30

Go 为什么使用 Plan9 作为汇编语言?

因为 Go 的作者大部分以前都是搞 Plan9 的,他们在 Go 里重新启用了这种类似的汇编语法,微跨平台汇编语法,所有指令集通用的 ADD MOV JUMP PUSH 之类的指令在 Plan9 里相当于有了一个统一的指令名称,有的指令实在是只在某个指令集上有,那就没办法了,只能说能省一点力气是一点力气。

编译为 Plan9 汇编之后,Go 实现了一个比较轻量的汇编器来真正将 Plan9 翻译成对应平台的机器码。

https://9p.io/sys/doc/asm.html

Go 反射的实现原理

[Go 反射的的三大定律]()

反射的入口主要是两个方法:reflect.TypeOf 和 reflect.ValueOf,对应的它们的入参都是一个 interface{},interface{} 内部会有两个变量,一个是 type,一个是指向原始数据的指针,而反射的两个方法就是分别获取的对应的这两个值。

reflect.TypeOf:

func TypeOf(i interface{}) Type {
	eface := *(*emptyInterface)(unsafe.Pointer(&i))
	return toType(eface.typ)
}

reflect.reflect.ValueOf:

func ValueOf(i interface{}) Value {
	e := (*emptyInterface)(unsafe.Pointer(&i))
	t := e.typ
	f := flag(t.Kind())
	if ifaceIndir(t) {
		f |= flagIndir
	}
	return Value{t, e.word, f}
}

变量之间的可比较性

比较运算符:

==    equal
!=    not equal
<     less
<=    less or equal
>     greater
>=    greater or equal

go 的比较都是基于值去比较的,结构体则会一个一个值的判断,而指针则会比较指针的内存地址,不过我们可以调用reflect.DeepEqual 来强行判断指向的值是否相等。

但也并不是所有的类型都是可以比较的。

不可以比较的类型:

  • 切片:切片是引用类型, 如果只判断指针地址的话,没有太大意义并且会带来很大的困惑,而如果比较值的,因为是引用类型,会出现自身指向自身这种导致程序崩溃的情况,所以 go 团队干脆就没有实现这个了,Example:

    	// 切片,自身指向自身,会 stack overflow
    	a := []interface{}{ 1, 2.0 }
    	a[1] = a
    	fmt.Println(a) // 输出:fatal error: stack overflow
      	
    	// 而数组则不会
    	b := [2]interface{}{ 1, 2.0 }
    	b[1] = b
    	fmt.Println(b) // 输出:[1 [1 2]]
    
  • map: map 和切片是一样的,也会出现循环引用的场景。

所有包含这两种类型的类型也仍然是不可比较的,比如切片的数组,包含有切片的结构体。

另外 map 的 key 判断也是使用的 == 符号,所以也不能使用不可比较的类型做为 map 的 key。

复杂类型的可比较性的实现原理是,它的类型 _type 中会有一个 equal 方法:func(unsafe.Pointer, unsafe.Pointer) bool,如果为 nil,则代表这个类型是不可比较的。

内置类型的 equal 方法实现于 src/runtime/alg.go 文件中。

另外有一个特殊的地方,即使是 map slice 这种不能比较的类型,也是可以和 nil 判等的。Go 变量初始化的执行顺序

这里写图片描述

main 函数是系统的入口,从 main 函数所在的包开始初始化:

包之间:一个包在初始化之前,会按照引入顺序先初始化其所依赖的的包,一直递归下去;

包内:一个包内不同的文件之间会按照文件名字符顺序初始化:a.go > b.go

文件内:初始化按照字段类型顺序为:常量 -> 变量 -> init 函数

一种字段类型内:按照申明顺序从上到下初始化

关键字

defer的实现原理

defer 关键字在调用的时候会通过 runtime.deferproc 创建出来一个 runtime._defer 结构体,这就是 defer 的运行时表示:

type _defer struct {
	siz     int32 		 // defer 参数长度,单位字节
	openDefer bool
	sp        uintptr  // sp
	pc        uintptr  // pc
	fn        *funcval // 指向对应的方法
	_panic    *_panic  // 指向调用 defer 的 panic
	link      *_defer	 // 指向下一个 defer 

	fd   unsafe.Pointer 
	varp uintptr
	framepc uintptr
}

runtime.deferproc 的逻辑也很简单,传入参数的总长度,获取到当前协程,创建defer结构体挂到协程 defer 队列的头部:

func newdefer(siz int32) *_defer {
   var d *_defer
   gp := getg() // get the current goroutine
   [...]
   // deferred list is now attached to the new _defer struct
   d.link = gp._defer 
   gp._defer = d // the new struct is now the first to be called
   return d
}

defer 在调用的时候会立即计算出参数的值,这是容易让很多人困惑的一点:

func main() {
	i := 0
	defer func(i int) {
		println(i)
	}(i)
	i++
}

输出:0,即使 main 函数退出的时候 i 的值已经变成了1

在函数退出或者 panic 的时候就会把函数出栈同时调用 runtime.deferreturn 把当前协程队列的第一个协程执行,因此 defer 的执行顺序总是 Last-In-First-Out 的。

defer执行顺序

Last-In-First-Out

接口

interface{} 之 eface 的实现

go 里的接口有两种,带方法的接口:iface,和不带方法的 eface,我们平常在处理任意类型时所用的参数类型,就是一种特殊的接口 interface{},这个接口没有方法,所以所有的结构体都实现了它,由于 go 的接口是 duck type 的,所以任何结构体都可以传递到以 interface{} 为参数的方法中,这种传值方式跟:io.Reader 这种接口其实是一个性质。空的接口在 go 中做了特殊处理,interface{} 就是 eface,eface 包括一个类型指针和一个指向真实数据的指针。

type eface struct { // 16 bytes
	_type *_type
	data  unsafe.Pointer
}

接口判空:判断如下代码返回的结果

func main() {
   println(ErrorA() == nil)
   println(ErrorB() == nil)
}

func ErrorA() error {
   return nil
}

func ErrorB() error {
   var err *os.PathError
   return err
}

返回的结果会是:true,false。

因为 Go 里面 interface 由两个元素组成,Type 和 Value,Type 表示对象的具体类型,Value 指向对应的内存地址,只有当 Type 和 Value 都为空时,interface 才为空。

而函数 B 中将 err 变量转换为了 error interface 返回的,而它的 Type 为 *os.PathError 不为空,所以判断是否为 nil 会返回否定的结果。

而在方法 A 中,虽然 nil 也被转换成了 interface,但是它的 Type 就是 nil,所以能走到正常的逻辑。

这也是为什么当代码中没有错误时推荐显示返回 nil 而不是空指针的原因了。

容器

Go Map 遍历顺序是固定的吗

不是,map 遍历并不保证遍历顺序的固定同时也不是有序的,Go 甚至会在用 for range 遍历 map 时,生成一个随机数,从随机的桶开始遍历,就是为了让大家不要依赖 map 遍历的顺序。 如果需要一个有序的 map 的话,需要自己引入其他的空间自行排序:

var m map[int]string
var keys []int
for k := range m {
    keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
    fmt.Println("Key:", k, "Value:", m[k])
}

切片是如何扩容的

参考下面这个问题。

切片的实现原理

Rob Pike 对于 slice 的定义:切片一种描述数组中的一个连续片段

我们在创建切片时,会先创建一个数组,然后再通过 [:] 的方式转化为切片:

slice := []int{1, 2, 3} 

的实现原理

var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]

[:]是创建切片的最底层方法,其他方式都是它的语法糖。

Go 语言中切片在运行时是用 SliceHeader 结构体表示的:

type SliceHeader struct {
   Data uintptr
   Len  int
   Cap  int
}

指向底层数组第一个元素的指针,切片的长度和容量;切片是动态的,可以通过 append 来增加元素,append 的逻辑如下,以 append 三个元素为例:

// append(slice, 1, 2, 3)
ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)

如果 cap 够用,那么会直接在底层数组上赋值并右移,所以这里会存在一个 bug:

func main() {
	a := make([]int, 4, 8)
	b := append(a, 3, 3, 3)
	a = append(a, 4, 4, 4) 
	println(b[4])
}
最终输出:4

go slice 如果只基于 cap 够不够去判断要不要申请一个新数组的话,
第四行 a 的 len=4,cap=8,看起来是容量够用的,会直接在底层数组往后追加,而 b 和 a 指向的是同一个底层数组,b append 的值就会被覆盖。

因此切片在 append 之后,最好就不要再使用原来的值了,或者直接将新的切片赋值到原来的变量上去。

当如果 cap 不够用的时候,就会触发切片的扩容机制:

在容量小于 1024 时,会复制成双倍,当容量大于 1024 时,会一次扩 1/4,直到可以满足需求。

func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}

不过再初步确定了容量之后,还有一个逻辑:

capmem = roundupsize(uintptr(newcap))
newcap = int(capmem / et.size)

会根据类型进行内存对齐,会在双倍的基础上再对容量根据最佳对齐值进行一定的向上取整。

切片拷贝时会调用:memmove,将整块内存空间全部复制到新的地方,同时把源切片的 Len 复制给新的切片,而不是一个一个元素在 Go 代码层面的复制。

空切片和 nil 有什么区别: a := []int{} a := nil

我们可以通过 *(*reflect.SliceHeader)(unsafe.Pointer(&slice))显示的将切片转化为其底层数据结构,便于分析:

func main() {
	var a, b, c []int
	b = nil
	c = []int{}
	aHeader := *(*reflect.SliceHeader)(unsafe.Pointer(&a))
	bHeader := *(*reflect.SliceHeader)(unsafe.Pointer(&b))
	cHeader := *(*reflect.SliceHeader)(unsafe.Pointer(&c))
	fmt.Println(aHeader)
	fmt.Println(bHeader)
	fmt.Println(cHeader)
}

输出:
{0 0 0}
{0 0 0}
{824634355400 0 0}

我们分别用申明变量、显示赋值为 nil、初始化一个空切片的方式构造了三个切片,转化为底层数据结构在打印日志可以发现,前两种方式指向数组的指针其实并没有初始化,而第三种方式 Data 会指向一个数组,虽然它的长度为0。

这个不同会导致前两者在 json 序列化的时候被序列化成:null,而空切片则会序列化为:[]

func main() {
	var a, b, c []int
	b = nil
	c = []int{}
	aBytes, _ := json.Marshal(a)
	bBytes, _ := json.Marshal(b)
	cBytes, _ := json.Marshal(c)
	println(string(aBytes))
	println(string(bBytes))
	println(string(cBytes))
}

输出:
null
null
[]

长度不同的数组是同样的类型吗?

不是;[2]int 和 [3]int 并不是一个类型,因为他们的内存结构不是一样的,在 64 位机器下,[2]int 是占用两个16个字节,而[3]int 占用24个字节,不过切片的内存布局是一样的,都是 runtime.SliceHeader 结构体,数据部分只是一个指针。

reflect.TypeOf([3]int{}).String() 的结果是:[3]int,长度也是数组的一个元信息。

但它们的 Kind 是一样的,都是:array。

并发

select 实现超时逻辑

time.After 会在指定时间之后在返回的管道中发送当前时间,这样循环就会退出。

time.After 封装了一下 timer.Timer,在计时结束之后就 Timer 对象就会被垃圾回收。

for {
  select {
	case <-time.After(10 * time.Second):
  	break;
	default:
		// do something
  }
}

sync 包相关库实现原理

参考:https://huweicai.com/go-package-sync/

内存模型与GC

Go性能优化

找到瓶颈点

  • go tool pprof 分析内存
  • go tool trace 协程运行分析
  • go build -gcflags=“-m” 查看逃逸分析,编译器优化

优化 GC

  • map 存值而不是指针,指针会影响 gc 标记时长

减少内存拷贝

  • 大对象使用指针而不是值类型,减少方法传递时带来的性能损耗
  • 尽量避免在 []byte 和 string 两种类型之间频繁转换,每一次都要完整拷贝内存,性能影响很大
  • 使用 strings.Builder 而不是 butes.buffer 甚至是加号来拼接字符串,性能从左到右依次递减,bytes.buffer 底层是一个byte数组,最后会通过 string([]byte) 转成 string,会发生一次内存拷贝,而用加号拼接,每拼接一次都会申请一块新的内存,性能更差
  • 使用切片时尽量预分配长度,无法确定长度也可以先尽量预分配容量,减少切片扩容带来的内存拷贝

减少内存分配

  • 小对象使用值类型而不是指针,指针会导致逃逸分析失效,对象分配在堆上,对于小对象而言得不偿失
  • 使用空 struct{} 来进行占位,不需要分配内存,编译器会将空 struct{} 全部指向 runtime.zerobase
  • 合理复用对象,或者使用对象池:sync.Pool,减少内存分配,同时降低 GC [压力]()

并发优化

  • 使用 atomic.CAS atomic.Add 在单次操作上替代 sync.Mutex,前者只是一个 CPU 指令,而后者则是负责的软件逻辑

其他工具库

go Timer 实现原理

首先如果让我们自己去实现一个 Timer 的话,我们可以计算出来要具体下一次调度的时间,然后 sleep 对应的时长就好了,但是如果创建了很多的 Timer 的话,这样的性能其实是比较差的,go 里面会有一个协程统一负责管理 timer 来执行我们上面这个操作,所有的 timer 会放在一个堆中进行管理,每次取堆顶的值来判断要 sleep 多久即可。

待更新

  • select 多个条件满足时会走哪一个分支
  • go gc 三色标记+写屏障
  • go 协程调度模型:Machine-Processor-Goroutine
  • 什么是闭包
  • iota
  • Go Happens-Before
  • Go 内存模型