深度剖析channel

本文转自shanks’s Blog

##channel的用法
channel是golang中很重要的概念,配合goroutine是golang能够方便实现并发编程的关键。channel其实就是传统语言的阻塞消息队列,可以用来做不同goroutine之间的消息传递,由于goroutine是轻量级的线程能够在语言层面调度,所以channel在golang中也常被用来同步goroutine。

一般channel的声明形式为:var chanName chan ElementType
ElementType指定这个channel所能传递的元素类型。

定义一个channel也很简单,直接使用内置的函数make()即可:
ch := make(chan int,bufferSize) //bufferSize为缓冲区的大小,可以不传递该值代表不带缓冲区的channel

###消息传递

带有缓冲区的channel一般用来做不同goroutine之间的消息传递。最经典的解释莫过于生产者-消费者了。生产者向channel中写数据,如果channel缓冲区已满,则生产者会被阻塞直到消费者消费缓冲区中的数据后才能被唤醒。
消费者从channel中读取数据,如果缓冲区中没有任何数据则消费者会阻塞直到生产者将数据写入才能被唤醒。

假设我们有30个学生做作业,做完作业后由一个老师批改作业。用go怎么实现呢,我们首先定义一个带有缓冲区HomeWork chan(缓冲区的大小与学生数目相同,主要是为了防止学生提交作业时阻塞),学生做完作业向hwChan中发送数据,老师等待hwChan中有数据就取出学生作业然后批改。

    const StudentNum = 30
     type HomeWork struct {
     }
    function student(hwChan chan HomeWork) {
        //学生提交作业
        hwChan <- HomeWork{} 
    }
    function teacher(hwChan chan HomeWork){
        //老师取出30个学生的作业批改
        for  i:=0;i<StudentNum;i++ {
            hw := <- hwChan 
        }
    }
    func main(){
        hwChan := make(chan HomeWork,30)
        //每个学生开启一个goroutine,学生单独做作业,做完作业提交到hwChan中即可
        for i:=0;i<StudentNum;i++ {
            go student(hwChan)
        }
        //老师开启一个goroutine,去批改学生作业
        go teacher(hwChan)
        time.Sleep(5*time.Second)
    }

###单向channel
student只需要向channel写数据,而不能从channel中读数据,但在func student(hwChan chan HomeWork) 这个函数中学生其实是有权限从channel中读数据,能限制这个函数只能取数据而不能写入数据吗?
单向channel就是为了解决这个问题的:
chan<- ElementType 表示只能向这个channel写数据
<-chan ElementType 表示只能从这个channel中读数据

我们将student函数的参数变一下 func student(hwChan chan<- HomeWork)就可以将hwChan这个双向channel变成一个只能写的单向channel传到student函数中。
同理teacher函数的参数变成 function teacher(hwChan <-chan HomeWork)也就规定了在teacher函数内只能从这个channel中读数据。

###同步

main函数最后一段代码 time.Sleep(5*time.Second),主要是为了防止main函数所在的主goroutine没有等teacher所在的goroutine批改完30个学生作业就退出了,主goroutine的退出就导致整个程序的退出。Sleep 5秒是因为教师肯定能在五秒内批改完作业,但是如果超过5s还是会有问题。
我们现在有了新的需求,主goroutine开启了一个teacher goroutine,主goroutine需要等待teacher批改完30个学生的作业才能继续运行。这时我们就需要再创建一个channel用于goroutine之间的同步了。

    func main(){
        hwChan := make(chan HomeWork,30)
        exitChan := make(chan struct{}) 
        for i:=0;i<StudentNum;i++ {
            go student(hwChan)
        }
        go teacher(hwChan,exitChan)
        <-exitChan
    }
    function teacher(hwChan chan HomeWork,exitChan chan struct{}){
        for  i:=0;i<StudentNum;i++ {
            hw := <- hwChan 
        }
        close(exitChan)
    }

主goroutine会阻塞在 <-exitChan,直到teacher批改完三十个学生的作业,然后close(exitChan),<-exitChan返回时teacher已批改完学生作业,程序退出也就没有任何问题了。

至于为什么定义 exitChan为 chan struct{}而不是chan int{} 等是因为chan struct{}不需要占用任何内存空间
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // prints 0

##特殊状态的channel
###读写未初始化的channel

    var ch chan int 
    ch<-1 

往一个未初始化的channel中读写数据都会导致该goroutine永远阻塞。

至于为什么要永远阻塞而不是报panic,我目前还没有找到比较信服的答案。
###读写已关闭的channel

  • 不管channel带不带缓冲区,往已关闭的channel中写数据都会导致panic。
  • 从已关闭的channel中读数据不会导致panic,会返回该数据类型的零值
    ch :=  make(chan int)
    close(ch)
    i <- ch //i =0 

但是我们不能因为从一个channel中读出了零值就判断该channel被关闭了,因为零值也可能是正常的数据。

    ch :=  make(chan int)
    ch<-0 //向channel中写入0 
    i <- ch //虽然从channel中读出的数据是0,但不能以此判断channel已被关闭。 

从channel中读数据支持两个返回值得形式 i,ok <- ch
ok是一个bool值表示该channel是否被关闭

  • 从已关闭的带有缓存的channel中读数据
    ch := make(chan int,3)
    ch <-1 
    ch <-2 
    ch <-3
    i,ok <- ch //i为1,ok为false表示channel未关闭 
    close(ch)
    j,ok <- ch //j是2不是0,ok为true 
    m,ok <- ch //m是3不是0,ok为true
    n,ok <-ch //n是0,ok为false 表示channel关闭

在通道被关闭之后并不会立即把false作为相应接收操作的第二个结果,而是等到接收端把已在通道中的所有元素值都接收到之后才这样做。这样就保证了channel关闭之前发送到channel的数据都能够被消费端读取出来。
读取channel中的元素还有一种更简便的方法

    for elem <- range ch {
    }
    //当ch被关闭并且缓冲中的数据都被取出后,for循环会退出

源码实现

//一个channel其实是一个hchan变量
type hchan struct {
    buf      unsafe.Pointer // 指向缓冲区的指针,如果缓冲区为空则为nil
    dataqsiz uint           // 缓冲区的大小
    elemtype *_type // element type
    elemsize uint16 //单个元素的size
    //sendx recvx 实现一个FIFO的数组列表
    sendx  uint // send index
    recvx  uint // receive index
    qcount uint // 缓冲区中缓冲的元素数量
    recvq waitq // 消费者等待列表
    sendq waitq // 生产者等待列表
    closed uint32 //标识channel是否关闭
    lock   mutex
}
//初始化一个channel 
func makechan(t *chantype, size int64) *hchan {
    elem := t.elem
    var c *hchan
    if size == 0 {
        //初始化一个不带缓冲区的channel
        c = (*hchan)(mallocgc(hchanSize+uintptr(size)*uintptr(elem.size), nil, flagNoScan))
    } else {
        //初始化一个带缓冲区的channel,需要创建一个数组做缓冲区
        c = new(hchan)
        c.buf = newarray(elem, uintptr(size))
    }
    c.elemsize = uint16(elem.size)
    c.elemtype = elem
    c.dataqsiz = uint(size)
    return c
}
//往channel中写入数据
func chansend(t *chantype, c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
    if c == nil {
        //往一个未初始化的channel(nil)写数据会导致goroutine永久阻塞
        gopark(nil, nil, "chan send (nil chan)", traceEvGoStop, 2)
        throw("unreachable")
    }
    //加锁,所以多个gorotuine同时向一个channel中读写数据是不会有问题的
    lock(&c.lock)
    //往一个已经关闭的channel写数据会导致panic
    if c.closed != 0 {
        unlock(&c.lock)
        panic("send on closed channel")
    }
    //根据缓冲区的大小dataqsiz,判断是否存在缓冲区
    if c.dataqsiz == 0 {
        //试着从recvq等待队列里取出一个等待的goroutine
        sg := c.recvq.dequeue()
        if sg != nil {
            // 找到一个等待读取数据的goroutine(消费者),将要写入的元素直接交给(拷贝)这个goroutine,然后再将这个拿到元素的goroutine给设置为ready状态
            unlock(&c.lock)
            recvg := sg.g       //recvg指向下一个消费者
            syncsend(c, sg, ep) //将要写入的元素直接交给(拷贝)这个消费者
            goready(recvg, 3)
            return true
        }
        // 如果recvq里,并没有一个等待的goroutine,那么就将待写入的元素保存在当前执行写的goroutine的结构里,然后将当前goroutine入队到sendq中并被挂起,等待有人来读取元素后才会被唤醒
        mysg := acquireSudog()
        mysg.elem = ep //生产者自己保存数据
        c.sendq.enqueue(mysg)
        goparkunlock(&c.lock, "chan send", traceEvGoBlockSend, 3)
        unlock(&c.lock)
        return true
    }
    // 如果带有缓冲区的channel,则生产者要等待缓冲区中有空闲的空间存放生产者所生产的数据
    // qcount是缓冲区已有数据的数量,dataqsiz是可存放数据的数量
    for futile := byte(0); c.qcount >= c.dataqsiz; futile = traceFutileWakeup {
        //如果缓冲区没有可用空间,就将当前goroutine入队到sendq中并被挂起等待。
        mysg := acquireSudog()
        mysg.elem = nil
        c.sendq.enqueue(mysg)
        goparkunlock(&c.lock, "chan send", traceEvGoBlockSend|futile, 3)
        unlock(&c.lock)
    }
    //如果缓冲区有可用空间,就将元素追加到缓冲区中,再从消费者等待队列recvq中试着取出一个等待的goroutine开始进行读操作(如果recvq中有等待读的goroutine的话)
    typedmemmove(c.elemtype, chanbuf(c, c.sendx), ep)
    c.sendx++
    if c.sendx == c.dataqsiz {
        c.sendx = 0
    }
    c.qcount++
    // wake up a waiting receiver
    sg := c.recvq.dequeue()
    if sg != nil {
        recvg := sg.g
        unlock(&c.lock)
        goready(recvg, 3)
    } else {
        unlock(&c.lock)
    }
    return true
}
//从channel中读数据的过程和写过程差不多就不再贴代码了

参考资料
channel in Go’s runtime (skoo的博客,干货满满)
《Go语言编程》
《Go并发编程实战》