
理解Go语言通道死锁的根源
在go语言中,通道(channel)是goroutine之间进行通信和同步的关键机制。通道可以分为缓冲通道和非缓冲通道。非缓冲通道(unbuffered channel)的特性是:发送操作(c 同步机制也被称为“会合(rendezvous)”。
考虑以下代码示例,它尝试使用两个非缓冲通道计算一个整数切片的子和:
package main
import "fmt"
func sum(nums []int, c chan int) {
var total int = 0
for _, v := range nums {
total += v
}
c <- total // 尝试将结果发送到通道
}
func main() {
allNums := []int{1, 2, 3, 4, 5, 6, 7, 8}
c1 := make(chan int) // 创建非缓冲通道
c2 := make(chan int) // 创建非缓冲通道
// 直接调用 sum 函数
sum(allNums[:len(allNums)/2], c1) // 第一次 sum 调用
sum(allNums[len(allNums)/2:], c2) // 第二次 sum 调用 (永远不会被执行)
a := <-c1 // 从 c1 接收结果
b := <-c2 // 从 c2 接收结果
fmt.Printf("%d + %d is %d :D\n", a, b, a+b)
}运行这段代码会导致一个死锁错误:fatal error: all goroutines are asleep - deadlock!。
死锁分析:
- main 函数是程序的主Goroutine。
- c1 := make(chan int) 和 c2 := make(chan int) 创建了两个非缓冲通道。
- 当 main Goroutine执行到 sum(allNums[:len(allNums)/2], c1) 时,它会调用 sum 函数。
- 在 sum 函数内部,计算完成后,执行 c
- 因为 sum 函数是在 main Goroutine中直接调用的,所以 main Goroutine也随之被阻塞。
- main Goroutine被阻塞后,它永远无法到达 a :=
- 结果是,发送操作永远无法完成,main Goroutine陷入永久阻塞,导致整个程序死锁。sum(allNums[len(allNums)/2:], c2) 甚至都不会被调用。
问题在于,通道的发送和接收必须由不同的Goroutine来协调完成,或者至少在非缓冲通道的情况下,发送方和接收方必须“同时”准备就绪。在上述代码中,发送方和接收方都在同一个 main Goroutine的执行路径上,并且发送操作先于接收操作,从而打破了非缓冲通道的同步条件。
立即学习“go语言免费学习笔记(深入)”;
解决方案一:使用缓冲通道
解决上述死锁问题的一种方法是使用缓冲通道。缓冲通道允许在发送方和接收方之间存储一定数量的值,而不会立即阻塞。当通道的缓冲区未满时,发送操作不会阻塞;当缓冲区非空时,接收操作不会阻塞。
将通道 c1 和 c2 改为缓冲通道,容量设置为1,即可避免立即阻塞:
package main
import "fmt"
func sum(nums []int, c chan int) {
var total int = 0
for _, v := range nums {
total += v
}
c <- total // 发送操作不会立即阻塞,因为通道有缓冲区
}
func main() {
allNums := []int{1, 2, 3, 4, 5, 6, 7, 8}
// 创建容量为1的缓冲通道
c1 := make(chan int, 1)
c2 := make(chan int, 1)
sum(allNums[:len(allNums)/2], c1)
sum(allNums[len(allNums)/2:], c2)
a := <-c1
b := <-c2
fmt.Printf("%d + %d is %d :D\n", a, b, a+b)
}工作原理:
通过 make(chan int, 1) 创建的缓冲通道,允许在没有接收方准备就绪的情况下,至少发送一个值到通道中。因此,当 sum 函数执行 c
注意事项:
虽然缓冲通道解决了当前的死锁,但它并非总是最佳实践。缓冲通道引入了额外的复杂性:你需要仔细管理缓冲区的容量。如果发送的速度持续快于接收的速度,并且缓冲区满了,发送操作仍然会阻塞。对于更复杂的并发场景,通常推荐使用Goroutine。
解决方案二:利用Goroutine实现并发
Go语言的并发原语是Goroutine。将 sum 函数的调用放入独立的Goroutine中执行,是更符合Go语言并发哲学且更通用的解决方案。这样,main Goroutine可以启动这些并发任务,然后等待它们通过通道返回结果。
package main
import "fmt"
func sum(nums []int, c chan int) {
var total int = 0
for _, v := range nums {
total += v
}
c <- total // 发送结果到通道
}
func main() {
allNums := []int{1, 2, 3, 4, 5, 6, 7, 8}
c1 := make(chan int) // 仍是非缓冲通道
c2 := make(chan int) // 仍是非缓冲通道
// 使用 go 关键字将 sum 函数作为独立的 Goroutine 运行
go sum(allNums[:len(allNums)/2], c1)
go sum(allNums[len(allNums)/2:], c2)
// main Goroutine 等待从通道接收结果
a := <-c1
b := <-c2
fmt.Printf("%d + %d is %d :D\n", a, b, a+b)
}工作原理:
- main Goroutine 调用 go sum(...) 时,会立即启动一个新的Goroutine来执行 sum 函数,而 main Goroutine会继续向下执行,不会被阻塞。
- 现在,有两个独立的 sum Goroutine在后台并发运行,它们各自计算子和并将结果发送到 c1 和 c2。
- main Goroutine继续执行到 a :=
- 一旦 c1 接收到值,main Goroutine解除阻塞,并继续执行到 b :=
- 当两个子和都接收到后,main Goroutine继续打印最终结果。
这种方式下,发送操作和接收操作分别由不同的Goroutine执行,完美地满足了非缓冲通道的同步条件,避免了死锁。
总结与最佳实践
理解Go语言中通道的缓冲特性和Goroutine的并发模型对于避免死锁至关重要。
- 非缓冲通道:用于实现严格的同步(会合)。发送方和接收方必须同时准备就绪才能完成通信。如果发送方在没有接收方的情况下尝试发送,或者接收方在没有发送方的情况下尝试接收,都会导致阻塞。在单个Goroutine内,非缓冲通道的发送操作先于接收操作时,极易发生死锁。
- 缓冲通道:提供了一定程度的异步性。它们允许在缓冲区未满时发送,在缓冲区非空时接收,而不会立即阻塞。这在某些场景下可以简化代码,但需要谨慎管理缓冲区大小。
- Goroutine与通道的结合:这是Go语言实现并发的惯用方式。将耗时操作或并发任务封装在独立的Goroutine中,并通过通道进行数据交换和同步。这使得程序结构清晰,易于理解和维护,并且能够充分利用多核处理器的优势。
在设计Go并发程序时,优先考虑使用Goroutine来执行并发任务,并利用非缓冲或缓冲通道进行它们之间的通信。对于简单的、一次性的同步需求,缓冲通道(容量为1)可以作为快速解决方案,但对于复杂的并发流程,Goroutine与通道的组合是更健壮和灵活的选择。始终牢记,任何通道操作都必须有匹配的另一端(发送或接收)在某个Goroutine中执行,否则就可能导致死锁。










