概述
Channel 是一种特殊的引用类型,中文直译一般叫做通道,是 goroutine 执行体之间进行通信的桥梁,可以实现一个 goroutine 发生特定的值到另外一个 goroutine。
每个通道都是一个具体类型的导管,叫做通道的元素类型。使用 make 函数创建一个通道
ch := make(chan int)
和 map 一样,通道是一个使用 make 创建的数据结构的引用。所以当复制,或者作为参数传递到一个函数的时候,复制的是引用,这样调用者和被调用者都引用同一份数据结构。
通道之间可以比较吗?
- 同一个类型的通道之间是可以用 == 进行比较的,当二者都是同一个底层的引用的时候,结果是 true。
- 通道还可以和 nil 进行比较
声明
可以通过 var 关键字进行定义 channel,由于 channel 是一种引用类型,所以 var 定义的并没有在内存种实例化,var 定义后的 channel 零值为 nil,还需要初始化化,比较麻烦,因此这种定义方式不常用。
var ch1 chan int // 声明一个传递整型的通道
var ch2 chan bool // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道
fmt.Println(ch1, ch2, ch3) // 输出<nil> <nil> <nil>
另外一种方式是通过 make 函数定义,定义之后就已经在内存中初始化了,这个方式比较常用
chInt := make(chan int) // 无缓冲
chBool := make(chan bool, 0) // 无缓冲
chStr := make(chan string, 2) // 容量为2的缓冲通道
以上两种声明 channel 的都是双向的,意味着可以向该 channel 可以发送数据并接收数据。"发送"和"接收"是 channel 的两个基本操作,统称为通信,可以通过箭头操作符书写
ch <- x // 发送
x <- ch // 接收
<-ch // 接收并且丢弃结果
通道的第三个个操作是关闭。它设置一个标志位来表明现在已经发送完毕,这个通道不会再发送新的值了。
close(ch)
- 关闭后的发送操作会导致宕机。
- 在已经关闭的通道进行接收操作,将获取已经发送的值,直到通道为空。这个时候对已关闭的空通道上进行接收,会立即返回零值。
- 关闭一个 nil 的通道也会导致宕机
- 关闭以一个已经关闭的通道也会导致宕机
通道缓冲
无缓冲通道
如下用 make 创建无缓冲通道
chInt := make(chan int) // 无缓冲
chBool := make(chan bool, 0) // 无缓冲
对无缓冲通道的发送操作,将会阻塞(相当于挂起休眠了),直到其他 goroutine 在对应的通道上执行接收操作(发送的 goroutine 再次被唤醒),这时候传值完成。相反,如果接收操作先执行,接收方的 goroutine 将阻塞,直到另一个 goroutine 在同一个通道上发送值。
因为无缓冲通道会导致发送和接收 goroutine 同步化,所以无缓冲通道也称作同步通道。这个也是它的一个重要特征。
无缓冲通道如何导致死锁?
当在一个 goroutine 中对同一个无缓冲的通道进行收发操作,就会导致死锁,因为收发都在同一协程,就会导致互相等待,导致死锁
func main() {
ch := make(chan string)
ch <- "ping"
fmt.Println(<-ch)
}
缓冲通道
缓冲通道有一个元素队列,队列长度可以在 make 的时候指定
// 队列的长度是2
ch := make(chan int, 2)
缓冲通道的发送操作,会在队列的尾部插入元素(lpush),接收操作会在对了的头部移出(rpop)。如果通道满了,发送操作就会阻塞所在的 goroutine,直到另一个 goroutine 对它进行了接收操作来腾出空间。反过来,如果通道是空的,执行接收操作的 goroutine 阻塞,直到另一个 goroutine 在通道上发送数据。
因为缓冲空间足够的时候,不会阻塞,所以缓冲通道的一个重要特点是解耦。
在同一个 goroutine 上收发缓冲通道,会导致死锁吗?
如果没有超过缓冲的容量,就不会,但是如果超过容量就会触发阻塞,从而导致死锁。通道一般是用于 goroutine 之间通信的,所以一般不要在同一个 goroutine 上进行收发操作。
// 利用channel 实现了一个队列的操作,不过有死锁的风险
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
// ch <- 3 如果增加这个,就会死锁
fmt.Println(<-ch)
fmt.Println(<-ch)
}
一般所谓的死锁,是阻塞了主进程,如果只是阻塞子协程,并不会导致死锁
func main() {
go func() {
ch := make(chan int, 2)
ch <- 1 // 会一直阻塞在这里,但是并不会报死锁
<-ch
}()
for {
fmt.Println("end")
time.Sleep(time.Second)
}
}
需要注意的是,在内存无法提供缓冲容量的情况下,可能导致程序死锁。
如何获取通道的容量呢?
可以通过 cap 函数获取,例如 cap(ch)
如何获取通道的内的元素的个数
可以通过 len 函数获取,例如 len(ch)
缓冲通道的使用场景?
一般用于上游生产比下游消耗的速度快的场景。可以一下子生产很多东西,下游慢慢的去消费。当一个下游 goroutine 消费速度太慢的时候,可以再创建一个 goroutine 来一起消费这个通道,提高消费速度。
通道的遍历
下例中 goroutine 产生自然数传递给另外一个 goroutine,并且求平方后给主 goroutine 打印
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for i := 1; ; i++ {
// 无限产生自然数到通道naturals
naturals <- i
}
}()
go func() {
for {
// 无限从naturals通道读取数据,并且求平方
x := <-naturals
squares <- (x * x)
}
}()
for {
// 求得的平方无限打印出来
val := <-squares
fmt.Println(val)
}
}
上述例子是发送无限的数字,如果想要发送有限的数字怎么办呢?
如果发送方直到没有更多的数据要发送,就应该告诉接受者所在的 goroutine 不要再等待了。通过 close(naturals)
来通知接收者就好了。
通道 naturals 被关闭后,后续就不能再往该通道发送数据,否则会异常退出。并且关闭的通道 naturals 被读完之后,所有后续的接收操作都不会被阻塞,而是顺畅的获取到零值,传递给了 squares 通道
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
naturals <- i
}
// 产生5个自然数到通道naturals
close(naturals)
}()
go func() {
for {
// 获取5个自然数后,从关闭的通道上获取的数都是0
x := <-naturals
squares <- (x * x)
}
}()
for {
// 打印出1,4,9,16,25,0,0....后续都是0
val := <-squares
fmt.Println(val)
}
}
那么,有没一个方式来判断该通道是否已经被关闭了呢?
可以在接收参数上加一个 ok 参数,当为 true 的时候表示接收成功,为 false 的话表示当前接收操作在一个关闭并且读完的通道上。
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
// 产生5个自然数到通道naturals,然后关闭通道
for i := 1; i <= 5; i++ {
naturals <- i
}
close(naturals)
}()
go func() {
for {
// 当5个数接收完后,由于通道被关闭,所有ok会为false
x, ok := <-naturals
if !ok {
break
}
squares <- (x * x)
}
close(squares)
}()
for {
val, ok := <-squares
if !ok {
break
}
// 打印出1,4,9,16,25
fmt.Println(val)
}
}
有没有更简单的写法呢?
由于 for 循环里通过 ok 判断来退出循环的方式比较笨拙,所以提供了 range 循环语法,可以直接在通道上迭代。Range 更方便的从通道上接收值,并且接收完最后一个后退出循环。所以可以简写为
func main() {
naturals := make(chan int)
squares := make(chan int)
go func() {
// 产生5个自然数到通道naturals,然后关闭通道
for i := 1; i <= 5; i++ {
naturals <- i
}
close(naturals)
}()
go func() {
for x := range naturals {
squares <- (x * x)
}
close(squares)
}()
for val := range squares {
// 打印出1,4,9,16,25
fmt.Println(val)
}
}
通道关闭是必须的吗?
并不是必须的,只有需要通知接收方的 goroutine 所有的数据都发送完毕的时候,才需要关闭通道。因为如果像是 range 循环,不通知的通道关闭的话,会一直等到,导致死锁,例如
func main() {
naturals := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
naturals <- i
}
}()
// 由于naturals 没有被关闭,所以这里会一直等待,导致死锁
for val := range naturals {
fmt.Println(val)
}
}
对于未关闭的通道,垃圾回收器可以通过它是否可以被访问来决定是否回收它。
单向通道
当一个通道作为函数的参数时候,它经常会被有意的设置不能发送或不能接收。Go 的类型系统提供了单向通道类型,仅仅导出发送或者接收操作。
- 类型
chan<- int
是一个只能发送的通道,允许向这个通道发送,不能从这个通过接收 - 类型
<-chan int
是一个只能接收的通道,允许向从这个通道接收,不能向这个通过发送
// 只能发送
func counter(out chan<- int) {
for i := 1; i <= 5; i++ {
out <- i
}
close(out)
}
// 发送和接收
func squarer(out chan<- int, in <-chan int) {
for x := range in {
out <- (x * x)
}
close(out)
}
func main() {
naturals := make(chan int)
squares := make(chan int)
go counter(naturals)
go squarer(squares, naturals)
for val := range squares {
// 打印出1,4,9,16,25
fmt.Println(val)
}
}
需要注意的是:在变量声明中是不应该出现单向通道的,因为通道本来就是为了通信而生,定义一个只能接收或者只能发送数据的通道是没有意义的。
ch := make(chan<- string, 1)
ch <- "str"
这个例子中定义了一个只能用来接收数据的通道,从语法上来看没有错误,但这是一种糟糕的实践。
Select 多路复用
正常我们一行代码只能监听一个 channel,看下该 channel 是否有数据,有时候我们希望同时监听多个 channel,如果同时有数据,多个 channel 则是并发执行。在下述例子中,通过 select 的使用,使得代码阻塞在 select 那,直到每个通道都关闭了,才会退出 select,保证了 worker 中的事务可以执行完毕后才退出 main 函数
func strWorker(ch chan string) {
time.Sleep(1 * time.Second)
fmt.Println("do something with strWorker...")
ch <- "str"
}
func intWorker(ch chan int) {
time.Sleep(2 * time.Second)
fmt.Println("do something with intWorker...")
ch <- 1
}
func main() {
chStr := make(chan string)
chInt := make(chan int)
go strWorker(chStr)
go intWorker(chInt)
for i := 0; i < 2; i++ {
select {
case <-chStr:// 这个通道一有值就会执行这
fmt.Println("get value from strWorker")
case <-chInt:// 这个通道一有值就会执行这
fmt.Println("get value from intWorker")
}
}
}
通过 select 里的default,我们还可以实现不阻塞 channel
package main
import (
"fmt"
"time"
)
func readBlocWithNoBlock() {
dataCh := make(chan int)
go func() {
for i := 0; i < 100; i++ {
dataCh <- i
time.Sleep(time.Second)
}
}()
for i := 0; i < 100; i++ {
select {
// 这个取到值后,就走这个
case data := <-dataCh:
fmt.Println(data)
// 如果上面的阻塞,就默认执行这个
default:
}
fmt.Println("YES")
time.Sleep(500 * time.Millisecond)
}
}
func main() {
readBlocWithNoBlock()
}
通过 channel 实现同步机制
一个经典的例子如下,main 函数中起了一个 goroutine,通过非缓冲队列的使用,能够保证在 goroutine 执行结束之前 main 函数不会提前退出。
func worker(done chan bool) {
fmt.Println("start working...")
done <- true
fmt.Println("end working...")
}
func main() {
done := make(chan bool, 1)
go worker(done)
<-done // 阻塞在这里,确保协程执行完成
}
如果没有 <-done
的话,worker 里的内容将不会执行,当然我们还可以通过 sync. WaitGroup
来实现同步,见 go语言的goroutine 章节
通道总结
哪些情况会导致 channel 死锁?
- 在同一个协程了使用非缓冲通道
- Foreach 变量通道,没有关闭协程