在 Go 项目里,“怎么知道一个 goroutine 已经停了”看上去像是个简单判断题,实际牵扯的是并发程序里最关键的生命周期管理。不同场景下,等待完成、发送通知、统一取消、批量收错用的都不是同一种工具;把它们混用,代码往往就会变得难维护。下面按常见开发问题拆开来看,顺着“只是等结束”到“还要处理取消和错误”的路径,梳理每种方案适合解决什么问题。
只需要等待执行结束:先看 sync.WaitGroup
如果你的需求很直接:主 goroutine 启动几个工作 goroutine,然后等它们全部跑完再继续,那么 sync.WaitGroup 仍然是最标准、最省成本的做法。
它的核心就是一个计数器:启动一个 goroutine 时加一,任务结束时减一,计数归零后 Wait() 返回。这个模型不负责传数据,也不负责取消任务,只专注于“等所有人结束”。
Go 1.25 引入了 wg.Go(),把常见的 Add(1) 和 Done() 包装成了统一写法,代码更紧凑:
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(),因此特别适合一组并行任务的统一收尾。
这类场景通常包括:批量启动几个后台任务、主流程必须等所有任务结束后再退出、任务之间没有额外通信需求。只要你的目标是“知道它们都结束了”,优先考虑 WaitGroup 基本不会错。
需要明确的完成通知:用 Channel 传信号
有些时候,你关心的不是“一组任务都结束了吗”,而是“某个 goroutine 结束时能不能主动通知我”。这时候用 Channel 传递完成信号,会比纯计数更贴近语义。

最简单的写法,就是让工作 goroutine 在退出前向通道发送一个值,主 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收到结束信号")
}
这里的 done 通道本身就表达了“完成通知”的含义。主 goroutine 在 <-done 处阻塞,直到工作 goroutine 发出信号。
示例里使用了容量为 1 的缓冲通道,这样发送方在某些时机下不必立刻等接收方准备好,能减少因为时序问题带来的阻塞风险。
如果要等待多个 goroutine,也可以分别接收多个完成信号:
done1, done2 := make(chan bool), make(chan bool)
go worker(done1)
go worker(done2)
<-done1
<-done2
这种方式的优势在于表达非常直接:通道不仅能传“结束了”这个事实,后面还可以扩展成传结果、传错误、传状态。代价是当 goroutine 数量增多时,手工管理多个通道会比较繁琐,因此它更适合点对点通知,或者任务数量有限、通信含义明确的场景。
任务可能提前终止:context.Context 更适合控制生命周期
很多真实项目里,问题不是“等它自然结束”,而是“我需要告诉它现在就停下来”。比如 HTTP 请求已经超时、用户主动取消操作、上游任务失败后下游没必要继续执行。这时 context.Context 才是更标准的控制方式。
从 Go 1.7 起,context 就进入了标准库,它解决的是请求作用域、超时和取消传播问题。goroutine 通过监听 ctx.Done(),就能在收到取消信号时退出:
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 已经结束时,这个通道会被关闭。监听它的 goroutine 因此能同时收到停止信号。
context 真正有价值的地方,在于它不是单个 goroutine 的局部技巧,而是整条调用链都能复用的标准协议。一个请求处理函数里如果派生出多个子 goroutine,只要把同一个 context 继续传下去,取消就会沿着调用树传播,不需要为每个子任务单独设计停止接口。
所以,凡是跟 HTTP 请求、数据库操作、RPC 调用、超时控制有关的并发逻辑,优先把“任务什么时候该停”交给 context.Context,而不是自己散落地维护多个停止标记。
一对多通知与统一收错:close 通道和 errgroup 怎么选
close 通道适合广播式停止
如果一个信号需要同时通知多个 goroutine,关闭通道往往比逐个发送消息更利落。因为“关闭”本身就是一种广播动作:所有正在等待该通道的 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)
}
这里的重点不在于通道里传了什么数据,而在于通道一旦被关闭,所有监听 stopCh 的 goroutine 都会收到“该停了”的信号。用 struct{} 作为元素类型也很常见,因为这个场景并不需要携带实际数据。
它很适合优雅停机、停止接受新任务、同时终止一批后台 worker 这类一对多场景。相比逐个发通知,关闭通道的意图也更明确:这是一次全体广播,不是一次普通消息发送。
errgroup 适合同时等待和收集错误
如果你的需求再进一步,不只是等待 goroutine 结束,还要知道其中有没有任务失败,那么 golang.org/x/sync/errgroup 会比单独使用 WaitGroup 更省事。
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() 启动任务,用 Wait() 等全部完成;只要其中任何一个 goroutine 返回了非 nil 错误,Wait() 就会把这个错误带出来。
它特别适合并发下载、批量查询、并行处理任务这类场景。因为这类业务通常不只是“都跑完”,还要回答“有没有谁失败了”。把等待和错误汇总放在一套接口里,代码会比自己拼通道和共享变量更清楚。
实际开发怎么选:按需求而不是按工具名选
把这些方法放在一起看,选择其实并不复杂,关键是先确认你到底要解决哪类问题。
如果只是等待一组 goroutine 自然执行结束,首选 sync.WaitGroup。它轻量、直接,成本最低。
如果 goroutine 结束时需要主动发出一个完成通知,或者还要顺手传一点结果,Channel 更贴近通信模型。
如果任务可能被提前取消,或者需要和超时、请求生命周期绑定,应该优先用 context.Context。这已经是 Go 生态里最通用的约定。
如果一个停止信号要同时发给多个 goroutine,关闭通道比逐个发送消息更自然,适合广播式控制。
如果你既要并发执行,又要统一等待,还要把错误收上来,errgroup 会让代码更整洁。
换句话说,“如何知道 goroutine 已经停止”并没有唯一答案。真正该问的是:你是在等它完成、通知它停止、广播停止信号,还是同时管理错误和退出。把问题问清楚,方案自然就选出来了。













