相信学过GO的人,对协程不陌生,但管道(通道)中有很多细节可能是你们不清楚的,比如管道关闭后,里面是否还有数据?为什么要管道关闭后再遍历?管道带不带缓冲区有什么区别?管道的同步和异步操作又是怎么回事?select对管道的影响又是什么?别急,本文带你一次性搞懂管道!
一、基本概念
channel,通道,又译作管道,是GO中的重要数据类型,用于传递数据。通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。
通道必须使用 make 函数创建,有三种创建方式:
ch := make(chan int) //双向通道
ch := make(<-chan int) //只读通道
ch := make(chan <-int) //只写通道
其中<-
为取出(读)或放入(写)操作,操作符在chan左边为读,在chan右边为写。
通道可以设置缓冲区,通过 make 的第二个参数指定缓冲区大小:
ch := make(chan int, 100)
二、无缓冲通道的作用
如果通道不带缓冲,就是同步操作,即发送方会阻塞直到接收方从通道中接收了值,这可以用于使主线程等待协程完成通信。为了验证这一点,我们写两个函数,一个用来跑 goroutine,另一个作为主线程,并且两个都带Sleep,看看会发生什么:
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s, (i+1)*100)
}
}
func say2(s string) {
for i := 0; i < 5; i++ {
time.Sleep(150 * time.Millisecond)
fmt.Println(s, (i+1)*150)
}
}
func main() {
go say2("world")
say("hello")
}
输出结果:
hello 100
world 150
hello 200
hello 300
world 300
hello 400
world 450
hello 500
问题来了,say2 只执行了 3 次,而不是设想的 5 次,为什么呢?原来,在 goroutine 还没来得及跑完 5 次的时候,主函数已经退出了。我们要想办法阻止主函数的结束,要等待 goroutine 执行完成之后,再退出主函数:
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s, (i+1)*100)
}
}
func say2(s string, ch chan int) {
for i := 0; i < 5; i++ {
time.Sleep(150 * time.Millisecond)
fmt.Println(s, (i+1)*150)
}
ch <- 0
close(ch)
}
func main() {
ch := make(chan int)
go say2("world", ch)
say("hello")
fmt.Println(<-ch) //关键语句,这句会阻塞,直到say2执行完毕
}
我们引入一个通道,默认的,通道的存消息和取消息都是阻塞的,在 goroutine 中执行完成后给通道一个值 0,则主函数会一直等待通道中的值,一旦通道有值,主函数才会结束。
三、通道的同步与异步
通道缓冲的形象解释:
无缓冲是同步的,例如 make(chan int)
,就是一个送信人去你家门口送信,你不在家他不走,你一定要接下信,他才会走,无缓冲保证信能到你手上。
有缓冲是异步的,例如 make(chan int, 1)
,就是一个送信人去你家仍到你家的信箱,转身就走,除非你的信箱满了,他必须等信箱空下来,有缓冲的保证信能进你家的邮箱。
接下来演示异步通道:
package main
import (
"fmt"
"time"
)
func put(c chan int) {
for i := 0; i < 10; i++ {
c <- i
time.Sleep(100 * time.Millisecond)
fmt.Println("->放入", i)
}
fmt.Println("=所有的都放进去了!关闭缓冲区,但是里面的数据不会丢失,还能取出。")
close(c)
}
func main() {
ch := make(chan int, 5)
go put(ch)
for {
time.Sleep(1000 * time.Millisecond)
data, ok := <-ch
if ok {
fmt.Println("<-取出", data)
} else {
break
}
}
}
输出结果:
->放入 0
->放入 1
->放入 2
->放入 3
->放入 4
<-取出 0
->放入 5
<-取出 1
->放入 6
<-取出 2
->放入 7
<-取出 3
->放入 8
<-取出 4
->放入 9
=所有的都放进去了!关闭缓冲区,但是里面的数据不会丢失,还能取出。
<-取出 5
<-取出 6
<-取出 7
<-取出 8
<-取出 9
可以看到,放的速度较快,先放满了 4 个,阻塞住。取的速度较慢,放了4个才开始取,由于缓冲区已经满了,所以取出一个之后,才能再次放入。放完了之后虽然缓冲区关闭了,但是缓冲区的内容还保留,所以还能继续取出。
四、通道的遍历与协程死锁
关闭通道并不会丢失里面的数据,只是防止通道数据被读完后一直阻塞,即等待新数据写入。所以遍历通道前必须关闭,关闭后的管道,读完数据才不会阻塞,实例如下:
package main
import (
"fmt"
)
func fibonacci(n int, c chan int) {
x, y := 0, 1
for i := 0; i < n; i++ {
c <- x
x, y = y, x+y
}
close(c)
}
func main() {
c := make(chan int, 10)
go fibonacci(cap(c), c)
// range 函数遍历每个从通道接收到的数据,因为 c 在发送完 10 个
// 数据之后就关闭了通道,所以这里我们 range 函数在接收到 10 个数据
// 之后就结束了。如果上面的 c 通道不关闭,那么 range 函数就不
// 会结束,从而在接收第 11 个数据的时候就阻塞了。
for i := range c {
fmt.Println(i)
}
}
输出结果:
0
1
1
2
3
5
8
13
21
34
如果把close那句注释,则结果会是这样:
0
1
1
2
3
5
8
13
21
34
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
d:/code/golang/02并发编程/04travel/travel.go:23 +0xdb
exit status 2
其中all goroutines are asleep - deadlock!
这句说明管道阻塞,原因前面说了,管道未关闭,数据被读完后会一直等待新数据进入,就发生了管道阻塞,而此时所有协程会相互等待,由于程序中没有管道写入操作了,所以协程之间会无限地等待下去,这样就发生了死锁(deadlock)!
五、select的作用
select 语句使得一个 goroutine 可以等待多个通信操作。select中的每个case必须是一个通道操作。select会随机执行一个可运行的case。如果没有case可运行,它将阻塞,直到有case可运行:
package main
import "fmt"
func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
}
}
}
func main() {
c := make(chan int)
quit := make(chan int)
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}
输出结果:
0
1
1
2
3
5
8
13
21
34
quit
显然,case <-quit
这句是在所有case c <- x
执行完后再执行的。原因不难分析:quit通道在协程中的for循环结束前是空的,取出操作会造成通道阻塞,所以对于select来说,quit是一直未准备好的。而前面说过,无缓冲通道无论进行存还是取都会阻塞,但这里的协程中,每次循环时,c都会有取出操作,所以c的读和写能一直进行,即第一个case会一直是准备(可执行)状态,直到循环结束。循环结束后,c不再有取出操作,所以第一个case就会阻塞(注意不再有写入操作也行,无缓冲的阻塞是双向的),变成未准备状态,而quit <- 0
又刚好使第二个case变成准备状态,所以程序输出34后一定会执行第二个case,输出quit。主线程输出完毕后return,主线程结束,那么整个程序也跟着结束。
相信如果你认真读到这里,一定会对通道的认识更深,甚至有了一个新的高度。你不理解管道,是因为有很多细节没被你注意到,如果你注意到并总结了这些细节,你会发现其实管道也就这点东西。