深入理解Golang Channel

一、引言

Golang使用Groutinechannels实现了CSP(Communicating Sequential Processes)模型,channlesgoroutine的通信和同步中承担着重要的角色。

channel可以天然的实现了以下特性:

二、chan底层结构

channel的整体结构图:

hchan

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
}

简单说明:

三、创建

ch := make(chan int, 3)

创建channel实际上就是在该进程的heap申请一块内存,创建了一个hchan的结构体,并返回一个ch指针,我们使用过程中channel在函数之间的传递都是用的这个指针,这就是为什么函数传递中无需使用channel的指针,而直接用channel就行了,因为channel本身就是一个指针。

hchan结构体使用一个循环队列来保存groutine之间传递的数据(如果是缓存channel的话),使用两个list保存像该chan发送和从该chan接收数据的goroutine,还有一个mutex来保证操作这些结构的安全。

至于为什么channel会使用循环链表作为缓存结构,我个人认为是在缓存列表在动态的sendrecv过程中,定位当前send或者recvx的位置、选择send的和recvx的位置比较方便吧,只要顺着链表顺序一直旋转操作就好。

缓存中按链表顺序存放,取数据的时候按链表顺序读取,符合FIFO的原则。

四、发送和接收

channel发送和从channel接收数据主要涉及hchan里的四个成员变量,我们通过以下动图来分析发送和接收的过程。

channel_buf

举个🌰:

//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为空,sendxrecvx都为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检测到对应的hchanbuf已经满了,会通知调度器,调度器会将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的方式)

sudog

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,提高了效率。

wait-empty-channel

五、总结

Golang的一大特色就是其简单高效的天然并发机制,使用goroutinechannel实现了CSP模型。理解channel的底层运行机制对灵活运用golang开发并发程序有很大的帮助,然后结合golang runtime相关的源码,对channel的认识更加的深刻。