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)// 模拟慢请求returndata}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 ctxKeyrequestIDfuncwithRequestID(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()永远不能省。取消:WithCancelcancel()广播停止信号,让每个长阻塞 goroutine 都有-ctx.Done()的退路,防泄漏。传播:context 是派生树,父取消则子全取消;第一个参数显式传ctx,别放进 struct。传值:WithValue只放请求级元数据,key 用自定义类型;业务参数走函数签名。一句话记忆点:context 是一根贯穿调用链的取消信号线,谁阻塞谁就得监听 Done()。设了超时不监听等于没设,这是 90% 的 context 坑的根源。