Go context 上下文

本文介绍如何使用Context来管理Goroutine,实现并发任务的取消和传值功能,包括Context的基本概念、使用方法及示例。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

问题场景

一个任务会有很多个 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 上绑定的值,是一个键值对
}

说明:

  1. 到了截止时间,context 会自动发起取消的请求;
  2. 空的结构体,在 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值