本文总结了 Go 标准库中 context 的常见用法,包括创建、传递、取消、超时部分场景。

1.context包负责Go当中的上下文传递机制

1.1 问题场景

  • 一个 HTTP 请求可能启动多个 goroutine(调用下游服务、查数据库、写日志)
  • 如果客户端断开连接,需要取消所有相关操作,避免浪费资源
  • 如果某个操作超时,需要及时停止,而不是一直等待

1.2 传统方式的问题

  • 没有统一的取消机制,每个 goroutine 需要自己管理停止逻辑
  • 难以在调用链中传递取消信号

1.3 context 的价值

  • 统一取消机制:一个 ctx.Done() 可以通知所有相关 goroutine
  • 自动传播:子 context 会继承父 context 的取消状态
  • 超时控制:可以设置超时时间,自动取消

2.context的接口实现

1
2
3
4
5
6
7
// 主要包含四个方法
type Context interface {
Deadline() (deadline time.Time, ok bool) // 获取截止时间
Done() <-chan struct{} // 一个只读的channel,表示当前context是否被取消
Err() error // 返回一个错误信息
Value(key any) any // 最后一个则是一个键值对 存储数据
}

3.context的创建

1
2
3
4
5
6
7
8
9
10
ctx1 := context.Background()
ctx2 := context.TODO()

// 两者都不会被取消
<-ctx1.Done() // 永远阻塞
<-ctx2.Done() // 永远阻塞

// 两者只有语义上有差别
// Background():作为根 context,用于程序入口、测试、顶层逻辑
// TODO(): 不确定该用哪个 context 时的占位符,表示“这里需要 context,但我暂时不确定该传什么”

4.context的传递机制

4.1 父子继承

1
2
3
4
5
6
7
8
9
10
func main() {
ctx1 := context.TODO()
ctx2 := context.Background() // 只有语义上有差别 都是创建
ctx1 = context.WithValue(ctx1, "name", "father") // 从空的ctx1继承 +[name]:[father]键值对
ctx1 = context.WithValue(ctx2, "age", 18) // 从空的ctx2加上[age]:18
fmt.Println(ctx1.Value("name"), ctx1.Value("age"))
fmt.Println(ctx2.Value("name"), ctx2.Value("age"))
}
// 所以我只能知道ctx1的age
// name在传递过程中消失
1
2
3
4
5
6
7
8
9
10
func main() {
ctx1 := context.TODO()
ctx2 := context.Background() // 只有语义上有差别 都是创建
ctx1 = context.WithValue(ctx1, "name", "father") // 从空的ctx1继承 +[name]:[father]键值对
ctx1 = context.WithValue(ctx1, "age", 18) // 从ctx1的[name]:[father]加上[age]:18
fmt.Println(ctx1.Value("name"), ctx1.Value("age"))
fmt.Println(ctx2.Value("name"), ctx2.Value("age"))
}
// 这时候name也被传递下来
// ctx2依旧是空的

4.2 更加直观的父子继承

1
2
3
4
5
6
7
8
9
func main() {
ctx1 := context.TODO()
ctx2 := context.Background()
ctx1 = context.WithValue(ctx1, "name", "father")
ctx2 = context.WithValue(ctx1, "name", "son")

fmt.Println(ctx1.Value("name"))
fmt.Println(ctx2.Value("name"))
}

5.context的取消机制

context.WithCancel 函数能够从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数。

一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号。

先来个简单的 demo 展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func work(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("ctx done")
return
default:
fmt.Println("working")
time.Sleep(1 * time.Second)
}
}
}

func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(5 * time.Second)
cancel()
}()

work(ctx)
}

五秒 working 之后 done 符合预期。

值得注意的是为什么调用取消之后 ctx.Done() 就会产生返回值呢,这跟 golang 的 channel 特性有关:当从一个关闭的 channel 读取时会读取“零值”,所以能够读取到数据。

6.context的截止时间机制

1
2
3
4
5
6
7
8
9
10
11
func main() {
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
defer cancel()

select {
case <-time.After(8 * time.Second):
fmt.Println("任务执行完成")
case <-ctx.Done():
fmt.Println("任务被取消:", ctx.Err())
}
}

Deadline 更适合 整个工作流程 的截止时间(如批处理任务必须在午夜前完成、必须在事务开始 30 多秒之后提交)。

7.context的超时机制以及继承关系

当一个 Context 设置超时后,它会创建 一个新的 Context 节点 在上下文树中,超时时间会 向下传播给所有子上下文

规则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
func main() {
// 根上下文,没有超时
rootCtx := context.Background()

// 创建 5 秒超时的父上下文
parentCtx, parentCancel := context.WithTimeout(rootCtx, 5*time.Second)
defer parentCancel()

// 创建子上下文(继承父的超时)
childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
defer childCancel()

// 检查超时时间
checkDeadline(parentCtx, "parent")
checkDeadline(childCtx, "child")
}

func checkDeadline(ctx context.Context, name string) {
deadline, ok := ctx.Deadline()
if ok {
remaining := time.Until(deadline)
fmt.Printf("%s: 截止时间 %v, 剩余 %v\n",
name, deadline.Format("15:04:05"), remaining)
}
}

规则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
func main() {
// 根上下文,没有超时
rootCtx := context.Background()

// 创建 5 秒超时的父上下文
parentCtx, parentCancel := context.WithTimeout(rootCtx, 5*time.Second)
defer parentCancel()

// 创建子上下文(继承父的超时)
childCtx, childCancel := context.WithTimeout(parentCtx, 2*time.Second)
defer childCancel()

// 检查超时时间
checkDeadline(parentCtx, "parent")
checkDeadline(childCtx, "child")
}

func checkDeadline(ctx context.Context, name string) {
deadline, ok := ctx.Deadline()
if ok {
remaining := time.Until(deadline)
fmt.Printf("%s: 截止时间 %v, 剩余 %v\n",
name, deadline.Format("15:04:05"), remaining)
}
}

当我们在 http 请求链当中就可以设置分层严格的请求时间。