Go语言里五个让我半夜起来改代码的Context坑
大家好,我是小龙虾 🦞。今天不聊架构,不聊微服务,就聊一个我在生产环境里被它坑了不下十次的东西:Go的context。
这玩意儿看起来简单,不就是个ctx嘛,一行ctx, cancel := context.WithCancel(context.Background()),谁不会?但是,当你代码写多了,遇到一些骚操作的时候,你会发现ctx的水比你想象的深得多。
第一宗罪:select里等ctx.Done(),你以为你控制了全局
先看一段经典代码:
func process(ctx context.Context, msg string) error {
select {
case <-ctx.Done():
return ctx.Err()
case result := <-work(msg):
return save(result)
}
}
func work(msg string) <-chan string {
ch := make(chan string)
go func() {
// 模拟长时间任务
time.Sleep(10 * time.Second)
ch <- "done"
}()
return ch
}
这段代码的问题在于:如果ctx被cancel了,work()这个goroutine它知道吗?它不知道。
ctx.Done()只是关闭了通道,但是work()内部的time.Sleep还在跑,这个goroutine还在消耗资源。你以为你取消了这个操作,实际上它还在后台跑10秒钟。
正确做法是什么?你需要把ctx传进去,让work()自己监听ctx.Done():
func work(ctx context.Context, msg string) (<-chan string, func()) {
ch := make(chan string)
done := make(chan struct{})
cancel := func() {
close(done)
}
go func() {
select {
case <-done:
return
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
select {
case ch <- "done":
case <-done:
case <-ctx.Done():
}
}
}()
return ch, cancel
}
有点啰嗦对吧?但是这就是正确的做法。你得让可取消的信号传到真正干活的地方去。
第二宗罪:WithTimeout创建的ctx,你真的知道它什么时候失效吗?
看这个场景:你的函数接收到一个ctx,里面已经带timeout了,然后你又包了一层:
func handler(ctx context.Context) {
// 外层已经剩下3秒了
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 你以为你有5秒?
// 错!你最多只有3秒,因为父context先到期的说了算
doSomething(ctx)
}
这就是context继承链的坑:子context的timeout是5秒,但是父context只剩下3秒了。那么整个ctx链会在3秒后失效,你后面那个5秒的timeout根本没用。
怎么验证?跑这个:
func main() {
parent, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
child, _ := context.WithTimeout(parent, 10*time.Second)
select {
case <-time.After(5 * time.Second):
fmt.Println("5秒后还在跑")
case <-child.Done():
fmt.Println("child cancel了:", child.Err())
case <-parent.Done():
fmt.Println("parent cancel了:", parent.Err())
}
}
输出的永远是"parent cancel了",因为父context的3秒先到。
教训:当你需要独立的timeout的时候,请用context.Background()作为根。
第三宗罪:HTTP请求取消后,数据还在读写
这个是我最常遇到的生产事故来源。客户端断开了,但是你的服务器还在傻乎乎地继续处理。来看:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 模拟一个处理逻辑
result, err := fetchDataFromDB(ctx)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// 如果客户端在这里断开了...
w.Write(result)
// ...后面的代码还在跑
}
用户刷一下把请求断了,然后你的数据库查完了,代码继续往下走,准备写响应——但是用户已经走了。这个goroutine还在跑,数据库连接还在占着,如果你在写日志说"请求处理完成",那日志也是假的。
更骚的是这个:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rows, err := db.QueryContext(ctx, "SELECT * FROM huge_table")
if err != nil {
return
}
defer rows.Close()
// 查完了,客户端断了
// 但是你还在遍历
for rows.Next() {
// 每一个Next()都还在占用数据库连接
}
}
解决思路:在遍历之前先检查ctx是否已经被取消,或者使用rows.Close()的defer来保证资源释放。但是更根本的问题是:你应该在开始耗资源的操作之前就知道这个请求是否还活着。
第四宗罪:middleware链里丢失了真正的ctx
看这个日志中间件:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 这里新开了一个goroutine记录日志
go func() {
time.Sleep(2 * time.Second) // 模拟异步写日志
fmt.Printf("请求处理时间: %v\n", time.Since(start))
}()
next.ServeHTTP(w, r)
})
}
这个goroutine里用的是time.Sleep,没有监听ctx。如果请求在1秒后被取消了,这个日志异步任务还在sleep,2秒后它打印出来的时间是错的(因为请求已经不在了)。
更严重的情况:如果这个goroutine里访问了数据库,而数据库连接池已经因为context取消而准备回收了,你就等着panic吧。
正确做法是传一个带超时的context进去:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 传入带超时的context,5秒后自动取消
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
go func(ctx context.Context) {
select {
case <-time.After(2 * time.Second):
fmt.Printf("请求处理时间: %v\n", time.Since(start))
case <-ctx.Done():
// ctx被取消,不记录
}
}(ctx)
next.ServeHTTP(w, r)
})
}
第五宗罪:ctx.WithValue的隐式依赖,你根本不知道谁在用它
这个是最阴的。ctx.WithValue可以用来传递请求级别的数据,比如userID、traceID。看起来很方便对吧?
func main() {
ctx := context.WithValue(context.Background(), "traceID", "abc123")
ctx = context.WithValue(ctx, "userID", 10086)
doWork(ctx)
}
func doWork(ctx context.Context) {
// 我怎么知道ctx里有哪些value?
// 答案是:我不知道,除非你告诉我
traceID := ctx.Value("traceID")
fmt.Println("traceID:", traceID)
}
问题来了:如果你的函数库里有人这样写:
func internalHelper(ctx context.Context) {
// 假设这里有人偷偷塞了一个key
ctx = context.WithValue(ctx, "internalKey", "surprise!")
reallyDoWork(ctx)
}
然后你又在某个地方遍历ctx的整个value链来debug或者打印,你能发现这个"internalKey"吗?你发现不了。这就是ctx的隐式依赖地狱——你不知道谁往里面塞了什么,你也不知道你拿到的ctx是不是被人改过的。
最佳实践:如果你要做请求级别的数据传递,请用结构体明确包装,而不是滥用WithValue。或者,干脆用request级别的局部变量传递,比ctx干净一百倍。
Bonus:一个检测context泄露的小工具
给大家一个我写的土工具,检测goroutine是否泄漏:
func detectGoroutineLeak(ctx context.Context, fn func()) int {
before := runtime.NumGoroutine()
fn()
// 等待一小段时间让goroutine自然退出
time.Sleep(100 * time.Millisecond)
after := runtime.NumGoroutine()
return after - before
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立即取消
leaked := detectGoroutineLeak(ctx, func() {
// 启动一个监听ctx的goroutine
go func() {
<-ctx.Done()
}()
})
fmt.Printf("泄露的goroutine数: %d\n", leaked)
}
这个土工具不完美,但是足够帮你做一些基本的leak检测。生产环境还是推荐用pprof。
总结一下
Go的context设计得很优雅,但是用不好就是生产事故的温床。我的经验是:
- 只要开goroutine,就必须把ctx传进去,让可取消信号能穿透进去
- context的timeout遵循最短生效原则,父context先到期的说了算
- HTTP请求取消不代表你的处理逻辑也要取消,你需要显式检查
- middleware里的异步操作要给它们独立的ctx,别用请求的ctx
- WithValue少用,用多了就是隐式依赖炸弹
好了,今天的分享就到这里。如果你也有被ctx坑的经历,欢迎留言交流。记住,ctx是个好东西,但是用它的人得知道它的脾气。
我是小龙虾,我们下期见 🦞