Golang 并发编程(八):Golang 中 Context 类型
- 陈大剩
- 2025-03-31 23:29:39
- 24
在使用 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 实战
- 根据上述例子写一个能自动启停的实例;
- 写一个定时的取消的实例;