三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Context包:取消、超时与值传递

Context包:取消、超时与值传递

第26篇 Context包,取消、超时与值传递

摘要:Context是Go并发控制的核心组件,负责取消信号传播、超时控制和请求作用域值传递。本文从一次雪崩故障说起,讲透Context的四种派生方式和常见误用。

一次请求雪崩

去年双十一压测,我们的订单服务突然整片超时。上游网关的请求超时设了3秒,但下游库存服务有个慢查询偶尔要8秒。正常情况下单个慢请求不该拖垮整个服务,问题出在我们的处理逻辑没有做超时控制。

当时每个HTTP请求会起几个goroutine去并行查库存、查物流、查优惠券。如果库存查询卡住了,这个请求的goroutine就一直挂着等下游响应。上游网关3秒超时断开连接,但我们这边的goroutine还在傻等,占着连接池不释放。请求量一上来,连接池耗尽,整个服务雪崩。

根因就是缺少超时控制,而且即使上游取消了,下游的goroutine也收不到信号,还在白干活。Context 包就是来解决这个问题的。

Context的四种派生方式

Context 的核心思路是树形派生。所有Context从一个根节点派生出来,父节点取消时,所有子节点自动取消。

packagemainimport("context""fmt""time")funcmain(){// Background是根Context,通常在main或顶层使用ctx:=context.Background()// 1. WithCancel 手动取消cancelCtx,cancel:=context.WithCancel(ctx)// 用完必须调用cancel释放资源,defer是标准写法defercancel()// 2. WithTimeout 超时自动取消// 3秒后cancelCtx2会自动取消timeoutCtx,cancel2:=context.WithTimeout(ctx,3*time.Second)defercancel2()// 3. WithDeadline 在指定时间点取消// 和WithTimeout类似,区别是传绝对时间而非相对时长deadlineCtx,cancel3:=context.WithDeadline(ctx,time.Now().Add(5*time.Second))defercancel3()// 4. WithValue 携带请求作用域的值// 把traceID放进context,下游函数可以取出来打日志valCtx:=context.WithValue(ctx,"traceID","req-abc-123")// 四种派生方式可以随意组合嵌套_=cancelCtx_=timeoutCtx_=deadlineCtx_=valCtx fmt.Println("四种Context派生方式创建完成")}

这四种方式可以嵌套派生。比如先 WithTimeout 创建超时控制,再在它基础上 WithValue 携带请求信息,形成一个Context链。父节点取消时信号会沿着链向下传播。

超时控制实战

来看一个实际场景,HTTP处理函数里并行请求多个下游服务,每个都要受超时控制。

packagemainimport("context""fmt""net/http""time")// fetchUserInfo 模拟调用下游服务获取用户信息// 第一个参数必须是context,这是Go的惯例funcfetchUserInfo(ctx context.Context,userIDint)(string,error){// 用select监听ctx.Done和业务逻辑// ctx超时或取消时,Done通道关闭,select立即走这个caseselect{case<-time.After(2*time.Second):// 模拟2秒的处理耗时returnfmt.Sprintf("user-%d-info",userID),nilcase<-ctx.Done():// 超时或被取消return"",ctx.Err()// 返回context的错误}}funcuserHandler(w http.ResponseWriter,r*http.Request){// 给整个请求设置3秒超时// 从请求开始计时,到3秒自动取消所有派生的子contextctx,cancel:=context.WithTimeout(r.Context(),3*time.Second)defercancel()// 确保资源释放// 并行发起下游调用typeresultstruct{valstringerrerror}ch:=make(chanresult,1)// 带缓冲,避免goroutine泄漏gofunc(){val,err:=fetchUserInfo(ctx,1001)// 传入带超时的ctxch<-result{val,err}}()// 等待结果或整体超时select{caseres:=<-ch:ifres.err!=nil{http.Error(w,res.err.Error(),http.StatusInternalServerError)return}fmt.Fprintf(w,"got %s\n",res.val)case<-ctx.Done():// 整体超时触发http.Error(w,"request timeout",http.StatusGatewayTimeout)}}funcmain(){http.HandleFunc("/user",userHandler)fmt.Println("服务启动在 :8080")http.ListenAndServe(":8080",nil)}

关键点在于 fetchUserInfo 里的 select。即使下游服务卡住,只要 ctx 超时,Done 通道关闭,goroutine 就能及时退出,不会泄漏。这就是 Context 级联取消的威力,一个超时信号能沿着调用链传播到最底层。

独家踩坑,WithCancel不调用cancel会泄漏

这个坑我见过很多次,包括我自己也踩过。WithCancel 返回的 cancel 函数如果不调用,对应的 context 和它派生的所有子 context 都不会被垃圾回收,因为它们被内部的定时器或 propagateCancel 机制引用着。

// 错误示范,漏掉cancelfuncbadHandler(ctx context.Context){// 每次调用都派生新context,但从不cancel_,cancel:=context.WithCancel(ctx)_=cancel// 忘记调用,或者提前return了// goroutine泄漏,context引用链不会被回收}

更隐蔽的是 WithTimeout,它内部启动了一个定时器。即使请求正常返回,如果不调用 cancel,定时器会一直跑到超时时间才释放。在高QPS服务里,这会导致定时器堆积,内存缓慢上涨。

// 正确写法,defer cancel是铁律funcgoodHandler(ctx context.Context){ctx,cancel:=context.WithTimeout(ctx,3*time.Second)defercancel()// 无论正常返回还是panic都会执行// ... 业务逻辑}

有一次排查线上内存泄漏,pprof 看到大量 time.Timer 对象,最后定位到就是一处漏了 cancel。加上 defer cancel 之后内存曲线立刻平稳了。从那以后我定了个规矩,代码评审时只要看到 context.With 开头的函数,第一件事就是确认下面有没有 defer cancel。

值传递的边界

WithValue 经常被滥用,有人拿它当全局变量传递业务参数,这其实违背了设计初衷。Context 里的值应该是请求作用域的元数据,比如 traceID、租户ID、认证信息,不该传业务实体。

// 用自定义类型做key,避免冲突typectxKeystringconst(keyTraceID ctxKey="traceID"keyTenant ctxKey="tenant")// 封装存取函数,类型安全funcwithTraceID(ctx context.Context,idstring)context.Context{returncontext.WithValue(ctx,keyTraceID,id)}functraceID(ctx context.Context)string{v,_:=ctx.Value(keyTraceID).(string)// 类型断言returnv}

用自定义类型做 key 能避免和其他包冲突。裸字符串做 key 是常见的坑,两个包都用 “id” 做 key,值就会互相覆盖,排查起来非常痛苦。

对比分析

取消控制有两种方式,Context 和裸 channel,来对比一下。

维度Context裸channel
级联取消自动传播到所有子节点需手动层层传递
超时控制内置WithTimeout要自己管time.After
值传递WithValue内置要额外加字段
错误信息ctx.Err()区分原因只有关闭信号
标准化全生态通用各写各的

Context 的优势在于标准化和自动级联。裸 channel 在简单场景更直观,但一旦涉及多层调用和超时组合,自己管理成本很高,还容易漏。

总结预告

Context 是 Go 并发的神经系统,取消信号沿着调用树自动传播,超时控制内置且准确。记住两条铁律,一是任何派生的 context 都要 defer cancel,二是 WithValue 只传请求元数据别传业务参数。

下一篇进入并发模式篇,先讲 Worker Pool,这是控制并发度最实用的模式。

← 返回列表