前言
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 通信,按先写再读的顺序,则具体流程如下:
可以看到,由于 channel 是无缓冲的,所以 G1 暂时被挂在 sendq 队列里,然后 G1 调用了 gopark 休眠了起来。
接着,又有 goroutine 来 channel 读取数据了:
此时 G2 发现 sendq 等待队列里有 goroutine 存在,于是直接从 G1 copy 数据过来,并且会对 G1 设置 goready 函数,这样下次调度发生时, G1 就可以继续运行,并且会从等待队列里移除掉。
channel 先读再写
先读再写的流程跟上面一样。
G1 暂时被挂在了 recvq 队列,然后休眠起来。
G2 在写数据时,发现 recvq 队列有 goroutine 存在,于是直接将数据发送给 G1。同时设置 G1 goready 函数,等待下次调度运行。
有缓冲 channel
在分析完了无缓冲 channel 的读写后,我们继续看看有缓冲 channel 的读写。同样的,我们分为 2 种情况:
channel 先写再读
这一次会优先判断缓冲数据区域是否已满,如果未满,则将数据保存在缓冲数据区域,即环形队列里。如果已满,则和之前的流程是一样的。
当 G2 要读取数据时,会优先从缓冲数据区域去读取,并且在读取完后,会检查 sendq 队列,如果 goroutine 有等待队列,则会将它上面的 data 补充到缓冲数据区域,并且也对其设置 goready 函数。
channel 先读再写
此种情况和无缓冲的先读再写是一样流程,此处不再重复说明。
四、总结
有缓冲 channel 和无缓冲 channel 的读写基本相差不大,只是多了缓冲数据区域的判断而已。
channel 在使用的时候大多时候得和 select 配合使用,尽管只需要简单的用 <- ch 和 ch <- 来读写数据,但它的底层还是很有讲究的,特别是涉及到调度的休眠唤起。
这也能看出 Go 的精妙之处:复杂底层,优雅运用。