且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

golang 系列:channel 全面解析

更新时间:2022-01-11 05:47:19

前言

channel 是 goroutine 与 goroutine 之间通信的重要桥梁,借助 channel,我们能很轻易的写出一个多协程通信程序。今天,我们就来看看这个 channel 的常用用法以及底层原理。

一、channel 的概念

channel 是一个通道,用于端到端的数据传输,这有点像我们平常使用的消息队列,只不过 channel 的发送方和接受方是 goroutine 对象,属于内存级别的通信。

这里涉及到了 goroutine 概念,goroutine 是轻量级的协程,有属于自己的栈空间。 我们可以把它理解为线程,只不过 goroutine 的性能开销很小,并且在用户态上实现了属于自己的调度模型。

传统的线程通信有很多方式,像内存共享、信号量等。其中内存共享实现较为简单,只需要对变量进行并发控制,加锁即可。但这种在后续业务逐渐复杂时,将很难维护,耦合性也比较强。

后来提出了 CSP 模型,即在通信双方抽象出中间层,数据的流转由中间层来控制,通信双方只负责数据的发送和接收,从而实现了数据的共享,这就是所谓的通过通信来共享内存。 channel 就是按这个模型来实现的。

channel 在多并发操作里是属于协程安全的,并且遵循了 FIFO 特性。即先执行读取的 goroutine 会先获取到数据,先发送数据的 goroutine 会先输入数据。

另外,channel 的使用将会引起 Go runtime 的调度调用,会有阻塞和唤起 goroutine 的情况产生。

二、channel 的使用

在深入了解 channel 的底层之前,我们先来看看 channel 的常用用法。

channel 的创建

    ch := make(chan int)

上面是创建了无缓冲的 channel,一旦有 goroutine 往 channel 发送数据,那么当前的 goroutine 会被阻塞住,直到有其他的 goroutine 消费了 channel 里的数据,才能继续运行。

还有另外一种是有缓冲的 channel,它的创建是这样的:

ch := make(chan int, 2)

第二个参数表示 channel 可缓冲数据的容量。只要当前 channel 里的元素总数不大于这个可缓冲容量,则当前的 goroutine 就不会被阻塞住。

需要注意的是,上面 make 后返回的是一个指向 hchan 结构的指针变量,等会将会聊聊 hchan 的底层结构。

另外,我们也可以声明一个 nil 的 channel,只是创建这样的 channel 没有意义,读、写 channel 都将会被阻塞住。一般 nil channel 用在 select 上,让 select 不再从这个 channel 里读取数据,如下用法:

    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        if !ok { // 某些原因,设置 ch1 为 nil
            ch1 = nil
        }
    }()

    for {
        select {
        case <-ch1: // 当 ch1 被设置为 nil 后,将不会到达此分支了。
            doSomething1()
        case <-ch2:
            doSomething2()
        }
    }

使用 channel 时我们还可以控制 channel 只读只写操作:

    func readChan(ch <-chan int){
        // chan 只允许被读
    }

    func main(){
        ch := make(chan int)
        readChan(ch)
    }

反之,如果要求只写操作,则可以这样:

    func writeChan(ch chan<- int){
        // chan 只允许被写
    }

channel 的读写

往一个 channel 发送数据,可以这样

    ch := make(chan int)
    ch <- 1

对应的操作:

    data <- ch

当我们不再使用 channel 的时候,可以对其进行关闭:

    close(ch)

当 channel 被关闭后,如果继续往里面写数据,则程序会直接 panic 退出。

不过读取关闭后的 channel,不会产生 pannic,还是可以读到数据。

如果关闭后的 channel 没有数据可读取时,将得到零值,即对应类型的默认值。

为了能知道当前 channel 是否被关闭,可以使用下面的写法来判断。

    if v, ok := <-ch; !ok {
        fmt.Println("channel 已关闭,读取不到数据")
    }

还可以使用下面的写法不断的获取 channel 里的数据:

    for data := range ch {
        // get data dosomething
    }

这种用法会在读取完 channel 里的数据后就结束 for 循环,执行后面的代码。

channel 和 select

在写程序时,有时并不单单只会和一个 goroutine 通信,当我们要进行多 goroutine 通信时,则会使用 select 写法来管理多个 channel 的通信数据:

    ch1 := make(chan struct{})
    ch2 := make(chan struct{})

    // ch1, ch2 发送数据
    go sendCh1(ch1)
    go sendCh1(ch2)

    // channel 数据接受处理
    for {
        select {
        case <-ch1:
            doSomething1()
        case <-ch2:
            doSomething2()
        }
    }

channel 的 deadlock

前面提到过,往 channel 里读写数据时是有可能被阻塞住的,一旦被阻塞,则需要其他的 goroutine 执行对应的读写操作,才能解除阻塞状态。

然而,阻塞后一直没能发生调度行为,没有可用的 goroutine 可执行,则会一直卡在这个地方,程序就失去执行意义了。此时 Go 就会报 deadlock 错误,如下代码:

    func main() {
        ch := make(chan int)
        <-ch

        // 执行后将 panic:
        // fatal error: all goroutines are asleep - deadlock!
    }

因此,在使用 channel 时要注意 goroutine 的一发一取,避免 goroutine 永久阻塞!

三、channel 的底层原理

前面提及过 channel 创建后返回了 hchan 结构体,现在我们来研究下这个结构体,它的主要字段如下:

type hchan struct {
    qcount   uint   // channel 里的元素计数
    dataqsiz uint   // 可以缓冲的数量,如 ch := make(chan int, 10)。 此处的 10 即 dataqsiz
    elemsize uint16 // 要发送或接收的数据类型大小
    buf      unsafe.Pointer // 当 channel 设置了缓冲数量时,该 buf 指向一个存储缓冲数据的区域,该区域是一个循环队列的数据结构
    closed   uint32 // 关闭状态
    sendx    uint  // 当 channel 设置了缓冲数量时,数据区域即循环队列此时已发送数据的索引位置
    recvx    uint  // 当 channel 设置了缓冲数量时,数据区域即循环队列此时已接收数据的索引位置
    recvq    waitq // 想读取数据但又被阻塞住的 goroutine 队列
    sendq    waitq // 想发送数据但又被阻塞住的 goroutine 队列

    lock mutex
    ...
}

channel 在进行读写数据时,会根据无缓冲、有缓冲设置进行对应的阻塞唤起动作,它们之间还是有区别的。下面我们来捋一下这些不同之处。

无缓冲 channel

由于对 channel 的读写先后顺序不同,处理也会有所不同,所以,还得再进一步区分:

channel 先写再读

在这里,我们暂时认为有 2 个 goroutine 在使用 channel 通信,按先写再读的顺序,则具体流程如下:

golang 系列:channel 全面解析

可以看到,由于 channel 是无缓冲的,所以 G1 暂时被挂在 sendq 队列里,然后 G1 调用了 gopark 休眠了起来。

接着,又有 goroutine 来 channel 读取数据了:

golang 系列:channel 全面解析

此时 G2 发现 sendq 等待队列里有 goroutine 存在,于是直接从 G1 copy 数据过来,并且会对 G1 设置 goready 函数,这样下次调度发生时, G1 就可以继续运行,并且会从等待队列里移除掉。

channel 先读再写

先读再写的流程跟上面一样。

golang 系列:channel 全面解析

G1 暂时被挂在了 recvq 队列,然后休眠起来。

G2 在写数据时,发现 recvq 队列有 goroutine 存在,于是直接将数据发送给 G1。同时设置 G1 goready 函数,等待下次调度运行。

golang 系列:channel 全面解析

有缓冲 channel

在分析完了无缓冲 channel 的读写后,我们继续看看有缓冲 channel 的读写。同样的,我们分为 2 种情况:

channel 先写再读

这一次会优先判断缓冲数据区域是否已满,如果未满,则将数据保存在缓冲数据区域,即环形队列里。如果已满,则和之前的流程是一样的。

golang 系列:channel 全面解析
当 G2 要读取数据时,会优先从缓冲数据区域去读取,并且在读取完后,会检查 sendq 队列,如果 goroutine 有等待队列,则会将它上面的 data 补充到缓冲数据区域,并且也对其设置 goready 函数。

golang 系列:channel 全面解析

channel 先读再写

此种情况和无缓冲的先读再写是一样流程,此处不再重复说明。

四、总结

有缓冲 channel 和无缓冲 channel 的读写基本相差不大,只是多了缓冲数据区域的判断而已。

channel 在使用的时候大多时候得和 select 配合使用,尽管只需要简单的用 <- ch 和 ch <- 来读写数据,但它的底层还是很有讲究的,特别是涉及到调度的休眠唤起。

这也能看出 Go 的精妙之处:复杂底层,优雅运用。