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还有哪些坑?说出来让大家一起避避雷。