【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的日志就可以串联起来,达到日志跟踪的目的

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 221,695评论 6 515
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,569评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,130评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,648评论 1 297
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,655评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,268评论 1 309
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,835评论 3 421
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,740评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,286评论 1 318
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,375评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,505评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,185评论 5 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,873评论 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,357评论 0 24
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,466评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,921评论 3 376
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,515评论 2 359

推荐阅读更多精彩内容