Go 无缓存通道与有缓存通道

2,259 阅读4分钟

问题

在 Go 语言中,线程通信方式有两种。一种是共享内存模式,另一种是消息通信模式。消息通信有四要素:发送者、接受者、通道、消息。发送者和接受者通过通道传递消息,从而实现通信。通信是线程间协作的核心。如果通信出错,则会造成发送者和接受者同时阻塞,产生死锁。

试看一道题目:

// 发送者
func main() {
	ch := make(chan int)
    
	ch <- 1 // 发送
	fmt.Println("发送", 1)

	// 接收者
	go func() {
		v := <-ch // 接收
		fmt.Println("接收", v)
	}()
}

题目输出什么?

A. 发送1;接收1
B. deadlock

解决方式

如果你执行代码,会发现程序出错,输出deadlock。为什么会死锁?因为使用了无缓冲通道。在 Go 中,通道有两种类型,无缓存通道和有缓存通道。使用无缓存通道,稍不留神就会死锁。

无缓存通道是指无缓存空间,有缓存通道有缓存空间。除了有无缓存空间外,其阻塞策略不同。

在无缓存通道下,发送者的发送操作将阻塞,直到接收者执行接受操作。同样接受者的接受操作将阻塞,直到发送者执行发送操作。发送者的发送操作和接受者的接受操作是同步的。

在有缓存通道下,如果缓存空间满了,那么发送者的发送操作将阻塞。直到接受者取走数据,有了缓存空间,发送者的发送操作才会继续执行。如果缓存是空的,那么接受者的接受操作将阻塞。

在开始那道题中,因为使用了无缓存通道,发送者的发送操作阻塞了。发送操作后面的语句,不会执行。要想不阻塞,有两种解法。

第一种,使用无缓存通道,先创建接受者,再发送数据。 代码如下:

func main() {
	ch := make(chan int)

	// 接收者
	go func() {
		v := <-ch // 这里阻塞
		fmt.Println("收到:", v)
	}()

	// 发送者
	ch <- 1
	fmt.Println("发送:", 1)
}

第二种,使用有缓存通道。 至于创建接受者,执行发送操作的顺序,变得无关紧要了。代码如下:

func main() {
	ch := make(chan int, 1)
    
    // 发送者
	ch <- 1
	fmt.Println("发送:", 1)

	// 接收者
	go func() {
		v := <-ch // 这里阻塞
		fmt.Println("收到:", v)
	}()
}

原理

以上问题,在操作系统进程通信模型中,早已描述。在操作系统进程通信中,虽然发送者和接受者是进程,而不是线程,不是goroutine。但是从通信角度来看,并无区别。不管是进程间通信,还是线程间通信,其核心是通信。在操作系统中,进程间消息通信,可以通过send()receive()来进行。消息通信,可以是阻塞或非阻塞,也称为同步或异步。

很多开发者认为,阻塞、非阻塞与同步、异步意思不同,并广泛讨论。然而,与认识相反,阻塞、非阻塞与同步、异步意思相同。

所以,讨论阻塞、非阻塞与同步、异步的差别,没有意义。因为他们本就是对称概念(也就是不同词语表达相同概念)。讨论什么有意义呢?讨论是发送者阻塞,还是接受者阻塞,更有意义。阻塞、非阻塞与发送者、接受者,组合成二乘二矩阵:

image.png

在通信过程中,总共有四种状态:

  • 发送者阻塞:发送者阻塞,直到消息被接受者(或者邮箱)接收。
  • 发送者非阻塞:发送者发送消息,并继续操作。不会关联接收者的状态。
  • 接收者阻塞:接收者阻塞,直到有消息可用。
  • 接收者非阻塞:接收者收到一个有效消息或空消息。

发送者、接受者的阻塞状态,是编程中需要经常思考的事情。

原则

如何正确使用 Go 的通道进行通信?根据通道类型判断。如果使用无缓存通道,先创建发送者或接受者,再执行发送或接收操作。如果使用有缓存通道,则注意缓存空间满了,会阻塞发送操作。

除了根据通道类型判断,最重要的是熟练掌握发送者、接受者、通道的关系,以及发送操作、接收操作何时阻塞。在 Go 中,你要关注哪个 goroutine 是发送者,哪个 goroutine 是接受者?你还要关注发送者和接受者之间的通道是哪个?最后,你要关注发送操作和接收操作何时阻塞?

这些加起来,就叫做消息通信模式。

参考