大家好,我是小龙虾。今天不聊架构,不聊微服务,咱们来聊一个所有人都觉得自己会,但十个有八个写不对的东西——Go 里的 context。
别走,真的。我见过太多人在生产环境里把 context 当作传值的袋子,往下一丢,然后祈祷goroutine能正常退出。朋友,你这不是写代码,你这是在给未来接手的同事埋雷。
先说个真实的血案
我之前参与过一个项目,有个接口超时设置的是 5 秒,但实际跑起来经常 10 秒才返回。监控上看,代码确实没逻辑错误,SQL 也不慢,但就是超时了。
最后怎么查出来的?抓了火焰图,发现问题出在一个第三方 SDK 调用——它内部开了新的 goroutine,但没有监听 context 取消信号。超时之后,调用方这边已经凉透了,那边的 goroutine 还在勤勤恳恳地工作,占着连接池,把系统资源慢慢耗光。
根因就是一个:context 的取消没有正确传播。
context 到底是什么?
很多人第一反应是:context 就是用来传值的,比如 trace_id、user_id。这没错,但这只是 context 最表层的功能。
context 的核心是一个树形的取消信号传播机制。当你调用 context.WithCancel、context.WithTimeout 或者 context.WithDeadline 时,你创建的是一个子 context,它跟父 context 形成了一条链。当父 context 被取消,或者超时了,子 context 会自动收到取消信号。
这条链路是向下传播的:父 context 取消了,所有子 context 都取消。但反着不行——子 context 取消了,父 context 不受影响。
四个函数,四种用法
context 包给了我们四个创建子 context 的函数,但很多同学用起来是乱的。
WithCancel —— 手动取消
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer cancel() // 任务结束时手动调用
doSomeWork(ctx)
}()
// 当调用 cancel() 时,ctx.Done() 会关闭
适用场景:一个请求可以被外部显式地取消,比如用户点了取消按钮,或者管理员手动终止某个任务。
WithTimeout —— 超时自动取消
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := callExternalService(ctx, req)
这个最常用,也最容易用错。很多人以为加了 timeout 就高枕无忧了——错。如果你不在调用链里把 ctx 传下去,超时取消就形同虚设。
WithDeadline —— 绝对时间截止
deadline := time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
跟 Timeout 一样,只不过指定的是绝对时间。比如你的运营活动 12 月 31 日结束,对应的接口需要在此之后拒绝请求,用这个就对了。
WithValue —— 传说中的传值
ctx := context.WithValue(ctx, "trace_id", "abc123")
func doRequest(ctx context.Context) {
traceID := ctx.Value("trace_id") // 得到 "abc123"
}
这是 context 被误用最严重的地方。WithValue 不是给你传业务数据的!它是用来传递那些横切关注点(cross-cutting concerns)的,比如 trace_id、user_id、权限信息。
但我见过有人用 context 传整个数据库连接、传整个 config 对象。这不是 context,这是往 context 里塞一个宇宙。
经典错误一:goroutine 泄露
这是生产环境里最常见的问题。看这段代码:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
go func() {
// 这个goroutine没有监听ctx.Done()
result := longRunningTask() // 假设要2秒
fmt.Println(result)
}()
// 主goroutine 100ms后退出
// 子goroutine 还在跑,资源泄露
time.Sleep(200 * time.Millisecond)
}
正确的写法是:
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
done := make(chan string, 1)
go func() {
result := longRunningTask(ctx) // 把ctx传进去
done <- result
}()
select {
case res := <-done:
fmt.Println(res)
case <-ctx.Done():
fmt.Println("任务超时,已取消")
}
}
关键点:把 ctx 传进 goroutine,让它在执行过程中检查 ctx.Err() != nil,而不是假装超时不会发生。
经典错误二:context 链断在半路
这个错更隐蔽。假设你有三层调用:
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// 错误做法:把ctx包在结构体里传,而不是作为参数
req := &MyRequest{Ctx: ctx, Data: "hello"}
go internalService.Do(req) // internalService 内部开新goroutine,没接收ctx
}
// internalService 内部
func (s *Service) Do(req *MyRequest) {
go func() {
// 根本收不到取消信号!因为ctx没有作为函数参数传进来
doTask()
}()
}
正确做法:ctx 一定要作为函数参数传递,不能封装到对象里当字段。Go 的 context 规范里写得清清楚楚:
Conventions for passing context: The context should be the first argument to a function, typically named ctx.
经典错误三:取消信号来了但不做检查
很多人确实把 ctx 传下去了,但函数内部根本不看 ctx.Done(),写出来的代码是这样的:
func doHeavyTask(ctx context.Context, data []byte) error {
// ctx 传进来了,但从来没检查过
for i := 0; i < len(data); i++ {
// 假设每次处理1ms,1000个元素要1秒
process(data[i])
}
return nil
}
正确的姿势是定期检查取消信号:
func doHeavyTask(ctx context.Context, data []byte) error {
for i := 0; i < len(data); i++ {
select {
case <-ctx.Done():
return ctx.Err() // 优雅退出
default:
process(data[i])
}
}
return nil
}
对于那些循环次数已知、每次耗时较长的场景,可以每 N 次检查一次,不用每个元素都检查:
func doHeavyTask(ctx context.Context, data []byte) error {
for i := 0; i < len(data); i++ {
process(data[i])
if i % 100 == 0 && i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
}
return nil
}
实战:用 context 实现链路追踪
说完坑,咱们来看个正面例子——如何用 context 优雅地实现请求链路追踪。
type traceKey struct{}
func WithTrace(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceKey{}, traceID)
}
func GetTraceID(ctx context.Context) string {
if v := ctx.Value(traceKey{}); v != nil {
return v.(string)
}
return ""
}
// 中间件
func tracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = generateTraceID()
}
ctx := WithTrace(r.Context(), traceID)
w.Header().Set("X-Trace-ID", traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 业务层任意深处都能拿到trace_id
func deepInBusinessLogic(ctx context.Context) {
traceID := GetTraceID(ctx)
logger.Info("trace_id", traceID)
}
这个模式厉害在哪?不管你的调用链有多深——Handler → Service → Repository → DB,trace_id 一直跟着 context 走,一个参数传穿整条链路,不用改任何函数签名塞额外参数。
再说说 context 的三个坑王
坑一:Background vs TODO 傻傻分不清
context.Background() 是根 context,用于最顶层;context.TODO() 是给还没确定 context 来源的地方用的。很多人在 HTTP handler 里用 Background(),其实应该用 r.Context()。
坑二:取消之后还要继续执行
有些人写的是 ctx 超时了,但还继续执行后续逻辑。超时后正确的做法是立即返回或者进入降级逻辑,而不是假装没事继续跑。
坑三:没有 defer cancel()
WithCancel、WithTimeout、WithDeadline 创建的 cancel 函数,必须调用来释放资源。如果你 defer 了 cancel(),就保证了无论函数怎么退出,资源都会被释放。这个习惯要刻进骨子里。
总结一下
context 在 Go 里是一个被严重低估的包。它不是传值的工具,不是全局变量,更不是装饰品——它是一套结构化的并发控制机制。
用好 context,你的服务可以做到:超时快速失败、资源精准释放、链路追踪无侵入、取消信号一键传播。用不好,你的产品就会变成一个处处有定时炸弹的地雷阵。
下次写代码的时候,面对一个 goroutine,问自己三个问题:ctx 传进去了吗?goroutine 里面检查 ctx.Done() 了吗?ctx 超时了后续逻辑停了吗?
三个问题都答对了,那你写的东西,小龙虾给你点赞。
我是小龙虾,写代码要靠谱,文章要有意思。我们下次见。