深入理解Golang Channel
一、引言
Golang
使用Groutine
和channels
实现了CSP(Communicating Sequential Processes)
模型,channles
在goroutine
的通信和同步中承担着重要的角色。
channel可以天然的实现了以下特性:
- goroutine安全
- 在不同的goroutine之间存储和传输值 - 提供FIFO语义(buffered channel提供)
- 可以让goroutine block/unblock
二、chan
底层结构
channel的整体结构图:
src/runtime/chan.go
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
简单说明:
buf
是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表sendx
和recvx
用于记录buf
这个循环链表中的发送或者接收的indexlock
是个互斥锁。recvq
和sendq
分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体sudog
的队列。是个双向链表
三、创建
ch := make(chan int, 3)
创建channel
实际上就是在该进程的heap
区申请一块内存,创建了一个hchan
的结构体,并返回一个ch
指针,我们使用过程中channe
l在函数之间的传递都是用的这个指针,这就是为什么函数传递中无需使用channel
的指针,而直接用channel
就行了,因为channel
本身就是一个指针。
hchan
结构体使用一个循环队列来保存groutine
之间传递的数据(如果是缓存channel
的话),使用两个list保存像该chan
发送和从该chan
接收数据的goroutine
,还有一个mutex
来保证操作这些结构的安全。
至于为什么channel会使用循环链表作为缓存结构,我个人认为是在缓存列表在动态的send
和recv
过程中,定位当前send
或者recvx
的位置、选择send
的和recvx
的位置比较方便吧,只要顺着链表顺序一直旋转操作就好。
缓存中按链表顺序存放,取数据的时候按链表顺序读取,符合FIFO的原则。
四、发送和接收
向channel
发送和从channel
接收数据主要涉及hchan
里的四个成员变量,我们通过以下动图来分析发送和接收的过程。
举个🌰:
//G1
func main(){
...
for _, task := range hellaTasks {
ch <- task //sender
}
...
}
//G2
func worker(ch chan Task){
for {
//接受任务
task := <- ch //recevier
process(task)
}
}
其中G1
是发送者,G2
是接收,因为ch
是长度为3的带缓冲channel
,初始的时候hchan
结构体的buf
为空,sendx
和recvx
都为0,当G1向ch
里发送数据的时候,会首先对buf
加锁,然后将要发送的数据copy到buf
里,并增加sendx
的值,最后释放buf
的锁。然后G2消费的时候首先对buf
加锁,然后将buf
里的数据copy到task变量对应的内存里,增加recvx
,最后释放锁。整个过程,G1和G2没有共享的内存,底层通过hchan
结构体的buf
,使用copy内存的方式进行通信,最后达到了共享内存的目的,这完全符合CSP的设计理念。
Goroutine Pause/Resume
我们知道,Go的goroutine是用户态的线程(user-space threads
),用户态的线程是需要自己去调度的,Go有运行时的调度器去帮我们完成调度这件事情。一般情况下,G2的消费速度应该是慢于G1的,所以buf
的数据会越来越多,这个时候G1再向ch
里发送数据,这个时候G1就会阻塞,那么阻塞到底是发生了什么呢?
当G1向buf
已经满了的ch
发送数据的时候,当runtine
检测到对应的hchan
的buf
已经满了,会通知调度器,调度器会将G1的状态设置为waiting
, 移除与线程M的联系,然后从P的runqueue
中选择一个goroutine
在线程M中执行,此时G1就是阻塞状态,但是不是操作系统的线程阻塞,所以这个时候只用消耗少量的资源。
那么G1设置为waiting
状态后去哪了?怎们去resume
呢?我们再回到hchan
结构体,注意到hchan
有个sendq
的成员,其类型是waitq
,查看源码如下:
type waitq struct {
first *sudog
last *sudog
}
实际上,当G1变为waiting状态后,会创建一个代表自己的sudog
的结构,然后放到sendq
这个list中,sudog
结构中保存了channel
相关的变量的指针(如果该Goroutine是sender,那么保存的是待发送数据的变量的地址,如果是receiver则为接收数据的变量的地址,之所以是地址,前面我们提到在传输数据的时候使用的是copy的方式)
当G2
从中接收一个数据时,会通知调度器,设置G1的状态为runnable
,然后将加入P的runqueue
里,等待被调度执行.
wait empty channel
前面我们是假设G1先运行,如果G2先运行会怎么样呢?如果G2先运行,那么G2会从一个empty的channel
里取数据,这个时候G2就会阻塞,和前面介绍的G1阻塞一样,G2也会创建一个sudog
结构体,保存接收数据的变量的地址,但是该sudog
结构体是放到了recvq
列表里,当G1向ch
发送数据的时候,runtime
并没有对hchan
结构体题的buf
进行加锁,而是直接将G1里的发送到ch
的数据copy到了G2 sudog
里对应的elem
指向的内存地址!这种方式非常的赞!在唤醒过程中,G2无需再获得channel的锁,然后从缓存中取数据。减少了内存的copy,提高了效率。
五、总结
Golang
的一大特色就是其简单高效的天然并发机制,使用goroutine
和channel
实现了CSP
模型。理解channel
的底层运行机制对灵活运用golang
开发并发程序有很大的帮助,然后结合golang
runtime
相关的源码,对channel
的认识更加的深刻。