【golang】使用通道时需要注意的一些问题

环境

Go 1.20
Windows 11

常识

1.定义通道变量:

ch := make(chan int) // 可存放int类型数据,缓冲为0
ch := make(chan any) // 可存放任意类型数据,缓冲为0
ch := make(chan int, 5) // 存放int类型数据,缓冲为5

// 默认的通道是既可以写入又可以读取的,但我们也可以限制通道的方向
ch := make(<-chan int) // 只能从此通道读取数据,且不能关闭此通道
ch := make(chan<- int) // 只能写入数据到此通道

length := len(ch) // 通道里有多少个数据
capacity := cap(ch) // 通道的缓冲区大小

2.通道遵循FIFO先入先出规则,可以保证元素的顺序

3.通道是并发安全的,不会因多个协程的同时写入而发生数据错乱

注意点

下面的代码例子会经常出现调用display函数,这是我自己定义的一个函数,主要用于打印信息,代码如下:

func display(msg ...any) {
	fmt.Print(time.Now().Format(time.DateTime), " ")
	fmt.Println(msg...)
}

为了减少代码冗余,下面的代码例子就不再贴出此函数的代码了。

1、对一个没有关闭的通道进行读写时,如果遇上了阻塞,并且此时已经没有其它活跃(非阻塞)的协程在运行了,会报deadlock错误!

怎么理解这句话呢,首先要了解读写通道时什么情况下会阻塞:

  1. 往缓冲已满的通道写入数据时会阻塞
  2. 读取空的通道会阻塞
  3. 通道未初始化,例如var ch chan int就是未初始化的

针对第1点,假设通道缓冲是N,那么在第 N + 1 次写入时会阻塞(定义通道变量时如果不指定N的大小,则N默认等于0)

针对第2点,如果这个空的通道是已关闭的,则不会阻塞,读取到的是这个通道数据类型的零值

例子1:

func main() {
	ch := make(chan int)
	// 协程1
	go func() {
		for i := 0; i < 3; i++ {
			display("准备发送:", i)
			ch <- i
			display("已发送完毕:", i)
		}
	}()

	for data := range ch {
		display("获得数据:", data)
	}
}

上面代码运行后会报错:fatal error: all goroutines are asleep - deadlock!

原因是,当【协程1】往通道写入3个数据后,【协程1】就结束运行了,这时【main协程】(是的,main函数也是运行在协程里的)读取出这3个数据后,并没有退出for-range循环,而是继续读取已空的ch通道,发生了阻塞,但这时只有【main协程】在运行了,只剩下一个协程,所以报错。

例子1修改一下:

func main() {
	ch := make(chan int)
	// 协程1
	go func() {
		for i := 0; i < 3; i++ {
			display("准备发送:", i)
			ch <- i
			display("已发送完毕:", i)
		}
	}()
    // 协程2
	go func() {
		for data := range ch {
			display("获得数据:", data)
		}
	}()

    // 死循环
	for {
	}
}

经修改后代码不会再报错了,原因是,【协程1】退出后,虽然【协程2】还在阻塞式地读取空通道,但这时除了【协程2】以外,还有一个活跃的【main协程】在运行,所以不会报错。

例子1再修改下:

func main() {
	ch := make(chan int)
	// 协程1
	go func() {
		for i := 0; i < 3; i++ {
			display("准备发送:", i)
			ch <- i
			display("已发送完毕:", i)
		}
		close(ch) // 新添加代码
	}()

	for data := range ch {
		display("获得数据:", data)
	}
}

协程1在写入完所有数据后,使用close(ch)关闭了通道,这时也不会再报错了。原因是,对于已关闭的通道,for-range循环读取完通道的数据后,会自动结束循环,不会阻塞在读取通道处,所以不会报错。

2、给一个已关闭的通道发送数据,或者再次关闭一个已关闭的通道,会导致panic

这句话告诉我们,当发送方不再需要发送数据时,可以关闭通道,但不能让接收方去关闭。
因为接收方并不知道发送方是否还需要发送数据,如果胡乱关闭了通道,会导致发送方触发panic

3、已关闭的通道是可以继续读取里面的数据的

func main() {
	ch := make(chan int, 2)
	ch <- 123
	ch <- 456
	close(ch)

	// 使用for-range读取已关闭通道,通道空了之后会自动跳出循环
	for data := range ch {
		display(data)
	}

	// 方式2:使用ok变量判断通道是否已空
	/*for {
		data, ok := <-ch
		if !ok {
			break
		}
		display(data)
	}*/

    // 方式3:通过通道长度来判断通道是否已空
	/*num := len(ch)
	for i := 0; i < num; i++ {
		data := <-ch
		display(data)
	}*/
}

4、双向通道可以传递给参数为单向通道的函数

// 函数参数是单向通道
func sendMessage(in chan<- int) {
	for i := 0; i < 3; i++ {
		in <- i
	}
	close(in)
}

func main() {
	ch := make(chan int) // 双向通道
	go sendMessage(ch)

	for data := range ch {
		display(data)
	}
}

5、当读取通道与select搭配使用,并且设置了超时时间时,通道一定要设置缓冲

先看例子:

func sendMessage(in chan<- int, sleep time.Duration) {
	time.Sleep(sleep)
	in <- 1
}

func main() {
	display("开始")
	display("协程数量:", runtime.NumGoroutine())

	ch1 := make(chan int) // 错误
	// 正确:ch1 := make(chan int, 1)

    // 协程1
	go sendMessage(ch1, 5 * time.Second)

	select {
	case v := <-ch1:
		display("从通道1获取到了数据:", v)
	case <-time.After(1 * time.Second):
		display("超时了,退出select")
	}

	for {
		display("协程数量:", runtime.NumGoroutine())
		time.Sleep(1 * time.Second)
	}
}

如上面代码所示,一开始我们创建了一个无缓冲的通道ch1,然后开启【协程1】,【协程1】在 5 秒后会往通道写入一个数据,但select的超时时间只设置了 1 秒。也就是说,在【协程1】往通道写入数据前,select语句就已经因为超时而结束了,此时的ch1通道已经没有接收方,只剩下发送方了。往一个无缓冲的通道写入数据会导致【协程1】阻塞,而且没有了接收方,【协程1】就会永远阻塞下去,无法结束退出,从而导致协程泄露

观察超时后打印出来的协程数量,一直都是2,不会降低为1,也证实了上面的说法。所以在定义通道变量时,一定要设置缓冲区。

其实调高 select的超时时间,也能解决这个问题。但有时候我们可能无法得知协程具体的执行耗时,从而预估出一个合理的超时时间,所以稳妥起见,还是定义一个带缓冲的通道比较好。

你可能感兴趣的:(Golang,golang,通道)