
问题现象与原因分析
在 go 语言中,通道(channel)是实现协程(goroutine)间通信的重要机制。然而,不当使用通道,尤其是无缓冲通道,很容易导致程序死锁。考虑以下计算自然数和的 go 程序示例:
package main
import "fmt"
func sum(nums []int, c chan int) {
var sum int = 0
for _, v := range nums {
sum += v
}
c <- sum // 将结果发送到通道
}
func main() {
allNums := []int{1, 2, 3, 4, 5, 6, 7, 8}
c1 := make(chan int) // 创建无缓冲通道
c2 := make(chan int) // 创建无缓冲通道
sum(allNums[:len(allNums)/2], c1) // 直接调用 sum 函数
sum(allNums[len(allNums)/2:], c2) // 直接调用 sum 函数
a := <-c1 // 从通道接收数据
b := <-c2 // 从通道接收数据
fmt.Printf("%d + %d is %d :D", a, b, a+b)
}运行这段代码会产生以下死锁错误:
throw: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.sum(0x44213af00, 0x800000004, 0x420fbaa0, 0x2f29f, 0x7aaa8, ...)
main.go:9 +0x6e
main.main()
main.go:16 +0xe6
goroutine 2 [syscall]:
created by runtime.main
/usr/local/go/src/pkg/runtime/proc.c:221
exit status 2这个死锁的根本原因在于 sum 函数被直接调用,而不是在一个独立的 Goroutine 中运行。当执行到 sum(allNums[:len(allNums)/2], c1) 这一行时,sum 函数会在当前(即 main)Goroutine 中执行。在 sum 函数内部,c
由于 main Goroutine 阻塞,程序无法继续执行到第二个 sum 函数调用或任何通道接收操作。最终,Go 运行时检测到所有 Goroutine(包括 main Goroutine)都处于阻塞状态,没有 Goroutine 可以继续执行,从而报告死锁。尽管使用了两个独立的通道 c1 和 c2,但问题在于 sum 函数的同步调用模式,导致 main Goroutine 无法同时扮演发送者和接收者的角色。
解决方案一:使用带缓冲的通道
解决上述死锁问题的一种方法是使用带缓冲的通道。带缓冲的通道允许在没有接收者准备就绪的情况下,发送一定数量的数据到通道中,直到缓冲区满。
修改 main 函数中通道的创建方式:
package main
import "fmt"
func sum(nums []int, c chan int) {
var sum int = 0
for _, v := range nums {
sum += v
}
c <- sum // 将结果发送到通道
}
func main() {
allNums := []int{1, 2, 3, 4, 5, 6, 7, 8}
c1 := make(chan int, 1) // 创建一个容量为1的缓冲通道
c2 := make(chan int, 1) // 创建一个容量为1的缓冲通道
sum(allNums[:len(allNums)/2], c1) // 直接调用 sum 函数
sum(allNums[len(allNums)/2:], c2) // 直接调用 sum 函数
a := <-c1 // 从通道接收数据
b := <-c2 // 从通道接收数据
fmt.Printf("%d + %d is %d :D", a, b, a+b)
}通过将通道 c1 和 c2 创建为容量为 1 的缓冲通道 (make(chan int, 1)),sum 函数中的 c
注意事项: 使用缓冲通道时,需要仔细考虑缓冲区的容量。如果发送的数据量超过缓冲区容量,发送操作仍然会阻塞。对于本例,每个 sum 函数只发送一个整数,因此容量为 1 的缓冲区足以解决问题。
解决方案二:利用 Goroutine 实现并发
更符合 Go 语言并发编程范式,也是解决此类问题的推荐方法,是将 sum 函数的调用封装到独立的 Goroutine 中。这将使得 sum 函数与 main 函数并发执行,从而确保在 sum 函数尝试发送数据时,main 函数能够及时准备好接收数据。
修改 main 函数中 sum 函数的调用方式:
package main
import "fmt"
func sum(nums []int, c chan int) {
var sum int = 0
for _, v := range nums {
sum += v
}
c <- sum // 将结果发送到通道
}
func main() {
allNums := []int{1, 2, 3, 4, 5, 6, 7, 8}
c1 := make(chan int) // 保持无缓冲通道
c2 := make(chan int) // 保持无缓冲通道
go sum(allNums[:len(allNums)/2], c1) // 在新的 Goroutine 中运行
go sum(allNums[len(allNums)/2:], c2) // 在新的 Goroutine 中运行
a := <-c1 // 从通道接收数据
b := <-c2 // 从通道接收数据
fmt.Printf("%d + %d is %d :D", a, b, a+b)
}在此方案中,我们保留了无缓冲通道。关键的改变在于 go sum(...) 的使用。当 go sum(...) 被调用时,Go 运行时会启动一个新的 Goroutine 来执行 sum 函数,而 main Goroutine 会立即继续执行下一行代码。这意味着:
- main Goroutine 启动第一个 sum Goroutine。
- main Goroutine 立即启动第二个 sum Goroutine。
- main Goroutine 接着执行 a :=
此时,两个 sum Goroutine 正在并行计算它们的子和,并将结果发送到 c1 和 c2。当其中一个 sum Goroutine 完成计算并执行 c
总结与最佳实践
理解 Go 语言中通道的缓冲特性和 Goroutine 的并发执行是避免死锁的关键。
- 无缓冲通道:强制发送和接收操作同步发生。如果发送者发送数据而没有接收者准备好,或者接收者尝试接收数据而没有发送者准备好,操作就会阻塞。这非常适合需要严格同步的场景。
- 带缓冲通道:允许在缓冲区未满时进行非阻塞发送,在缓冲区非空时进行非阻塞接收。它在一定程度上解耦了发送者和接收者,但如果缓冲区满或空,操作仍会阻塞。适用于生产者-消费者模型中,允许一定程度的异步处理。
- Goroutine:是 Go 并发模型的核心。当需要函数并发执行并通过通道进行通信时,应始终将函数调用封装在 Goroutine 中 (go func())。
在上述示例中,使用 Goroutine 来并发执行 sum 函数是更符合 Go 语言并发哲学的做法。它清晰地表达了“计算子和是独立的任务,可以并行进行”的意图,并通过通道安全地将结果传递回主逻辑。虽然使用缓冲通道也能解决特定场景下的死锁,但它通常用于流量控制或解耦,而不是作为替代 Goroutine 实现并发执行的主要手段。
因此,在设计并发程序时,应优先考虑使用 Goroutine 来启动并发任务,并根据同步需求和数据流特征选择合适的通道类型(无缓冲或带缓冲)。






