context、超时与取消
context、超时与取消 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课让取消信号贯穿全链路:从 http.Request.Context() 出发,带着超时进入数据库查询和慢速下游调用,客户端一走,服务端的相关工作全部及时收尾。
问题场景
有人在前端狂点刷新然后直接关页面。服务端呢?每个请求还在慢悠悠查库、拼结果——写回给一个已经不存在的连接。这些「为离开的人工作」的请求堆积起来,连接池被占满,还在的用户也跟着卡。context 就是把「客户端还在吗」这个信号传给每一层工作的管道。
核心解释
每个 http.Request 都自带 ctx,它是取消信号的源头:
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
b, err := store.GetBookmark(ctx, id) // ctx 一路往下传
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
return
}
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, b)
})
两层含义叠在一起:客户端断开会取消 r.Context(),handler 又用 WithTimeout 给自己加了 2 秒上限——哪个先到都触发取消,并沿着 ctx 传给所有接收它的函数。数据库层只要用 *Context 系列方法,取消就真正生效:
func (s *SQLiteStore) GetBookmark(ctx context.Context, id int64) (Bookmark, error) {
const q = "SELECT id, title, url, created_at FROM bookmarks WHERE id = ?"
row := s.db.QueryRowContext(ctx, q, id)
var b Bookmark
if err := row.Scan(&b.ID, &b.Title, &b.URL, &b.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Bookmark{}, fmt.Errorf("get bookmark %d: %w", id, ErrNotFound)
}
return Bookmark{}, fmt.Errorf("query bookmark %d: %w", id, err)
}
return b, nil
}
慢速调用要主动检查 ctx,这是协作式取消的含义:
func slowWork(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // 模拟慢下游
return nil
case <-ctx.Done():
return fmt.Errorf("slow work aborted: %w", ctx.Err())
}
}
常见病是「假 context」:函数签名收 ctx 却从不使用,查询用的是不带 ctx 的版本(Query 而不是 QueryContext),信号传到门口就断了。审计方法:全库搜索 .Query(、.Exec( 这类不带 Context 后缀的调用,漏网的逐个换成 *Context 版本。
示例拆解
把 slowWork 插进 handler 路径后验证两条止损路径:
go run .
curl --max-time 1 http://127.0.0.1:8080/bookmarks/1
# curl 1 秒后主动断开(模拟客户端走人)
# 服务端日志几乎立刻打出: slow work aborted: context canceled
# (远早于 slowWork 的 10 秒)
curl http://127.0.0.1:8080/bookmarks/1
# 等 2 秒:handler 超时生效,返回 504,日志出现 DeadlineExceeded
第一条验证「客户端取消」,第二条验证「服务端超时」,都能及时止损。记住一个不对称性:context 取消不会自动杀死正在跑的纯 CPU 计算,它只在 channel 操作、网络调用、带 ctx 的库调用这些检查点上生效——所以慢函数要主动 select ctx.Done(),把检查点埋进去。
动手任务
- 给列表接口加 3 秒超时,验证超时后返回 504 且日志出现 DeadlineExceeded。
- 写一个不检查 ctx 的假慢函数,与 slowWork 对照:curl 断开后看两边服务端日志各自何时打出,量化止损速度差。
- 审计自己的 store:把所有 Query/Exec 换成 *Context 版本,用 grep 确认没有漏网。
常见错误
- 函数收 ctx 却调用不带 ctx 的库方法:链路断在最后一厘米,信号白传。
- WithTimeout 之后忘了 defer cancel():计时器和资源泄漏,cancel 必须在所有路径上被调用。
- 把 ctx 存进结构体长期持有:context 的生命周期是单次请求,存字段会让取消信号错乱,ctx 永远作第一参数传递。
- 把 context.Canceled 当严重错误大惊小怪:客户端断开属于正常流量噪音,日志降级,别进错误告警。
检查与复盘
完成下方检查题。自查清单:
- handler 从 r.Context() 派生超时,ctx 作为第一参数层层传递
- 数据库与下游调用全部用 *Context 方法,grep 审计过
- 用「断开连接看日志时间」实测过取消生效,而不是只看代码
参考资料
- 标准库 context —— WithTimeout 与取消语义
- 标准库 http.Request.Context —— 客户端断开自动取消的官方说明
- 标准库 database/sql.QueryRowContext —— 带 ctx 查询
读完这一课了?
标记完成后,工作台会安排接下来的学习。