Go语言36讲笔记--11通道的高级使用方式

159 阅读8分钟

单向通道

声明+初始化

var uselessChan = make(chan<- int, 1)

func test(ch chan<- int)

chan<- int类型:channel中的元素类型时int,单向通道,当前函数test只能向channel发送,即,这是一个发送通道。

与发送操作和接收操作对应,这里的“发”和“收”都是在操作通道的代码(func)的角度上说的。 问题:单向通道有什么应用价值?

典型回答

概括地说,单向通道最主要的用途就是约束其他代码的行为。

问题解析

这需要从两个方面讲,都跟函数的声明有些关系

  1. 声明中的函数参数
func SendInt(ch chan<- int) {
	ch <- rand.Intn(1000)
}

func关键字声明了一个叫做SendInt的函数。这个函数只接受一个chan<- int类型的参数。在这个函数中的代码只能向参数ch发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。

👆这个例子比较理想化。经典的约束体现在interface内函数的参数,可以影响后面的所有实现类型。

在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。请看这个叫Notifier的接口类型声明:

type Notifier interface {
  SendInt(ch chan<- int)
}

在接口类型声明的花括号中,每一行都代表着一个方法的定义。接口中的方法定义与函数声明很类似,但是只包含了方法名称、参数列表和结果列表

一个类型如果想成为一个接口类型的实现类型,那么就必须实现这个接口中定义的所有方法。因此,如果我们在某个接口内方法的定义中使用了单向通道类型,那么就相当于在对它的所有实现做出约束

👆.go中,Notifier接口中的SendInt方法只会接受一个发送通道作为参数,所以,在该接口的所有实现类型中的SendInt方法都会受到限制。这种约束方式还是很有用的,尤其是在我们编写模板代码或者可扩展的程序库的时候。

Tips

调用SendInt函数的时候(👇.go),只需要把一个元素类型匹配的双向通道传给它就行了,因为 Go 语言在这种情况下会自动地把双向通道转换为函数所需的单向通道

intChan1 := make(chan int, 3)
SendInt(intChan1)
  1. 函数声明的结果列表为单向channel
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类型的通道,这就意味着得到该通道的程序,只能从通道中接收元素值。

调用函数getIntChan举例

intChan2 := getIntChan()
for elem := range intChan2 {
	fmt.Printf("The element in intChan2: %v\n", elem)
}

关于迭代使用channel中元素的两种方式:带range的for语句;select语句(专门为了操作通道而存在)。

对第一种方式的使用说明

  • 一、这样一条for语句会不断地尝试从intChan2种取出元素值,即使intChan2被关闭,它也会在取出所有剩余的元素值之后再结束执行。
  • 二、当intChan2中没有元素值时,它会被阻塞在有for关键字的那一行,直到有新的元素值可取。
  • 三、若intChan2的值为nil,那么它会被永远阻塞在有for关键字的那一行。

知识扩展

问题 1:select语句与通道怎样联用,应该注意些什么?

  1. select语句只能与channel连用且每个表达式中都只能操作包含操作通道的表达式,比如接收表达式。如果我们需要把接收表达式的结果赋给变量的话,还可以把这里写成赋值语句或者短变量声明
  2. 一次只会选择其中一条分支的代码执行。
  3. 两个分支类型,case xxx :|| default: 举例子
// 准备好几个通道,放在通道数组里。
intChannels := [3]chan int{ //通道数组的短变量声明+初始化方式
	make(chan int, 1), //类型为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!")
}

几点总结:

  1. select语句加入default:后,无论channel是否阻塞,该代码块都不会阻塞。

  2. 接👆讨论,若无default:,当所有case都不满足求值条件时,select语句会被阻塞,直到其中一个case满足条件。

  3. 当某通道关闭了,select语句会直接从通道接收到一个其元素类型的零值,并且每次循环都会执行该case,当select中只有这个case时,会出现死循环。所以,在很多时候,我们需要通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,应该及时地屏蔽掉对应的分支或者采取其他措施。

  4. select语句只能对其中的每一个case表达式各求值一次。所以,如果我们想连续或定时地操作其中的通道的话,就往往需要通过for语句中嵌入select语句的方式实现。but,简单地在select语句的分支中使用break语句,只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。容易引起死循环。

intChan := make(chan int, 1) //类型为 chan int,容量为1
// 一秒后关闭通道。
time.AfterFunc(time.Second, func() {
	close(intChan)
})
select {
case _, ok := <-intChan: //利用接收表达式的第二个值来判断channel是否关闭
	if !ok {
		fmt.Println("The candidate case is closed.")
		break //直接终止select执行
	}
	fmt.Println("The candidate case is selected.")
}

问题 2:select语句的分支选择规则都有哪些?

规则描述(结合👇的demo看)

  1. 对于每一个case表达式,都至少会包含一个代表发送操作的发送表达式或者一个代表接收操作的接收表达式,同时也可能会包含其他的表达式。比如,如果case表达式是包含了接收表达式的短变量声明时,那么在赋值符号左边的就可以是一个或两个表达式,不过此处的表达式的结果必须是可以被赋值的。当这样的case表达式被求值时,它包含的多个表达式总会以从左到右的顺序被求值。

  2. select语句包含的候选分支中的case表达式都会在该语句执行开始时先被求值,并且求值的顺序是依从代码编写的顺序从上到下的。结合上一条规则,在select语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。仅当最上边的候选分支中的所有表达式都被求值完毕后,从上边数第二个候选分支中的表达式才会被求值,顺序同样是从左到右,然后是第三个候选分支、第四个候选分支,以此类推。

  3. 对于每一个case表达式,如果其中的发送表达式或者接收表达式在被求值时,相应的操作正处于阻塞状态,那么对该case表达式的求值就是不成功的。在这种情况下,我们可以说,这个case表达式所在的候选分支是不满足选择条件的。

  4. select语句中的所有case表达式都被求值完毕后,它才会开始选择候选分支。这时候,它只会挑选满足选择条件的候选分支执行。如果所有的候选分支都不满足选择条件,那么默认分支就会被执行。如果这时没有默认分支,那么select语句就会立即进入阻塞状态,直到至少有一个候选分支满足选择条件为止。一旦有一个候选分支满足选择条件,select语句(或者说它所在的 goroutine)就会被唤醒,这个候选分支就会被执行。

  5. 如果select语句发现同时有多个候选分支满足选择条件,那么它就会用一种伪随机的算法在这些分支中选择一个并执行。注意,即使select语句是在被唤醒时发现的这种情况,也会这样做。

  6. 一条select语句中只能够有一个默认分支。并且,默认分支只在无候选分支可选时才会被执行,这与它的编写位置无关。

  7. select语句的每次执行,包括case表达式求值和分支选择,都是独立的。不过,至于它的执行是否是并发安全的,就要看其中的case表达式以及分支中,是否包含并发不安全的代码了。

package main

import "fmt"

var channels = [3]chan int{
	nil,
	make(chan int),
	nil,
}

var numbers = []int{1, 2, 3}

func main() {
	select {
	case getChan(0) <- getNumber(0):
		fmt.Println("The first candidate case is selected.")
	case getChan(1) <- getNumber(1):
		fmt.Println("The second candidate case is selected.")
	case getChan(2) <- getNumber(2):
		fmt.Println("The third candidate case is selected")
	default:
		fmt.Println("No candidate case is selected!")
	}
}

func getNumber(i int) int {
	fmt.Printf("numbers[%d]\n", i)
	return numbers[i]
}

func getChan(i int) chan int {
	fmt.Printf("channels[%d]\n", i)
	return channels[i]
}


总结

  1. 单向通道的表示方法,操作符“<-”。概括单向通道存在的意义的话,是“约束”,对代码的约束。

  2. 我们可以使用带range子句的for语句从通道中获取数据,也可以通过select语句操纵通道。

  3. select语句是专门为通道而设计的,它可以包含若干个候选分支,每个分支中的case表达式都会包含针对某个通道的发送或接收操作。

  4. select语句被执行时,它会根据一套分支选择规则选中某一个分支并执行其中的代码。如果所有的候选分支都没有被选中,那么默认分支(如果有的话)就会被执行。注意,发送和接收操作的阻塞是分支选择规则的一个很重要的依据。

思考题

  1. 如果在select语句中发现某个通道已关闭,那么应该怎样屏蔽掉它所在的分支?

很简单,把nil赋给代表了这个通道的变量就可以了。如此一来,对于这个通道(那个变量)的发送操作和接收操作就会永远被阻塞。

  1. select语句与for语句联用时,怎样直接退出外层的for语句?

这一般会用到goto语句和标签(label),具体请参看 Go 语言规范的这部分。