Golang中的defer、select和range的底层原理
一、Defer
defer语句用于延迟函数的调用,每次defer都会把一个函数压入栈中,当前函数返回前,再把延迟的函数取出来并执行,所以多个defer语句是后写的先执行的。
几个题目
问题1:以下输出什么?
func deferFuncParameter() {
var aInt = 1
defer fmt.Println(aInt)
aInt = 2
return
}
答:输出1,因为defer调用的时候,传进去的参数就确定是1,并且压入栈中,defer语句在调用时后续执行的参数就确定了,所以后续调用的时候参数还是1。
问题2:以下输出什么?
func printArray(array *[3]int) {
for i := range array {
fmt.Println(array[i])
}
}
func deferFuncParameter() {
var aArray = [3]int{1, 2, 3}
defer printArray(&aArray)
aArray[0] = 10
return
}
func main() {
deferFuncParameter()
}
答:输出[10, 2, 3]三个值,因为defer传入的是指针,虽然指针确定了,但最后指针指向的数组却发生更改了。
问题3:以下返回什么?
func deferFuncReturn() (result int) {
i := 1
defer func() {
result++
}()
return i
}
答:defer让具名返回值result增加1,而return语句并不是原子的,实际上分为设置返回值->ret,defer语句执行在返回前,所以过程为:设置返回值 ->执行defer -> ret,所以在真正返回前,result已经被赋值为1了,defer语句自增后,result=2。
Defer的规则
- defer执行的延迟函数的参数在defer语句出现时就已经确定(题目1)。
- 延迟函数执行按照后进先出的顺序执行,是一个栈。
- 延迟函数可以操作主函数中的具名返回值(题目3),因为defer发生在确定返回值和ret之间。
函数返回的过程
return不是一个原子操作,return只代理汇编指令ret,即将跳转程序执行。比如return i,实际上分两步:
- 将i存入栈作为返回值
- 然后执行跳转
而defer的执行时机正是跳转前和返回值和压栈后,所以说defer执行时还是可以操作返回值,前提是这个返回值得有名字,要不也操作不了。
总体而言,defer语句操作返回值有两种情况:
-
主函数拥有匿名返回值,返回一个局部变量时,此时defer语句可以引用到返回值,但不会改变返回值
func foo() int { var i int defer func() { i++ }() return i }
上面的函数,返回一个局部变量,返回之前defer也会操作这个变量,对于匿名的返回值来说,可以假定仍有一个变量保存返回值,所以以上代码可以拆分成以下过程:
annoy = i // 固定返回值,发生在defer之前 i ++ // defer语句发生在固定返回值和最终返回之间,defe修改的也只是i,而不能修改annoy这个变量 return annoy
-
当主函数拥有具名返回值时,就可以被defer修改了
func foo() (ret int) { defer func() { ret++ }() return 0 }
以上代码可以拆解为:
ret = 0 // 固定返回值 ret ++ // 因为返回值有名字,所以最终返回前可以操作这个变量 return ret
Defer的实现原理
defer的数据结构
type _defer struct {
sp uintptr //函数栈指针
pc uintptr //程序计数器
fn *funcval //函数地址
link *_defer //指向自身结构的指针,用于链接多个defer
}
defer的数据结构和一般函数类似,也有栈地址,程序计数器,函数地址等。与函数不同的是它含有一个指针,可以指向另一个defer。每次声明一个defer时,就将defer插入到单链表表头,执行时则按顺序执行。
源码包runtime/panic.go
定义了两个方法分别用于创建defer和执行defer:
- deferproc():在声明defer处调用,其将defer函数存入goroutine的链表中。
- deferreturn():在return指令,准确讲是在ret指令前调用,将defer从goroutine链表中取出并执行。
二、Select
select是golang语言层面提供的IO多路复用的机制,可以同时检测多个channel是否ready。
几个题目
题目1:以下程序输出什么
chan1 := make(chan int)
chan2 := make(chan int)
go func(){
chan1 <- 1
}
go func(){
chan2 <- 1
}
select {
case <-chan1:
fmt.Println("chan1")
case <- chan2:
fmt.Println("chan2")
default:
fmt.Println("default")
}
答:三种都有可能。因为select中各个case的执行顺序是随机的。
题目2:以下程序输出什么
chan1 := make(chan int)
chan2 := make(chan int)
go func(){
close(chan1)
}
go func(){
close(chan2)
}
select {
case <-chan1:
fmt.Println("chan1")
case <- chan2:
fmt.Println("chan2")
default:
fmt.Println("default")
}
答:close()不会导致chan不可读,所以select依然会检测各个case,输出什么依然是随机的。
题目3:以下程序会发生什么?
func main(){
select{
}
}
答:空select会阻塞,准确说是当前协程被阻塞,同时golang自带死锁检测机制,当发现协程阻塞且再也没有机会唤醒时,会panic。
select的数据结构
golang实现select时,定义了一个数据结构表示每个case语句(包括default),select执行过程可以类比为一个函数,函数输入case数组,输出选中的case,然后程序流转到这个case块。
type scase struct{
c *hchan
kind uint16
elem unsafe.Pointer
}
- c是当前case语句的channel指针,一个case只能操作一个channel。
- kind表示该case的类型,分为读channel、写channel和default,又三个常量定义:
- caseRecv:case语句中尝试读取scase.c中的数据
- caseSend:case语句尝试向scase.c中写数据
- caseDefault:default语句
- elem表示缓冲区地址,根据scase.kind不同,有不同的用途:
- scase.kind == caseRecv,elem表示要读出channel的数据存放地址。
- scase.kind == caseSend,elem表示要写入channel的数据存放地址。
select的实现逻辑
源码包runtime/select.go
中定义了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:是否成功从chennel中读取了数据,如果选中的case是从channel中读数据,则该返回值表示是否读取成功。
一个select的执行过程
- 锁定scase语句中所有的channel
- 按照随机顺序检测scase中的channel是否ready
- 如果case可读,则读取channel中数据,解锁所有的channel,然后返回(case index, true)
- 如果case可写,则将数据写入channel,解锁所有的channel,然后返回(case index, false)
- 所有case都未ready,则解锁所有的channel,然后返回(default index, false)
- 所有case都未ready,且没有default语句
- 将当前协程加入到所有channel的等待队列
- 当将协程转入阻塞,等待被唤醒
- 唤醒后返回channel对应的case index
- 唤醒后返回channel对应的case index
- 如果是写操作,解锁所有的channel,然后返回(case index, false)
总结
- select语句中除了default外,每个case操作一个channel,要么读要么写。
- select语句除了default,各case执行顺序是随机的。
- select语句中如果没有default语句,则会阻塞等待任意一个case。
- 对于select语句中读操作,要判断是否成功读取,因为关闭的channel也可以读取,此时ok=false,可能会读到错误的信息。
三、Range
range是一种迭代遍历手段,可操作的类型有数组,切片,Map,channel等。
几个题目
题目1:切片遍历,请问以下程序性能上有没有优化的空间?
func RangeSlice(slice []int) {
for index, value := range slice {
_, _ = index, value
}
}
答:遍历过程中,每次的遍历都会给index和value赋值,而实际上给value赋值的步骤是多余的,因为可以直接通过slice[index]直接访问到value。所以可以用_忽略value的值,用slice[index]来访问元素。
题目2:map遍历打印key和value,有没有优化的空间?
func RangeMap(myMap map[int]string){
for key, _ := range myMap{
_, _ = key, myMap[key]
}
}
答:遍历时只获取到了key,忽略了value,虽然少了一次赋值,但实际上多了一步通过key查找value的步骤,而查找的性能消耗可能高于赋值的性能消耗(map的查找和slice的查找不同的,后者直接就能找到,前者则需要运算),所以这里能否优化需要取决于map存储数据结构特征。
题目3:动态遍历,以下程序能否正常结束?
func main(){
v := []int{1, 2, 3}
for i := range v{
v = append(v, i)
}
}
答:可以在遍历三次后结束。因为循环次数在循环开始时就确定了,后续切片变长后并不会改变循环次数。
range实现原理
range是个C风格的循环结构,range支持数组,数组指针,切片,map和channel类型,对于不同类型的实现有细节差异。
遍历切片的过程
- 先获取到切片的长度作为循环次数(正因如此,循环中如果切片长度发生改变,新添加的元素是没办法遍历到的。但是修改的后面元素却是可以遍历到的)。
- 循环中,每次循环会先获取元素的索引和值作为一个匿名的变量。
- 如果range语句中对两个值都有接收的话,则会对index和value进行一次赋值。
遍历map过程
- 遍历map时没有指定循环次数。循环体与slice类似。
- 由于map底层使用hash表,如果遍历时插入数据,则插入数据的位置是随机的。
- 如果遍历时新插入的数据在当前遍历位置的后面,则可以被后续遍历到,如果在前面则遍历不到。无法保证新键值对会被遍历到还是不会被遍历到。
遍历channel过程
- channel在被遍历时是不知道里面有多少个元素的。
- 循环时会按顺序从channel中取数据出来。
- 如果channel中没有元素,则会阻塞等待。
- 如果channel被关闭,则会解除阻塞并退出循环。
Range使用的一些技巧
- 使用range遍历切片时,可以适当的放弃接收index和value,减少数据的拷贝可以提高一定性能。
- 遍历channel的表现很不同,需要特别注意。
- 尽量不要在遍历时修改原数据,因为可能会产生不好确定的效果。需要这样的场景,最好重新开辟一块内存暂存需要修改的数据。