Go 并发编程 - channel 小结

date
Nov 29, 2022
slug
go-channel
status
Published
tags
Go
summary
channel 使用注意事项
type
Post

简介

使用通信(channel)来共享内存,而不是通过共享内存来通信
channel 可以用于实现一个生产者消费者模型,用于生产者和消费者之间共享数据
notion image
日常使用中,我们可以用来做 goruntine 的同步,channel 使用简单效果可靠,下面来看看如何 channel 吧

初始化

使用 make 来初始化一个 channel,channel 支持多种类型元素
除了 channel 以外,make 还可以用来初始化 slice、map
ch1 := make(chan int, 3) // 初始化一个大小为 3 的 channel
ch2 := make(chan int)    // 初始化一个无缓冲的 channel
channel 根据我们初始化的大小分为有缓冲 channel 和无缓冲 channel
有缓冲 channel
notion image
这种 channel,就相当于生产者消费者模型里面的缓冲队列,在这里我们初始化了大小为 3 的队列。
这种有缓冲队列的最大特点是异步,生产者和消费者之间不必同步执行。但是也会引起阻塞:
  • 队列满时生产者阻塞
  • 队列空时消费者阻塞
队列未满且存有一定数据的时候,生产者和消费者可以各司其职的完成存取。
举一个生活中的例子,有缓冲的 channel 相当于信箱,信箱未满的时候邮差直接把信件放到信箱里面就行了,而我们可以随时去检查信箱取出信件。(这里其实不太准确,日常生活中我们检查如果空信箱的话直接走掉了,但是程序中如果 channel 是空的话等待的 goroutine 是会被阻塞的,举这个例子只是为了突出异步的特点。)
无缓冲 channel
notion image
这种情况的消费者和生产者是同步的,同步指的是要求双方都准备好才能进行下一步。生产者生产数据后阻塞到数据被取走,而消费者获取数据之前也是阻塞在尝试获取的状态。下面这张图更加形象:
notion image
还是信箱的例子,无缓冲的 channel 相当于没有信箱了,快递员和收件人必须同时准备好才能结束发件和收件的动作。
开发中如果我们使用无缓冲 channel,务必要小心死锁的情况
func TestChannel(t *testing.T) {
	ch2 := make(chan int)
	ch2 <- 1
	fmt.Println(<-ch2)
}
这种情况运行后是会发生死锁的
fatal error: all goroutines are asleep - deadlock!
正确的写法是使用 goroutine 提前准备好接收:
func TestChannel(t *testing.T) {
	ch2 := make(chan int)
	go func() {
		fmt.Println(<-ch2)
	}()
	ch2 <- 1
}
另外需要注意的是,我们需要提前准备好接收,不然还是会死锁
func TestChannel(t *testing.T) {
	ch2 := make(chan int)
	ch2 <- 1 // 提前发送, deadlock
	go func() {
		fmt.Println(<-ch2)
	}()
}

channel 的使用

遍历

遍历有两种方式
func TestFor(t *testing.T) {
	ch1 := make(chan int, 100)
	for i := 0; i < 100; i++ {
		ch1 <- i
	}
	
	// 方式一, 用 for 取出所有元素,建议使用
	for i := range ch1 {
		fmt.Println(i)
	}
	fmt.Println("-")

	// 方式二
	for {
		i, ok := <-ch1 // 通道关闭后再取值ok=false
		if !ok {
			break
		}
		fmt.Println(i)
	}
}
推荐使用方式一,简单又清晰

select

搭配 select 是的我们在遍历 channel 的时候可以进行选择性操作,例如下面的例子:
func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}

func TestSelect(t *testing.T) {
	c := make(chan int)
	quit := make(chan int)
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Println(<-c)
		}
		quit <- 0
	}()
	fibonacci(c, quit)
}
// output:
//0
//1
//1
//2
//3
//5
//8
//13
//21
//34
//quit
上面的例子中,我们启动了一个 goruntine,读取 c 中前 10 个元素,然后我们调用 fibonacci,它执行了一个for … select 的操作,第一个 select 不断往 c 中写入,第二个 select 从 quit 中读取,一旦从 quit 中获取到元素后,就退出循环
而在外部,我们成功获取到了 fibonacci 数,可以看到 select 的使用和 Go 简单易用且强大的并发能力。
进阶:超时 channel
func TestTimeout(t *testing.T) {
	c1 := make(chan string, 1)
	go func() {
		time.Sleep(time.Second * 2)
		c1 <- "result 1"
	}()
	select {
	case res := <-c1:
		fmt.Println(res)
	case <-time.After(time.Second * 1):
		fmt.Println("timeout 1")
	}
}
// output:
// timeout 1

func After(d Duration) <-chan Time {
	return NewTimer(d).C
}
我们通过计时器和 select,可以完成定时任务,比如说上面的代码,我们调用After获取到一个 <-chan,触发了相应的 case,终止了 select
上面的思想可以扩展到在某时刻 or 某条件下做相应操作的功能

通道的限制

有的时候我们需要限制通道的读写,那么我们可以提前声明通道的类型,这种通道称为单向通道,特点是限制了写入或者读取的操作:
func op(writer <-chan int, reader chan<- int) {
	for data := range writer{
		reader <- data
	}
}

通道的关闭和异常

使用 close() 函数可以关闭一个通道,如果我们确认通道不再读写,那么记得及时关闭
func TestClose(t *testing.T) {
	ch := make(chan int, 10)
	defer func(){
		close(ch)
	}()
	// do something ... 
}
这里我们会好奇,对已经关闭的通道读写会有什么影响?这里也是面试会经常问到的,我们自己实验一下看看会有什么结果:
首先我们根据缓冲类型分为无缓冲 channel 和有缓冲 channel 实验:
无缓冲 channel
// 无缓冲 channel,读
func TestClose(t *testing.T) {
	ch := make(chan int)
	close(ch)
	fmt.Println(<-ch) // 0
}
// 无缓冲 channel,写
func TestClose(t *testing.T) {
	ch := make(chan int)
	close(ch)
	ch <- 0 // panic: send on closed channel
}
读会读取到零值,而写会 panic
有缓冲 channel
// 有缓冲 channel, 无写入值, 读
func TestClose(t *testing.T) {
	ch := make(chan int, 10)
	close(ch)
	fmt.Println(<-ch) // 0
}
// 有缓冲 channel,有写入值,读
func TestClose(t *testing.T) {
	ch := make(chan int, 10)
	for i := 1; i <= 10; i++ {
		ch <- i
	}
	close(ch)
	for i := range ch {
		fmt.Println(i) // 输出 1 到 10
	}
	fmt.Println(<-ch) // 输出 0
}
// 有缓冲 channel, 写
func TestClose(t *testing.T) {
	ch := make(chan int, 10)
	close(ch)
	ch <- 1 // panic: send on closed channel
}
 
可以看到有缓冲 channel 的行为读取的时候,如果有预留值则读取预留值,没有则读取到零值,写同样会 panic
而我们再次 close 的话,不管有无缓冲,都会提示我们 panic: close of closed channel

现在我们可以总结了,从已经关闭的 channel 里面操作
操作 / channel 类型
有缓冲 channel
无缓冲 channel
如果有预留值则返回,否则返回零值
返回零值
panic: send on closed channel
panic: send on closed channel
再次 close
panic: close of closed channel
panic: close of closed channel
如果是 nil 的 channel 呢?
我们依次尝试读,写,close,发现都是 panic
func TestClose(t *testing.T) {
	ch := make(chan int, 1)
	ch = nil
	fmt.Println(<-ch) // fatal error: all goroutines are asleep - deadlock!
	ch <- 1           // fatal error: all goroutines are asleep - deadlock!
	close(ch)         // panic: close of nil channel
}
那么我们可以进一步总结,读取已经关闭的 channel 操作结果:
操作 / channel 类型
有缓冲 channel
无缓冲 channel
nil chahnel
如果有预留值则返回,否则返回零值
返回零值
死锁
panic: send on closed channel
panic: send on closed channel
死锁
再次 close
panic: close of closed channel
panic: close of closed channel
panic: close of closed channel

源码分析

深入理解Golang之channel - 掘金 (juejin.cn) 这篇文章分析得非常好,我先学习一下,后面再补充
 

Ref

 

© hhmy 2019 - 2023

powered by nobelium