go语言笔记之defer、select和range(四)

73 阅读10分钟

学习一下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数组的长度

函数返回值:

  1. int: 选中case的编号,这个case编号跟代码一致
  2. 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中没有数据, 可能会阻塞
  • 尽量避免遍历过程中修改原数据