1. Context 到底是什么:从一个线上事故说起
干 Go 的同学应该都见过这么一段报错:context deadline exceeded。我最早遇到它是在一个 HTTP 服务里,下游 RPC 偶发超时,然后整个请求链路像多米诺骨牌一样连环超时,日志里满满全是这一句。当时第一反应是网络问题,查了半天发现根本不是,问题出在我自己写的代码上没有正确传递和使用 context。
打个比方,context 就是一次请求从进入到离开全程跟着的一本“任务手册”。手册上记着三件事:这个任务什么时候该停止(取消信号)、这个任务最晚什么时候必须完成(截止时间)、这个任务随身带了哪些"钥匙"(元数据)。goroutine 想看手册就直接拿 context 参数指过去,谁也不能另起炉灶丢掉手册,丢了就断了传递链。
Context 的核心设计目标就一个:让上游能够控制下游。上游超时了、用户断开了、服务要下线了,下游每个环节都得知道,并且立刻停止干活释放资源。没有这套机制,goroutine 会一直跑下去,数据库连接不释放,临时文件不清理,服务迟早被拖垮。
这套东西在 Go 里其实很轻,核心就是一个接口context.Context,加上几个创建派生 context 的函数。但越轻的东西越容易被用歪,网上关于 context 的争议基本都是 misuse 引发的,比如把context.WithValue当全局变量用、拿着context.Background()到处传、忘了调用 CancelFunc 导致泄漏等等。
这篇我会先把 context 的核心机制讲透,然后直接给出一套能落地的传参、取消、超时控制方案,最后配上高频面试题和踩坑实录,希望看完你能直接把这套东西用进项目里。
2. 四个创建函数的底层逻辑与选型依据
2.1 context.Background 和 context.TODO:一切开始的地方
任何 context 链条都有源头,源头只能是context.Background()或context.TODO()。这俩函数返回的都是同一个空的 Context 实现,没有任何值、不绑定取消信号、没有截止时间,代码里看内部其实是一个emptyCtx类型,本质上就是一空壳。
区别纯粹是语义上的:
Background表示"这个 context 就是根,没有父 context,我明确要在程序顶层使用它",一般出现在main函数、HTTP server 的入口、init里,或者测试用例里。TODO表示"我现在还没想好该传什么 context 进来,先用它占个位",理论上应该在代码 review 中全部消灭掉,因为它是"欠债"的标记,说明这里存在未完成的改造。
这两个函数很多人混着用,其实问题不大,但语义不清晰的项目后续维护起来特别累。我的习惯是:新项目里强制go vet扫不到就不要管,但 code review 里看到context.TODO()一定会追问什么时候改成真正的 Context。
这里有个关键点:不要自己在代码里造"根 context"。有人图省事直接context.WithTimeout(context.Background(), 5*time.Second)当作全局变量封装出来到处用,这是错误的。根 context 必须从入口一路传下来,中间每层调用根据自己的职责决定是否衍生新的子 context,而不是全局共用一个。
2.2 context.WithCancel:手动取消信号的核心
context.WithCancel是最基础、也最容易被忽略的一个函数。签名很简单:
ctx, cancel := context.WithCancel(parent)调用之后拿到一个新的子 context 和一个cancel函数。子 context 的取消会和父 context 联动,父取消,子必取消;子取消,父不会受影响。
这个联动机制在实现上是一个树状结构,每个子 context 内部维护一个parent指针,cancel调用时会把取消信号往所有"后代"广播。广播的底层实现是关闭一个 channel,为什么用关闭而不是往 channel 里写东西?因为关闭 channel 天然具备"只发生一次、所有监听者都能收到"的特性,用sync.Once保证只关闭一次,就能完美避免重复取消带来的并发问题。
实际工程里最常见的错误就是:调用了WithCancel但忘了调用cancel。这会导致子 context 泄露,它关联的 goroutine 永远收不到取消信号,一直悬挂在内存里。如果你用pprof看过 goroutine 数量,会发现这类问题藏都藏不住。后面第 5 节我会具体讲怎么排查。
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 永远不要忘记取消 go worker(ctx) time.Sleep(3 * time.Second) cancel() // 手动触发取消 time.Sleep(time.Second) }注意defer cancel()的位置。放在context.WithCancel后面马上defer,这是惯例,千万不要等业务逻辑跑完才想起 cancel。
2.3 WithTimeout 和 WithDeadline:超时控制的两副面孔
context.WithTimeout和context.WithDeadline本质上是同一件事,文档里写着 WithTimeout 内部就是调用的 WithDeadline:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout)) }区别在于参数表达方式:一个是"从现在起多久之后超时"(相对时间),一个是"绝对截止时间点"(绝对时间)。比如调用下游接口最多给 2 秒,用WithTimeout;清理任务必须在凌晨 3 点前完成,用WithDeadline。
用WithDeadline有个好处是它可以拿到"失效时刻",如果你的逻辑需要判断"我再等 500ms 就真的来不及了",可以调用Deadline()方法获取绝对时间,然后自行对比。很多服务端框架的熔断器就是这么干的,提前算好剩余时间决定是否直接短路请求。
超时触发时会自动调用内部的 cancel,所以很多人写代码时不接收CancelFunc,直接ctx, _ := context.WithTimeout(...)。这样可以吗?运行上没问题,因为定时器到点会自动取消,但我建议还是要接收并defer cancel()。原因是一个函数可能在超时之前就返回了,比如下游提前返回了结果、或者参数校验失败直接return,此时定时器还没有触发,你不主动 cancel,定时器会一直挂在堆里,直到自然到期,这个窗口期在高并发下会积压大量定时器对象,白白浪费内存。
看一个实际例子:
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() result, err := queryDatabase(ctx, "select ...") if err != nil { log.Printf("query failed: %v", err) return }这里无论queryDatabase是成功还是超时,函数返回前defer cancel()都会执行,内部定时器被及时回收。
2.4 WithValue:传值的正确姿势与隐蔽风险
context.WithValue常用于在调用链上传递请求级别的元数据,比如请求 ID、用户 ID、trace ID。它的实现就是在一棵链表树上挂个 key-value 节点,查询时从叶子往根方向逐层找。这就带来一个特性:子 context 能查到父亲的 key,但查不到兄弟节点的,同一层节点之间是隔离的。
官方文档对 key 有两个硬性要求:
- key 必须是自定义类型,不能用基础类型。原因是多个包同时往 context 里塞 string 类型的 key,很容易撞车。常规做法是定义一个私有类型:
type requestIDKey struct{} func WithRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, requestIDKey{}, id) } func RequestIDFrom(ctx context.Context) (string, bool) { id, ok := ctx.Value(requestIDKey{}).(string) return id, ok }这种写法把 key 封装在函数内部,外部根本拿不到,你不 open source 这个包,别人就永远访问不到这个 key,自然就不会撞车。
- value 必须是线程安全的。因为 context 会被多个 goroutine 同时读取,
Value方法本身只读,是不加锁的,如果 value 是一个可变的 map,并发读写就会 panic。
再说说争议点。很多人把WithValue滥用成全局依赖注入,什么 DB 连接、配置文件全部塞进去,这是反模式。context 传值应当仅用于"请求生命周期内唯一不变且横跨多个调用层级"的数据,比如 trace ID、device ID、user ID。凡是能在编译期确定依赖的,都应该用参数显式传,让类型系统帮你兜底。
MySQL 8 驱动和 gRPC 的 metadata 都是典型的 WithValue 用法,自己写中间件时会经常用到,但业务代码里能不用就不用,别让 context 变成另一个全局变量。
3. 项目里的 Context 长链路:从 HTTP 入口到底层
3.1 HTTP Server 层:从 r.Context() 开始
Go 的net/http包从 1.7 开始就把 context 织进了请求生命周期。每个http.Request自带一个 Context,请求结束、连接断开、客户端取消时这个 context 会自动取消。服务端处理器里拿到的就是它:
func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 后续所有调用都传 ctx data, err := fetchData(ctx, r.URL.Query().Get("id")) if err != nil { if errors.Is(err, context.Canceled) { // 客户端已经断开,不用写 response 了 return } http.Error(w, "internal error", http.StatusInternalServerError) return } _ = data }这里有一个很容易被忽略的细节:下游函数必须处理context.Canceled和context.DeadlineExceeded的错误。客户端断开时,r.Context().Err()会变成context.Canceled;超时时则变成context.DeadlineExceeded。不判断就直接http.Error写响应,浏览器那边连接已经断了,写也白写,日志里反而多一堆 misleading error。
web 框架里,Gin 的c.Request.Context()、Fiber 的c.UserContext()底层拿到的都是这同一个请求级别的 context,只是在外层做了封装。有的框架会自动把框架内部的值塞进去,比如 Fiber 的实现里你用UserContext得到的是一个包含路由参数的 context,所以框架使用者只需要保证"拿到 ctx 之后继续向下传"这一条主线不错位就行。
3.2 中间层:跨服务调用与 gRPC 的透传
服务端之间的调用,context 要跨网络传输就复杂了。以 gRPC 为例,grpc.Dial返回的连接对象在建立时如果带上了grpc.WithBlock或超时 context,就能在拨号阶段就掐表。每次 RPC 调用时,你都要把当前 context 传进去:
conn, err := grpc.NewClient("service:8080", grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return err } defer conn.Close() client := pb.NewUserServiceClient(conn) ctx, cancel := context.WithTimeout(baseCtx, 800*time.Millisecond) defer cancel() resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: id})gRPC 内部会把 context 的 deadline 信息编码到 HTTP/2 的 header 里发给服务端,服务端解析出来后,它会自动设置一个同 deadline 的 context 向下传。这就是 context 在分布式链路里"跨进程传播"的机制。特别注意,context.Value中的数据默认不会跨进程传输;如果业务需要传递 trace ID、鉴权信息这类数据,需要注册对应的拦截器(interceptor),或者用 gRPC metadata 显式传递。
我踩过的一个真实坑:业务代码传了一个 2 秒的 context 去调 gRPC,服务端处理很慢,1.9 秒时客户端以为必然超时了,准备取消并返回错误。但服务端实际在 1.95 秒把响应返回了,客户端因为 context 已取消直接丢弃了结果,最后日志显示客户端报context canceled,服务端却显示成功。这个现象在链路追踪里叫"斩断效应",排查方向永远是先看哪一层先触发了 deadline,而不是单看服务端耗时。
3.3 数据处理层:数据库、Redis 与文件 IO
数据库驱动和 Redis 客户端都原生支持 context。database/sql提供的QueryContext、ExecContext、QueryRowContext都会把 context 的取消和 deadline 透传给驱动层。
ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond) defer cancel() rows, err := db.QueryContext(ctx, "SELECT ... FROM orders WHERE user_id = ?", uid) if err != nil { if errors.Is(err, context.DeadlineExceeded) { // 查询超时 } return } defer rows.Close()Crucial point:很多人都只给 RPC 调用加了超时,结果却发现数据库那边也超时了。实际上所有 IO 操作都应该用同一份 context 来控制,这样一条链路共用一个 deadline,不会出现 RPC 花 1.5 秒、数据库花 1.5 秒、总共花了 3 秒才返回的情况。只要你全程传同一个 ctx,每一层的 WithTimeout 都会以"上一层的剩余时间"为上限重新设定时限,链路总耗时永远被最顶层的 deadline 约束。
文件 IO 就不太一样了。标准库的os.File操作是不认 context 的,它没有任何办法中断一个阻塞在 read 上的系统调用。想让文件读取也能响应取消,得自己封一层 goroutine 加 select:
func readFileWithContext(ctx context.Context, path string) ([]byte, error) { type result struct { data []byte err error } ch := make(chan result, 1) go func() { data, err := os.ReadFile(path) ch <- result{data, err} }() select { case <-ctx.Done(): return nil, ctx.Err() case r := <-ch: return r.data, r.err } }这种写法的问题是"被取消的 goroutine 不会真正停止",它还在后台啃文件。如果想更彻底,得配合带超时的 syscall 或者干脆用golang.org/x/sys里的异步 IO 方案,普通项目里上面这种封装够用了,至少能做到调用方不再干等。
4. 面试高频考点:看过这篇才算真懂
4.1 八股文高频题目:原理与底层实现
Go 面试中 context 几乎是必考模块,高频问题包括这些:
Q1:context 的 cancel 机制底层是怎么实现的?
答案是树形结构加 channel 广播。每个 context 在被WithCancel创建时会在父 context 上注册一个child节点,调用cancel()时,它会执行三件事:将自身标记为 canceled、关闭内部的donechannel、遍历所有 child 节点并取消。由于 channel 关闭后读端会立即收到零值,所有监听ctx.Done()的 goroutine 都会被唤醒,实现广播取消。
Q2:context.WithCancel时如果不调 cancel,会产生什么问题?
goroutine 泄漏。父 context 的事件循环、计时器、注册表都不会被清理。尤其是在高并发的服务里,每次请求都创建 context 但不 cancel,会越积越多,内存直线上升。
Q3:context.Context能被比较吗?
Context 接口类型是不能直接比较的,因为它是一个接口,底层指向的具体值可能是不可比较的类型。实际使用中更常见的问题是你需要判断两个 context 是否相同,标准做法是直接比较接口值ctx1 == ctx2,但这在某些情况下会 panic,所以更好的做法是比较内部值。
Q4:WithTimeout 和 WithDeadline 有什么区别?
前文说过:WithTimeout 是相对时间,内部用time.Now().Add(timeout)转换成 deadline;WithDeadline 是绝对时间点。在 RPC 嵌套调用中,子调用使用绝对 deadline 能确保整条链路时间上限一致,而使用相对 timeout 会导致误差叠加。
Q5:这个context deadline exceeded是什么?
它是context.Context调用Deadline()后超时未完成时,Err()方法返回的标准错误值。绝大多数超时错误都源于两个标准错误:context.DeadlineExceeded和context.Canceled。判断时要用errors.Is(err, context.DeadlineExceeded),不要直接==比较。
平时多积累这类问答不是背题,真正把它们放到性能调优和故障排查中才有价值。面试官更想看到的是你讲出了源码级原理,以及你确实踩过坑并且总结出了应对方案。
4.2 深水区追问:并发安全、泄漏排查与误用识别
【并发安全】context 是并发安全的。多个 goroutine 可以同时调用 ctx.Done() 读取取消信号,也可以同时调用 ctx.Value() 读取值,内部不加锁也安全,因为所有字段都是只读的,取消动作靠 channel 关闭实现,具备天然的线程安全语义。
但要警惕 context 携带的 value 本身不一定是并发安全的,比如一个 map 类型的 value,两个 goroutine 同时读写这个 map 就会 panic。context 只保证容器安全,不保证内容安全,value 的线程安全要自己保证。
【泄漏排查经验】真实生产里 goroutine 泄漏的排查套路我是这么做的,分享一套完整流程:
- 访问
/debug/pprof/goroutine?debug=2,导出 goroutine dump 文件。 - 分析 goroutine 栈顶,重点找卡在
select { case <-ctx.Done(): ... }或 channel 等待上的 goroutine。 - 看栈里是谁创建了 context,谁在等待取消信号。
- 翻代码检查所有
context.WithCancel/WithTimeout的调用点,确认cancel()是否一定会被执行到。
一次真实案例:我们的定时任务框架里,每个任务都WithTimeout创建 context,但defer cancel()写在go func内层,而真正该被取消的必须由外层控制,结果任务超时后,内层整个 goroutine 直接卡死,直到进程重启。用 pprof 看下来,goroutine 数量就是一条水平的线往上涨。修正方式是取消职责统一由任务的调度器持有,内层只负责监听。
【误用识别,运维视角的黄金法则】三个红线:
- 不要把
context.WithValue当依赖注入工具,塞 DB、配置、logger 进去。context 的定位是请求级元数据,不是全局容器。 - 不要在结构体里存 context 和 cancel 函数。规范写法是所有函数第一个参数都是 context(当然现在 Go 新版本引入了
context.AfterFunc等,但原则不变)。 - 不要同时用多个超时 context 叠加控制。链路上每层都
WithTimeout会相互影响,造成行为不可预期,最好由最外层统一设定,内层使用context.WithDeadline并用Deadline()动态计算剩余时间。
4.3 结合信号量、中间件与取消传播的复合面试题
还有一类进阶面试题,会结合信号量、中间件这些概念一起考。
比如"如何用带超时的 context 实现并发限制?"这就涉及信号量(semaphore)概念。常见的解法是golang.org/x/sync/semaphore包,配合 context:
sem := semaphore.NewWeighted(10) func handleRequest(ctx context.Context, id int) error { if err := sem.Acquire(ctx, 1); err != nil { return err // ctx 超时或被取消 } defer sem.Release(1) // 业务处理 return nil }sem.Acquire(ctx, 1)会阻塞等待权重,但一旦 ctx 取消,会立刻返回错误,不会一直等信号量。这在大流量削峰场景下非常有用:请求排队时客户端断开了,context 取消,acquire 立即返回,排队任务被清理掉,不会堆积造成"死等"。
再如"如何写一个 context 感知的中间件,让所有路由共享超时和日志字段?"以 Fiber 为例:
// Fiber 中间件:统一超时 + trace ID 注入 func TimeoutMiddleware(timeout time.Duration) fiber.Handler { return func(c *fiber.Ctx) error { ctx, cancel := context.WithTimeout(c.UserContext(), timeout) defer cancel() // 注入 trace ID ctx = context.WithValue(ctx, TraceIDKey, uuid.NewString()) c.SetUserContext(ctx) err := c.Next() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return fiber.ErrRequestTimeout } return err } }这类题目核心考的是:你对 context 生命周期、信号量阻塞模型、中间件挂载点三者之间的关系有没有整体认知。很多同学单独讲 context 头头是道,一到复合场景就乱了,原因就在于没有在代码里实际写过中间件。
5. 常见问题与排查技巧实录
5.1context canceled还是deadline exceeded,傻傻分不清
这两个错误在线上日志里非常常见,很多人遇到第一反应是把两者混为一谈。实际上它们的产生源头完全不同:
| 错误 | 触发方式 | 常见场景 |
|---|---|---|
context.Canceled | 有人主动调用 cancel() | 客户端断开、上游熔断、手动取消 |
context.DeadlineExceeded | 到达 deadline 自动触发 | 超时控制、RPC 慢调用 |
判断错误时推荐用errors.Is():
switch { case errors.Is(err, context.DeadlineExceeded): // 超时逻辑 case errors.Is(err, context.Canceled): // 取消逻辑 }为什么用errors.Is而不是==?因为业务层经常用fmt.Errorf("wrap: %w", context.DeadlineExceeded)包装错误,==只能匹配原值,errors.Is能沿错误链逐个比较,match 到最底层原因。
实际排查时,如果请求里同时有多个下游调用,不可能每个都自己写超时,链路里最先触发的那个 deadline 才是根因。但通过错误信息定位到"谁先超时"非常难,因为所有下游都返回同一个context deadline exceeded。建议在每个 RPC 调用点把服务名和 operation 名拼进错误信息里包装一层,排查效率会高非常多。
5.2 忘记 defer cancel 导致的内存泄漏
这是新手最容易犯的问题。每次WithCancel/WithTimeout创建 context 后都会产生一个定时器或注册表对象,你不调用 cancel,这些对象就一直存在,直到父 context 结束。如果父 context 是Background(),永远都不会结束,泄漏就是永久性的。
我在真实项目里见过的一次事故:某网关服务每来一个请求就context.WithTimeout但不 cancel,高并发跑了 40 分钟后 goroutine 数量从几百涨到 30 万,最后直接 OOM 挂掉。看堆内存,全是context.timerCtx。
预防办法:
- 创建 context 后,下一行就必须
defer cancel(),把它当成铁律。 - code review 时扫所有
WithCancel/WithTimeout调用点,检查 cancel 是否被调用。 - 写一个简单的单元测试,在循环里创建 context 并检查 goroutine 数是否有积压。
5.3 在 goroutine 内部协程泄露的场景分析
context 取消信号的传播是树状的,你在父 goroutine 里cancel(),所有从同一个 context 派生出去的子 goroutine 都会收到信号。但这个传播是有前提的:子 goroutine 必须真的在监听ctx.Done()。
最常见的错误是:
go func() { time.Sleep(time.Second) fmt.Println(ctx.Value("key")) }()这个 goroutine 在一秒内不会响应 ctx 取消,因为time.Sleep无法被打断。如果这里换成真正阻塞的 IO 操作,比如http.Get(ctx)或数据库查询,那没问题;但如果是自定义的耗时逻辑,记住一个结论:context 只能通知"你应该停了",不能强制终止 goroutine。在 goroutine 里要响应取消,必须用select包一层,把ctx.Done()和业务完成事件同时监听。
go func() { select { case <-ctx.Done(): log.Println("task canceled") return case res := <-doWork(): // 正常完成 } }()5.4 context 传值被业务塞得太深的恶性案例
有一段时间我们代码里有个"万能 ctx"函数,入口处把*sql.DB、*redis.Client、配置文件全塞进 context,下游直接用ctx.Value(dbKey)拿出来。后来加代码的人越来越多,根本说不清谁能读到什么,有些 key 的 value 类型被悄悄改了,老代码一运行就 panic,查了整整两天才发现是类型断言失败。
这个教训特别痛。context 传值一旦被当成全局容器,项目后期基本失控。现在的规范是:
- 只传不可变且具有请求唯一性的数据,如 trace ID、租户 ID、user ID。
- 所有 context 读值都封装成公开函数,外部不要直接接触 key 和 value。
- 依赖项必须走构造函数或参数注入,禁止走 context。
如果你维护的项目里已经烂成一锅粥,建议先把所有ctx.Value()调用点列出来,逐个改成显式参数传递。这是一次性重构,但收益是长期可维护性。
5.5 框架对 context 的额外改造:Fiber 与 Gin 的行为差异
Gin 的c.Request.Context()是标准的*http.Request自带的 context,相对干净。Fiber 的c.UserContext()则是框架自己造的 context,内部路由参数都挂在上面,如果你直接把 Fiber 的 context 传给纯 Go 的 HTTP 客户端,顺手再 Downstream 传 gRPC,可能会导致一些意外的 field 不存在而 panic。Fiber 官方文档也提醒过:需要把UserContext和标准库 context 结合使用时,最好先在入口处把业务需要的 value 拷贝到新的 context,不要直接操作框架的 context。
之前遇到一个线上 bug:Fiber 服务里使用了一个数据库操作库,它内部对 context 做了Value断言,结果传进去的 Fiber context 里存的参数类型不符合库的预期,直接 panic。解决办法是创建一个独立的业务 context:
ctx := context.WithValue(c.UserContext(), TraceIDKey, tracer.GetTraceID())这样业务代码和框架的 context 解耦,后续再替换框架也不会影响业务层。
6. Context 的性能成本与深度优化方向
6.1 context 的创建开销有多大,需要关心吗
很多同学会问:每个请求都派生 context,性能上扛得住吗?实测下来,context.WithCancel的创建成本是非常低的,单次在几十纳秒到一两百纳秒级别,一次 HTTP 请求创建三五个 context 也就是几百纳秒,对于绝大多数业务系统毫无压力。
真正的问题不在创建,而在取消链路的扩展。如果你的服务里一个请求会拆分出上万个 goroutine,每个 goroutine 都从同一个 context 派生,取消信号广播时要遍历所有子节点,这个成本就会放大。官方在 1.21 版本之后做了一些优化,但遇到真正极大规模的扇出场景,还是建议考虑用context.AfterFunc这种更轻量的取消注册机制。
stop := context.AfterFunc(ctx, func() { // 在这里做清理工作,比如关连接、删临时文件 })AfterFunc会在 ctx 被取消时异步执行传入的函数,它不占用 child 节点表,避免了取消广播时遍历大量子节点的问题,适合"大扇出小回调"场景。
6.2 如何把 context 用在并发编排中实现优雅退出
系统优雅退出是 context 的高阶用法。服务收到 SIGTERM 信号时,希望先把正在处理的请求全部完成或快速失败,而不是直接杀掉进程。
我的做法是服务里维护一个"根 context":
// 全局根 context var rootCtx context.Context var rootCancel context.CancelFunc func main() { rootCtx, rootCancel = context.WithCancel(context.Background()) go handleSignal(rootCancel) server := &http.Server{ Addr: ":8080", Handler: router, } go server.ListenAndServe() <-rootCtx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() server.Shutdown(shutdownCtx) }当进程收到退出信号,rootCancel触发,所有依赖 rootCtx 的业务请求都会收到取消信号,HTTP server 开始优雅关机,等待 10 秒内把存量请求处理完,处理不完的直接掐掉。这种设计在容器编排环境下特别重要,因为容器滚动更新时,旧实例收到的 SIGTERM 信号如果不处理,就会直接强制 kill,用户请求大面积中断。
6.3 错误处理:如何把 context 错误透传给上层框架
最后再讲一个很多人困惑的点:context deadline exceeded为什么会出现在日志里?原因是你的框架(比如 Gin)会把错误原封不动地返回出去。但真正应该展示给调用方的是定制的业务错误码和信息,所以务必要在抽象层做一个转换:
func mapContextErr(err error) error { switch { case errors.Is(err, context.Canceled): return fmt.Errorf("请求已取消: %w", err) case errors.Is(err, context.DeadlineExceeded): return fmt.Errorf("处理超时: %w", err) default: return err } }这样日志里能直接看到"处理超时"关键字,业务层再配合 trace ID,排查路径会短很多。
我个人在实际操作中的体会是:context 这套东西,读源码比看十篇文章都有用。你把cancelCtx、timerCtx、valueCtx这三个实现各读一遍,很多面试题自然就有答案了。你写任何 RPC、HTTP、数据库调用的代码时都该条件反射式地先问一句:context 传了吗?cancel 掉了吗?超时设了吗?这三点做到位,线上事故至少能少一半。