浅谈Go语言(5) - 通道的使用与进阶

文章目录

  • 1. 写在前面
  • 2. 通道的基础知识
    • (1) 通道的定义
    • (2) 通道的使用
    • (3) 通道的基本特性
      • 发送/接收的互斥性
      • 元素值的处理不可分割
      • 发送/接收过程中会阻塞
    • (4) 通道须注意的问题
      • 通道的初始化
      • 通道的关闭
      • 通道goroutine示例
      • 通道遍历获取
  • 3. 通道的高级用法
    • (1) 单向通道
      • 只能发送的通道
      • 只能接收的通道
    • (2) for遍历注意事项
    • (3) select语句
      • 基本使用
      • 使用须知

1. 写在前面

  作为 Go 语言最有特色的数据类型,通道channel完全可以与 协程goroutine并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学。

Don’t communicate by sharing memory; share memory by communicating. – by Rob Pike.

  从 Go 语言创造者之一的这句话也能看出 Go 语言的重要编程理念,我们通常就是利用通道channel在多个goroutine中传递数据的。

2. 通道的基础知识

  通道类型的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。

(1) 通道的定义

  类型字面量chan int,其中的chan是表示通道类型的关键字,而int则说明了该通道类型的元素类型。chan string代表了一个元素类型为string的通道类型。

  在初始化通道的时候,make函数除了必须接收这样的类型字面量作为参数,还可以接收一个int类型的参数。后者是可选的,用于表示该通道的容量。所谓通道的容量,就是指通道最多可以缓存多少个元素值。由此,虽然这个参数是int类型的,但是它是不能小于0的。

ch1 := make(chan int, 3)

  当容量为0时,我们可以称通道为非缓冲通道,也就是不带缓冲的通道。而当容量大于0时,我们可以称为缓冲通道,也就是带有缓冲的通道。

(2) 通道的使用

  通道是一个先进先出(FIFO)的队列。

  接下来我们直接看Demo代码

package main

import "fmt"

func main() {
	ch1 := make(chan int, 3)
	ch1 <- 2
	ch1 <- 1
	ch1 <- 3
	elem1 := <-ch1
	fmt.Printf("element 1 received from channel ch1: %v\n", elem1)
	elem2 := <-ch1
	fmt.Printf("element 2 received from channel ch1: %v\n", elem2)
	elem3, ret := <-ch1
	if ret == true {
		fmt.Printf("element 3 received from channel ch1: %v\n", elem3)
	}
}

  运行结果

element 1 received from channel ch1: 2
element 2 received from channel ch1: 1
element 3 received from channel ch1: 3

(3) 通道的基本特性

  首先大家要知道,元素值从外界进入通道时会被复制,也就是说进入通道的并不是在接收操作符右边的那个元素值,而是它的副本。

  元素值从通道进入外界进行了两步操作:第一步是生成正在通道中的这个元素值的副本,并准备给到接收方,第二步是删除在通道中的这个元素值。

发送/接收的互斥性

  Go 程序执行后,同一个时刻只会执行对同一个通道的任意个发送操作中的某一个,直到这个元素值完全被移出该通道之后,其他针对该通道的接收操作才可能被执行。即使这些操作是并发执行的也是如此。这也是channel原生保证并发安全的底层逻辑。

元素值的处理不可分割

  为了保证通道中元素值的完整性,也是为了保证通道操作的唯一性。对于通道中的同一个元素值来说,只可能是某一个发送操作放入的,同时也只可能被某一个接收操作取出。

  不会存在,A变量进行了拷贝了以后,还没移动进通道,就被B变量移动进去了,一定是一气呵成的。

发送/接收过程中会阻塞

  跟进前面的特性,发送操作包括了“复制元素值”和“放置副本到通道内部”这两个步骤。这两个步骤完全完成之前,发起这个发送操作的那句代码会一直阻塞在那里。

  什么情况下会引起通道阻塞呢?

  缓冲通道的情况:如果通道已满,所有发送操作都会被阻塞,直到通道中有元素值被接取走。然后通道会优先通知最早因此而等待的、那个发送操作所在的goroutine,后者会再次执行发送操作。由于发送操作在这种情况下被阻塞后,它们所在的goroutine会顺序地进入通道内部的发送等待队列,所以通知的顺序总是公平的。如果通道已空,那么对它的所有接收操作都会被阻塞,直到通道中有新的元素值出现。这时,通道会通知最早等待的那个接收操作所在的goroutine,并使它再次执行接收操作。因此而等待的、所有接收操作所在的goroutine,都会按照先后顺序被放入通道内部的接收等待队列。

  非缓冲通道,简单一些。无论是发送还是接收,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。

  根据上面2中缓冲方式能够看出,缓冲通道是在用异步的方式传递数据,非缓冲通道是用同步的方式传递数据。

(4) 通道须注意的问题

通道的初始化

  对于值为nil的通道,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态。它们所属的goroutine中的任何代码,都不再会被执行。

  注意,由于通道类型是引用类型,所以它的零值就是nil。当我们只声明该类型的变量但没有用make函数对它进行初始化时,该变量的值就会是nil,所以一定要初始化通道。

通道的关闭

  当我们把接收表达式的结果同时赋给两个变量时,第二个变量的类型就是一定bool类型。它的值如果为false就说明通道已经关闭,并且再没有元素值可取了。如果通道关闭时,里面还有元素值未被取出,那么接收表达式的第一个结果,仍会是通道中的某一个元素值,而第二个结果值一定会是true

  关闭channel后,无法向channel再发送数据(引发panic错误后导致接收立即返回零值);

  关闭channel后,可以继续从channel接收数据;

  下面我们继续看代码

func main() {
	ch1 := make(chan int, 3)
	ch1 <- 2
	ch1 <- 1
	ch1 <- 3
	elem1 := <-ch1
	close(ch1) // 如不执行该操作,那么elem4获取会报错
	fmt.Printf("element 1 received from channel ch1: %v\n", elem1)
	elem2 := <-ch1
	fmt.Printf("element 2 received from channel ch1: %v\n", elem2)
	elem3, ret := <-ch1
	if ret == true {
		fmt.Printf("element 3 received from channel ch1: %v\n", elem3)
	}
	elem4, ret4 := <-ch1
	if ret4 == true {
		fmt.Printf("element 4 received from channel ch1: %v\n", elem4)
	}
}

通道goroutine示例

  为了保证程序的健壮性,在设计程序时,最好将channel的读、写分别在goroutine中进行,写完数据后,要关闭channel

  示例代码如下:

var ch2 = make(chan int, 6)

func mm1() {
	for i := 0; i < 10; i++ {
		ch2 <- 8 * i
	}
	close(ch2)
}

func mm2() {
	for {
		for data := range ch2 {
			fmt.Print(data, "\t")
		}
	}
}

func main() {
	go mm1()
	go mm2()
	for {
		runtime.GC()
	}
}

通道遍历获取

  可进行for遍历,如下代码:

func main() {
	ch3 := make(chan int)
	go func() {
		for i := 0; i < 3; i++ {
			ch3 <- i
			fmt.Printf("send %d\n", i)
			time.Sleep(time.Second)
		}
		fmt.Println("ready close channel")
		close(ch3)
	}()

	for i := range ch3 {
		fmt.Printf("receive %d\n", i)
	}
	fmt.Println("quit for loop")
}

  关于for循环遍历注意事项会在下面提到。

3. 通道的高级用法

(1) 单向通道

  我们把操作符<-用在通道的类型字面量中,代表的不是“发送”或“接收”的动作,而是表示通道的方向。

只能发送的通道

   <-表示了这个通道是单向的,并且只能发而不能收。

var ch1 = make(chan<- int, 1)
ch1 <- 1
fmt.Println(<-ch1) // invalid operation: <-ch1 (receive from send-only type chan<- int)

只能接收的通道

  如果这个操作符紧挨在chan的左边,那么就说明该通道只能收不能发。

var ch2 = make(<-chan int, 1)
ch2 <- 1 // invalid operation: ch2 <- 1 (send to receive-only type <-chan int)

  单向通道最主要的用途就是约束其他代码的行为。比如我们封装一个工具,以通道的方式对外提供使用,只希望别人从通道中读数据,不希望别人从通道中写数据,那么此处就可以使用只读通道。

  下面上一段实际使用的demo代码:

// 这个函数中的代码只能向参数ch发送元素值,而不能从它那里接收元素值。这就起到了约束函数行为的作用。
func SendInt(ch chan<- int) {
	// 初始化随机数的资源库, 如果不执行这行, 不管运行多少次都返回同样的值
	rand.Seed(time.Now().UnixNano())
	setNum := rand.Intn(1000)
	fmt.Printf("A random number from 1-1000: %v\n", setNum)
	ch <- setNum
	close(ch)
}

func getIntChan() <-chan int {
	num := 3
	ch := make(chan int, num)
	for i := 0; i < num; i++ {
		ch <- i
	}
	close(ch)
	return ch
}

func main() {

	intChan1 := make(chan<- int, 1)
	SendInt(intChan1)

	fmt.Println("-----------")

	intChan2 := make(chan int, 1)
	SendInt(intChan2)
	for elem2 := range intChan2 {
		fmt.Printf("The element in intChan2: %v\n", elem2)
	}

	fmt.Println("-----------")

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

  代码输出结果是:

A random number from 1-1000: 281
-----------
A random number from 1-1000: 799
The element in intChan2: 799
-----------
The element in intChan3: 0
The element in intChan3: 1
The element in intChan3: 2

  看完上面intChan2的代码,是不是觉得很诡异,在实际场景中,这种约束一般会出现在接口类型声明中的某个方法定义上。

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

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

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

(2) for遍历注意事项

  上述demo代码中for语句会不断地尝试从通道intChan3中取出元素值。即使intChan3已经被关闭了,它也会在取出所有剩余的元素值之后再结束执行。

  通常,当通道intChan3中没有元素值时,这条for语句会被阻塞在有for关键字的那一行,直到有新的元素值可取。不过,由于getIntChan函数会事先将intChan3关闭,所以它在取出intChan3中的所有元素值之后会直接结束执行。

  倘若通道intChan3的值为nil,那么这条for语句就会被永远地阻塞在有for关键字的那一行。

(3) select语句

  select语句只能与通道联用,它一般由若干个分支组成。每次执行这种语句的时候,一般只有一个分支中的代码会被运行。

  由于select语句是专为通道而设计的,所以每个case表达式中都只能包含操作通道的表达式,比如接收表达式。

基本使用

  select语句的分支分为两种,一种叫做候选分支case <-intChannels[0]:,另一种叫做默认分支default:

  接下来我们上一段代码:

func main() {
	// 准备好几个通道。
	intChannels := [3]chan int{
		make(chan int, 1),
		make(chan int, 1),
		make(chan int, 1),
	}
	for i := 0; i < 2; i++ {
		// 随机选择一个通道,并向它发送元素值。
		rand.Seed(time.Now().UnixNano())
		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!")
		}
	}
}

  执行结果:

The index: 1
The second candidate case is selected.
The index: 2
The third candidate case is selected, the element is 2.

使用须知

  上述示例代码写了默认分支,无论涉及通道操作的表达式是否有阻塞,select语句不会被阻塞。

  如果没有加入默认分支,一旦所有的case表达式都没有满足求值条件,select语句就会被阻塞,直到至少有一个case表达式满足条件为止。

  通道可能在select的时候已经关闭了,而直接从通道接收到一个其元素类型的零值。我们可以通过接收表达式的第二个结果值来判断通道是否已经关闭。一旦发现某个通道关闭了,我们就应该及时地屏蔽掉对应的分支或者采取其他措施。这对于程序逻辑和程序性能都是有好处的。

  在for循环中使用select的时候,我们在select中使用break只会跳出这个select而不会跳出for循环,需要重点注意,防止死循环。

  下面我们继续看一段相关的示例代码:

func main() {
	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.") // Execute this
			break
		}
		fmt.Println("The candidate case is selected.")
	}
}

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

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

// 把这个channel重新赋值成为一个长度为0的非缓冲通道,这样这个case就一直被阻塞了
for {
	select {
	case _, ok := <-ch1:
		if !ok {
			ch1 = make(chan int)
		}
	case ..... :
		
	default:
		
	}
}

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

// 方案1: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")
// 方案2: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")
#Reference:
极客时间 - Go语言核心36讲
https://blog.csdn.net/weixin_42117918/article/details/82055634
https://www.cnblogs.com/lianggx6/p/12558663.html

你可能感兴趣的:(Go,go,golang)