学习一下go语言基础所做笔记,主要参考《Go专家编程》和《Go程序设计语言》这两本书。
一 defer
defer语句用于延迟函数的调用, 每次defer都会把一个函数压入栈中, 函数返回前再把延迟的函数取出并执行。
为了方便描述, 我们把创建defer的函数称为主函数, defer语句后面的函数称为延迟函数。
延迟函数可能有输入参数, 这些参数可能来源于定义defer的函数, 延迟函数也可能引用主函数用于返回的变量, 也就是说延迟函数可能会影响主函数的一些行为, 这些场景下, 如果不了解defer的规则很容易出错。
实现原理
源码包src/src/runtime/runtime2.go:_defer定义了defer的数据结构:
我们知道defer后面一定要接一个函数的,所以defer的数据结构跟一般函数类似,也有栈地址、程序计数器、函数地址等等。
与函数不同的一点是它含有一个指针,可用于指向另一个defer,每个goroutine数据结构中实际上也有一个defer指针,该指针指向一个defer的单链表,每次声明一个defer时就将defer插入到单链表表头,每次执行defer时就从单链表表头取出一个defer执行。
下图展示多个defer被链接的过程:
从上图可以看到,新声明的defer总是添加到链表头部。
函数返回前执行defer则是从链表首部依次取出执行,不再赘述。
一个goroutine可能连续调用多个函数,defer添加过程跟上述流程一致,进入函数时添加defer,离开函数时取出defer,所以即便调用多个函数,也总是能保证defer是按LIFO方式执行的。
源码包 src/runtime/panic.go 定义了两个方法分别用于创建defer和执行defer。
- deferproc(): 在声明defer处调用, 其将defer函数存入goroutine的链表中;
- deferreturn(): 在return指令, 准确的讲是在ret指令前调用, 其将defer从goroutine链表中取出并执行。
可以简单这么理解, 在编译在阶段, 声明defer处插入了函数deferproc(), 在函数return前插入了函数deferreturn()。
defer规则
Golang官方博客里总结了defer的行为规则, 只有三条, 我们围绕这三条进行说明。 我觉得把书上的顺序反过来更好懂,先讲原理,再讲规则的时候就容易理解规则。
规则一: 延迟函数的参数在defer语句出现时就已经确定下来了
func a() {
i := 0
defer fmt.Println(i)
i++
return
}
defer语句中的fmt.Println()参数i值在defer出现时就已经确定下来,实际上是拷贝了一份。后面对变量i的修改不会影响fmt.Println()函数的执行,仍然打印”0”。
注意:对于指针类型参数,规则仍然适用,只不过延迟函数的参数是一个地址值,这种情况下,defer后面的语句对变量的修改可能会影响延迟函数。(ps:其实感觉就是基本类型存的是值,其他的是指针,指针指向的数据可能后续被修改)
规则二: 延迟函数执行按后进先出顺序执行, 即先出现的defer最后执行
这个我感觉没啥说的,看原理图就能明白FIFO,defer A()->defer B()->defer C(),执行顺序位C()->B()->A()。
规则三: 延迟函数可能操作主函数的具名返回值
1.函数返回过程
有一个事实必须要了解,关键字return不是一个原子操作,实际上return只代理汇编指令ret,即将跳转程序执行。比如语句return i,实际上分两步进行,即将i值存入栈中作为返回值,然后执行跳转,而defer的执行时机正是跳转前,所以说defer执行时还是有机会操作返回值的。
举个实际的例子进行说明这个过程:
func deferFuncReturn() (result int) {
i := 1
defer func() {
result++
}()
return i
}
该函数的return语句可以拆分成下面两行:
result = i
return
而延迟函数的执行正是在return之前,即加入defer后的执行过程如下:
result = i
result++
return
所以上面函数实际返回i++值。
关于主函数有不同的返回方式,但返回机制就如上机介绍所说,只要把return语句拆开都可以很好的理解,下面分别举例说明。
2.主函数拥有匿名返回值,返回字面值
一个主函数拥有一个匿名的返回值,返回时使用字面值,比如返回”1”、”2”、”Hello”这样的值,这种情况下defer语句是无法操作返回值的。
一个返回字面值的函数,如下所示:
func foo() int {
var i int
defer func() {
i++
}()
return 1
}
上面的return语句,直接把1写入栈中作为返回值,延迟函数无法操作该返回值,所以就无法影响返回值。
3.主函数拥有匿名返回值,返回变量
一个主函数拥有一个匿名的返回值,返回使用本地或全局变量,这种情况下defer语句可以引用到返回值,但不会改变返回值。
一个返回本地变量的函数,如下所示:
func foo() int {
var i int
defer func() {
i++
}()
return i
}
上面的函数,返回一个局部变量,同时defer函数也会操作这个局部变量。对于匿名返回值来说,可以假定仍然有一个变量存储返回值,假定返回值变量为”anony”,上面的返回语句可以拆分成以下过程:
anony = i
i++
return
由于i是整型,会将值拷贝给anony,所以defer语句中修改i值,对函数返回值不造成影响。
4.主函数拥有具名返回值
主函声明语句中带名字的返回值,会被初始化成一个局部变量,函数内部可以像使用局部变量一样使用该返回值。如果defer语句操作该返回值,可能会改变返回结果。
一个影响函返回值的例子:
func foo() (ret int) {
defer func() {
ret++
}()
return 0
}
上面的函数拆解出来,如下所示:
ret = 0
ret++
return
函数真正返回前,在defer中对返回值做了+1操作,所以函数最终返回1。
其实规则三相当于是规则一的扩展,return 0 其实就等于 ret = 0,return ret。理解了这个,可以猜猜这个结果是什么?
func foo() (ret int) {
defer func() {
ret++
}()
ret++
return 0
}
二 select
select是Golang在语言层面提供的多路IO复用的机制, 其可以检测多个channel是否ready(即是否可读或可写),使用起来非常方便。
Golang实现select时, 定义了一个数据结构表示每个case语句(含defaut, default实际上是一种特殊的case), select执行过程可以类比成一个函数, 函数输入case数组, 输出选中的case, 然后程序流程转到选中的case块。
实现原理
源码包 src/runtime/select.go:scase 定义了表示case语句的数据结构:
源码包 src/runtime/select.go:selectgo() 定义了select选择case的函数:
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)
函数参数:
- cas0为scase数组的首地址,selectgo()就是从这些scase中找出一个返回。
- order0为一个两倍cas0数组长度的buffer,保存scase随机序列pollorder和scase中channel地址序列lockorder
-
- pollorder:每次selectgo执行都会把scase序列打乱,以达到随机检测case的目的。
- lockorder:所有case语句中channel序列,以达到去重防止对channel加锁时重复加锁的目的。
- ncases表示scase数组的长度
函数返回值:
- int: 选中case的编号,这个case编号跟代码一致
- bool: 是否成功从channle中读取了数据,如果选中的case是从channel中读数据,则该返回值表示是否读取成功。
selectgo实现伪代码如下:
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool) {
//1. 锁定scase语句中所有的channel
//2. 按照随机顺序检测scase中的channel是否ready
// 2.1 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
// 2.2 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
// 2.3 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
//3. 所有case都未ready,且没有default语句
// 3.1 将当前协程加入到所有channel的等待队列
// 3.2 当将协程转入阻塞,等待被唤醒
//4. 唤醒后返回channel对应的case index
// 4.1 如果是读操作,解锁所有的channel,然后返回(case index, true)
// 4.2 如果是写操作,解锁所有的channel,然后返回(case index, false)
}
特别说明:对于读channel的case来说,如case elem, ok := <-chan1:, 如果channel有可能被其他协程关闭的情况下,一定要检测读取是否成功,因为close的channel也有可能返回,此时ok == false。
为了方便理解,我自己也画了一个流程图
注意:当多个 channel 同时就绪时,select 不会按照代码中 case 的书写顺序依次检测,而是随机打乱顺序选择一个就绪的 case 执行。
三 range
range是Golang提供的一种迭代遍历手段, 可操作的类型有数组、 切片、 Map、 channel等, 实际使用频率非常高。
实现原理
对于for-range语句的实现, 可以从编译器源码中找到答案。 编译器源码 gofrontend/go/statements.cc/For_range_statement::do_lower() 方法中有如下注释
// Arrange to do a loop appropriate for the type. We will produce
// for INIT ; COND ; POST {
// ITER_INIT
// INDEX = INDEX_TEMP
// VALUE = VALUE_TEMP // If there is a value
// original statements
// }
前面是书上写的,下面我自己尝试去理解了一下:
// Arrange to do a loop appropriate for the type. We will produce
// for i=0 ; i < len ; i++ {
// ITER_INIT //初始化操作,比如初始化 INDEX_TEMP和VALUE_TEMP
// INDEX = INDEX_TEMP //没什么说的赋值
// VALUE = VALUE_TEMP // If there is a value
// original statements //用户编写的原始代码
// }
slice
有了上面的代码,直接套公式就行了,也就是value=slice[index]
// The loop we generate:
// for_temp := range
// len_temp := len(for_temp)
// for index_temp = 0; index_temp < len_temp; index_temp++ {
// value_temp = for_temp[index_temp]
// index = index_temp
// value = value_temp
// original body
// }
遍历slice前会先获以slice的长度len_temp作为循环次数, 循环体中, 每次循环会先获取元素值, 如果forrange中接收index和value的话, 则会对index和value进行一次赋值。
由于循环开始前循环次数就已经确定了, 所以循环过程中新添加的元素是没办法遍历到的。
另外, 数组与数组指针的遍历过程与slice基本一致, 不再赘述。
map
猜一猜的话也能想到index_temp=key,value_temp=value。
// The loop we generate:
// var hiter map_iteration_struct
// for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) {
// index_temp = *hiter.key
// value_temp = *hiter.val
// index = index_temp
// value = value_temp
// original body
// }
遍历map时没有指定循环次数, 循环体与遍历slice类似。 由于map底层实现与slice不同, map底层使用hash表实现, 插入数据位置是随机的, 所以遍历过程中新插入的数据不能保证遍历到。
channel
这个就只能瞎猜了,index_temp = <-channel的返回值,for循环是一个死循环。
// The loop we generate:
// for {
// index_temp, ok_temp = <-range //阻塞读取,如果ok_temp为false,代表管道已关闭
// if !ok_temp {
// break
// }
// index = index_temp
// original body
// }
channel遍历是依次从channel中读取数据,读取前是不知道里面有多少个元素的。如果channel中没有元素,则会阻塞等待,如果channel已被关闭,则会解除阻塞并退出循环。
注:
- 上述注释中index_temp实际上描述是有误的,应该为value_temp,因为index对于channel是没有意义的。
- 使用for-range遍历channel时只能获取一个返回值。
编程Tips
- 遍历过程中可以适情况放弃接收index或value, 可以一定程度上提升性能
- 遍历channel时, 如果channel中没有数据, 可能会阻塞
- 尽量避免遍历过程中修改原数据