深入 Golang channel (一)基础用法都在这了
1. 前言
你好哇!本文是「Golang 并发编程」系列的第 2 篇文章~
现在感觉这个坑开得有点大,没个一年半载的讲不清楚了……
上篇文章我们学习了 Go 语言中的 Goroutine 创建和调度的机制,理解了一条简单的 go 命令背后的原理。本文开始,将深入研究 go 语言中的 channel,将拆分为基础用法、实用场景、反面教材、源码实现四篇文章来介绍,欢迎关注追更~
本文将介绍 channel 相关的概念、语法和规则,不涉及原理和源码分析,更深入的内容,后面的更新会覆盖到,敬请期待~
2. Channel 简介
Channel 是 go 语言内置的一个非常重要的特性,也是 go 并发编程的两大基石之一(另一个是 go ,也就是 goroutine )。Channel 也是 go 里面非常有趣的一个功能,有时候甚至有点烧脑,相对于传统的 mutex 等同步并发原语,掌握 channel 门槛更高、更容易出错,因此更值得深入学习。
关于并发编程,Rob Pike 有个名言:
不要通过共享内存来通信,要通过通信来共享内存
Don't (let computations) communicate by sharing memory, (let them) share memory by communicating (through channels)
在 go 语言中,channel 就是 goroutine 之间通过通信来共享内存的手段。可以把 channel 看作 go 程序内部的一个 FIFO (first in, first out) 队列,一些 goroutine 向其中生产数据,另外一些消费数据。
另外 channel 是 go 语言中的一等公民,不像使用 mutex 、waitgroup 等其它并发编程原语需要引入 sync 、atomic 等包,channel 可以直接使用,不需要引入任何包。
3. Channel 的类型和值
跟 slice 、map 这些内置类型一样,channel 作为一种元素类型,也是有具体的类型的,channel 只能传递声明的类型的值。
基础类型:双向与单向 channel
对于类型 T,可以声明三种类型的 channel:
chan T双向 channel ,既能接收值又能发送值chan<- Tsend-only channel,只能往里写(chan是箭头的终点)<-chan Treceive-only channel,只能从里读(chan是箭头的起点) 在函数中使用即:
func foo(ch1 <-chan int) // 只能从 ch1 里读
func bar(ch2 chan<- int) // 只能往 ch2 里写
双向 channel chan T 可以被隐式转换成 send-only channel chan<- T 或 receive-only channel <-chan T;而 chan<- T 与 <-chan T 两者则不能互相转换(显式转换也不行)
单向 channel 是一种函数传参时的安全性约束,在实际使用中几乎不可能单独去声明一个单向的 channel。
buffered channel + unbuffered channel + nil channel
每个 channel 类型的值都会有一个容量(capacity),根据 capacity 大小来区分,可以分为两种:
- buffered channel:带缓冲的 channel,cap > 0
- unbuffered channel:不带缓冲的 channel,cap = 0
使用 make 创建 channel:
ch1 := make(chan int, 10) // buffered channel, cap = 10
ch2 := make(chan int) // unbuffered channel, cap = 0 (make chan 函数第二参数默认值为 0)
var ch3 chan int // nil 是 chan 的零值(zero value)
注意到上面的例子中还有一种特殊的 channel :nil channel,在只声明但是并未 make 时,channel 的值是零值 nil。nil channel 无论是写入还是读取都会永久阻塞住。
4. channel 的 7 种操作
1. 向 channel 发送值
ch <- v
需要注意:
v需要和ch声明的元素类型相同<-是channel-send即 channel 发送操作符- 这里的
ch不能是 receive-only channel
2. 从 channel 里读取结果
<- ch
- 从 channel 里取数据的操作符叫
channel-receive操作符,使用它总会有至少一个返回值,它跟channel-send操作符长得一样 - 这里的
ch不能是 send-only channel channel-receive操作符大部分时候都返回一个结果,但是也能返回两个结果,第二个结果是用来指示从 channel 里出来的值是否是在 channel 关闭之前读到的,如以下代码所示:
v = <-ch
v, sentBeforeClosed = <-ch // 先关闭再发送 v,则返回 false
这种方式与操作 map 时的方式类似,被称作 ok-idiom,并且,在 close(ch) 函数执行的时候,会对 ch 发送一条消息,这个动作可以用来通知所有的 goroutine 退出,例如:
package main
func main() {
done := make(chan struct{})
c := make(chan int)
go func() {
defer close(done)
for {
x, ok := <-c
if !ok { // close 时会收到一条消息,x 值为 0,ok 为 false
return
}
println(x)
}
}()
c <- 1
c <- 2
c <- 3
close(c)
<-done // close 时会收到消息,解除阻塞
}
3. for-range 操作
使用 for-range 遍历 channel 会比使用 ok-idiom 更简洁,将上面的例子用 for-range 的方式来实现:
func main() {
done := make(chan struct{})
c := make(chan int)
go func() {
defer close(done)
for x := range c {
println(x)
}
}()
c <- 1
c <- 2
c <- 3
close(c)
<-done // close 时会收到消息,解除阻塞
}
4. select 多路选择
将在下文专门讨论
5. 关闭 channel
close(ch)
close 是个 go 内置函数,只能操作 channel 类型,且 close 的对象不能是 receive-only channel。另外,在前面的例子里可以看到, close 发生时,会向被关闭的 channel 发送一条消息,解除阻塞,这个特性可以用来做一些一次性的操作。
错误的 close 会引发程序的 panic,关于如何优雅关闭 channel,我会在后面的「反面教材:panic 和内存泄漏」主题里展开,敬请期待~继续挖坑……
6. 返回 channel 的容量(capacity)
cap(ch)
cap 是 go 的内置函数,会返回一个 int 类型的 channel 容量,可以参考作用在 slice 时的表现
7. 返回 channel buffer 中值的数量
len(ch)
跟 cap 函数类似,len也是 go 语言的内置函数,返回值是已经成功写入 channel buffer 但是还没有从 channel 里读出来的值的数量。在 channel 的操作中,cap 和 len 函数其实用得都不多。
5. 阻塞场景梳理
针对根据 channel 是否为空和是否关闭,可以分成以下三类来讨论:
- 空 channel (nil channel)
- 非空已关闭 channel
- 非空未关闭 channel
| 操作 | 为空 | 非空已关闭 | 非空未关闭 |
|---|---|---|---|
| close | panic | panic | 成功 close |
| 写入 | 永久阻塞 | panic | 成功写入或阻塞 |
| 读取 | 永久阻塞 | 永不阻塞 | 成功读取或阻塞 |
要理解这几种现象,就要看下 channel 的内部结构了,可以认为 channel 内部有三个 FIFO 队列
- 接收数据的 goroutine 队列,是一个无限长的链表,这个队列里的 goroutine 都处于阻塞状态,等待数据从 channel 写入
- 发送数据的 goroutine 队列,也是一个无限长的链表,这个队列里的 goroutine 都处于阻塞状态,等待数据向 channel 写入。每个 goroutine 尝试发送的值也和 goroutine 一起存在这个队列里
- 值 buffer 队列,是一个环形队列(ringbuffer),它的大小跟 channel 的容量相同。存在这个 buffer 队列里的值跟 channel 元素的类型相同。如果当前 buffer 队列里储存的值的数量达到了 channel 的容量,这个 channel 就「满了」,对于 unbuffered channel 而言,它总是既在「空」状态,又在「满」状态。
更多的原理性的分析,将在本系列的后续「原理与源码分析」文章展开。
6. 多路选择操作符 select
select 语句是专门为 channel 操作而设计的,使用时类似 switch-case 的用法,适用于处理多通道的场景,会通过类似 are-you-ready-polling 的机制来工作。当多个 case 中有一个准备好了,就能执行,无论是收还是发。
阻塞与非阻塞 select
select 默认是阻塞的,当没有 case 处于激活状态时,会一直阻塞住,极端的甚至可以这样用...
select {
// 啥也不干,一直阻塞住
}
通过增加 default,可以实现非阻塞的 select
select {
case x, ok := <-ch1:
...
case ch2 <- y:
...
default:
fmt.Println("default")
}
多 case 与 default 执行的顺序
整体流程如图所示:
需要注意:
- 随机性:多个 case 之间并非顺序的,遵循「先到先执行,同时到则随机执行」的原则
- 一次性:和
switch-case一样,select-case也只会执行一次,如果需要多次处理,需要在外层套一个循环 - default 不会阻塞,会一直执行,当与 for 循环组合使用时可能出现死循环,如下面代码所示:
func main() {
var ch chan int
i := 0
for {
select {
case <-ch: // nil channel 永远阻塞
fmt.Println("never...")
default:
fmt.Printf("in default, i = %d\n", i)
}
i++
}
}
小结
本文完整介绍了 channel 的所有基础用法,包括 <- 、ok-idiom、select-case、close、for-range 等,并分析了阻塞与非阻塞的场景。后面三篇文章将分别介绍 channel 在实际的程序世界中的多种实用案例、底层实现及使用不当导致的死锁、内存泄漏等问题,敬请期待!
如果本文对你有帮助,记得「关注」、「点赞」、「在看」走起,也欢迎留言讨论,你的反馈是我更新的动力~
To be continued...
参考资料
- 《Go 101 - Channels in Go》
- legendtkl -《深入理解 Go Channel》
- 雨痕 -《Go 语言学习笔记》第 8 章,并发