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() 自己收手。

把这几招摸熟,并发代码就不会被某个卡住的调用拖垮了。

更多推荐

章节目录