本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Golang channel底层剖析
内存模型
type hchan struct {
qcount uint // queue 里面有效用户元素,这个字段是在元素出对,入队改变的;
dataqsiz uint // 初始化的时候赋值,之后不再改变,指明数组 buffer 的大小;
buf unsafe.Pointer // 指明 buffer 数组的地址,初始化赋值,之后不会再改变;
elemsize uint16 // 指明元素的大小,和 dataqsiz 配合使用就能知道 buffer 内存块的大小了;
closed uint32
elemtype *_type // 元素类型,初始化赋值;
sendx uint // send index
recvx uint // receive index
recvq waitq // 等待 recv 响应的对象列表,抽象成 waiters
sendq waitq // 等待 sedn 响应的对象列表,抽象成 waiters
// 互斥资源的保护锁,官方特意说明,在持有本互斥锁的时候,绝对不要修改 Goroutine 的状态,不能很有可能在栈扩缩容的时候,出现死锁
lock mutex
}
channel的send和recv
初始状态下,ch的缓冲区为空,读和写的下标都指向下标0的位置,等待队列也都为空。
然后一个协程g1向ch中发送数据,因为没有协程在等待接收数据,所以元素会被存入缓冲区中,sendx会从0开始向后挪,第五个元素会放到下标为的4的位置,然后sendx会重新回到0。
此时缓冲区中已经没有空闲的位置了。所以接下来发送的第6个元素无处可放,g1会进到ch的发送等待队列中。
发送等待队列是一个sudog类型的链表。
type sudog struct {
g *g // 记录哪个协程在等待
isSelect bool
next *sudog
prev *sudog
elem unsafe.Pointer // 等待发送的信息在哪
acquiretime int64
ticket uint64
parent *sudog
waitlink *sudog
waittail *sudog
c *hchan // 等待哪一个channel
}
里面会记录哪个协程在等待,等待哪一个channel,等待发送的信息在哪等信息。
接下来协程g2从ch接收一个元素,recvx指向下一个位置,第0个位置就空出来了。所以会唤醒sendq中的g1,将这里的数据发送给ch。然后缓冲区再次满了,sendq队列为空。
在这一过程中,可以看到sendx和recvx,都会从0到4再到0这样循环变化,所以channel的缓冲区被称为环形缓冲区。
发送数据
所以像诸如 ch <- 10 这样给channel发送数据时,只有在缓冲区还有空闲位置,或者有协程在等着接收数据的时候才不会发生阻塞。
碰到channel为nil,或者ch没有缓冲区,而且也没有协程等着接收数据,又或者ch有缓冲区但缓冲区已用尽的情况,都会发生阻塞。
那如果不想阻塞的话,就可以使用select关键字。
select {
case ch <- 10;
...
default:
...
}
使用这种写法时,如果检测到ch可以发送数据,就会执行case分支;如果会阻塞,就会执行default分支了。
接收数据
接收数据的写法要更多一些。
// 将结果直接丢弃
<-ch
// 将结果赋值给变量v
v := ch
// common ok风格写法
// ok为false时表示ch已经关闭,此时v是channe元素类型的零值
v, ok := <-ch
以上几种写法都允许发生阻塞,只有在缓冲区中有数据,或者有协程等着发送数据时,才不会发生阻塞。如果channel为nil,或者channel无缓冲而且没有协程等着发送数据,又或者channel有缓冲但是缓冲区无数据时,都会发生阻塞。
如果无论如何都不想阻塞,t=同样可以采用select关键字。这样在检测到channel的recv操作不会阻塞时,就会执行case分支;如果会阻塞,就会执行default分支。
多路select
多路select指的是存在两个或更多的case分支,每个分支可以是一个channel的send或者recv操作。
例如一个协程通过多路select,等待ch1和ch2。这里default分支是可选的,我们暂且把这个协程记为g1。
var a, b int
b = 10
select {
case a = <-ch1:
println(v)
case ch2 <- b:
default:
}
多路select会被编译器转换为对runtime.selectgo函数调用。
func selectgo(cas0 *scase, order0 *unint16, pc0 *unintptr, nsends, nrecvs int, block bool) (int, bool)
第一个参数cas0指向一个数组,数组里面装的是select中所有的case分支。顺序是send在前recv在后。
第二个参数order0指向一个uint16类型的数组,数组大小等于case分支的两倍,实际上被用作两个数组。第一个数组用来对所有channel的轮询进行乱序。第二个数组用来对所有channel的加锁操作进行排序。因为轮询需要乱序才能保障公正性。而按照固定算法确定加锁顺序才能避免死锁。
第三个参数和race检测有关,我们暂且不关心。
剩下的nsends和nrecvs分别表示所有case中,执行send和recv操作的分支分别有多少个。 block表示多路select是否需要阻塞等待,对应到代码只能就是有default分支的不会阻塞,没有的会阻塞。
再来看第一个返回值,它代表最终哪个case分支被执行了。 第二个返回值用于在执行recv操作的case分支时,表明是实际接收到了一个值,还是因channel关闭而得到了零值。 多路select需要进行轮询来确定哪个case分支可操作了。但是轮询前要先加锁,所以selectgo函数在执行时,会先按照有序的加锁顺序,对所有的channel加锁,然后按照乱序的轮询顺序,检查所有channel的等待队列和缓冲区。
假如检查到ch1时,发现有数据可读,那就直接拷贝数据,进入对应分支;假如所有channel都不可操作,就把当前协程添加到所有channel的sendq或recvq中。
对应到这个例子,g1会被添加到ch1的recq,以及ch2的sendq中。之后g1会挂起,并解锁所有的channel。
假如接下来的ch1有数据可读了,g1就会被唤醒,完成对应的分支操作后,会再次按照加锁顺序对所有channel加锁,然后从所有sendq或recvq中将自己移除。最后全部解锁后返回。
channel的常规send和recv操作
send操作
事实上channel的常规send操作,会被编译器转换为对runtime.chansend1()的调用,而它的内部只是调用了runtime.chansend()。
非阻塞式的send操作会被编译器转换为对runtime.selectnbsend()的调用,它也仅仅是调用了runtime.chansend()。
所以send操作主要是通过这个函数实现的。
recv操作
同样的,常规recv操作会被编译器转换为对runtime.chanrecv1()的调用。而它内部只是调用了runtime.chanrecv()。
common ok风格的写法会被编译器转换为对runtime.chanrecv2() 的调用,它的内部也是对调用chanrecv(),只不过比chanrecv1()多了一个返回值。
非阻塞式的recv操作会根据是否为common ok风格,被编译器转换为对runtime.selectnbrecv()或者selectbrecv2()的调用,而它们两个也仅仅是调用了runtime.chanrecv()。
所以recv操作主要是通过chanrecv()函数实现的。