G12第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

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(),把检查点埋进去。

动手任务

  1. 给列表接口加 3 秒超时,验证超时后返回 504 且日志出现 DeadlineExceeded。
  2. 写一个不检查 ctx 的假慢函数,与 slowWork 对照:curl 断开后看两边服务端日志各自何时打出,量化止损速度差。
  3. 审计自己的 store:把所有 Query/Exec 换成 *Context 版本,用 grep 确认没有漏网。

常见错误

  • 函数收 ctx 却调用不带 ctx 的库方法:链路断在最后一厘米,信号白传。
  • WithTimeout 之后忘了 defer cancel():计时器和资源泄漏,cancel 必须在所有路径上被调用。
  • 把 ctx 存进结构体长期持有:context 的生命周期是单次请求,存字段会让取消信号错乱,ctx 永远作第一参数传递。
  • 把 context.Canceled 当严重错误大惊小怪:客户端断开属于正常流量噪音,日志降级,别进错误告警。

检查与复盘

完成下方检查题。自查清单:

  • handler 从 r.Context() 派生超时,ctx 作为第一参数层层传递
  • 数据库与下游调用全部用 *Context 方法,grep 审计过
  • 用「断开连接看日志时间」实测过取消生效,而不是只看代码

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码较难

阅读代码(Go 1.22+,go run 可直接运行)。输出什么?

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context) {
	select {
	case <-time.After(3 * time.Second):
		fmt.Println("done")
	case <-ctx.Done():
		fmt.Println("worker:", ctx.Err())
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()
	go worker(ctx)
	<-ctx.Done()
	fmt.Println("main:", ctx.Err())
	time.Sleep(100 * time.Millisecond)
}
Q2单选中等

HTTP handler 里要调用下游服务与数据库,下列 context 用法正确的是?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...