协程如何退出
一个协程启动后,大部分情况需要等待里面的代码执行完毕,然后协程会自动退出。但是如果有一种情景,需要让协程提前退出怎么办?
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func(){
defer wg.Done()
watchDog("[监控狗]")
}()
wg.Wait()
}
func watchDog(name string) {
// 开启for select循环,一直在后台监控
for {
select {
default :
fmt.Println(name, "正在监控……")
}
time.Sleep(2 * time.Second)
}
}
// 通过watchDog函数实现了一个监控狗,他会在后台一直运行,每个2秒答应一串字符串
如果需要让监控狗停止监控、退出程序,一个办法是定义全局变量,其他地方可以通过修改这个全局变量发出停止监控狗的通知,然后在协程中先检查这个变量,如果发现被通知关闭,退出当前协程。但是这个方法需要通过加锁来保证多协程并发的安全,基于这个思路,升级版方案:用select + channel 做检测:
func main() {
var wg sync.WaitGroup
wg.Add(1)
stopCh := make(chan bool)
go func(){
defer wg.Down()
watchDog(stopCh, "监控狗")
}()
time.Sleep(5 * time.Second)
stopCh <- true
wg.Wait()
}
func watchDog(stopCh chan bool, name string){
for {
select {
case <- stopCh :
fmt.Println(name, "停止监控")
return
default :
fmt.Println(name, "正在监控")
}
time.Sleep(1 * time.Second)
}
}
以上是使用select + channel方式改造watchDog函数,实现了通过channel发送指令让监控狗停止,进而达到协程退出的目的。
初识Context
通过 select+ channel 让协程退出的方式比较优雅,但是如果我们需要做到同事取消很多协程呢?如果是定时取消呢?这时候select+ channel的局限性就凸显出来了,即使定义了多个channel解决问题,代码逻辑也会非常复杂、难以维护。要解决这种复杂的协程问题,必须要有一种可以跟踪协程的方案,只有跟踪到每个协程,才能更好的控制他们,这种方案就是Go语言标准库为我们提供的Contex。
func main() {
var wg sync.WaitGroup
wg.Add(1)
ctx, stop := context.WithCancel(context.Background())
go func(){
defer wg.Down()
watchDog(ctx, "监控狗")
}()
time.Sleep(5 * time.Second)
stop()
wg.Wait()
}
func watchDog(stopCh chan bool, name string){
for {
select {
case <- ctx.Done() :
fmt.Println(name, "停止监控")
return
default :
fmt.Println(name, "正在监控")
}
time.Sleep(1 * time.Second)
}
}
相比select + channel 方案,Context方案主要有4个改动点:
- watchDog函数的stopCh 参数换成了ctx,类型为context.Context
- 原来case <- stopCh 改为 ctx.Done(),用于判断是否停止
- 使用context.WithCancel(context.Background())函数生成一个可以取消的Context,用于发送停止指令。这里的context.Background()用于生成一个空的Context,一般作为整个Context树的根节点
- 原来stopCh <- true 停止指令,改为context.WithCancel函数返回的取消函数stop()
可以看到,这个修改之前的代码结构一样,只不过从channel换成了Context。上述示例只是Context的一种使用场景,它的能力不止于此。
什么是Context
一个任务会有很多协程协作完成,一次HTTP请求会触发很多协程启动,而这些协程有可能会启动更多子协程,并且无法预知有多少层协程、每一层有多少个协程。如果因为某些原因导致任务终止了,HTTP请求取消了,那么他们启动的协程怎么办?该如何取消呢?因为取消这些协程可以节约内存,提升性能,同时避免了不可预料的Bug。
Context就是用来简化这些问题的,并且是并发安全的。Context是一个接口,它具备手动、定时、超时发出取消信号、传值等功能,主要用于控制多个协程之间的协作,尤其是取消操作。一旦取消指令下达,那么被Context跟踪的协程都会受到取消信号,可以做清理和退出操作。
Context接口只有四个方法:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <- chan struct{}
Err() error
Value(ke interface{}) interface{}
}
- Deadline 方法可以获取设置的截止时间,第一个返回值deadline是截止时间,到了这个时间,Context会自动发起取消请求,第二个值ok代表是否设置了截止时间
- Done 方法返回一个只读的channel,类型为struct{}。在协程中,如果该方法返回的chan可以读取,则意味着Context已经发起了取消信号。通过Done方法接收到这个信号后,就可以做清理操作,然后退出协程,释放资源
- Err方法返回取消的错误原因,即因为什么原因Context被取消
- Value方法获取该Context上绑定的值,是一个键值对,所以要通过一个key才能获取对应的值
Context接口的四个方法中最常用的就是Done方法,它返回一个只读的channel,用于接收取消信号。当Context取消的时候,会关闭这个只读channel,也就等于发出了取消信号。
Context树
我们不需要自己实现Context接口,Go语言提供了函数可以帮我们生成不同的Context,通过这些函数可以生成一颗Context树,这样Context可以关联起来,父Context发出取消信号的时候,子Context也会发出,这样就能控制不同层级的协程退出。从使用功能上分,有四种实现好的Context:
- 空Context:不可取消,没有截止时间,主要用于Context树根节点
- 可取消的Context:用于发出取消信号,当取消的时候,它的子Context也会取消
- 可定时取消的Context:多了一个定时的功能
- 值Context:用于存储一个key-value键值对
有了根节点Context后,这颗Context树如何生成? 需要使用Go语言提供的四个函数:
- WithCancel(parent Contenxt): 生成一个可取消的Context
- **WithDeadline(parent Context, d timt.Time):**生成一个可以定是取消的Context,参数d为定时取消的具体时间
- **WithTimeout(parent Context, timeout time.Duration):**生成一个可超时取消的Context,参数timeout用于设置多久后取消
- **WithValue(parent Context, key, val interface{}) :**生成一个可携带key-value键值对的Context
上述四个函数中,前三个都属于可取消的Context,他们是一类函数,最后一个是值Context,用于存储一个key-value键值对。
使用Context取消多个协程
取消多个协程也比较简单,把Context作为参数传递给协程即可。
当节点Ctx2取消时,它的子节点Ctx4、Ctx6都会被取消,如果还有子节点的子节点,也会被取消。其他节点不受影响。
Context传值
Context不仅可以取消,还可以传值,通过这个能力,可以把Context存储的值供其他协程使用。
func main() {
wg.Add(4)
valCtx := context.WithValue(ctx, "userid", 3)
go func(){
defer wg.Done()
getUser(valCtx)
}()
}
func getUser(ctx context.Context) {
for {
select {
case <- ctx.Done():
fmt.Println("协程退出")
return
default :
userId := ctx.Value("userid")
fmt.Println("用户ID为:", userId)
time.Sleep(2 * time.Second)
}
}
}
Context使用原则
Context是一种非常好用的工具,使用它可以很方便的控制取消多个协程。在Go语言标准库中也使用了它们,比如net/http中使用Context取消网络请求。要更好的使用Context,有一些原则需要尽可能的遵守:
- Context不要放在结构体中,要以参数的方式传递
- Context 作为函数参数时,要放在第一位,即第一个参数
- 要使用context.Background函数生成根节点的Context,也就是最顶层Context
- Context 传值要传必须的值,尽可能的少,不要什么都传
- Context 多协程安全,可以在多个协程中放心使用
这就是规范类的,Go语言的编译器不会做这些检查,要靠自己遵守。
如何通过Context实现日志跟踪?
要想跟踪一个用户请求,必须有一个唯一的ID来标识这次请求调用了哪些函数、执行了哪些代码,然后通过这个唯一ID把日志信息串联起来。这样就形成了一个日志轨迹,也就实现了用户的跟踪。
- 在用户请求的入口点生成TraceID
- 通过context.WithValue保存TraceID
- 然后这个保存着TraceID的Context作为参数在各个协程或函数间传递
- 在需要记录日志的地方,通过Context的Value方法获取保存的TraceID,然后把它和其他日志信息记录下来
- 这样具备同样TraceID的日志就可以串联起来,达到日志跟踪的目的