极客时间已完结课程限时免费阅读

11 | 通道的高级玩法

11 | 通道的高级玩法-极客时间

11 | 通道的高级玩法

讲述:黄洲君

时长13:54大小6.36M

我们已经讨论过了通道的基本操作以及背后的规则。今天,我再来讲讲通道的高级玩法。
首先来说说单向通道。我们在说“通道”的时候指的都是双向通道,即:既可以发也可以收的通道。
所谓单向通道就是,只能发不能收,或者只能收不能发的通道。一个通道是双向的,还是单向的是由它的类型字面量体现的。
还记得我们在上篇文章中说过的接收操作符<-吗?如果我们把它用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。
比如:
var uselessChan = make(chan<- int, 1)
我声明并初始化了一个名叫uselessChan的变量。这个变量的类型是chan<- int,容量是1
请注意紧挨在关键字chan右边的那个<-,这表示了这个通道是单向的,并且只能发而不能收。
类似的,如果这个操作符紧挨在chan的左边,那么就说明该通道只能收不能发。所以,前者可以被简称为发送通道,后者可以被简称为接收通道。
注意,与发送操作和接收操作对应,这里的“发”和“收”都是站在操作通道的代码的角度上说的。
从上述变量的名字上你也能猜到,这样的通道是没用的。通道就是为了传递数据而存在的,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义。那么,单向通道的用途究竟在哪儿呢?
问题:单向通道有什么应用价值?
你可以先自己想想,然后再接着往下看。
典型回答
概括地说,单向通道最主要的用途就是约束其他代码的行为。
问题解析
这需要从两个方面讲,都跟函数的声明有些关系。先来看下面的代码:
func SendInt(ch chan<- int) {
ch <- rand.Intn(1000)
}
我用func关键字声明了一个叫做SendInt的函数。这个函数只接受一个chan<- int类型的参数。在这个函数中的代码只能向参数ch发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。
你可能会问,我自己写的函数自己肯定能确定操作通道的方式,为什么还要再约束?好吧,这个例子可能过于简单了。在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫Notifier的接口类型声明:
type Notifier interface {
SendInt(ch chan<- int)
}
在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表。
一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束。
在这里,Notifier接口中的SendInt方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的SendInt方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。
顺便说一下,我们在调用SendInt函数的时候,只需要把一个元素类型匹配的双向通道传给它就行了,没必要用发送通道,因为 Go 语言在这种情况下会自动地把双向通道转换为函数所需的单向通道。
intChan1 := make(chan int, 3)
SendInt(intChan1)
在另一个方面,我们还可以在函数声明的结果列表中使用单向通道。如下所示:
func getIntChan() <-chan int {
num := 5
ch := make(chan int, num)
for i := 0; i < num; i++ {
ch <- i
}
close(ch)
return ch
}
函数getIntChan会返回一个<-chan int类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。这实际上就是对函数调用方的一种约束了。
另外,我们在 Go 语言中还可以声明函数类型,如果我们在函数类型中使用了单向通道,那么就相等于在约束所有实现了这个函数类型的函数。
我们再顺便看一下调用getIntChan的代码:
intChan2 := getIntChan()
for elem := range intChan2 {
fmt.Printf("The element in intChan2: %v\n", elem)
}
我把调用getIntChan得到的结果值赋给了变量intChan2,然后用for语句循环地取出了该通道中的所有元素值,并打印出来。
这里的for语句也可以被称为带有range子句的for语句。它的用法我在后面讲for语句的时候专门说明。现在你只需要知道关于它的三件事:
上述for语句会不断地尝试从通道intChan2中取出元素值。即使intChan2已经被关闭了,它也会在取出所有剩余的元素值之后再结束执行。
通常,当通道intChan2中没有元素值时,这条for语句会被阻塞在有for关键字的那一行,直到有新的元素值可取。不过,由于这里的getIntChan函数会事先将intChan2关闭,所以它在取出intChan2中的所有元素值之后会直接结束执行。
倘若通道intChan2的值为nil,那么这条for语句就会被永远地阻塞在有for关键字的那一行。
这就是带range子句的for语句与通道的联用方式。不过,它是一种用途比较广泛的语句,还可以被用来从其他一些类型的值中获取元素。除此之外,Go 语言还有一种专门为了操作通道而存在的语句:select语句。
知识扩展
问题 1:select语句与通道怎样联用,应该注意些什么?
select语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。
select语句的分支分为两种,一种叫做候选分支,另一种叫做默认分支。候选分支总是以关键字case开头,后跟一个case表达式和一个冒号,然后我们可以从下一行开始写入当分支被选中时需要执行的语句。
默认分支其实就是 default case,因为,当且仅当没有候选分支被选中时它才会被执行,所以它以关键字default开头并直接后跟一个冒号。同样的,我们可以在default:的下一行写入要执行的语句。
由于select语句是专为通道而设计的,所以每个case表达式中都只能包含操作通道的表达式,比如接收表达式。
当然,如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明。下面展示一个简单的例子。
// 准备好几个通道。
intChannels := [3]chan int{
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
}
// 随机选择一个通道,并向它发送元素值。
index := rand.Intn(3)
fmt.Printf("The index: %d\n", index)
intChannels[index] <- index
// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。
select {
case <-intChannels[0]:
fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
fmt.Printf("The third candidate case is selected, the element is %d.\n", elem)
default:
fmt.Println("No candidate case is selected!")
}
我先准备好了三个类型为chan int、容量为1的通道,并把它们存入了一个叫做intChannels的数组。
然后,我随机选择一个范围在[0, 2]的整数,把它作为索引在上述数组中选择一个通道,并向其中发送一个元素值。
最后,我用一个包含了三个候选分支的select语句,分别尝试从上述三个通道中接收元素值,哪一个通道中有值,哪一个对应的候选分支就会被执行。后面还有一个默认分支,不过在这里它是不可能被选中的。
在使用select语句的时候,我们首先需要注意下面几个事情。
如果像上述示例那样加入了默认分支,那么无论涉及通道操作的表达式是否有阻塞,select语句都不会被阻塞。如果那几个表达式都阻塞了,或者说都没有满足求值的条件,那么默认分支就会被选中并执行。
如果没有加入默认分支,那么一旦所有的case表达式都没有满足求值条件,那么select语句就会被阻塞。直到至少有一个case表达式满足条件为止。
还记得吗?我们可能会因为通道关闭了,而直接从通道接收到一个其元素类型的零值。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。
select语句只能对其中的每一个case表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过在for语句中嵌入select语句的方式实现。但这时要注意,简单地在select语句的分支中使用break语句,只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。这种错误的用法可能会让这个for语句无休止地运行下去。
下面是一个简单的示例。
intChan := make(chan int, 1)
// 一秒后关闭通道。
time.AfterFunc(time.Second, func() {
close(intChan)
})
select {
case _, ok := <-intChan:
if !ok {
fmt.Println("The candidate case is closed.")
break
}
fmt.Println("The candidate case is selected.")
}
我先声明并初始化了一个叫做intChan的通道,然后通过time包中的AfterFunc函数约定在一秒钟之后关闭该通道。
后面的select语句只有一个候选分支,我在其中利用接收表达式的第二个结果值对intChan通道是否已关闭做了判断,并在得到肯定结果后,通过break语句立即结束当前select语句的执行。
这个例子以及前面那个例子都可以在 demo24.go 文件中被找到。你应该运行下,看看结果如何。
上面这些注意事项中的一部分涉及到了select语句的分支选择规则。我觉得很有必要再专门整理和总结一下这些规则。
问题 2:select语句的分支选择规则都有哪些?
规则如下面所示。
对于每一个case表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果case表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的case表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。
select语句包含的候选分支中的case表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在select语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。
对于每一个case表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case表达式的求值就是不成功的。在这种情况下,我们可以说,这个case表达式所在的候选分支是不满足选择条件的。
仅当select语句中的所有case表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么select语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,select语句(或者说它所在的 goroutine)就会被唤醒,这个候选分支就会被执行。
如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使select语句是在被唤醒时发现的这种情况,也会这样做。
一条select语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。
select语句的每次执行,包括case表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的case表达式以及分支中,是否包含并发不安全的代码了。
我把与以上规则相关的示例放在 demo25.go 文件中了。你一定要去试运行一下,然后尝试用上面的规则去解释它的输出内容。
总结
今天,我们先讲了单向通道的表示方法,操作符“<-”仍然是关键。如果只用一个词来概括单向通道存在的意义的话,那就是“约束”,也就是对代码的约束。
我们可以使用带range子句的for语句从通道中获取数据,也可以通过select语句操纵通道。
select语句是专门为通道而设计的,它可以包含若干个候选分支,每个分支中的case表达式都会包含针对某个通道的发送或接收操作。
select语句被执行时,它会根据一套分支选择规则选中某一个分支并执行其中的代码。如果所有的候选分支都没有被选中,那么默认分支(如果有的话)就会被执行。注意,发送和接收操作的阻塞是分支选择规则的一个很重要的依据。
思考题
今天的思考题都由上述内容中的线索延伸而来。
如果在select语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支?
select语句与for语句联用时,怎样直接退出外层的for语句?
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 29

提建议

上一篇
10 | 通道的基本操作
下一篇
12 | 使用函数的正确姿势
unpreview
 写留言

精选留言(58)

  • 江山如画
    2018-09-05
    感觉方法应该挺多,就看解决的是不是优雅 第一个问题:发现某个channel被关闭后,为了防止再次进入这个分支,可以把这个channel重新赋值成为一个长度为0的非缓冲通道,这样这个case就一直被阻塞了: for { select { case _, ok := <-ch1: if !ok { ch1 = make(chan int) } case ..... : //// default: //// } } 第二个问题:可以用 break和标签配合使用,直接break出指定的循环体,或者goto语句直接跳转到指定标签执行 break配合标签: ch1 := make(chan int, 1) time.AfterFunc(time.Second, func() { close(ch1) }) loop: for { select { case _, ok := <-ch1: if !ok { break loop } fmt.Println("ch1") } } fmt.Println("END") goto配合标签: ch1 := make(chan int, 1) time.AfterFunc(time.Second, func() { close(ch1) }) for { select { case _, ok := <-ch1: if !ok { goto loop } fmt.Println("ch1") } } loop: fmt.Println("END")
    展开
    共 7 条评论
    100
  • 任性😀
    2018-09-25
    demo24里边少了rand.Seed(time.Now().Unix()),不然每次随机数都是固定的顺序
    58
  • 左氧佛沙星人
    2019-03-25
    老师好,demo25中的这段代码我没看懂,不是这个匹配上了吗?为啥没有执行呢?我理解应该打印The second candidate case is selected.。。。。 ``` case getChan(1) <- getNumber(1): ``` 能指点一下吗?
    展开

    作者回复: 因为 make(chan int) 初始化的是不带缓冲的通道。非缓冲通道只有在收发双方都就绪的情况下才能传递元素值,否则就阻塞。

    共 2 条评论
    31
  • 到不了的塔
    2018-10-24
    郝老师,请问第一题的答案是啥,不知道怎么屏蔽分支呢

    作者回复: 设置为nil就可以了。

    共 2 条评论
    18
  • 笨笨
    2018-09-05
    谢谢赫老师今日分享,回答问题如下 1.对于select中被close的channel判断其第二个boolean参数,如果是false则被关闭,那么赋值此channel为nil,那么每次到这个nil的channel就会阻塞,select会忽略阻塞的通道,如果再搭配上default就一定能保证不会被阻塞了。 2.通过定义标签,配合goto或者break能实现在同一个函数内任意跳转,故可以跳出多层嵌套的循环。
    展开

    作者回复: 你的问题是什么?

    17
  • zhaopan
    2019-02-19
    老师好: 仅当select语句中的所有case表达式都被求值完毕后,它才会开始选择候选分支。 当接收通道操作有多个满足条件时, 这里的所有case表达式都被求值完毕, 应该怎么理解? 是多个case表达式都能接收到通道的数据么? 如果都接收了, 随机选择一个分支去处理接收的通道数据. 那其他满足条件的case分支怎么执行到了? 如果是外层加for循环, 重新select语句, 那上一次select的操作其他满足条件未被选择的case还能收到上一次的数据么? 这里的原理是什么呢?
    展开

    作者回复: 这里只会检查一下接收操作或发送操作是否可以进行(是否不会被阻塞)。有兴趣的话可以看一下 runtime/select.go 中的 selectgo 函数的源码。

    11
  • 🐻
    2019-02-10
    https://stackoverflow.com/questions/25469682/break-out-of-select-loop for-select break 方法。
    9
  • 阿海
    2019-01-05
    运行了demo25.go, 发现结果是No candidate case is selected,原因跟 var channels = [3]chan int{ nil, make(chan int, 1), nil, } 有关,因为channels[0], channels[2]都是nil,所以select case时阻塞,而channels[1]初始化为无缓存channel,当没有从channels[1]取值时,select case阻塞,所以一轮下来,没有符合的条件case,只能运行case default了。
    展开
    共 1 条评论
    8
  • 啵啵
    2018-09-11
    第一个问题,如果判断到chan关闭,即取到的第二个值为false。则将该chan赋值为nil。 第二个问题,根据情况使用goto或者return。或者加一个是否结束的标识,goto然后用两个break。
    9
  • heha37
    2018-11-05
    当第二个boolean参数为false的时候,在相应的case中设置chan为nil零值,再次case求值的时候会遭遇阻塞,会屏蔽该case。

    作者回复: 是的。

    8
  • Tron
    2019-09-04
    请教老师一个问题, 如果我用context 取消一个正在执行的下载任务,形如: select { case i := <- jobs: downloadBigBigFile() case ctx <-Done return } 当 父进程发出cancel 指令后, 能够取消downloadBigBigFile()里面运行的任务吗?
    展开

    作者回复: 首先修正一点,这与进程无关,与 goroutine 有关,而且没有父子关系。 正题回答:不能。因为程序流程已经走到 downloadBigBigFile() 这里了,不可能在没有循环或跳转的情况下再往回走。 看起来你应该把 ctx 放入 downloadBigBigFile 函数,然后在这个函数里做判断。

    6
  • Flo
    2019-04-23
    对zhaopan的问题中老师的回复存在疑惑,老师回复如下:“作者回复: 这里只会检查一下接收操作或发送操作是否可以进行(是否不会被阻塞)。有兴趣的话可以看一下 runtime/select.go 中的 selectgo 函数的源码。” -----这里是不是表示,对于那些符合条件但没有执行到的case,之前判断的时候是不是并没有从chan中取出数据?

    作者回复: 不好意思,问问题的人太多了,你问的时候最好带上比较完整的上下文。我下面按我目前对你问题的理解回答你吧。 select 语句对某个 case 中的通道接收表达式的实际执行需要两个前提条件: 1. 接收操作不会被阻塞。 2. 当前 case 被选中。 对于 case 中的通道发送表达式来说也是类似。因此,我们完全不用担心那些未被选中的通道操作会突然执行。

    5
  • 癫狂的小兵
    2018-09-05
    请问当select语句发现多个分支满足条件时随机选择一个分支执行 那怎样让其他满足条件的分支执行呢? for 循环 等待下一次循环时再执行?

    作者回复: 放在for循环里每次也是随机的,不过可以用for循环,或者再次执行select语句。

    5
  • 长杰
    2019-01-13
    函数定义,有的用首字母大写的命名规则,有的用驼峰命名规则,老师能介绍一下go语言编程的规范介绍吗?

    作者回复: 你可以看看我在前面的(程序实体相关的)文章中讲的。程序实体的名称如果首字母大写那么就说明是其访问权限是公开的,否则就是包级私有的。

    4
  • 许森森
    2018-09-14
    1 发现channel是closed之后,重新make,使得为nil,保证一直 阻塞。 i := 0 for { select { case _, ok := <-c: if !ok { c = make(chan bool) fmt.Println("!ok") } else { fmt.Println("c:", c) } default: time.Sleep(1e9) // 等待1秒钟 fmt.Println("default, ", i) i = i + 1 if i > 3 { return } } } 2 return 直接退出程序 break loop // 结合loop,会退出for循环 goto end //结合end,跳转 //在select语句与for语句联用时,怎样直接退出外层的for语句? tick := time.Tick(100 * time.Millisecond) boom := time.After(500 * time.Millisecond) //loop: for { select { case <-tick: fmt.Println("tick.") case <-boom: fmt.Println("BOOM!") // return //直接退出程序 //break loop // 结合loop,会退出for循环 goto end //结合end default: fmt.Println(" .") time.Sleep(50 * time.Millisecond) } } end: fmt.Println("END...")
    展开
    5
  • Kennedy
    2018-09-10
    通道的类型如果是方法,性能会差吗?
    4
  • 水先生
    2019-12-19
    “二、当intChan2中没有元素值时,它会被阻塞在有for关键字的那一行,直到有新的元素值可取。” 老师,我有一个疑问:for...range..会把channel的元素全部取出来的,意思是到最后,就会阻塞么?

    作者回复: 在这里不会,因为getIntChan函数已经把intChan2关闭了。 文章里的描述我改进了一下: 2. 通常,当通道 intChan2 中没有元素值时,这条 for 语句会被阻塞在有 for 关键字的那一行,直到有新的元素值可取。不过,由于这里的 getIntChan 函数会事先将 intChan2 关闭,所以它在取出 intChan2 中的所有元素值之后会直接结束执行。 后面我会让编辑帮我更新一下。

    3
  • 一只傻哈皮
    2019-02-22
    请问select伪随机执行的目的是什么呢?没太理解这样做的目的。

    作者回复: 这属于语言特性之一,没有什么特殊原因。你可以理解为避免case书写顺序影响到执行顺序。

    共 2 条评论
    3
  • Neo
    2018-09-12
    请问老师: select 分支选择规则中第5个:"如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机算法在这些分支中选择一个执行" 随机选一个执行 那我们是不是就不能确定程序会执行哪一条与语句了?

    作者回复: 这种情况下是这样。

    3
  • Forever
    2018-09-10
    老师请问如果我是100个管道并行读入1亿的数字 然后100个管道分别去调用判断数字是否是质数的函数 然后本来运行着好好的cpu占用率到了99 后来突然不再运算了 cpu也直接占用率骤降到5% 但是程序没有报错而是暂停不动 请问老师是什么原因
    共 1 条评论
    2
新人学习立返 50% 购课币
去订阅《Go语言核心36讲》新人首单¥59