Golang的channel底层实现

Sat, Sep 18, 2021 阅读时间 1 分钟

一、Channel的底层数据结构

channel是golang在语言层面提供的goroutine通信机制,也是我们经常使用的数据结构,它的数据结构如下:

// 在src/runtime/chan.go中定义

type hchan struct{
    // 环形队列相关
    qcount uint         // 当前队列中剩余元素的个数
    dataqsiz uint       // 环形队列的长度,即可以存放的元素个数
    buf unsafe.Pointer  // 环形队列的指针
    sendx uint          // 队列下标,指示元素写入时存放到队列中的位置
    recvx uint          // 队列下标,指示元素从队列的该位置处读出
    
    // 元素信息
    elemtype *_type     // 元素类型
    elemsize uint16     // 每个元素的大小
    
    closed uint32       // 标识关闭状态
    
    // 等待队列相关
    recvq waitq         // 等待读消息的goroutine队列
    sendq waitq         // 等待写消息的goroutine队列
    
    lock mutex          // 互斥锁,chan不允许并发读写
}

如上,chan的字段可以分成5类

  • 环形队列相关
  • 存储消息的信息
  • 等待队列相关
  • 关闭状态的标识
  • 互斥锁

环形队列

chan内部使用了一个环形队列作为其缓冲区(buf字段),队列的长度就是创建chan时指定的长度(dataqsiz字段):

  • dataqsize指示了队列长度为6,即可缓存6个元素。超过6个就得阻塞,等有空位了才能插入。

  • buf指向这个环形队列的内存地址。

  • qcount表示队列中还有几个空位。

  • sendx指示后续写入的数据存储的位置,长度为6的话取值就是0-5。

  • recvx表示chan接收端从该位置处读取数据,取值也是0-5。

等待队列

当从channel中读数据,而chan中没数据,或往chan中写数据,而chan中没有空位时(即qcount=0),当前这个goroutine会被阻塞。被阻塞的goroutine将会挂在channel的等待队列中。

等待队列有两个:recvq就是读阻塞的等待队列,sendq就是写阻塞的等待队列。

  • 因读阻塞的goroutine将会被向chan中写入数据的goroutine唤醒

    读阻塞是因为channel中没数据,当有goroutine向chan中写入数据时,读操作自然就解阻塞了。

  • 因写阻塞的goroutine将会被从chan中读数据的goroutine唤醒

    写阻塞是因为channel满了,写不了,当有goroutine从chan中读出一个数据时,自然就有空位去写了。

如上图,每个G都是一个goroutine,这几个goroutine都挂在recvq这个读等待队列中,即chan中没数据,所有的goroutine想从chan中读时都阻塞在这了。

一般情况下,recvq和sendq中至少一个为空的,即要么都在阻塞读,要么都在阻塞写。

消息的相关信息

chan结构体中有两个字段用于标识chan中存储的消息信息:elemtype和elemsize

  • elemtype代表消息的数据类型,用于数据传递过程中的赋值
  • elemsize代表消息的大小,用于在buf中定位元素的位置

lock字段是一个mutex,明显channel是并发安全的。channel同一时刻仅允许被一个goroutine读写,每次读写都要先获取到锁。用完了再释放。

channel的读写过程

写数据的过程

写数据可以分成三种情况:有goroutine在读阻塞、没有goroutine在读阻塞、缓冲区满了。

有goroutine读阻塞

当读等待队列recvq不为空,说明有goroutine在阻塞,此时说明缓冲区没有数据或者这个channel没有缓冲区,这种情况下写入时会从recvq中取出一个G,然后把数据写入到缓冲区,最后把这个G唤醒,结束发送过程。

即该情形下,不仅要写入缓冲区,还要唤醒阻塞的goroutine。

没有goroutine读阻塞

这种情况比较简单,没有读阻塞就说明缓冲区有空余位置,直接将数据写入缓冲区,就可以结束发送过程。

缓冲区满了

如果缓冲区没有空余位置,则此时写入时就是写阻塞,需要将当前G加入到sendq中,同时该goroutine进入睡眠状态,等待被唤醒,被唤醒后才能接着写。

二、读数据的过程

读数据分为四种情况:有写阻塞且chan没有缓冲区、有写阻塞且chan有缓冲区、没有写阻塞且缓冲区中有数据、没有写阻塞且缓冲区中无数据。

有写阻塞且chan没有缓冲区

chan没有缓冲区的情况,每个写入都会是写阻塞,每个读取都是读阻塞,此时不管是要写数据还是读数据,都需要另一个goroutine同时在读或写。换句话说,没有缓冲区的chan的读写,实际上就是一个goroutine把数据直接交给了另一个goroutine。

所以这种情况下读数据,一定要有另一个goroutine在写阻塞中才能读到,此时本goroutine会直接从sendq中取出第一个写阻塞的G,从G中取出数据,然后把G唤醒,即可。

有写阻塞且chan有缓冲区

chan有缓冲区,且写阻塞,此时一定是chan的缓冲区满了,此时需要先从缓冲区的头部取出一个数据,然后把sendq中第一个写阻塞的G的数据写入到缓冲区的尾部,最后唤醒这个G即可。

没有写阻塞且缓冲区中有数据

这种是最简单的情况,也是最常见的情况,这种情况直接从缓冲区头部取出一条数据,即可。

没有写阻塞且缓冲区中无数据

这种情况下可能是刚开始创建chan时,还没有goroutine向chan中写入数据,此时从chan中读取数据就会被阻塞,这个goroutine会把自己的G写入到recvq中,同时自己进入睡眠状态,等待被写操作的G唤醒。

三、Channel的关闭的问题

关闭channel时会对等待队列中的G做如下操作:

  • 把读阻塞recvq中的G全部唤醒,并向这些G中发送chan数据类型的0值。
  • 把sendq中的G全部唤醒,但是这些写入的G都会panic。

channel引发panic的场景

  • 关闭一个值为nil的channel
  • 关闭已经关闭的channel
  • 向已经关闭的channel写数据

从一个已关闭的channel读数据的情形

  • 如果这个chan中还有值,那么会把这些值读完
  • 如果这个chan的值已被读完,那么依然可以读取到这个chan中存储数据类型的基本值,如int会读出0,string会读出"",bool会读出false

从chan中读数据可以用两个变量接收,第二个变量为bool值,表示接收到的值是不是有效的,为true是有效:

c, ok := <- channel
if ok{
    有效
}

四、Channel的常见用法

单向chan

指只用于发送,或者只用于接收的chan,实际上没有单向的chan,这里说的只是使用限制,即限制一个chan在一个函数内为只读或者只写,通过函数传参时指定:

func readChan(c <- chan int){} // 通过形参指定函数中只能读c的数据
func writeChan(c chan<- int){} // 通过形参限定函数内部只能写入c 

配合select多路复用

select多路复用可以监控多个channel,当其中某个chan有数据时,就从中读取数据:

for{
    select {
    case a := chan1:
        // do something
    case b := chan2:
        // do something
    default:
        // if chan1 and chan2 has block, go this way
    }
}

配合range

通过range可以持续的从一个chan中取出数据,像是在遍历一个数组一样,当channel中没有数据时则会阻塞在某一次读取中

for e := range chan1{
    fmt.Println(e)
}