Go语言context超时传播与goroutine泄漏防护实战

context超时传播的常见陷阱

Go语言的context包是并发控制的核心基础设施,但context超时传播在实际使用中存在多个易错点。最常见的问题是:父context取消后,使用该context创建的子goroutine如果没有正确检查ctx.Done(),将无限挂起,造成goroutine泄漏

错误示例:

func fetchData(ctx context.Context) ([]byte, error) {
    ch := make(chan []byte, 1)
    go func() {
        data, _ := slowAPICall() // 未传入ctx,无法被取消
        ch <- data
    }()
    select {
    case data := <-ch:
        return data, nil
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

当ctx超时退出时,内部goroutine的 slowAPICall() 仍在运行,且channel写入也会阻塞(ch容量为1,但只有一条数据)。修正方法是将ctx传递到子goroutine,并确保子goroutine有退出路径。

正确模式:可取消的子goroutine

func fetchData(ctx context.Context) ([]byte, error) {
    ch := make(chan result, 1)
    go func() {
        data, err := slowAPICall(ctx) // 传入ctx
        ch <- result{data, err}
    }()
    select {
    case r := <-ch:
        return r.data, r.err
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

type result struct {
    data []byte
    err  error
}

关键点:channel容量为1且使用struct包装结果,即使父goroutine因超时退出select,子goroutine写入channel也不会阻塞——写入成功后goroutine正常退出。

context嵌套与超时覆盖规则

context的超时传播遵循"最短超时优先"原则:

func handler(ctx context.Context) {
    // 父context超时5秒
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    // 子context超时3秒——实际超时取min(剩余5s, 3s) = 3秒
    childCtx, childCancel := context.WithTimeout(ctx, 3*time.Second)
    defer childCancel()

    // 子context超时10秒——实际超时取min(剩余5s, 10s) = 5秒
    // 父context先超时,子context随之取消
    longCtx, longCancel := context.WithTimeout(ctx, 10*time.Second)
    defer longCancel()
}

子context的超时不能超过父context的剩余时间。这是设计时容易忽略的:设置一个看似很长的子超时,实际上被父context提前截断。在微服务调用链中,网关层设置的全局超时会自动传播到下游每个服务。

HTTP客户端请求超时与context联动

Go标准库的http.Client天然支持context取消:

func (s *Service) CallAPI(ctx context.Context, url string) (*http.Response, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    // Client级别的Timeout会覆盖context超时
    client := &http.Client{
        Timeout: 30 * time.Second, // 最大超时
    }

    resp, err := client.Do(req)
    if err != nil {
        if ctx.Err() != nil {
            return nil, fmt.Errorf("context canceled: %w", ctx.Err())
        }
        return nil, err
    }
    return resp, nil
}

注意:当Client.Timeout和context.Deadline同时存在时,取两者中较短的超时。当context取消时,底层TCP连接会被关闭(设置 req.Cancel 或关闭连接),不会等到服务器响应。

goroutine泄漏检测与防护

使用runtime监控goroutine数量,检测泄漏:

func monitorGoroutines(ctx context.Context) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()
    baseline := runtime.NumGoroutine()

    for {
        select {
        case <-ticker.C:
            current := runtime.NumGoroutine()
            if current > baseline*2 {
                log.Warn("goroutine count spike",
                    "baseline", baseline,
                    "current", current)
            }
            baseline = current
        case <-ctx.Done():
            return
        }
    }
}

防护模式总结:

1. 所有启动goroutine的地方必须传入context,goroutine内必须在阻塞操作前检查 ctx.Done()

2. 使用带缓冲的channel(容量1)传递结果,避免父goroutine退出后子goroutine永久阻塞在channel写入。

3. 使用 errgroup 管理goroutine生命周期,它自动处理context传播和错误收集:

g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
    return taskA(ctx)
})
g.Go(func() error {
    return taskB(ctx)
})
if err := g.Wait(); err != nil {
    // 任一goroutine返回错误,其余自动取消
}

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/go-yu-yan-context-chao-shi-chuan-bo-yu-goroutine-xie-lou/

(0)
小编小编
上一篇 2小时前
下一篇 2小时前

相关推荐