Go channel底层实现
channel是Go定义的一个数据类型,用于goroutine之间的通信且是并发安全,正是Go所提倡的利用通信来共享内存。 Go在语言层面支持这种数据通信,提供非常简单的api(这也是Go为什么入门上手非常快的原因),让并发的信息同步和通信变得容易,减少了开发者很多心智负担。 当然我们也很有兴趣想知道channel的背后是怎样实现的?
创建一个channel
带buffer的channel
ch := make(chan int, 3)
不带buffer
ch := make(chan int)
创建channel,会在堆中生成一个hchan的结构体,并返回一个channel的指针,这就是为啥函数间传递可以直接使用它,而不用再取地址。
hchan结构
hchan结构的定义在runtime/chan.go
中:
type hchan struct {
buf unsafe.Pointer
sendx uint
recvx uint
recvq waitq
sendq waitq
lock mutex
qcount uint
dataqsiz uintqueue
dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type
}
简单介绍各个field含义:
-
buf buffer是个指针,指向队列里的元素,buf利用环形队列
-
sendx 发送元素在buf环形队列的索引,需要知道下一个发送的元素是谁
-
recvx 接收元素在buf环形队列的索引,需要知道下一个接收的元素放哪
-
recvq 等待消费channel里数据的goroutine的list
-
sendq 要往channel里发送数据的goroutine的list
-
lock 保证并发安全的锁
-
qcount 队列里元素个数
-
dataqsiz 环形队列的size,channel的队列采用的环形队列。make channel的大小。
-
elemsize 队列元素的大小
-
closed 需要知道channel的状态,是否关闭。注意这里是uint32类型,不是bool类型,说明还有其他一些状态
-
elemtype 元素类型,比如channel里是string还是int 还是等等其他自定义类型
带buf的channel发送和接收操作
- 当发送一个值,并且还没有谁在接收这个chan时,sendx增长,recvx还是0。
- 当buf满了时,此时sendx在环形队列中又指向了0。
- 此时有一个消费chan的goroutine消费一个值,sendx还是0,recvx就变成了1。
具体说下带buf的发送操作的步骤:
- 获取锁 其实channel保证并发安全的原因,没错就是用了lock。这样设计的原因是无锁队列的数据结构更复杂,而性能的提升不是凭空的,使得实现更复杂了,所以go最后使用了锁。
- 拷贝值 注意这里会发生拷贝动作,值会拷贝到buf里
- 释放锁
block 阻塞
这里阻塞和恢复涉及到Go的调度,插一个GMP图:
resume 恢复
当有接收者时,怎么让waiting的G1恢复呢?这就是sendq、recvq在起作用。 在发送满,调度到waiting前会把这个goroutine和发送元素记录为一个sudog,挂到sendq中。这样接受者拿到这个发送元素时候知道这个发送goroutine是谁,并再将其调度到runqueue中。
如下几个图描述:
接收者比发送者先一步读取的情况
这种情况有什么不同么?是的。
参考发送优先的情况,接收会把hchan的recvq中挂上sudog,标记该goroutine和接收的变量地址。如代码 t := <-ch
此时调度gopark把该goroutine置为waiting,直到有发送者来唤醒他。
如图:
事实上,Go不是这样实现的,换了个更聪明的实现,这需要跨goroutine的栈写。发送者把这个值直接写给接收变量t。
如图:
总结
- channel的并发安全:
- 靠加锁实现的。
- channel存储值,遵循FIFO
- 利用环形队列buf。
- goroutine的阻塞和恢复操作:
- 利用sendq、recvq的sudog
- go的调度gopark、goready
参考
Kavya非常简单易懂的介绍,文中基本参考她的分享。