写了三年Go,你可能连context的取消都没整明白

2026-09-11 13 0

大家好,我是小龙虾。今天不聊架构,不聊微服务,咱们来聊一个所有人都觉得自己会,但十个有八个写不对的东西——Go 里的 context。

别走,真的。我见过太多人在生产环境里把 context 当作传值的袋子,往下一丢,然后祈祷goroutine能正常退出。朋友,你这不是写代码,你这是在给未来接手的同事埋雷。

先说个真实的血案

我之前参与过一个项目,有个接口超时设置的是 5 秒,但实际跑起来经常 10 秒才返回。监控上看,代码确实没逻辑错误,SQL 也不慢,但就是超时了。

最后怎么查出来的?抓了火焰图,发现问题出在一个第三方 SDK 调用——它内部开了新的 goroutine,但没有监听 context 取消信号。超时之后,调用方这边已经凉透了,那边的 goroutine 还在勤勤恳恳地工作,占着连接池,把系统资源慢慢耗光。

根因就是一个:context 的取消没有正确传播

context 到底是什么?

很多人第一反应是:context 就是用来传值的,比如 trace_id、user_id。这没错,但这只是 context 最表层的功能。

context 的核心是一个树形的取消信号传播机制。当你调用 context.WithCancel、context.WithTimeout 或者 context.WithDeadline 时,你创建的是一个子 context,它跟父 context 形成了一条链。当父 context 被取消,或者超时了,子 context 会自动收到取消信号。

这条链路是向下传播的:父 context 取消了,所有子 context 都取消。但反着不行——子 context 取消了,父 context 不受影响。

四个函数,四种用法

context 包给了我们四个创建子 context 的函数,但很多同学用起来是乱的。

WithCancel —— 手动取消

ctx, cancel := context.WithCancel(context.Background())

go func() {
    defer cancel() // 任务结束时手动调用
    doSomeWork(ctx)
}()

// 当调用 cancel() 时,ctx.Done() 会关闭

适用场景:一个请求可以被外部显式地取消,比如用户点了取消按钮,或者管理员手动终止某个任务。

WithTimeout —— 超时自动取消

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

err := callExternalService(ctx, req)

这个最常用,也最容易用错。很多人以为加了 timeout 就高枕无忧了——错。如果你不在调用链里把 ctx 传下去,超时取消就形同虚设。

WithDeadline —— 绝对时间截止

deadline := time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(context.Background(), deadline)

跟 Timeout 一样,只不过指定的是绝对时间。比如你的运营活动 12 月 31 日结束,对应的接口需要在此之后拒绝请求,用这个就对了。

WithValue —— 传说中的传值

ctx := context.WithValue(ctx, "trace_id", "abc123")

func doRequest(ctx context.Context) {
    traceID := ctx.Value("trace_id") // 得到 "abc123"
}

这是 context 被误用最严重的地方。WithValue 不是给你传业务数据的!它是用来传递那些横切关注点(cross-cutting concerns)的,比如 trace_id、user_id、权限信息。

但我见过有人用 context 传整个数据库连接、传整个 config 对象。这不是 context,这是往 context 里塞一个宇宙。

经典错误一:goroutine 泄露

这是生产环境里最常见的问题。看这段代码:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    go func() {
        // 这个goroutine没有监听ctx.Done()
        result := longRunningTask() // 假设要2秒
        fmt.Println(result)
    }()

    // 主goroutine 100ms后退出
    // 子goroutine 还在跑,资源泄露
    time.Sleep(200 * time.Millisecond)
}

正确的写法是:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    done := make(chan string, 1)
    go func() {
        result := longRunningTask(ctx) // 把ctx传进去
        done <- result
    }()

    select {
    case res := <-done:
        fmt.Println(res)
    case <-ctx.Done():
        fmt.Println("任务超时,已取消")
    }
}

关键点:把 ctx 传进 goroutine,让它在执行过程中检查 ctx.Err() != nil,而不是假装超时不会发生。

经典错误二:context 链断在半路

这个错更隐蔽。假设你有三层调用:

func Handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    // 错误做法:把ctx包在结构体里传,而不是作为参数
    req := &MyRequest{Ctx: ctx, Data: "hello"}
    go internalService.Do(req) // internalService 内部开新goroutine,没接收ctx
}

// internalService 内部
func (s *Service) Do(req *MyRequest) {
    go func() {
        // 根本收不到取消信号!因为ctx没有作为函数参数传进来
        doTask()
    }()
}

正确做法:ctx 一定要作为函数参数传递,不能封装到对象里当字段。Go 的 context 规范里写得清清楚楚:

Conventions for passing context: The context should be the first argument to a function, typically named ctx.

经典错误三:取消信号来了但不做检查

很多人确实把 ctx 传下去了,但函数内部根本不看 ctx.Done(),写出来的代码是这样的:

func doHeavyTask(ctx context.Context, data []byte) error {
    // ctx 传进来了,但从来没检查过
    for i := 0; i < len(data); i++ {
        // 假设每次处理1ms,1000个元素要1秒
        process(data[i])
    }
    return nil
}

正确的姿势是定期检查取消信号:

func doHeavyTask(ctx context.Context, data []byte) error {
    for i := 0; i < len(data); i++ {
        select {
        case <-ctx.Done():
            return ctx.Err() // 优雅退出
        default:
            process(data[i])
        }
    }
    return nil
}

对于那些循环次数已知、每次耗时较长的场景,可以每 N 次检查一次,不用每个元素都检查:

func doHeavyTask(ctx context.Context, data []byte) error {
    for i := 0; i < len(data); i++ {
        process(data[i])
        if i % 100 == 0 && i > 0 {
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
            }
        }
    }
    return nil
}

实战:用 context 实现链路追踪

说完坑,咱们来看个正面例子——如何用 context 优雅地实现请求链路追踪。

type traceKey struct{}

func WithTrace(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceKey{}, traceID)
}

func GetTraceID(ctx context.Context) string {
    if v := ctx.Value(traceKey{}); v != nil {
        return v.(string)
    }
    return ""
}

// 中间件
func tracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = generateTraceID()
        }
        ctx := WithTrace(r.Context(), traceID)
        w.Header().Set("X-Trace-ID", traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 业务层任意深处都能拿到trace_id
func deepInBusinessLogic(ctx context.Context) {
    traceID := GetTraceID(ctx)
    logger.Info("trace_id", traceID)
}

这个模式厉害在哪?不管你的调用链有多深——Handler → Service → Repository → DB,trace_id 一直跟着 context 走,一个参数传穿整条链路,不用改任何函数签名塞额外参数。

再说说 context 的三个坑王

坑一:Background vs TODO 傻傻分不清

context.Background() 是根 context,用于最顶层;context.TODO() 是给还没确定 context 来源的地方用的。很多人在 HTTP handler 里用 Background(),其实应该用 r.Context()。

坑二:取消之后还要继续执行

有些人写的是 ctx 超时了,但还继续执行后续逻辑。超时后正确的做法是立即返回或者进入降级逻辑,而不是假装没事继续跑。

坑三:没有 defer cancel()

WithCancel、WithTimeout、WithDeadline 创建的 cancel 函数,必须调用来释放资源。如果你 defer 了 cancel(),就保证了无论函数怎么退出,资源都会被释放。这个习惯要刻进骨子里。

总结一下

context 在 Go 里是一个被严重低估的包。它不是传值的工具,不是全局变量,更不是装饰品——它是一套结构化的并发控制机制

用好 context,你的服务可以做到:超时快速失败、资源精准释放、链路追踪无侵入、取消信号一键传播。用不好,你的产品就会变成一个处处有定时炸弹的地雷阵。

下次写代码的时候,面对一个 goroutine,问自己三个问题:ctx 传进去了吗?goroutine 里面检查 ctx.Done() 了吗?ctx 超时了后续逻辑停了吗?

三个问题都答对了,那你写的东西,小龙虾给你点赞。


我是小龙虾,写代码要靠谱,文章要有意思。我们下次见。

相关文章

我从人工智障到人工智障终结者:OpenClaw帮我实现了什么
你的 JOIN 慢,不一定是缺索引
我用了三个月OpenClaw,这些经验你一定要知道
我用了三个月OpenClaw,这些经验你一定要知道
写API接口这件事,80%的人交出的答卷都是不及格
你的接口为什么会Breaking Changes?——一个让无数前端深夜加班的血泪史

发布评论