问题场景
一个任务会有很多个 goroutine 协作完成,比如一次 HTTP 请求也会启动很多个 goroutine。这些启动的 goroutine,可能会启动更多的sub-goroutine,并且无法预知会有多少层,每一层有多少个,如果因为某些原因导致任务终止,比如取消 HTTP 请求,需要取消启动的 goroutine,因为取消这些 goroutine 可以节约内存,提升性能,同时又可以避免不可预料的 bug,如何取消?
Context 上下文
Context,goroutine 并发的利器,可以解决上面的问题,特别是取消正在执行的 goroutine,它是并发安全的。
Context 是一个接口,它具备手动、定时、超时发出取消信号、传值等功能,主要用于控制多个协程之间的协作,尤其是取消操作。一旦下达取消指令,被 context 跟踪的这些 goroutine 都会收到取消的信号,就可以做清理和退出的操作。
Context 接口有4个方法比较简单常用,自己不需要实现 Context 接口,Go 语言提供了函数。
type Context interface {
Deadline()(deadline time.Time, ok bool) // 获取设置的截至时间
Done() <-chan struct{} // 返回一个只读的 channel,类型为 struct{}
Err() error // 返回取消的错误原因
Value(key interface{}) interface{} // 获取该 Context 上绑定的值,是一个键值对
}
说明:
- 到了截止时间,context 会自动发起取消的请求;
- 空的结构体,在 goroutine 中,如果该方法返回的通道是可读取的,就意味着 context 已经发出取消的信号,通过 done 方法可以接收这个信号,之后做出一些清理的操作,然后退出协程,并释放资源。总之,返回一个只读的通道,用于接收取消的信号,context 取消的时候会关闭只读的通道,也就等于发出了取消的信号。
Context 树
Go 语言提供了函数可以帮助我们生成不同的 Context,通过这些函数可以生成一颗 Context 树,这样 Context 才可以关联起来。父 Context 发出取消信号时,子 Context 也会发出,这样就能控制不同层级的 goroutine 的退出。
从使用功能上,有4种实现好的 Context,分别是空 Context、可取消的 Context、可定时取消的 Context、值 Context。
- 空 Context:不可取消,没有截至时间,主要用于 Context 树的根节点;
- 可取消的 Context:用于发出取消信号,当取消的时候,它的子 Context 也会取消;
- 可定时取消的 Context:多了一个定时的功能;
- 值 Context:用于存储一个 kv 键值对。
图中最顶部的是一个空的 context,作为整颗 context 树的根节点,Background() 用于生成一个空的 context。有了根节点之后,context 树怎么生成,就需要 Go 语言提供的4个函数了,4个生成 context的函数中,前三个都是可取消的 context,最后一个是值的 context,用于存储一个键值对。
// 生成一个可取消的 Context
WithCancel(parent Context)
// 生成一个可定时取消的 Context,参数 d 为定时取消的具体时间
WithDeadline(parent Context, d time.Time)
// 生成一个可超时取消的 Context,参数 timeout 用于设置多久后取消
WithTimeout(parent Context, timeout time.Duration)
// 生成一个可携带 kv 键值对的 Context
WithValue(parent Context, key, val interface{})
一个简单的例子
eg. 实现了一个监控 deamon,会让程序一直运行,每隔1秒就会打印一次
package main
import (
"fmt"
"sync"
"time"
)
func watcher(strName string) {
for {
select {
default:
fmt.Println(strName, "watching...")
}
time.Sleep(time.Second)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
watcher("watcher")
}()
wg.Wait()
}
注意,上面代码创建子协程时,在 for 中包含的 select 加了 default 语句,实际开发中不建议这么做,想像一下为无限循环开了大门,服务器会是什么状态,这里为了实现一个永不退出的deamon,才这么做的。
运行结果如下,
一个协程启动之后,大部分情况都是要等到代码执行完毕,然后协程会自动退出。
如果有一种场景,需要协程提前退出,怎么办?比如让上面这个deamon停止打印,退出程序。
一个办法是,定义一个全局变量,其他地方可以通过修改这个变量,发出停止打印程序的通知。然后在 goroutine 中先检查这个变量,如果发现被通知关闭就停止监控,退出当前的协程,加锁保护并发安全。
使用 channel 升级
eg. select + channel 方式就是基于这个思路来控制协程退出的。
package main
import (
"fmt"
"sync"
"time"
)
func watcher(stChan chan bool, strName string) {
for {
select {
case <-stChan:
fmt.Println("stop watch.")
return
default:
fmt.Println(strName, "watching...")
}
time.Sleep(time.Second)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
channel := make(chan bool)
go func() {
defer wg.Done()
watcher(channel, "watcher")
}()
time.Sleep(10*time.Second)
channel <-true
wg.Wait()
}
使用 channel 发送指令的方式,让 deamon 停止,进而达到退出 goroutine 的目的。
运行效果如下,
使用 Context 升级
上面的控制非常简单,只有一个协程,但如果要同时取消很多个协程呢,或者定时取消协程怎么做?这是 select+channel 的局限性,实现起来很繁琐。要解决这种复杂问题,必须要有一种可以跟踪协程的方案,只有跟踪到每个协程,才能更好地去控制。Go 语言所提供的 Context 就可以很好地实现,使用时把 context 作为参数传递给 sub goroutine 即可。
package main
import (
"context"
"fmt"
"sync"
"time"
)
func watcher(ctx context.Context, strName string) {
for {
select {
case <-ctx.Done():
fmt.Println("stop watch.")
return
default:
fmt.Println(strName, "watching...")
}
time.Sleep(time.Second)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx, stop := context.WithCancel(context.Background())
go func() {
defer wg.Done()
watcher(ctx, "watcher")
}()
time.Sleep(10*time.Second)
stop()
wg.Wait()
}
没错,运行结果就是下面这样的,
Context 再升级(取消多个 sub goroutine)
上面只有一个 sub goroutine,取消多个 sub goroutine 呢?
在上个例子的基础上新增加两个 goroutine,一个 Context 同时控制了3个协程,一旦 Context 发出取消信号之后,这3个协程都会取消退出。
package main
import (
"context"
"fmt"
"sync"
"time"
)
func watcher(ctx context.Context, n int, strName string) {
for {
select {
case <-ctx.Done():
fmt.Println(strName, n, "stop watch.")
return
default:
fmt.Println(strName, n, "watching...")
}
time.Sleep(time.Second)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(3)
ctx, stop := context.WithCancel(context.Background())
for i := 0; i < 3; i++ {
go func(n int) {
defer wg.Done()
watcher(ctx, n, "watcher")
}(i)
}
time.Sleep(10*time.Second)
stop()
wg.Wait()
}
贴出运行结果,
在以上实例中,Context 没有子 Context,如果一个 Context 有子 Context,在取消该 Context 会发生什么呢?
Context 传值
Context 不仅可以用于取消,还可以用于传值,可以实现 Context 存储的值供其他 goroutine 来使用。
场景 服务日志染色
假如一个用户请求访问某个网站,如何通过 Context 实现日志跟踪?
要想跟踪一个用户的请求,必须要有一个唯一的ID来标识这次请求,调用了哪些函数,执行了哪些代码,然后通过这个唯一的ID把日志信息串联起来,这样就形成了一个日志的轨迹,也就实现了用户的跟踪,对服务日志进行染色。
在用户请求的入口点生成 TraceID,通过 context.WithValue 保存TraceID,保存着 TraceID 的 Context 就可以作为参数在各个协程或者函数间传递,在需要记录日志的地方,通过 Context 的 Value 方法获取保存的 TraceID,然后把它和其他日志信息记录下来,这样具备同样 TraceID 的日志就可以被串联起来,达到日志跟踪的目的,实现的核心就是 Context 的传值功能。
最后
Context 就是通过 With 系列函数生成 Context 树,把相关的 Context 关联起来,这样就可以统一进行控制。如果在定义函数时,想让外部给这个函数发取消信号,就可以为这个函数增加一个 Context 参数,比如下载一个文件超时退出的需求。为了更好地使用 Context,有些较好的使用规则需要注意,以下这些规则,编译器不会做检查,开发者需要去遵守。
- Context不要放在结构体中,要以参数的方式传递;
- Context 作为函数的参数时,要放在第一位,也就是第一个参数;
- 要使用 context.Background() 生成根节点的Context,也就是最顶层的 Context;
- Context 传值要传递必须的值,而且要尽可能地少,不要什么都传;
- Context 并发安全,可以在多个协程中放心使用。
Have Fun