Go拥有一个特殊的关键字select
,它允许用户从多个通道中选择一个通道来执行接收或者发送操作。select
关键字就像是专门为通道而设的switch
语句,代码清单9-9展示了一个使用select
关键字的例子。
代码清单9-9 从多个通道中选择
package main
import (
"fmt"
)
func callerA(c chan string) {
c <- "Hello World!"
}
func callerB(c chan string) {
c <- "Hola Mundo!"
}
func main() {
a, b := make(chan string), make(chan string)
go callerA(a)
go callerB(b)
for i := 0; i < 5; i++ {
select {
case msg := <-a:
fmt.Printf("%s from A\n", msg)
case msg := <-b:
fmt.Printf("%s from B\n", msg)
}
}
}
这个程序中的callerA
和callerB
两个函数都会接受一个字符串通道作为参数,并向该通道发送信息。在以goroutine方式调用callerA
和callerB
之后,程序会进行5次迭代(次数的多少无关紧要,5是一个随意选取的数字),并且在每次迭代中,Go的运行时环境都会根据通道a或者通道b是否有值来决定应该对哪个通道执行取值操作。如果两个通道都有值,那么Go运行环境将随机选择其中一个通道。
我们的计划听上去似乎完美无瑕,但是在实际运行程序的时候,Go却向我们报告了一个死锁错误:
Hello World! from A
Hola Mundo! from B
fatal error: all goroutines are asleep - deadlock!
出现这个错误的原因我们前面已经提到过了,当一个goroutine取出无缓冲通道中唯一的值之后,无缓冲通道将变为空,之后任何尝试从空通道获取值的goroutine都会被阻塞并进入休眠状态。在这个例子中,main
函数首先在第一次迭代中从通道a
里取出了值,并导致通道a
为空;接着又在第二次迭代中从通道b
里取出了值,并导致通道b
为空;然后在进行第三次迭代时,main
函数发现通道a
和通道b
都为空,于是它就会被阻塞并进入休眠,但由于这时callerA
和callerB
这两个goroutine都已执行完毕,所以通道a和通道b将永远也不会再有值,而main
函数也只能永远等待下去——在检测到这一情况之后,Go运行时环境抛出了死锁错误。
解决这个问题并不困难,我们只需要为select
语句添加一个默认分支,让select
语句在所有可选通道都已被阻塞的情况下执行默认分支即可,以下代码中加粗的部分就是新添加的默认分支:
select {
case msg := < -a:fmt.Printf("%s from A\n", msg)
case msg := < -b:fmt.Printf("%s from B\n", msg)
//新添加的分支
default: fmt.Println("Default")
}
当select
语句没有发现任何可用的通道时,它就会执行默认分支中的代码。对于上面的例子来说,当存储在通道a
和通道b
里面的值都被取出之后,程序就会在下一次迭代中执行默认分支中的代码。但是,如果现在就执行这段代码,就只会看到默认分支打印的输出:这是因为程序太早就调用select
语句了,以至于通道a
和通道b
还没来得及接受callerA
和callerB
发送给它们的值,select
语句就跳过两个还没有值的通道直接执行默认分支了。为了让这个程序能够正确工作,我们需要在每次迭代之前添加1s的延迟,从而使通道能够正常接收goroutine发送给它们的值,以下代码中加粗显示的就是新添加的语句:
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Microsecond)
select {
case msg := < -a:
fmt.Printf("%s from A\n", msg)
case msg := < -b:
fmt.Printf("%s from B\n", msg)
default:
fmt.Println("Default")
}
}
运行这个修改后的程序,死锁将不会再出现:
Hello World! from A
Hola Mundo! from B
Default
Default
Default
从程序输出的结果可以看到,在通道a
和通道b
包含的值都被取出之后,select
语句的前两个分支就会被阻塞,而默认分支则会被执行。
在循环里添加延迟时间的做法初看上去会让人感觉有些奇怪,但这其实只是为了展示select
语句的用法而想出来的权宜之计。在实际中,大部分情况下用户使用的都是无限循环,而不是有限次数的迭代,这时程序的处理方式就会有所不同。比如,如果我们是在一个无限循环中使用select
语句,那么在所有通道都为空之后,程序将无限次执行默认分支,这时我们就可以对默认分支的执行次数进行计数,并在计数到达指定限制时退出循环。
其实在实际中,我们并不需要像上面所说的那样,通过计数器来退出带有select
语句的无限循环,这是因为使用内置的close
函数来关闭通道能够更好地达到这一目的:使用close
函数关闭通道,相当于向通道的接收者表明该通道将不会再收到任何值。只能执行接收操作的通道无法被关闭,尝试向一个已关闭的通道发送信息将会引发一个panic,尝试关闭一个已经被关闭的通道也是如此。尝试从一个已关闭的通道取值总是会得到一个与通道类型相对应的零值,因此从已关闭的通道取值并不会导致goroutine被阻塞。
代码清单9-10展示了一个例子,在这个例子中,我们将会看到关闭通道的方法以及被关闭通道是如何帮助程序跳出无限循环的。
代码清单9-10 关闭通道
package main
import (
"fmt"
)
func callerA(c chan string) {
c <- "Hello World!"
close(c)//在函数被调用之后关闭通道
}
func callerB(c chan string) {
c <- "Hola Mundo!"
close(c)
}
func main() {
a, b := make(chan string), make(chan string)
go callerA(a)
go callerB(b)
var msg string ok1, ok2 := true, true
//在通道被关闭之后,变量ok1和ok2的值将被设置为false
for ok1 || ok2 {
select {
case msg, ok1 = <-a:
if ok1 {
fmt.Printf("%s from A\n", msg)
}
case msg, ok2 = <-b:
if ok2 {
fmt.Printf("%s from B\n", msg)
}
}
}
}
这个新程序不再只迭代5次,并且它也不需要在迭代之前添加时间延迟。在将一个字符串发送至通道之后,程序调用内置的close
函数关闭了该通道。需要注意的是,跟关闭文件或者关闭套接字不一样,关闭通道并不会导致通道的机能完全停止——它的作用就是通知其他正在尝试从这个通道接收值的goroutine,这个通道已经不会再接收到任何值了。
另外需要注意的是,程序在从通道里面取值时,使用的是多值格式(multivalue form):
case value, ok1 = <-a
在执行这条语句时,从通道a
里面取出的值将被赋值给变量value
,而变量ok1
则会被设置为用于表示通道是否仍然处于打开状态的布尔值。如果通道已被关闭,那么ok1
的值将被设置为false
。
对于关闭通道我们需要知道的最后一点就是,关闭通道并不是必需的。正如之前所说,关闭通道只不过是在告知接收者该通道不会再接收到任何值而已。在代码清单9-10剩余的代码中,程序将通过检测语句来判断通道是否已被关闭,并在通道已被关闭的情况下,跳出循环,不再打印任何信息。下面是执行该程序得出的结果:
Hello World! from A
Hola Mundo! from B