Golang-channel底层实现精要
一.channel 背景知识
- channel是Go语言内置的核心类型,可以将其看做一个管道,channel和goroutine一起为go并发编程提供了最优雅和便利的方案
- 在Go中有一句经典名言,永远不要通过共享内存来通信,而是要通过通信来共享内存,channel便是用于实现goroutine间通信的
- channel提供了三种类型
- 单向只能发送:chan<- struct{} 只能发送struct (箭头指向channel,则代表发送)
- 单向只能接收:<-chan struct{} 只能从chan里接收struct (箭头远离channel,则代表接收)
- 双向即可发送也可接收:chan string 既能接收也能发送
- nil是channel的零值,对值是nil的channel发送和接收总是会阻塞
二.channel 底层实现
1.channel底层结构
简要说明:
- buf是带缓冲的channle所特有的结构,是个循环链表,用来存储缓存数据
- sendx和recvx是用于记录buf中发送和接收的index
- lock是个互斥锁,目的是为了保证goroutine以先进先出FIFO的方式进入结构体
- recvq和sendq分别是往channel接收或发送数据的goroutine所抽象出来的数据结构,是个双向链表
channel结构体的源码位于/runtime/chan.go中,结构体为hchan,源码如下(版本1.11)
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
}
2.channel实现原理
1) channel 创建
ch := make(chan int, 3)
因为 channel 的创建全部调用的 mallocgc(),在堆上开辟的内存空间,所以channel 本身会被 GC 自动回收。回收的条件是没有goroutine引用
简要说明:
- 创建channel实际上就是在内存中实例化了一个hchan结构体,并返回一个chan指针
- channle在函数间传递都是使用的这个指针,这就是为什么函数传递中无需使用channel的指针,而是直接用channel就行了,因为channel本身就是一个指针
2) channel 发送数据
以有缓冲的channel为例
ch <- 1
ch <- 2
ch <- 3
简要说明:
- 发送数据前,会先锁住hchan这个结构体
- 然后逐步往buf中填充数据(从goroutine中copy数据到buf),然后解锁
- 注意sendx的变化,其记录了发送数据的index
3) channel 接收数据
<-ch
<-ch
<-ch
简要说明:
- 接收数据前,同样会先锁住hchan这个结构体
- 然后逐步往buf中获取数据(buf中copy数据到goroutine),然后解锁
- 注意recvx的变化,其记录了接收数据的index
4) channel存储满了,底层如何处理的?
我们都知道,当channle缓存满了的时候,会阻塞当前goroutine,但是,这是如何实现的呢?
- goroutine的阻塞操作,实际上是调用send (ch <- xx)或者recv ( <-ch)主动触发的
//goroutine1 中,记做G1
ch := make(chan int, 3)
ch <- 1
ch <- 1
ch <- 1
这个时候,G1在正常运行,当再次调度send操作的时候,会主动调用Go的调度器,让当前协程G1等待,并且让出内核线程M,交给其他G使用
同时,G1也会被抽象成含有G1指针和send元素的sudog结构体,保存到*sendq中等待被唤醒,那G1什么时候被唤醒呢?在有其他协程(G2)接收数据后被唤醒
G2执行了recv,于是会发生以下操作:
- 1)G2从buf中取出数据
- 2)channel从sendq中推出G1,将G1当时的send数据推到buf中
- 3)调用Go的调度器scheduler,唤醒G1,并把G1放到可运行的Goroutine队列中
5) channel是空的,底层如何处理的?
当channel中无数据时,先执行G2的接收数据操作,G2会阻塞,这又是如何实现的呢?其实跟上面相差不大,可以顺着思路反推
- 1)G2首先会主动调用Go调度器,让G2等待,并且让出M,交给其他G使用
- 2)然后G2还好被抽象成含有G2指针和recv空元素的sudog结构体,保存到recvq中等待被唤醒
此时,如果G1向channel中发送数据,会发生一个有意思的事情:
- G1并没有锁住channel,然后将数据放入buf中,而是直接将数据从G1 copy到了 G2,这种方式非常好
- 这样的话,在唤醒G2的过程中,G2无需再获得channel的锁,然后从buf中取数据,减少了内存cpoy,提高了效率
- 通过Go的调度器唤醒G2,将G2加入到GPM模型中P的本地可运行G队列中
3.总结
- channel缓冲器满或空,其底层的处理都非常的精妙,主动调用调度器,阻塞当前G,将M交给其他G使用,然后将G指针和其他数据组装成sudog,加入recvq或者sendq队列,等待被调度,
- 唤醒的流程也非常有趣,当G2接收但channel空阻塞时,G1发送数据,采用了直接copy方式,并没有锁住channel,将数据放入buf,而是直接从G1 复制到G2,减少了内存copy
本文参考: