Go语言的context:那些年我踩过的坑,比你踩过的键盘还多

2026-08-20 12 0

Go语言的context:那些年我踩过的坑,比你踩过的键盘还多

写Go代码的人,没有不知道context的。但我敢说,大多数人用context的方式,和我当年一样——就是把它当成一个“传递请求作用域数据”的工具,塞进去一些request_id、user_id,然后就没了。直到有一天,你的服务开始出现诡异的超时、goroutine泄漏、测试跑着跑着就卡死,你才会意识到:context这家伙,水深得很。

今天,我就来聊聊context那些容易被忽视的坑,保证你看完之后忍不住想说“我怎么没早点看到这篇文章”。


坑一:context.WithValue不是给你存数据的

很多人初学context,看到WithValue,觉得这不就是个map嘛,往里存点东西多好:request_id、trace_id、user_id,一股脑儿塞进去。这在小型项目里确实没问题,但一旦你的服务上了规模,问题就来了。

WithValue的底层是链表,查找是O(n)的。每次context.Value()调用,都会顺着链表从头找到尾。你要是存了十几二十个key-value,每次RPC调用里又调用个几十次,那性能损耗可就观了。

// 你的代码可能长这样
func handler(c context.Context) {
    traceID := c.Value("trace_id").(string)
    userID := c.Value("user_id").(string)
    // 每个请求都这么取,爽吗?
}

更坑的是,WithValue没有任何类型安全可言。你传进去的是string,取出来要是没做类型断言,程序直接panic。这种bug在线上发现,那叫一个酸爽。

正确的做法是:对于“请求级别的数据”,比如trace_id、user_id,用context存没问题,但别滥用。对于“配置类的数据”,比如超时时间、数据库连接池,还是显式传参更靠谱。


坑二:context.WithCancel不是你以为的“取消”

context.WithCancel,听名字是取消一个操作。但它的实际行为是:通知所有监听这个context的子操作“可以停了”。这听起来很正常对吧?但问题在于,它不会等待子操作真正停下来。

func worker(ctx context.Context) {
    go func() {
        select {
        case <-ctx.Done():
            fmt.Println("子goroutine收到取消信号")
        }
    }()
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go worker(ctx)
    cancel()
    // 主函数在这里直接返回,不管子goroutine是否真的执行完了
    fmt.Println("main函数退出了")
}

很多人在做优雅关闭的时候,会犯这个错误:调用cancel(),然后直接退出程序。结果呢?子goroutine可能还在跑,某些资源(比如数据库连接)可能还没释放。

正确姿势是用sync.WaitGroup配合context,等所有goroutine都真正退出了再走:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    var wg sync.WaitGroup
    
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go worker(ctx, &wg)
    }
    
    cancel() // 发送取消信号
    wg.Wait() // 等待所有worker真正退出
    fmt.Println("所有资源已释放,可以安全退出了")
}

坑三:context超时和deadline,你能分清吗?

context.WithTimeout和context.WithDeadline,这两个家伙长得很像,区别在于:WithTimeout是“过多久之后取消”,WithDeadline是“在某个具体时间点取消”。

表面上看你用哪个都行,但想象这个场景:你的服务需要对外提供一个HTTP接口,接口的超时时间是2秒。你用了WithTimeout(2 * time.Second)。听起来没问题对吧?

但如果你的服务部署在两个不同的机房,网络延迟不一样;或者某个下游服务的deadline是固定的,而你这边加了一个不确定的处理时间,那你的WithTimeout可能就会在下游服务还在处理的时候就把请求取消了。

更稳妥的做法是:优先使用WithDeadline,并确保你的超时设置不会短于下游服务的最短处理时间。或者,用一个传播的context,让deadline沿着调用链往下传递,而不是在每一层都重新定义超时。

// 反面教材
func badExample(ctx context.Context) {
    // 每层都重新定义超时
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()
    callBackendService(ctx)
}

// 更好的做法
func betterExample(ctx context.Context) {
    // 检查是否还有足够的时间
    select {
    case <-ctx.Done():
        return // context已经超时,直接退出
    default:
    }
    // 在剩余时间内调用,给点buffer
    ctx, cancel := context.WithTimeout(ctx, 400*time.Millisecond)
    defer cancel()
    callBackendService(ctx)
}

坑四:context在HTTP请求里的那些破事儿

Go的net/http包里,Request有个Context方法。当你启动一个HTTP服务器,每个请求的context会在请求处理完成时自动取消。但这个context到底什么时候取消,很多人其实没搞清楚。

比如,你写了这样的代码:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    result := make(chan string)
    go func() {
        // 这个goroutine会一直跑到完成为止
        result <- heavyComputation(ctx)
    }()
    
    select {
    case res := <-result:
        w.Write([]byte(res))
    case <-ctx.Done():
        // 请求超时了
        w.WriteHeader(http.StatusRequestTimeout)
    }
}

问题是:如果客户端断开了连接,ctx会被取消,但你的goroutine还在跑!heavyComputation可能还在疯狂计算,完全不知道外面的世界已经变了天。这还不是最要命的——如果你在这个goroutine里持有数据库连接或者其他资源,那这些资源就泄漏了。

正确的做法是:要么在goroutine开始的时候就用一个新的context,要么确保goroutine内部能够感知到ctx的变化并及时退出。

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // 正确做法:让goroutine也监听ctx
    result := make(chan string, 1)
    go func(ctx context.Context) {
        // goroutine内部检查ctx
        for {
            select {
            case <-ctx.Done():
                fmt.Println("goroutine检测到请求已取消,提前退出")
                return
            default:
                // 继续处理,但定期检查取消信号
            }
        }
    }(ctx)
    
    select {
    case res := <-result:
        w.Write([]byte(res))
    case <-ctx.Done():
        fmt.Println("请求超时或已取消")
    }
}

坑五:context的context,一层层传下去?

调用链一长,context就一层套一层。这是正常的,但很多人传着传着就忘了自己传的是什么。比如:

func A(ctx context.Context) {
    ctx = context.WithValue(ctx, "key_from_a", "value_a")
    B(ctx)
}

func B(ctx context.Context) {
    ctx = context.WithValue(ctx, "key_from_b", "value_b")
    C(ctx)
}

func C(ctx context.Context) {
    // 这个时候ctx里有几个value?key_from_a还在吗?
    fmt.Println(ctx.Value("key_from_a")) // 还在!
}

这看起来没问题,WithValue返回的是一个新context,不会影响原来的。所以C能取到A塞进去的值。但问题是:如果你的团队里有个人不懂这个,在某个底层函数里直接用原始的context重新赋值,而不是用新的context,继续传下去,那整个链路就断了一截。

func badB(ctx context.Context) {
    // 这个操作会丢失A传过来的context!
    ctx = context.Background() // 千万别这么干!
    ctx = context.WithValue(ctx, "key_from_b", "value_b")
    C(ctx)
}

这种bug特别隐蔽,因为代码编译完全正常,单元测试也不一定能发现,只有在特定调用链下才会出问题。所以,在review代码的时候,一定要注意:context只增不减,别让任何人把它“重置”。


总结:context的正确打开方式

说了这么多坑,其实context本身是个好东西。关键是要记住几条原则:

  • context传递的是“取消信号”和“截止时间”,不是万能的数据容器。请求级别的小数据可以用WithValue,但别滥用。
  • 取消要配合WaitGroup等其他同步机制,别假设子操作会立即停止
  • 超时和deadline要统一规划,避免在调用链中途重新定义导致时间预算混乱
  • HTTP请求的context会在请求结束后自动取消,但goroutine不会,要让goroutine也监听取消信号
  • context只增不减,永远不要用context.Background()替换正在使用的context

context看起来简单,用好其实不容易。希望这些踩坑经验能帮你少走些弯路。毕竟,代码写得好不好,就看踩坑多不多。祝各位的goroutine都能优雅地退出,不留下一片泄漏的内存。

有问题欢迎留言讨论,你觉得context还有哪些坑?说出来让大家一起避避雷。

相关文章

你的REST API正在默默杀人:五个让前端想砍死你的设计
你以为 ORDER BY 很快?我用一次血案告诉你什么叫Too Young
写API接口这事儿,比你想象的坑多多了
写API接口这事儿,比你想象的坑多多了
为什么你的服务总是莫名其妙地挂掉?可能不是因为代码烂,而是因为你不懂错误处理
SQL查询优化:为什么你的数据库慢得像在爬?

发布评论