Golang 并发编程(八):Golang 中 Context 类型
- 陈大剩
- 2025-03-31 23:29:39
- 608

在使用 WaitGroup 值的时候,最好用 “先统一 Add,再并发 Done,最后 Wait” 的标准模式来构建协作流程。
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
fmt.Println("第一个协程")
}()
go func() {
defer wg.Done()
fmt.Println("第二个协程")
}()
wg.Wait()
上面例子必须要等到两个协程完毕后才能进行 wg.Done(),这就带来了一个问题:如果不能在一开始就确定执行子任务的 goroutine 的数量,那么使用 WaitGroup 值来协调它们和分发子任务的 goroutine,就是有一定风险的?那应该如何执行?
换句话说:如果上面的两个 goroutine 我们并不知道他什么时候结束(当然现在也不知道),goroutine 也不知道它自己什么时候结束, goroutine 需要我们给它一个信号,告诉他可以结束了,它才能够结束,这种过程应该怎么做呢?
syncChan := make(chan struct{}, 1)
go func() {
for {
select {
case <-syncChan:
fmt.Println("结束协程。。。")
return
default:
fmt.Println("持续运行中")
}
}
}()
time.Sleep(time.Second)
syncChan <- struct{}{}
time.Sleep(time.Second * 2)
看上去我们可以采用 channel 类型,通过类似信号的机制去访问它,但是如果 goroutine 比较多呢?不像当前一样只有一个,比如 goroutine 有 5 个?难道我们写用 5 个 channel 类型吗?接下来我们可以使用 Context 是群发停止操作。
Context
网络请求场景。例如,每个网络请求 Request 都需要启动一个 goroutine 来处理一些操作,而这些 goroutine 可能还会进一步启动其他 goroutine。
因此,我们需要一种可以有效跟踪这些 goroutine 的机制,以便对它们进行控制。Golang 语言为我们提供的 Context 就是这样一种机制,它被称为上下文,恰如其分地反映了它在 goroutine 之间的关联和管理功能。
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("结束协程。。。")
return
default:
fmt.Println("持续运行中")
}
}
}(ctx)
time.Sleep(time.Second)
cancel() // 发送通知
time.Sleep(time.Second * 2)
这里我将之前的例子改为 Context 类型,在 goroutine 中,使用 select 调用 <-ctx.Done() 判断是否要结束,如果接受到值的话,就可以返回结束goroutine了;如果接收不到,就会进行自旋操作。
这里 Context 也可以通过信号控制多个 goroutine :
ctx, cancel := context.WithCancel(context.Background())
total := 5
for i := 0; i < total; i++ {
go func(ctx context.Context, i int) {
for {
select {
case <-ctx.Done():
fmt.Printf("结束协 %d。。。\n", i)
return
default:
fmt.Printf("协程 %d,持续运行中\n", i)
time.Sleep(1 * time.Second)
}
}
}(ctx, i)
}
time.Sleep(2 * time.Second)
cancel() // 发送通知
time.Sleep(2 * time.Second)
这里通过一个 context 控制了多个 goroutine ,这在多个协程管理中十分方便,如果使用 chan struct{} 类型信号,工序上复杂很多。
Context 接口类型
Context 与其他同步类型不同的是,它不是一结构体类型,也就是说它和其他同步工具不同的是,可以被传播给多个 goroutine,Context 类型实际上是一个接口类型。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Deadline方法是获取设置的截止时间的意思,第一个返回式是截止时间,到了这个时间点,Context会自动发起取消请求;第二个返回值 ok==false 时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消。Done方法返回一个只读的chan,类型为struct{},在goroutine中,如果该方法返回的chan可以读取,则意味着parent context已经发起了取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源。Err方法返回取消的错误原因,因为什么Context被取消。Value方法获取该Context上绑定的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。
Context 接口并不需要我们实现,Golang 内置已经帮我们实现了 2 个,我们代码中最开始都是以这两个内置的作为最顶层的 partent context,衍生出更多的子 Context。
type emptyCtx struct{} // 空结构体
type backgroundCtx struct{ emptyCtx }
type todoCtx struct{ emptyCtx }
一个是 Background,主要用于 main 函数、初始化以及测试代码中,作为 Context 这个树结构的最顶层的 Context,也就是根Context。
一个是 TODO,它目前还不知道具体的使用场景,如果我们不知道该使用什么 Context 的时候,可以使用这个。
func (emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (emptyCtx) Done() <-chan struct{} {
return nil
}
func (emptyCtx) Err() error {
return nil
}
func (emptyCtx) Value(key any) any {
return nil
}
这几个是继承 Context 接口的方法,可以理解成什么都没有做,相当于一张白纸。Context 接口类型稍有点晦涩难懂,可以先看 Context 继承衍生再回顾这节可能会更有收获。
Context 继承衍生
我们可以把接口层面的空的 Background 和 TODO 作为整个 Context 的父级,类似于 HTML 中的 dom,Context 父级中又可以嵌套子集 Context,子集又可以继续嵌套 Context ,如果你愿意可以一直嵌套下去(虽然没有什么意义)。Context 包中还包含了四个用于继承 Context 值的函数,即:WithCancel、WithDeadline、WithTimeout 和 WithValue。
首先演示 WithTimeout(WithDeadline) 例子
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
t1 := time.Now()
defer cancel()
// 睡眠 500
time.Sleep(time.Millisecond * 100)
// 子 context
ctx1, cancel2 := context.WithTimeout(ctx, 1000*time.Millisecond)
t2 := time.Now()
defer cancel2()
// 阻塞等待
<-ctx1.Done()
t3 := time.Now()
fmt.Println(t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
}
输出结果如下:
100 499 context deadline exceeded
这个例子中:ctx1 继承 ctx ,其中父类级 ctx 使用的 WithTimeout 是 600*time.Millisecond ,子类 ctx1 使用的 1000*time.Millisecond 所以父类 ctx 会优先到时间,到期的结果为 100+499=599 的时间,其中第三个参数为打印超时时间的效果。
WithTimeout和WithDeadline基本上一样,主要区别是使用时间类型不一致。
WithValue 类型例子:
func step1(ctx context.Context) context.Context {
child := context.WithValue(ctx, "name", "陈大剩")
return child
}
func step2(ctx context.Context) context.Context {
child := context.WithValue(ctx, "age", "18")
return child
}
func step3(ctx context.Context) {
fmt.Printf("name %s \n", ctx.Value("name"))
fmt.Printf("age %s \n", ctx.Value("age"))
}
func main() {
parent := context.Background()
child1 := step1(parent)
child2 := step2(child1)
step3(child2)
}
输出结果如下:
name 陈大剩
age 18
WithValue 函数在产生新的 Context 值(以下简称含数据的 Context 值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。Context 类型的 Value 方法就是被用来获取数据的。
在我们调用含数据的 Context 值的 Value 方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。
WithCancel 类型例子:
ctx, cancel := context.WithCancel(context.Background())
t0 := time.Now()
go func() {
time.Sleep(1000 * time.Millisecond)
cancel()
}()
<-ctx.Done()
t1 := time.Now()
fmt.Println(t1.Sub(t0).Milliseconds(), ctx.Err())
输出结果如下:
1000 context canceled
在上述代码中 Done 方法会返回一个元素类型为 struct{} 的接收通道。不过,这个接收通道的用途并不是传递元素值,而是让调用方去感知“撤销”当前 Context值的那个信号。
一旦当前的 Context 值被撤销,这里的接收通道就会被立即关闭。对于一个未包含任何元素值的通道来说,它的关闭会使任何针对它的接收操作立即结束。
而对应的可撤销的 Context 值也只负责传达信号,它们都不会去管后边具体的“撤销”操作。实际上,我们的代码可以在感知到撤销信号之后,进行任意的操作,Context 值对此并没有任何的约束。
Context在golang源码中主要用在HTTP请求上,有兴趣可以自行去查看。
最后总结一下 Context 使用原则
- 不要把
Context放在结构体中,要以参数的方式传递; - 以
Context作为参数的函数方法,应该把Context作为第一个参数,放在第一位; - 给一个函数方法传递
Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO; Context的Value相关方法应该传递必须的数据,不要什么数据都使用这个传递;Context是线程安全的,可以放心的在多个goroutine中传递;
Context 实战
- 根据上述例子写一个能自动启停的实例;
- 写一个定时的取消的实例;













