Go 的 context 包:超时、取消与传值的正确用法
写 Go 服务时,context.Context几乎无处不在:HTTP handler 第一个参数是它、数据库查询要传它、调用下游 gRPC 也要传它。但很多人只是「函数签名要求了就传一个ctx」,并不清楚它到底在管什么。结果就是:超时设了不生效、goroutine 悄悄泄漏、用 context 存了不该存的东西。这篇把 context 的三件事——超时、取消、传值——讲清楚。
context 到底解决什么问题
一次请求进来,往往会 fan-out 成好几个 goroutine:查数据库、调下游服务、读缓存。如果客户端断开了,或者整体超时了,这些 goroutine 应该能被统一通知「别干了,收工」。context 就是这根贯穿整条调用链的「取消信号线」。
它的核心是一个Done()channel:一旦 context 被取消或超时,这个 channel 会被关闭,所有监听它的 goroutine 都能立刻感知到。
超时:别再用裸 time.After
先看一个没用 context 的朴素写法。假设要调一个可能很慢的下游:
funcfetchSlow()string{time.Sleep(3*time.Second)// 模拟慢请求return"data"}funchandler(){result:=fetchSlow()// 卡死 3 秒,没法中途放弃fmt.Println(result)}问题很明显:调用方完全没有喊停的能力。加上 context 超时:
funcfetchSlow(ctx context.Context)(string,error){done:=make(chanstring,1)gofunc(){time.Sleep(3*time.Second)done<-"data"}()select{caseres:=<-done:returnres,nilcase<-ctx.Done():// context 超时/被取消,立刻返回,不等那 3 秒return"",ctx.Err()// 超时会返回 context.DeadlineExceeded}}funchandler(){// 给整个操作 1 秒预算ctx,cancel:=context.WithTimeout(context.Background(),time.Second)defercancel()// 关键:无论如何都要调 cancel 释放资源res,err:=fetchSlow(ctx)iferr!=nil{fmt.Println("失败:",err)// 1 秒后打印 context deadline exceededreturn}fmt.Println(res)}两个要点:
defer cancel()不能省。WithTimeout会起一个内部定时器,cancel 负责回收它。不调用会导致定时器泄漏,go vet也会警告。- 真正响应超时的是被调函数里的
select { case <-ctx.Done(): }。光在顶层设了超时、底下的函数不监听 ctx.Done(),超时就是个摆设。
取消:让下游 goroutine 跟着停
WithCancel用于手动取消。典型场景:并发跑多个任务,只要有一个出错,其余的立刻停。
funcsearch(ctx context.Context,enginestring)error{select{case<-time.After(time.Duration(rand.Intn(500))*time.Millisecond):fmt.Println(engine,"完成")returnnilcase<-ctx.Done():// 收到取消信号,提前退出,不再浪费资源fmt.Println(engine,"被取消:",ctx.Err())returnctx.Err()}}funcmain(){ctx,cancel:=context.WithCancel(context.Background())defercancel()engines:=[]string{"A","B","C"}errCh:=make(chanerror,len(engines))for_,e:=rangeengines{gofunc(enginestring){errCh<-search(ctx,engine)}(e)}// 拿到第一个结果就取消其余的<-errChcancel()// 广播取消,其他 goroutine 的 ctx.Done() 会被触发time.Sleep(100*time.Millisecond)// 演示用,实际用 WaitGroup 等它们退出}cancel()一调,所有从这个 ctx 派生出来的 goroutine 的Done()channel 全部关闭,监听它的地方立刻收到信号。这就是避免 goroutine 泄漏的关键:每个可能长时间阻塞的 goroutine,都要有一条case <-ctx.Done()的退路。
context 的传播:派生链
context 是一棵树。WithTimeout、WithCancel、WithValue都是从父 context 派生子 context。父被取消,所有子孙一起被取消;子超时,不影响父和兄弟。
root:=context.Background()ctx1,cancel1:=context.WithTimeout(root,5*time.Second)defercancel1()// ctx2 继承 ctx1 的 5 秒 deadline,同时自己再收紧到 2 秒,取小的那个生效ctx2,cancel2:=context.WithTimeout(ctx1,2*time.Second)defercancel2()规矩:context 作为函数的第一个参数显式传递,命名为ctx,不要把它塞进 struct 字段长期持有。
传值:能不用就不用
context.WithValue可以在 context 里挂键值对,沿调用链传递。常见于传 request ID、trace ID 这类请求级元数据:
typectxKeystring// 用自定义类型做 key,避免和别的包撞constrequestIDKey ctxKey="requestID"funcwithRequestID(ctx context.Context,idstring)context.Context{returncontext.WithValue(ctx,requestIDKey,id)}funcgetRequestID(ctx context.Context)string{// 类型断言,取不到返回空ifid,ok:=ctx.Value(requestIDKey).(string);ok{returnid}return""}用 WithValue 的两条铁律:
- key 必须用自定义类型(如上面的
ctxKey),不要用裸string。否则不同包用了同名 string key 会互相覆盖。 - 只传请求作用域的元数据,不要拿它当函数传参的通道。业务参数(用户 ID 要处理的订单、配置项)老老实实走函数签名,别藏进 context。否则代码里到处
ctx.Value(...),类型不安全又难追踪。
小结
- 超时:
WithTimeout设预算,但真正生效靠被调函数里select { case <-ctx.Done() }主动响应;defer cancel()永远不能省。 - 取消:
WithCancel+cancel()广播停止信号,让每个长阻塞 goroutine 都有<-ctx.Done()的退路,防泄漏。 - 传播:context 是派生树,父取消则子全取消;第一个参数显式传
ctx,别放进 struct。 - 传值:
WithValue只放请求级元数据,key 用自定义类型;业务参数走函数签名。
一句话记忆点:context 是一根贯穿调用链的取消信号线,谁阻塞谁就得监听 Done()。设了超时不监听等于没设,这是 90% 的 context 坑的根源。