
本文深入探讨go语言中带缓冲通道的工作原理,以及协程(goroutine)在通道操作中可能遇到的阻塞行为。我们将区分主协程和子协程的阻塞对程序整体行为的影响,重点阐述go程序在主协程返回时如何处理其他未完成或已阻塞的协程,揭示为何子协程阻塞不一定会导致死锁,并强调正确的协程同步机制。
Go语言的通道(channel)是协程之间进行通信和同步的重要机制。根据其是否带有缓冲区,通道可以分为无缓冲通道和带缓冲通道。
无缓冲通道通过 make(chan T) 创建。它要求发送和接收操作同步进行,即发送者必须等待接收者准备好,反之亦然。任何一方未准备好都会导致操作阻塞,直到另一方准备就绪。
package main
func main() {
c := make(chan int) // 创建一个无缓冲通道
// c <- 1 // 如果没有接收者,此处会立即阻塞,若无其他协程解除,将导致死锁
// <-c // 如果没有发送者,此处会立即阻塞,若无其他协程解除,将导致死锁
}在上述代码中,如果尝试向无缓冲通道发送数据而没有对应的接收操作,或者尝试接收数据而没有对应的发送操作,当前执行的协程将立即阻塞。如果所有协程都因此阻塞,Go运行时将报告死锁错误。
带缓冲通道通过 make(chan T, capacity) 创建,其中 capacity 指定了通道可以存储的元素数量。带缓冲通道在一定程度上实现了异步通信:
package main
import "fmt"
func main() {
c := make(chan int, 2) // 创建一个容量为2的带缓冲通道
c <- 1 // 缓冲区未满,发送操作不阻塞
c <- 2 // 缓冲区未满,发送操作不阻塞
// c <- 3 // 缓冲区已满,此处会阻塞。如果没有其他协程从通道接收数据,将导致死锁
fmt.Println("成功发送了1和2")
// fmt.Println(<-c) // 如果取消注释,将从通道接收数据
}在这个例子中,主协程向容量为2的通道发送了两个数据。如果继续尝试发送第三个数据 c <- 3,由于缓冲区已满,主协程将在此处阻塞。如果没有其他协程从通道中接收数据以腾出空间,Go运行时会检测到所有协程都处于休眠状态,并报告死锁错误。
协程(goroutine)是Go语言实现并发的基本单位。当通过 go func() 启动一个新协程时,这个新协程会独立于调用它的协程(例如主协程)并发执行。
理解协程阻塞的关键在于:如果一个协程(无论是主协程还是子协程)在通道操作中阻塞,只有该协程本身会被挂起,而其他协程可以继续执行。
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int, 2) // 容量为2的带缓冲通道
go func() { // 启动一个子协程
c <- 1 // 缓冲区未满,不阻塞
c <- 2 // 缓冲区未满,不阻塞
fmt.Println("子协程尝试发送3...")
c <- 3 // 缓冲区已满,子协程将在此处阻塞,等待有接收者
fmt.Println("子协程发送了3") // 此行代码只有在子协程解除阻塞后才会执行
}()
time.Sleep(100 * time.Millisecond) // 等待子协程有机会执行
fmt.Println("主协程接收:", <-c) // 主协程从通道接收数据,为缓冲区腾出空间
fmt.Println("主协程接收:", <-c)
fmt.Println("主协程接收:", <-c) // 接收第三个元素,解除子协程的阻塞
time.Sleep(100 * time.Millisecond) // 确保子协程有时间打印“发送了3”
}在这个例子中,当子协程执行 c <- 3 时,由于通道已满,子协程会阻塞。但主协程并不会因此阻塞,它会继续执行 time.Sleep,然后从通道中接收数据。一旦主协程接收了数据,通道缓冲区有了空间,子协程的阻塞就会解除,它就能继续发送数据。
Go语言程序的执行始于初始化 main 包并调用 main 函数。当 main 函数返回时,程序便会退出。它不会等待其他(非 main)协程完成。
这是理解Go程序行为,特别是死锁与程序正常退出区别的核心。
让我们分析一个常见的困惑场景,即多个子协程向一个容量有限的通道发送数据,但程序却未报错死锁。
package main
import "time"
func main() {
c := make(chan int, 2) // 创建一个容量为2的带缓冲通道
// 如果主协程直接执行以下代码,会因为 c <- 3 阻塞而导致死锁
/*c <- 1
c <- 2
c <- 3 // 主协程在此阻塞,导致死锁*/
for i := 0; i < 4; i++ {
go func(i int) {
c <- i // 第一个发送,可能不会阻塞
c <- 9 // 第二个发送,可能不会阻塞
c <- 9 // 第三个发送,很可能阻塞
c <- 9 // 第四个发送,很可能阻塞
}(i)
}
time.Sleep(2000 * time.Millisecond) // 主协程等待一段时间
/*for i:=0; i<4*2; i++ { // 如果取消注释,会从通道接收数据
// fmt.Println(<-c)
}*/
}在上述代码中,main 函数启动了4个子协程,每个子协程都尝试向容量为2的通道 c 发送4个整数。这意味着总共有16个发送操作。由于通道容量只有2,很快就会有子协程在执行 c <- 9 时因通道满而阻塞。
然而,主协程并没有直接参与这些阻塞的发送操作。它只是启动了这些子协程,然后执行了 time.Sleep(2000 * time.Millisecond)。time.Sleep 结束后,main 函数就返回了。
因为 main 函数能够顺利返回,Go程序会正常退出。那些在子协程中因通道满而阻塞的发送操作,其所在的子协程会被Go运行时强制终止,因此不会报告死锁错误。这与主协程直接阻塞导致死锁的情况形成了鲜明对比。
以下是一个使用 sync.WaitGroup 改进的示例:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
c := make(chan int, 2) // 容量为2的带缓冲通道
var wg sync.WaitGroup // 声明一个 WaitGroup
// 启动4个发送协程
for i := 0; i < 4; i++ {
wg.Add(1) // 每次启动以上就是Go并发编程:深入理解通道缓冲、协程阻塞与程序退出机制的详细内容,更多请关注php中文网其它相关文章!
编程怎么学习?编程怎么入门?编程在哪学?编程怎么学才快?不用担心,这里为大家提供了编程速学教程(入门课程),有需要的小伙伴保存下载就能学习啦!
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号