Go并发编程 | channel

241 阅读8分钟

引言

channel 是 Go 语言实现 CSP 模型的另一个主要组成部分,channel 可以用于 goroutine 间的通信和同步。对于 channel,不应该将其视作一种数据结构,而应该视作一种信号传递机制,如果面前的问题无法利用这种机制解决,那么就要重新考虑使用 channel 的必要性。本文从 channel 的基本使用开始,然后介绍 channel 的实现和注意事项。

channel 的基本用法

Go 在语法层面将 channel 作为一等公民看待,可以像使用普通变量一样使用 channel,包括定义 channel 类型变量、给 channel 类型变量赋值,将 channel 作为函数的参数或返回值,甚至将 channel 发送到其他 channel。

创建 channel

与 slice、map 相同,channel 也是一种复合数据类型,使用make来分配并返回对底层数据结构的引用。

channel 可以分为有缓冲和无缓冲两种,可以基于make来设置缓冲区大小

ci := make(chan int)            // int 类型无缓冲 channel
cj := make(chan int, 5)         // int 类型有缓冲 channel
cs := make(chan *os.File, 100)  // 文件指针类型有缓冲 channel

向 channel 发送数据

通过ch<-向 channel 发送数据,如:

ch <- 2000

从 channel 接收数据

通过<-ch从 channel 接收数据,如:

x := <-ch   // 把接收的数据赋给 x
foo(<-ch)   // 把接收的数据作为参数
<-ch        // 丢弃接收的数据

因为 channel 分为两种并具有多种状态(已满、已关闭等),在不同情况下对 channel 进行读写有可能发生错误,这部分会在后面的注意事项中提到。

其他操作

因为 Go 将 channel 视作第一公民,所以可以使用close关闭 channel,使用cap获取 channel 的容量,使用len获取 channel 中缓存还未被取走的数量。

也可以在for-range中使用:

for v := range ch {
    fmt.Println(v)
}

除此以外,还可以使用select作为 channel 之间的粘合剂,同时对多个 channel 进行操作。

select {
case x := <-ch1:  
case ch2 <- y: 
default: 
}

channel 的实现

数据结构

在Go语言的运行时系统中,channel是通过hchan结构体实现的。这个结构体包含了管理channel的各种字段:

  • qcount:当前队列中元素的数量。
  • dataqsiz:固定大小的环形队列,用于存储channel中的元素,其大小在channel创建时被设定。
  • buf:指向环形队列的起始位置的指针,环形队列用于存储传递的值。
  • elemsize:每个元素的大小,这取决于channel传递的数据类型。
  • closed:标志位,表示channel是否已关闭。
  • sendxrecvx:分别表示发送和接收索引,用于管理环形队列中数据的位置。

为了实现同步机制,hchan结构体还包括两个队列并使用锁来保护对内部状态的访问:

  • sendq:等待发送数据到 channel 的goroutines队列。
  • recvq:等待从 channel 接收数据的goroutines队列。

在分配时,会根据 chan 的容量和类型初始化不同的存储空间。

发送

channel 实现发送的逻辑包括:

  1. 加锁与基本检查

当一个 goroutine 尝试向 channel 发送数据时,首先会对 channel 的内部数据结构进行加锁,以防止并发访问造成的数据竞态。之后进行基本的状态检查,例如:

  • 检查 channel 是否已经关闭。如果已关闭,则抛出 panic。
  • 检查是否有 goroutine 在接收队列等待数据(recvq)。如果有,则可以直接将数据传递给等待的 goroutine,并唤醒它。
  1. 直接传递(无缓冲或有接收者)

对于无缓冲 channel 或有 goroutine 在等待接收的情况,发送操作会尝试直接将数据交给接收者。这种情况下,数据不会进入 channel 的内部缓冲区,而是直接从发送者复制到接收者,这个过程在锁的保护下进行,确保数据的一致性。

  1. 数据放入缓冲区(有缓冲)

如果是有缓冲 channel,并且缓冲区内有空间,发送操作会将数据放入 channel 的环形缓冲区中。这包括:

  • 将数据复制到buf指向的环形缓冲区的sendx位置。
  • 更新sendx索引,如果sendx达到缓冲区末尾,则回绕到缓冲区开始。
  • 更新qcount,即队列中的元素数量。
  1. 阻塞与唤醒机制

如果 channel 是无缓冲的,或者缓冲区已满,发送 goroutine 将被阻塞:

  • 将发送 goroutine 加入到发送队列(sendq)。
  • 发送 goroutine 将挂起,直到有空间可用或其他 goroutine 接收数据后将其唤醒。
  1. 解锁

在数据发送完毕、goroutine 阻塞或唤醒等待的 goroutine 后,对 channel 的操作完成,将释放之前获得的锁,允许其他 goroutine 进行发送或接收操作。

接收

接收操作与发送类似,也可以分为五个部分。

  1. 加锁与基本检查

当 goroutine 尝试从 channel 接收数据时,第一步是对 channel 的内部数据结构进行加锁,以防止并发访问引发的数据竞态问题。接下来进行几项基本检查:

  • 检查 channel 是否已经关闭:如果 channel 已关闭并且缓冲区为空,则立即返回一个零值和false,表示没有更多的数据可接收。
  • 检查是否有 goroutines 在发送队列等待发送数据(sendq):如果有,则可以直接从等待的 goroutine 接收数据,并唤醒该 goroutine。
  1. 直接接收数据(无缓冲)

对于无缓冲 channel,接收操作会直接从发送者那里获取数据。数据会直接从发送者的上下文传输到接收者,而不会经过 channel 的缓冲区。

  1. 从缓冲区获取数据(有缓冲)

如果是有缓冲 channel,并且缓冲区中有数据,则接收操作将从 channel 的环形缓冲区中取出数据。如果缓冲区在取出数据前是满的,则会唤醒阻塞在sendq中的 goroutine 将其数据发送至缓冲区

  1. 阻塞与唤醒机制

如果 channel 是无缓冲的,或者缓冲区为空,接收 goroutine 将被阻塞:

  • 将接收 goroutine 加入到接收队列(recvq)。
  • 接收 goroutine 将挂起,直到有数据可用或其他 goroutine 发送数据后将其唤醒。
  1. 解锁

在数据接收完毕、goroutine 阻塞或唤醒等待的 goroutine 后,对 channel 的操作完成,将释放之前获得的锁,允许其他 goroutine 进行发送或接收操作。

关闭

  1. 加锁并检查

关闭操作首先需要获得对 channel 的互斥锁,以同步访问并防止其他 goroutine 同时进行发送、接收或尝试其他关闭操作。

关闭操作会检查channel是否已经关闭。在Go中,对已关闭的 channel 再次执行关闭操作会引发panic,因此这一步是必要的。如果channel已关闭,操作立即终止并报错。

  1. 设置关闭标志

如果 channel 尚未关闭,关闭操作会设置一个标志位,表明 channel 已经关闭。这个标志用于后续的发送和接收操作中,来决定是否应该继续操作或返回特定的错误或零值。

  1. 唤醒所有等待的 goroutine

关闭 channel 时,所有在发送队列(sendq)和接收队列(recvq)中等待的 goroutine 都会被唤醒:

  • 发送队列中的 goroutine:这些 goroutine 会因为 channel 已关闭而收到 panic 或错误,因为向已关闭的channel发送数据是不允许的。
  • 接收队列中的 goroutine:如果缓冲区内还有数据,这些 goroutine 会正常接收剩余数据。一旦缓冲区为空,后续的接收操作会返回零值,并根据 channel 的数据类型返回false
  1. 清理资源

如果有必要,关闭操作还会处理一些资源清理的工作,例如释放内部数据结构所占用的内存。这一步骤确保了关闭 channel 后不会有资源泄露。

  1. 解锁

最后,完成所有关闭相关的操作后,会释放对 channel 的锁,允许其他 goroutine 继续执行其他操作,如检查 channel 的关闭状态等。

channel 的注意事项

基于 channel 的receivesendclose三种基本操作和只声明未初始化、有缓冲、无缓冲等几种状态,可以归纳出 channel 的行为矩阵。在使用 channel 时注意会导致 panic 和阻塞的操作,可以有效避免程序崩溃和 goroutine 泄露。

屏幕截图 2024-05-21 204516.png

其中,非空的重点在于能否立即从channel接收数据,而不阻塞。不满的重点在于能否立即向channel发送数据,而不阻塞。

Go 的开发者推荐使用 channel 而不是传统的锁,但 Go 的开发者逐渐意识到 channel 并不是处理并发问题的银弹,有时使用同步原语更加简单,基本可以分为两种情况:

  • 使用传统的并发原语对共享资源并发访问(不转移所有权)。
  • 使用 channel 完成任务编排和消息传递(转移所有权)。