【Go上下文Context】

协程如何退出

一个协程启动后,大部分情况需要等待里面的代码执行完毕,然后协程会自动退出。但是如果有一种情景,需要让协程提前退出怎么办?
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个改动点:

  1. watchDog函数的stopCh 参数换成了ctx,类型为context.Context
  2. 原来case <- stopCh 改为 ctx.Done(),用于判断是否停止
  3. 使用context.WithCancel(context.Background())函数生成一个可以取消的Context,用于发送停止指令。这里的context.Background()用于生成一个空的Context,一般作为整个Context树的根节点
  4. 原来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{}
}
  1. Deadline 方法可以获取设置的截止时间,第一个返回值deadline是截止时间,到了这个时间,Context会自动发起取消请求,第二个值ok代表是否设置了截止时间
  2. Done 方法返回一个只读的channel,类型为struct{}。在协程中,如果该方法返回的chan可以读取,则意味着Context已经发起了取消信号。通过Done方法接收到这个信号后,就可以做清理操作,然后退出协程,释放资源
  3. Err方法返回取消的错误原因,即因为什么原因Context被取消
  4. 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键值对
image-20211215165624419

有了根节点Context后,这颗Context树如何生成? 需要使用Go语言提供的四个函数:

  1. WithCancel(parent Contenxt): 生成一个可取消的Context
  2. WithDeadline(parent Context, d timt.Time):生成一个可以定是取消的Context,参数d为定时取消的具体时间
  3. WithTimeout(parent Context, timeout time.Duration):生成一个可超时取消的Context,参数timeout用于设置多久后取消
  4. WithValue(parent Context, key, val interface{}) :生成一个可携带key-value键值对的Context

上述四个函数中,前三个都属于可取消的Context,他们是一类函数,最后一个是值Context,用于存储一个key-value键值对。

使用Context取消多个协程

取消多个协程也比较简单,把Context作为参数传递给协程即可。
image-20211215170228020

当节点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把日志信息串联起来。这样就形成了一个日志轨迹,也就实现了用户的跟踪。
  1. 在用户请求的入口点生成TraceID
  2. 通过context.WithValue保存TraceID
  3. 然后这个保存着TraceID的Context作为参数在各个协程或函数间传递
  4. 在需要记录日志的地方,通过Context的Value方法获取保存的TraceID,然后把它和其他日志信息记录下来
  5. 这样具备同样TraceID的日志就可以串联起来,达到日志跟踪的目的

你可能感兴趣的:(【Go上下文Context】)