Go 从 0 到精通 · 第 29 课:context 与取消机制
学习定位:这是整套 Go 教程的第 29 课,也是阶段五(并发与工程阶段)的第五课。
前置要求:已经完成第 28 课,掌握了 Mutex、RWMutex、Once 等同步原语。需要熟悉 goroutine、channel 和 select。
本课目标:理解 context 的设计目的,掌握 WithCancel、WithTimeout、WithDeadline 的使用方式,能在 goroutine 中监听取消信号,理解 context 的传递约定和使用规范。
1. 本课你要解决的核心问题
到目前为止你学了创建 goroutine、用 channel 通信、用锁保护数据。但有一个重要问题还没解决:
怎么让一个正在运行的 goroutine 停下来?
想象几个真实场景:
- 用户发起了一个搜索请求,但等了 3 秒没结果就关闭了页面。后台的搜索 goroutine 还在傻傻跑着,浪费资源。
- 你同时向 3 个服务器发请求,第一个返回了结果。另外两个还在跑,但你已经不需要它们了。
- 一个任务有子任务,子任务又有子子任务。最顶层取消了,下面所有的都应该停下来。
Go 1.7 引入的 context 包就是解决这个问题的:控制 goroutine 的生命周期。
你需要搞明白以下问题:
context 是什么,为什么需要它
context.Background() 和 context.TODO() 是什么
- 怎么用
WithCancel 手动取消
- 怎么用
WithTimeout 和 WithDeadline 自动超时
- 怎么在 goroutine 中监听取消信号
context 怎么传递,有什么规范
- 怎么用
context 传值
2. context 是什么
2.1 一句话定义
context 是 goroutine 的生命周期管理器。它能传递取消信号、超时限制和请求级数据。
2.2 核心接口
1 2 3 4 5 6
| type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key any) any }
|
你不需要自己实现这个接口。标准库提供了创建 context 的函数,直接用就行。
最核心的是 Done() 方法——它返回一个 channel。当 context 被取消时,这个 channel 会被关闭。goroutine 通过 select 监听这个 channel 来知道"该停下来了"。
2.3 context 的树形结构
context 是父子关系:从一个父 context 派生出子 context,子 context 又能派生出孙 context。
1 2 3 4 5
| Background (根) ├── WithCancel → ctx1 │ ├── WithTimeout → ctx2 │ └── WithCancel → ctx3 └── WithTimeout → ctx4
|
取消会向下传播:父 context 取消时,所有子 context 自动取消。但子 context 取消不会影响父 context。
3. 创建 context
3.1 根 context
每个 context 树都需要一个根。Go 提供了两个根 context:
1 2
| ctx := context.Background() ctx := context.TODO()
|
Background():程序启动、main 函数、测试的顶层 context
TODO():暂时不确定用什么 context 时的占位符
两者功能完全相同(都是空 context,永远不会取消),只是语义不同。99% 的情况用 Background()。
3.2 派生 context
从根 context 派生出有具体能力的子 context:
| 函数 |
作用 |
context.WithCancel(parent) |
手动取消 |
context.WithTimeout(parent, duration) |
超时自动取消 |
context.WithDeadline(parent, time) |
到达指定时间自动取消 |
context.WithValue(parent, key, value) |
携带值 |
4. WithCancel——手动取消
4.1 基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| package main
import ( "context" "fmt" "time" )
func worker(ctx context.Context, id int) { for { select { case <-ctx.Done(): fmt.Printf("Worker %d: 收到取消信号,退出 (原因: %v)\n", id, ctx.Err()) return default: fmt.Printf("Worker %d: 工作中...\n", id) time.Sleep(500 * time.Millisecond) } } }
func main() { ctx, cancel := context.WithCancel(context.Background())
go worker(ctx, 1) go worker(ctx, 2)
time.Sleep(2 * time.Second)
fmt.Println("Main: 发送取消信号") cancel()
time.Sleep(500 * time.Millisecond) fmt.Println("Main: 退出") }
|
输出:
1 2 3 4 5 6 7 8 9 10 11 12
| Worker 1: 工作中... Worker 2: 工作中... Worker 1: 工作中... Worker 2: 工作中... Worker 2: 工作中... Worker 1: 工作中... Worker 1: 工作中... Worker 2: 工作中... Main: 发送取消信号 Worker 2: 收到取消信号,退出 (原因: context canceled) Worker 1: 收到取消信号,退出 (原因: context canceled) Main: 退出
|
4.2 工作原理
1
| ctx, cancel := context.WithCancel(parent)
|
ctx:派生的子 context,传给 goroutine
cancel:取消函数,调用后 ctx.Done() 返回的 channel 被关闭
- goroutine 通过
select + <-ctx.Done() 监听取消信号
1 2 3 4 5 6 7
| 调用 cancel() ↓ ctx.Done() 的 channel 被关闭 ↓ 所有在 <-ctx.Done() 上等待的 goroutine 被唤醒 ↓ goroutine 检查 ctx.Err() 得知取消原因,执行清理后退出
|
4.3 cancel 必须调用
1 2 3 4
| ctx, cancel := context.WithCancel(context.Background()) defer cancel()
go doWork(ctx)
|
即使你不打算手动取消,也要 defer cancel()。不调用 cancel 会导致 context 内部的资源(goroutine、timer 等)泄漏。Go 的 vet 工具会对此发出警告。
5. WithTimeout——超时自动取消
5.1 基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package main
import ( "context" "fmt" "time" )
func slowOperation(ctx context.Context) (string, error) { select { case <-time.After(3 * time.Second): return "操作完成", nil case <-ctx.Done(): return "", ctx.Err() } }
func main() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel()
fmt.Println("开始操作...") result, err := slowOperation(ctx) if err != nil { fmt.Println("失败:", err) } else { fmt.Println("成功:", result) } }
|
输出:
1 2
| 开始操作... 失败: context deadline exceeded
|
操作需要 3 秒,但 context 只给了 2 秒。2 秒后 context 自动取消,ctx.Done() 的 channel 关闭,slowOperation 收到信号退出。
5.2 WithTimeout vs WithDeadline
1 2 3 4
| ctx, cancel := context.WithTimeout(parent, 5*time.Second)
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
|
WithTimeout(parent, 5*time.Second):5 秒后取消
WithDeadline(parent, someTime):到达 someTime 时取消
大多数情况用 WithTimeout,更直观。WithDeadline 适合需要精确时间点的场景。
5.3 查看截止时间
1 2 3 4 5 6 7 8
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
deadline, ok := ctx.Deadline() if ok { fmt.Println("截止时间:", deadline.Format("15:04:05")) fmt.Println("剩余时间:", time.Until(deadline)) }
|
5.4 子 context 不能延长父 context 的期限
1 2 3 4 5 6 7 8 9
| parentCtx, parentCancel := context.WithTimeout(context.Background(), 2*time.Second) defer parentCancel()
childCtx, childCancel := context.WithTimeout(parentCtx, 5*time.Second) defer childCancel()
|
子 context 的截止时间取父 context 和自己设置的较早者。你可以给子 context 更短的超时,但不能比父 context 更长。
6. ctx.Err()——取消的原因
| 返回值 |
含义 |
nil |
context 还没取消 |
context.Canceled |
被手动调用 cancel() 取消 |
context.DeadlineExceeded |
超时或到达截止时间 |
1 2 3 4 5 6 7 8 9
| select { case <-ctx.Done(): switch ctx.Err() { case context.Canceled: fmt.Println("被手动取消") case context.DeadlineExceeded: fmt.Println("超时了") } }
|
7. 在 goroutine 中正确监听取消
7.1 标准模式
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| func doWork(ctx context.Context) error { for { select { case <-ctx.Done(): return ctx.Err() default: if err := doOneStep(); err != nil { return err } } } }
|
7.2 耗时操作中检查取消
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| func processItems(ctx context.Context, items []string) error { for i, item := range items { select { case <-ctx.Done(): fmt.Printf("在第 %d 个元素处被取消\n", i) return ctx.Err() default: }
process(item) } return nil }
|
每做一步就检查一次 ctx.Done(),这样 goroutine 能尽快响应取消信号。如果你的操作是一个大循环,不要在循环外面检查——那样要等整个循环跑完才能响应取消。
7.3 传递 context 给子操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| func handler(ctx context.Context) error { data, err := fetchData(ctx) if err != nil { return err }
result, err := processData(ctx, data) if err != nil { return err }
return saveResult(ctx, result) }
func fetchData(ctx context.Context) ([]byte, error) { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(2 * time.Second): return []byte("data"), nil } }
|
context 像一条线,串起整个调用链。最顶层取消后,链上所有操作都能收到信号。
8. WithValue——通过 context 传值
8.1 基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package main
import ( "context" "fmt" )
type contextKey string
const ( keyRequestID contextKey = "request_id" keyUserID contextKey = "user_id" )
func handleRequest(ctx context.Context) { reqID := ctx.Value(keyRequestID) userID := ctx.Value(keyUserID) fmt.Printf("处理请求: reqID=%v, userID=%v\n", reqID, userID) }
func main() { ctx := context.Background() ctx = context.WithValue(ctx, keyRequestID, "req-12345") ctx = context.WithValue(ctx, keyUserID, 42)
handleRequest(ctx) }
|
输出:
1
| 处理请求: reqID=req-12345, userID=42
|
8.2 key 的类型
不要用 string 或其他内置类型做 key。不同包可能用相同的 string,造成冲突。
1 2 3 4 5 6 7
| ctx = context.WithValue(ctx, "user_id", 42)
type contextKey string const keyUserID contextKey = "user_id" ctx = context.WithValue(ctx, keyUserID, 42)
|
自定义类型即使底层是 string,也是不同的类型,不会和其他包的 key 冲突。
8.3 什么该用 WithValue 传,什么不该
该传的:请求级别的元数据(request ID、用户认证信息、追踪 ID)
不该传的:函数的参数。如果一个值是函数运行必需的,应该作为参数显式传递,不要塞到 context 里。
1 2 3 4 5 6
| ctx = context.WithValue(ctx, "username", "alice") processUser(ctx)
processUser(ctx, "alice")
|
原则:context.Value 只用来传请求范围的横切关注点,不用来替代函数参数。
9. 实战示例
9.1 HTTP 请求超时
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| package main
import ( "context" "fmt" "time" )
func httpGet(ctx context.Context, url string) (string, error) { delay := 1*time.Second + time.Duration(len(url)%3)*time.Second
select { case <-time.After(delay): return fmt.Sprintf("响应 from %s (耗时 %v)", url, delay), nil case <-ctx.Done(): return "", fmt.Errorf("请求 %s 被取消: %w", url, ctx.Err()) } }
func main() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()
urls := []string{ "api.example.com/users", "api.example.com/products", "api.example.com/orders", "api.example.com/inventory", }
for _, url := range urls { result, err := httpGet(ctx, url) if err != nil { fmt.Println("失败:", err) break } fmt.Println("成功:", result) } }
|
9.2 父子任务取消传播
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| package main
import ( "context" "fmt" "sync" "time" )
func subTask(ctx context.Context, name string, wg *sync.WaitGroup) { defer wg.Done() for i := 1; ; i++ { select { case <-ctx.Done(): fmt.Printf(" [%s] 第 %d 步被取消: %v\n", name, i, ctx.Err()) return default: fmt.Printf(" [%s] 第 %d 步\n", name, i) time.Sleep(300 * time.Millisecond) } } }
func parentTask(ctx context.Context) { childCtx, childCancel := context.WithTimeout(ctx, 1500*time.Millisecond) defer childCancel()
var wg sync.WaitGroup
wg.Add(3) go subTask(childCtx, "子任务A", &wg) go subTask(childCtx, "子任务B", &wg) go subTask(childCtx, "子任务C", &wg)
wg.Wait() fmt.Println("父任务: 所有子任务已退出") }
func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
fmt.Println("开始执行") parentTask(ctx) fmt.Println("执行结束") }
|
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 开始执行 [子任务C] 第 1 步 [子任务A] 第 1 步 [子任务B] 第 1 步 [子任务B] 第 2 步 [子任务C] 第 2 步 [子任务A] 第 2 步 [子任务A] 第 3 步 [子任务B] 第 3 步 [子任务C] 第 3 步 [子任务C] 第 4 步 [子任务A] 第 4 步 [子任务B] 第 4 步 [子任务B] 第 5 步被取消: context deadline exceeded [子任务C] 第 5 步被取消: context deadline exceeded [子任务A] 第 5 步被取消: context deadline exceeded 父任务: 所有子任务已退出 执行结束
|
父 context 有 5 秒,但 parentTask 给子任务只分配了 1.5 秒。1.5 秒后子 context 超时,三个子任务同时收到取消信号退出。
9.3 竞速请求——最快的响应胜出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| package main
import ( "context" "fmt" "math/rand" "time" )
func queryServer(ctx context.Context, server string, ch chan<- string) { delay := time.Duration(200+rand.Intn(800)) * time.Millisecond
select { case <-time.After(delay): ch <- fmt.Sprintf("%s 响应 (耗时 %v)", server, delay) case <-ctx.Done(): fmt.Printf(" %s: 被取消,停止工作\n", server) } }
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel()
result := make(chan string, 3)
servers := []string{"服务器A", "服务器B", "服务器C"} for _, s := range servers { go queryServer(ctx, s, result) }
fastest := <-result fmt.Println("最快响应:", fastest)
cancel() time.Sleep(100 * time.Millisecond) }
|
可能的输出:
1 2 3
| 最快响应: 服务器A 响应 (耗时 234ms) 服务器C: 被取消,停止工作 服务器B: 被取消,停止工作
|
三个请求同时发出,第一个返回结果后立刻取消其他两个。这在分布式系统中很常见——冗余请求降低延迟。
9.4 带取消的 Worker Pool
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| package main
import ( "context" "fmt" "sync" "time" )
func worker(ctx context.Context, id int, jobs <-chan int, wg *sync.WaitGroup) { defer wg.Done() for { select { case <-ctx.Done(): fmt.Printf("Worker %d: 收到取消,退出\n", id) return case job, ok := <-jobs: if !ok { fmt.Printf("Worker %d: 任务队列关闭,退出\n", id) return } fmt.Printf("Worker %d: 处理任务 %d\n", id, job) time.Sleep(200 * time.Millisecond) } } }
func main() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel()
jobs := make(chan int, 20) var wg sync.WaitGroup
for i := 1; i <= 3; i++ { wg.Add(1) go worker(ctx, i, jobs, &wg) }
go func() { for i := 1; i <= 50; i++ { select { case jobs <- i: case <-ctx.Done(): fmt.Printf("发送方: 第 %d 个任务时超时,停止发送\n", i) close(jobs) return } } close(jobs) }()
wg.Wait() fmt.Println("程序结束") }
|
2 秒超时后,所有 worker 和发送方都会收到取消信号并退出。不会有 goroutine 泄漏。
10. context 的使用规范
10.1 函数签名约定
1 2 3 4
| func DoSomething(ctx context.Context, arg1 string, arg2 int) error { }
|
- context 作为函数的第一个参数
- 参数名叫
ctx
- 不要把 context 放在结构体里
10.2 不要传 nil context
1 2 3 4 5
| DoSomething(nil, "hello", 42)
DoSomething(context.Background(), "hello", 42)
|
10.3 context 是不可变的
每次 WithCancel、WithTimeout、WithValue 都返回一个新的 context。原始 context 不会被修改。
1 2 3 4 5
| ctx := context.Background() ctx2 := context.WithValue(ctx, key, "value")
|
10.4 cancel 函数可以多次调用
1 2 3 4
| ctx, cancel := context.WithCancel(parent) cancel() cancel() cancel()
|
多次调用 cancel 不会 panic,第一次之后的调用没有效果。所以 defer cancel() 是安全的。
10.5 不要在结构体中存储 context
1 2 3 4 5 6 7 8 9
| type Server struct { ctx context.Context }
func (s *Server) HandleRequest(ctx context.Context, req Request) Response { }
|
context 是请求级别的,每次请求应该有自己的 context。存在结构体里意味着所有请求共享一个 context,无法单独取消。
11. 常见坑总结
11.1 忘记调用 cancel
1 2 3 4 5 6 7 8 9 10 11 12 13
| func bad() { ctx, _ := context.WithTimeout(context.Background(), 5*time.Second) doWork(ctx) }
func good() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() doWork(ctx) }
|
11.2 超时后没检查 context
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| func query(ctx context.Context) (string, error) { time.Sleep(2 * time.Second) return "result", nil }
func query(ctx context.Context) (string, error) { ch := make(chan string, 1) go func() { time.Sleep(2 * time.Second) ch <- "result" }()
select { case result := <-ch: return result, nil case <-ctx.Done(): return "", ctx.Err() } }
|
11.3 把 context 存在结构体里
前面规范里讲过,但再强调一遍。context 应该通过参数传递,不存在结构体中。
11.4 用 WithValue 传业务参数
1 2 3 4 5 6
| ctx = context.WithValue(ctx, "name", "Alice") greet(ctx)
greet(ctx, "Alice")
|
11.5 阻塞操作不检查 context
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| func bad(ctx context.Context, ch chan int) { v := <-ch fmt.Println(v) }
func good(ctx context.Context, ch chan int) { select { case v := <-ch: fmt.Println(v) case <-ctx.Done(): fmt.Println("超时退出") } }
|
所有可能阻塞的操作都应该配合 ctx.Done() 使用。 这是 context 发挥作用的前提。
12. 本课练习
练习 1:可取消的倒计时
要求:
- 实现一个 10 秒倒计时,每秒打印剩余时间
- 用一个 goroutine 监听用户输入,输入 “stop” 时取消倒计时
- 用
context.WithCancel 实现
练习 2:多服务聚合查询
要求:
- 模拟 3 个服务(用户服务、订单服务、推荐服务),各自耗时不同
- 同时发起 3 个查询,总超时 2 秒
- 收集所有在超时前返回的结果
- 超时后打印哪些服务成功了,哪些超时了
提示:用 context.WithTimeout + 多个 goroutine + channel 收集结果。
练习 3:分级超时
要求:
- 整体操作超时 5 秒
- 步骤 1(获取数据)超时 2 秒
- 步骤 2(处理数据)超时 2 秒
- 步骤 3(保存结果)超时 1 秒
- 每一步用子 context 设置独立超时
练习 4:带 context 的 Worker Pool
要求:
- 启动 3 个 worker goroutine
- 不断向它们分发任务
- 5 秒后通过 context 取消所有 worker
- 打印每个 worker 处理了多少个任务
练习 5:请求追踪
要求:
- 用
context.WithValue 在 context 中携带 request ID
- 实现 3 个函数:
handleRequest → queryDB → callAPI
- 每个函数从 context 中取出 request ID 并在日志中打印
- 模拟一个请求的完整链路
13. 自测题
13.1 概念题
context 解决的核心问题是什么?
context.Background() 和 context.TODO() 有什么区别?
WithCancel、WithTimeout、WithDeadline 各自的使用场景是什么?
- 父 context 取消后,子 context 会怎样?反过来呢?
- 子 context 能比父 context 有更长的超时时间吗?
- 为什么创建 context 后必须
defer cancel()?
ctx.Err() 可能返回哪些值?分别代表什么?
- 为什么不应该把 context 存在结构体中?
13.2 代码阅读题
预测以下代码的行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| package main
import ( "context" "fmt" "time" )
func main() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()
childCtx, childCancel := context.WithTimeout(ctx, 5*time.Second) defer childCancel()
select { case <-childCtx.Done(): fmt.Println("子 context 结束:", childCtx.Err()) } }
|
点击查看答案
3 秒后输出:
1
| 子 context 结束: context deadline exceeded
|
解释:
- 父 context 超时 3 秒
- 子 context 尝试设置 5 秒超时,但父 context 只有 3 秒
- 子 context 的实际超时 = min(父的 3 秒, 自己的 5 秒) = 3 秒
- 3 秒后父 context 超时,子 context 也被取消
childCtx.Err() 返回 context.DeadlineExceeded
子 context 不能比父 context 活得更久。
14. 本课总结
这一课你学到了用 context 管理 goroutine 的生命周期。
| 工具 |
用途 |
关键点 |
context.Background() |
根 context |
程序入口和顶层调用使用 |
WithCancel |
手动取消 |
调用 cancel() 触发取消 |
WithTimeout |
超时取消 |
指定持续时间 |
WithDeadline |
截止时间取消 |
指定具体时间点 |
WithValue |
传递值 |
只传请求级元数据,不替代函数参数 |
ctx.Done() |
取消信号 channel |
用 select 监听 |
ctx.Err() |
取消原因 |
Canceled 或 DeadlineExceeded |
最重要的三件事:
context 控制 goroutine 的生命周期——取消会从父 context 自动传播到所有子 context
- 创建 context 后立刻
defer cancel()——不调用会泄漏资源
- 所有可能阻塞的操作都用
select 配合 ctx.Done()——这是 context 生效的前提
15. 下一课预告
到这里,你已经学完了 Go 并发编程的四大核心工具:goroutine、channel、select、context。下一课把它们组合起来,学习几个经典的并发设计模式。
下一课:并发模式实战
会重点讲:
- 生产者-消费者模式
- Worker Pool(工作池)模式
- Pipeline(流水线)模式
- Fan-out / Fan-in(扇出/扇入)模式
- 怎么选择合适的并发模式
学完下一课,你就能写出结构清晰、可维护的并发程序了。