引言
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是否已关闭。
- sendx 和 recvx:分别表示发送和接收索引,用于管理环形队列中数据的位置。
为了实现同步机制,hchan结构体还包括两个队列并使用锁来保护对内部状态的访问:
- sendq:等待发送数据到 channel 的goroutines队列。
- recvq:等待从 channel 接收数据的goroutines队列。
在分配时,会根据 chan 的容量和类型初始化不同的存储空间。
发送
channel 实现发送的逻辑包括:
- 加锁与基本检查
当一个 goroutine 尝试向 channel 发送数据时,首先会对 channel 的内部数据结构进行加锁,以防止并发访问造成的数据竞态。之后进行基本的状态检查,例如:
- 检查 channel 是否已经关闭。如果已关闭,则抛出 panic。
- 检查是否有 goroutine 在接收队列等待数据(
recvq)。如果有,则可以直接将数据传递给等待的 goroutine,并唤醒它。
- 直接传递(无缓冲或有接收者)
对于无缓冲 channel 或有 goroutine 在等待接收的情况,发送操作会尝试直接将数据交给接收者。这种情况下,数据不会进入 channel 的内部缓冲区,而是直接从发送者复制到接收者,这个过程在锁的保护下进行,确保数据的一致性。
- 数据放入缓冲区(有缓冲)
如果是有缓冲 channel,并且缓冲区内有空间,发送操作会将数据放入 channel 的环形缓冲区中。这包括:
- 将数据复制到
buf指向的环形缓冲区的sendx位置。 - 更新
sendx索引,如果sendx达到缓冲区末尾,则回绕到缓冲区开始。 - 更新
qcount,即队列中的元素数量。
- 阻塞与唤醒机制
如果 channel 是无缓冲的,或者缓冲区已满,发送 goroutine 将被阻塞:
- 将发送 goroutine 加入到发送队列(
sendq)。 - 发送 goroutine 将挂起,直到有空间可用或其他 goroutine 接收数据后将其唤醒。
- 解锁
在数据发送完毕、goroutine 阻塞或唤醒等待的 goroutine 后,对 channel 的操作完成,将释放之前获得的锁,允许其他 goroutine 进行发送或接收操作。
接收
接收操作与发送类似,也可以分为五个部分。
- 加锁与基本检查
当 goroutine 尝试从 channel 接收数据时,第一步是对 channel 的内部数据结构进行加锁,以防止并发访问引发的数据竞态问题。接下来进行几项基本检查:
- 检查 channel 是否已经关闭:如果 channel 已关闭并且缓冲区为空,则立即返回一个零值和
false,表示没有更多的数据可接收。 - 检查是否有 goroutines 在发送队列等待发送数据(
sendq):如果有,则可以直接从等待的 goroutine 接收数据,并唤醒该 goroutine。
- 直接接收数据(无缓冲)
对于无缓冲 channel,接收操作会直接从发送者那里获取数据。数据会直接从发送者的上下文传输到接收者,而不会经过 channel 的缓冲区。
- 从缓冲区获取数据(有缓冲)
如果是有缓冲 channel,并且缓冲区中有数据,则接收操作将从 channel 的环形缓冲区中取出数据。如果缓冲区在取出数据前是满的,则会唤醒阻塞在sendq中的 goroutine 将其数据发送至缓冲区
- 阻塞与唤醒机制
如果 channel 是无缓冲的,或者缓冲区为空,接收 goroutine 将被阻塞:
- 将接收 goroutine 加入到接收队列(
recvq)。 - 接收 goroutine 将挂起,直到有数据可用或其他 goroutine 发送数据后将其唤醒。
- 解锁
在数据接收完毕、goroutine 阻塞或唤醒等待的 goroutine 后,对 channel 的操作完成,将释放之前获得的锁,允许其他 goroutine 进行发送或接收操作。
关闭
- 加锁并检查
关闭操作首先需要获得对 channel 的互斥锁,以同步访问并防止其他 goroutine 同时进行发送、接收或尝试其他关闭操作。
关闭操作会检查channel是否已经关闭。在Go中,对已关闭的 channel 再次执行关闭操作会引发panic,因此这一步是必要的。如果channel已关闭,操作立即终止并报错。
- 设置关闭标志
如果 channel 尚未关闭,关闭操作会设置一个标志位,表明 channel 已经关闭。这个标志用于后续的发送和接收操作中,来决定是否应该继续操作或返回特定的错误或零值。
- 唤醒所有等待的 goroutine
关闭 channel 时,所有在发送队列(sendq)和接收队列(recvq)中等待的 goroutine 都会被唤醒:
- 发送队列中的 goroutine:这些 goroutine 会因为 channel 已关闭而收到 panic 或错误,因为向已关闭的channel发送数据是不允许的。
- 接收队列中的 goroutine:如果缓冲区内还有数据,这些 goroutine 会正常接收剩余数据。一旦缓冲区为空,后续的接收操作会返回零值,并根据 channel 的数据类型返回
false。
- 清理资源
如果有必要,关闭操作还会处理一些资源清理的工作,例如释放内部数据结构所占用的内存。这一步骤确保了关闭 channel 后不会有资源泄露。
- 解锁
最后,完成所有关闭相关的操作后,会释放对 channel 的锁,允许其他 goroutine 继续执行其他操作,如检查 channel 的关闭状态等。
channel 的注意事项
基于 channel 的receive、send、close三种基本操作和只声明未初始化、有缓冲、无缓冲等几种状态,可以归纳出 channel 的行为矩阵。在使用 channel 时注意会导致 panic 和阻塞的操作,可以有效避免程序崩溃和 goroutine 泄露。
其中,非空的重点在于能否立即从channel接收数据,而不阻塞。不满的重点在于能否立即向channel发送数据,而不阻塞。
Go 的开发者推荐使用 channel 而不是传统的锁,但 Go 的开发者逐渐意识到 channel 并不是处理并发问题的银弹,有时使用同步原语更加简单,基本可以分为两种情况:
- 使用传统的并发原语对共享资源并发访问(不转移所有权)。
- 使用 channel 完成任务编排和消息传递(转移所有权)。