在Go语言的世界里,goroutine是最核心的概念之一。它让我们能够轻松地编写高并发程序,就像变魔术一样简单。但很多初学者都会有一个困惑:启动了一个goroutine后,我怎么知道它什么时候执行完了?
这个问题看似简单,实际上涉及到Go语言并发编程的核心——goroutine间的同步与通信。
sync.WaitGroup:计数器方式
sync.WaitGroup 是Go语言标准库提供的经典goroutine同步方式。它的设计理念源自计数器思想:每启动一个goroutine,计数器加一;goroutine执行完毕后,计数器减一;当计数器归零时,说明所有goroutine都已经完成。
Go 1.25 引入了 wg.Go() 方法,提供了更简洁的用法:
func worker(id int) {
fmt.Printf("goroutine %d 开始工作\n", id)
time.Sleep(time.Second)
fmt.Printf("goroutine %d 完成\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Go(func() {
worker(i)
})
}
wg.Wait()
fmt.Println("所有goroutine执行完毕!")
}
wg.Go() 会在内部自动调用 Add(1),并在函数返回时自动调用 Done(),省去了手动管理的麻烦。
这种模式特别适合"主goroutine需要等待多个工作goroutine完成"的场景。
Channel信号:利用通道传递完成通知
通道是Go语言的招牌特性,它可以用来在goroutine之间传递数据,自然也可以用来传递"完成信号"。
func worker(done chan<- bool) {
fmt.Println("goroutine工作中...")
done <- true
}
func main() {
done := make(chan bool, 1)
go worker(done)
<-done
fmt.Println("主goroutine收到结束信号")
}
这里创建了一个带缓冲的通道,容量为1。goroutine完成后向通道发送true,主goroutine则阻塞等待从这个通道接收数据。缓冲通道的好处是可以避免goroutine过早退出导致的死锁。
如果你需要等待多个goroutine,可以创建多个通道并使用 select 语句或者多次接收:
done1, done2 := make(chan bool), make(chan bool)
go worker(done1)
go worker(done2)
<-done1
<-done2
这种方式的优点是语义清晰——通道本身就代表"通知"的概念。
context.Context:支持取消传播的标准方案
context.Context 是Go语言处理请求作用域、取消信号和超时的标准做法。从Go 1.7开始,它就被纳入了标准库。
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("收到取消信号,退出")
return
default:
fmt.Println("工作中...")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
time.Sleep(2 * time.Second)
cancel()
}
ctx.Done() 返回一个通道,当调用 cancel() 或父context的Done通道关闭时,该通道就会被关闭。goroutine通过监听这个通道就能及时收到"该结束了"的通知。
context 的取消传播特性非常强大。当你在一个请求处理函数中创建子goroutine时,可以把同一个 context 传递给它们。一旦调用 cancel(),所有监听该 context 的子goroutine都会收到信号,形成一个完整的取消传播树。
实际开发中,context 特别适合处理HTTP请求、数据库操作等需要超时控制或取消操作的场景。
close通道广播:一对多的优雅通知
如果你想让一个信号同时通知所有相关goroutine,关闭通道是个优雅的选择。
func worker(stopCh <-chan struct{}) {
for {
select {
case <-stopCh:
fmt.Println("收到停止信号,退出")
return
default:
fmt.Println("goroutine工作...")
}
}
}
func main() {
stopCh := make(chan struct{})
for i := 0; i < 3; i++ {
go worker(stopCh)
}
close(stopCh)
time.Sleep(time.Second)
}
关闭一个通道会立即解除所有阻塞在该通道上的goroutine的阻塞状态。与向通道发送数据不同,关闭通道是一种广播机制——所有监听者都会同时收到信号。
这种方法非常适合"一对多"的场景,比如优雅关闭服务器时通知所有工作goroutine停止接受新任务。
errgroup:统一管理的并发利器
golang.org/x/sync/errgroup 是官方提供的并发处理包,特别适合需要统一错误处理和等待所有任务完成的场景。
func main() {
g := new(errgroup.Group)
g.Go(func() error {
fmt.Println("goroutine1完成")
return nil
})
g.Go(func() error {
fmt.Println("goroutine2完成")
return nil
})
if err := g.Wait(); err != nil {
fmt.Println("发生错误:", err)
}
fmt.Println("所有goroutine执行完毕")
}
errgroup.Group 提供了 Go() 方法来启动goroutine,Wait() 阻塞等待所有goroutine完成。如果任何一个goroutine返回了非nil的错误,Wait() 就会返回该错误。
这个包特别适合"批量处理任务并收集结果"的场景,比如并行下载多个文件、并发查询数据库等。
实际应用中的选择建议
说了这么多方法,实际开发中该如何选择呢?
如果只是简单地等待一组goroutine完成,首选 sync.WaitGroup,它是最轻量、最直接的选择。
如果需要在goroutine之间传递数据或信号,通道是当仁不让的选择。
如果涉及到取消传播、超时控制或者与标准库的请求上下文集成,context.Context 是最佳方案。
如果需要统一处理错误和等待,errgroup 能让你的代码更加简洁。
写在最后
Go语言提供了多种检测goroutine停止的方法,每种方法都有其适用场景。理解这些方法的原理和特点,能够帮助我们在实际开发中做出更好的选择。
并发编程的核心难点之一就是协调多个执行单元的生命周期。掌握了这些工具,我们就能写出更加健壮的并发程序。