Go多协程超时控制的几种写法
Go 的超时控制本质是任务完成和时间到了的赛跑,用 select 同时监听决胜负。单任务用 channel 加 time.After 最直接,但接收 channel 要带缓冲防 goroutine 泄漏。跨层调用用 context.WithTimeout 让超时信号一路传递,defer cancel 别忘。多协程用 WaitGroup 把全部完成包成信号再赛跑。
写 Go 并发,开 goroutine 很爽,go doSomething() 一甩就并行跑起来了。
但爽完就面临一个现实问题:有个 goroutine 去调外部接口,万一对方卡住不返回,我总不能干等到天荒地老吧?
得给它设个超时:到点还没结果,就不等了,该干嘛干嘛去。这篇把 Go 里实现超时控制的几种套路讲清楚,从最朴素的 channel + select,到更规范的 context。
超时的本质
先想清楚超时到底在干什么。
超时控制的核心是一场"赛跑":让"任务完成"和"时间到了"两件事去抢,谁先发生就听谁的。
- 任务先完成 → 拿结果,正常走。
- 时间先到 → 放弃等待,走超时分支。
Go 里实现这场赛跑,最趁手的工具就是 select,它能同时盯着多个 channel,哪个先就绪就走哪个。
最基础:channel + select
先看不带任何库的原始写法。
思路:开个 goroutine 干活,干完往一个 channel 塞结果;同时用 time.After 搞一个"定时响铃"的 channel。select 同时监听这俩。
func doWork() string {
// 模拟一个耗时任务
time.Sleep(3 * time.Second)
return "任务结果"
}
func main() {
// 用来接收任务结果的 channel
resultCh := make(chan string, 1) // 带缓冲,避免 goroutine 泄漏(后面讲)
go func() {
resultCh <- doWork()
}()
select {
case res := <-resultCh:
// 任务先完成
fmt.Println("拿到结果:", res)
case <-time.After(2 * time.Second):
// 2 秒的定时器先响,判定超时
fmt.Println("等太久了,超时放弃")
}
}
time.After(2 * time.Second) 返回一个 channel,2 秒后它会自己收到一个值。select 哪个先来走哪个:任务 3 秒才完成,定时器 2 秒先响,所以这里会走超时分支。
这里藏着一个泄漏坑
上面代码我特意把 resultCh 设成了带缓冲(容量 1),这不是随手写的。
设想 resultCh 是无缓冲的:主函数超时后不再接收了,但那个 goroutine 还在跑,3 秒后它想往 resultCh 塞结果,可没人收,它就永远阻塞在那——goroutine 泄漏了,占着内存一直不释放。
// 无缓冲 channel 的隐患:超时后 goroutine 卡死在发送处,泄漏
resultCh := make(chan string) // 危险
给个容量 1 的缓冲,goroutine 塞完结果就能走,哪怕没人收,它也不会卡住。这是 Go 超时控制里很隐蔽但很重要的一个细节。
更规范:context 控制超时
channel + select 适合单个任务。但真实项目里,一个请求往往要穿透好几层调用(handler → service → dao → 外部接口),超时信号得能一路传下去。
这时候就该用 context 了,它是 Go 官方为"跨 goroutine 传递取消/超时信号"设计的标准方案。
func doWork(ctx context.Context, resultCh chan<- string) {
time.Sleep(3 * time.Second)
// 真实场景里,耗时操作中间也应该检查 ctx 是否已取消
resultCh <- "任务结果"
}
func main() {
// 创建一个 2 秒超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// 养成习惯:cancel 一定要调,释放相关资源,defer 最稳
defer cancel()
resultCh := make(chan string, 1)
go doWork(ctx, resultCh)
select {
case res := <-resultCh:
fmt.Println("拿到结果:", res)
case <-ctx.Done():
// ctx 超时或被取消,Done() 这个 channel 会关闭
fmt.Println("超时:", ctx.Err()) // context deadline exceeded
}
}
context.WithTimeout 的好处是:它返回的 ctx 可以作为参数一层层传下去,任何一层都能通过 ctx.Done() 感知到"上面已经超时了,别白费劲了"。
defer cancel() 别忘,不调会导致 context 关联的资源(定时器)不及时释放,是常见的小泄漏。
多个 goroutine 一起等
标题说的"多协程超时",就是同时开好几个 goroutine,要么全部等到、要么统一超时。
sync.WaitGroup 负责等全部完成,再套一层超时赛跑:
func main() {
var wg sync.WaitGroup
tasks := []string{"查用户", "查订单", "查库存"}
for _, name := range tasks {
wg.Add(1)
go func(taskName string) {
defer wg.Done()
// 模拟各自耗时不同
time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
fmt.Println(taskName, "完成")
}(name)
}
// 把"等全部完成"也变成一个 channel 信号
done := make(chan struct{})
go func() {
wg.Wait() // 阻塞直到所有 goroutine 都 Done
close(done) // 全完成了,关闭 done 通知
}()
select {
case <-done:
fmt.Println("所有任务都完成了")
case <-time.After(2 * time.Second):
fmt.Println("有任务超时,不等了")
}
}
套路还是那个赛跑:把"全部完成"包装成 done 这个 channel,再和 time.After 一起丢进 select。谁先到走谁。
注意这里超时后,没完成的 goroutine 其实还在后台跑(Go 没法强杀 goroutine)。真要让它们也停下来,得结合前面的 context,让每个 goroutine 自己检查 ctx.Done() 主动退出。
几个要记住的点
实践里踩过的,归纳一下:
- 接结果的 channel 优先带缓冲,防止超时后发送方 goroutine 泄漏。
- context 是跨层超时的标准答案,单点用
time.After够,多层调用一律传ctx。 defer cancel()不能省,否则 context 资源泄漏。- Go 杀不死 goroutine,超时只是"我不等了",被等的那个要靠
ctx.Done()自己配合退出,不然它还在后台空跑。
小结
Go 的超时控制本质是"任务完成"和"时间到了"的赛跑,用 select 同时监听来决出胜负。
单个任务用 channel + time.After 最直接,但记得接收 channel 带缓冲防泄漏;跨层调用用 context.WithTimeout,超时信号能一路传递,defer cancel() 别忘;多 goroutine 用 WaitGroup 把"全部完成"包成信号再去赛跑。
最后记牢:超时只代表调用方不等了,goroutine 本身杀不死,要彻底停就得让它配合 ctx.Done() 自己收手。
把这几招摸熟,并发代码就不会被某个卡住的调用拖垮了。
