在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停止的方法,每种方法都有其适用场景。理解这些方法的原理和特点,能够帮助我们在实际开发中做出更好的选择。

并发编程的核心难点之一就是协调多个执行单元的生命周期。掌握了这些工具,我们就能写出更加健壮的并发程序。