菜鸟笔记
提升您的技术认知

深入理解Golang中的Context包

context.Context是Go语言中独特的设计,在其他编程语言中我们很少见到类似的概念。context.Context深度支持Golang的高并发。

1. Goroutine和Channel

在理解context包之前,应该首先熟悉Goroutine和Channel,能加深对context的理解。

1.1 Goroutine

Goroutine是一个轻量级的执行线程,多个Goroutine比一个线程轻量,所以管理Goroutine消耗的资源相对更少。Goroutine是Go中最基本的执行单元,每一个Go程序至少有一个Goroutine:主Goroutine。程序启动时会自动创建。为了能更好的理解Goroutine,先来看一看线程Thread与协程Coroutine的概念。

  • 线程(Thread)
    线程是一种轻量级进程,是CPU调度的最小单位。一个标准的线程由线程ID当前指令指针(PC)寄存器集合堆栈组成。线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属于一个进程的其他线程共享进程所拥有的全部资源。线程拥有自己独立的栈和共享的堆,共享堆,不共享栈 线 程 的 切 换 一 般 由 操 作 系 统 调 度 \color{red}{线程的切换一般由操作系统调度} 线程的切换一般由操作系统调度
  • 协程(Coroutine)
    协程又称为微线程,与子例程一样,协程也是一种程序组建,相对子例程而言,协程更为灵活,但在实践中使用没有子例程那样广泛。和线程类似,共享堆,不共享栈, 协 程 的 切 换 一 般 由 程 序 员 在 代 码 中 显 式 控 制 \color{red}{协程的切换一般由程序员在代码中显式控制} 协程的切换一般由程序员在代码中显式控制。他避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。

Goroutine和其他语言的协程(coroutine)在使用方式上类似,但从字面意义上来看不同(一个是Goroutine,一个是coroutine),再就是协程是一种协作任务控制机制,在最简单的意义上,协程不是并发的,而Goroutine支持并发的。因此Goroutine可以理解为一种Go语言的协程。同时,Gorotine可以运行在一个或多个线程上

1.2 使用Goroutine示例

func Hello()  {
  
    fmt.Println("hello everybody , I'm lineshen")
}

func main()  {
  
    go Hello()
    fmt.Println("Golang-Gorontine Example")
}

在该代码片段中,使用go又开启了一个Goroutine执行Hello方法,其运行结果如下:

Golang-Gorontine Example

从执行结果上,直观的看,我们的程序似乎没有执行Goroutine的Hello方法。出现这个问题的原因是我们启动的主Goroutine在main执行完就退出了,所以为了main等待Hello-Goroutine执行完,就需要一些方法,让Hello-Goroutine告诉main执行完了,这里就需要通道Channel了。

1.3 Channel

Channel就是多个Goroutine 之间的沟通渠道。当我们想要将结果或错误,或任何其他类型的信息从一个 goroutine 传递到另一个 goroutine 时就可以使用通道。通道是有类型的,可以是 int 类型的通道接收整数或错误类型的接收错误等。

假设有个 int 类型的通道 ch,如果想发一些信息到这个通道,语法是 ch <- 1,如果想从这个通道接收一些信息,语法就是 var := <-ch。这将从这个通道接收并存储值到 var 变量。

通过改善1.2中的代码片段,证明通道的使用确保了 goroutine 执行完成并将值返回给 main 。

func Hello(ch chan int)  {
  
    fmt.Println("hello everybody , I'm lineshen")
    ch <- 1
}

func main()  {
  
    ch := make(chan int)
    go Hello(ch)
    <-ch
    fmt.Println("Golang-Gorontine Example")
}

执行结果如下:

hello everybody , I'm lineshen
Golang-Gorontine Example

这里我们使用通道进行等待,这样main就会等待Hello-Goroutine执行完。熟悉了Goroutine、Channel的概念了,就很好理解Context了。

2. Context应用场景

熟悉了Goroutine、Channel的概念,先看一个请求服务场景示例:

func main()  {
  
    http.HandleFunc("/", SayHello) // 设置访问的路由
    log.Fatalln(http.ListenAndServe(":8080",nil))
}

func SayHello(writer http.ResponseWriter, request *http.Request)  {
  
    fmt.Println(&request)
    writer.Write([]byte("Hi, New Request Comes"))
}

上述代码,每次请求,Handler会创建一个Goroutine为其提供服务;
如果连续请求3次,request的地址也是不同的:

$ curl http://localhost:8080/
0xc0000b8030
0xc000186008
0xc000186018

在真实应用场景中,每个请求对应的Handler,常会启动额外的的goroutine进行数据查询或PRC调用等。这里可以理解为每次请求的业务处理逻辑中,需要多次访问其他服务,而这些服务是可以并发执行的,即主Gorontine内的多个Goroutine并存。而且,当请求返回时,这些额外创建的goroutine需要及时回收。此外,一个请求对应一组请求域内的数据可能会被该请求调用链条内的各goroutine所需要。

现在对上面代码在添加一点东西,当请求进来时,Handler创建一个监控goroutine,这样就会每隔1s打印一句Current request is in progress:

func main()  {
  
    http.HandleFunc("/", SayHello) // 设置访问的路由

    log.Fatalln(http.ListenAndServe(":8080",nil))
}

func SayHello(writer http.ResponseWriter, request *http.Request)  {
  
    fmt.Println(&request)

    go func() {
  
        for range time.Tick(time.Second) {
  
            fmt.Println("Current request is in progress")
        }
    }()

    time.Sleep(2 * time.Second)
    writer.Write([]byte("Hi, New Request Comes"))
}

这里假定请求需要耗时2s,在请求2s后返回,我们期望监控goroutine在打印2次Current request is in progress后即停止。但运行发现,监控goroutine打印2次后,其仍不会结束,而会一直打印下去。

问题出在创建监控goroutine后,未对其生命周期作控制,下面我们使用context作一下控制,即监控程序打印前需检测request.Context()是否已经结束,若结束则退出循环,即结束生命周期。

func main()  {
  
    http.HandleFunc("/", SayHello) // 设置访问的路由

    log.Fatalln(http.ListenAndServe(":8080",nil))
}

func SayHello(writer http.ResponseWriter, request *http.Request)  {
  
    fmt.Println(&request)

    go func() {
  
        for range time.Tick(time.Second) {
  
            select {
  
            case <- request.Context().Done():
                fmt.Println("request is outgoing")
                return
            default:
                fmt.Println("Current request is in progress")
            }
        }
    }()

    time.Sleep(2 * time.Second)
    writer.Write([]byte("Hi, New Request Comes"))
}

基于如上需求,context包应用而生。
context包可以提供一个请求从API请求边界到各goroutine的请求域数据传递、取消信号及截至时间等能力。

3. Context详解

在 Go 语言中 context 包允许传递一个 “context” 到程序中。 Context 如超时或截止日期(deadline)或通道,来指示停止运行和返回。例如,如果正在执行一个 web 请求或运行一个系统命令,定义一个超时对生产级系统通常是个好主意。因为,如果依赖的API运行缓慢,不希望在系统上备份(back up)请求,因为它可能最终会增加负载并降低所有请求的执行效率。导致级联效应。这是超时或截止日期 context 派上用场的地方。

3.1 设计原理

Go 语言中的每一个请求的都是通过一个单独的 Goroutine 进行处理的,HTTP/rpc 请求的处理器往往都会启动新的 Goroutine 访问数据库和 RPC 服务,我们可能会创建多个 Goroutine 来处理一次请求,而 Context 的主要作用就是在不同的 Goroutine 之间同步请求特定的数据、取消信号以及处理请求的截止日期。

每一个 Context 都会从最顶层的 Goroutine 一层一层传递到最下层,这也是 Golang 中上下文最常见的使用方式,如果没有 Context,当上层执行的操作出现错误时,下层其实不会收到错误而是会继续执行下去:

当最上层的 Goroutine 因为某些原因执行失败时,下两层的 Goroutine 由于没有接收到这个信号所以会继续工作;但是当我们正确地使用 Context 时,就可以在下层及时停掉无用的工作减少额外资源的消耗:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210221230717934.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmv0L3NoZW56aWhlbmcx,size_16,color_FFFFFF,t_70#pic_center = 600x300)
这其实就是 Golang 中上下文的最大作用,在不同 Goroutine 之间对信号进行同步避免对计算资源的浪费,与此同时 Context 还能携带以请求为作用域的键值对信息。

这里光说,其实也不能完全理解其中的作用,所以我们来看一个例子:

func main()  {
  
    ctx,cancel := context.WithTimeout(context.Background(),1 * time.Second)
    defer cancel()
    go HelloHandle(ctx,500*time.Millisecond)
    select {
  
    case <- ctx.Done():
        fmt.Println("Hello Handle ",ctx.Err())
    }
}

func HelloHandle(ctx context.Context,duration time.Duration)  {
  

    select {
  
    case <-ctx.Done():
        fmt.Println(ctx.Err())
    case <-time.After(duration):
        fmt.Println("process request with", duration)
    }

}

上面的代码,因为过期时间大于处理时间,所以我们有足够的时间处理改请求,所以运行代码如下图所示:

process request with 500ms
Hello Handle  context deadline exceeded

HelloHandle函数并没有进入超时的select分支,但是main函数的select却会等待context.Context的超时并打印出Hello Handle context deadline exceeded。如果我们将处理请求的时间增加至2000ms,程序就会因为上下文过期而被终止。

context deadline exceeded
Hello Handle  context deadline exceeded

3.2 Context接口

context.Context 是 Go 语言在 1.7 版本中引入标准库的接口1,该接口定义了四个需要实现的方法,其中包括:

  • Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  • Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消之后关闭,多次调用 Done 方法会返回同一个 Channel;
  • Err — 返回 context.Context 结束的原因,它只会在 Done 返回的 Channel 被关闭时才会返回非空的值;如果 context.Context 被取消,会返回 Canceled 错误;如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  • Value — 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
type Context interface {
  
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{
  }
    Err() error
    Value(key interface{
  }) interface{
  }
}

3.2.1 创建context

context包允许一下方式创建和获得context:

  • context.Background():这个函数返回一个空context。这只能用于高等级(在 main 或顶级请求处理中)。
  • context.TODO():这个函数也是创建一个空context。也只能用于高等级或当您不确定使用什么 context,或函数以后会更新以便接收一个 context 。这意味您(或维护者)计划将来要添加 context 到函数。

其实我们查看源代码。发现他俩都是通过 new(emptyCtx) 语句初始化的,它们是指向私有结构体 context.emptyCtx 的指针,这是最简单、最常用的上下文类型:

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
  
    return
}

func (*emptyCtx) Done() <-chan struct{
  } {
  
    return nil
}

func (*emptyCtx) Err() error {
  
    return nil
}

func (*emptyCtx) Value(key interface{
  }) interface{
  } {
  
    return nil
}

func (e *emptyCtx) String() string {
  
    switch e {
  
    case background:
        return "context.Background"
    case todo:
        return "context.TODO"
    }
    return "unknown empty Context"
}

从上述代码,我们不难发现 context.emptyCtx 通过返回 nil 实现了 context.Context 接口,它没有任何特殊的功能。

从源代码来看,context.Background 和 context.TODO 函数其实也只是互为别名,没有太大的差别。它们只是在使用和语义上稍有不同:

  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生(Derived)出来。
  • context.TODO 应该只在不确定应该使用哪种上下文时使用;
    在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。

3.2.2 context的继承衍生

有了如上的根Context,那么是如何衍生更多的子Context的呢?这就要靠context包为我们提供的With系列的函数了。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{
  }) Context

这四个With函数,接收的都有一个partent参数,就是父Context,我们要基于这个父Context创建出子Context的意思,这种方式可以理解为子Context对父Context的继承,也可以理解为基于父Context的衍生。

通过这些函数,就创建了一颗Context树,树的每个节点都可以有任意多个子节点,节点层级可以有任意多个。

WithCancel函数,传递一个父Context作为参数,返回子Context,以及一个取消函数用来取消Context。 WithDeadline函数,和WithCancel差不多,它会多传递一个截止时间参数,意味着到了这个时间点,会自动取消Context,当然我们也可以不等到这个时候,可以提前通过取消函数进行取消。

WithTimeout和WithDeadline基本上一样,这个表示是超时自动取消,是多少时间后自动取消Context的意思。

WithValue函数和取消Context无关,它是为了生成一个绑定了一个键值对数据的Context,这个绑定的数据可以通过Context.Value方法访问到.

  • WithValue
    context 包中的 context.WithValue 函数能从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型,我们看一下源码:
// stringify tries a bit to stringify v, without using fmt, since we don't
// want context depending on the unicode tables. This is only used by
// *valueCtx.String().
func stringify(v interface{
  }) string {
  
    switch s := v.(type) {
  
    case stringer:
        return s.String()
    case string:
        return s
    }
    return "<not Stringer>"
}

func (c *valueCtx) String() string {
  
    return contextName(c.Context) + ".WithValue(type " +
        reflectlite.TypeOf(c.key).String() +
        ", val " + stringify(c.val) + ")"
}

func (c *valueCtx) Value(key interface{
  }) interface{
  } {
  
    if c.key == key {
  
        return c.val
    }
    return c.Context.Value(key)
}

此函数接收 context 并返回派生 context,其中值 val 与 key 关联,并通过 context 树与 context 一起传递。这意味着一旦获得带有值的 context,从中派生的任何 context 都会获得此值。不建议使用 context 值传递关键参数,而是函数应接收签名中的那些值,使其显式化。

context.valueCtx 结构体会将除了 Value 之外的 Err、Deadline 等方法代理到父上下文中,它只会响应 context.valueCtx.Value 方法。如果 context.valueCtx 中存储的键值对与 context.valueCtx.Value 方法中传入的参数不匹配,就会从父上下文中查找该键对应的值直到在某个父上下文中返回 nil 或者查找到对应的值。

实际使用示例:

type key string

func main()  {
  
    ctx := context.WithValue(context.Background(),key("lineshen"),"tencent")
    Get(ctx,key("lineshen"))
    Get(ctx,key("line"))
}

func Get(ctx context.Context,k key)  {
  
    if v, ok := ctx.Value(k).(string); ok {
  
        fmt.Println(v)
    }
}

上面代码我们基于context.Background创建一个带值的ctx,然后可以根据key来取值。这里为了避免多个包同时使用context而带来冲突,key不建议使用string或其他内置类型,所以建议自定义key类型.

  • WithCancel
    此函数创建从传入的父 context 派生的新 context。父 context 可以是后台 context 或传递给函数的 context。返回派生 context 和取消函数。只有创建它的函数才能调用取消函数来取消此 context。如果您愿意,可以传递取消函数,但是,强烈建议不要这样做。这可能导致取消函数的调用者没有意识到取消 context 的下游影响。可能存在源自此的其他 context,这可能导致程序以意外的方式运行。简而言之,永远不要传递取消函数。

  • WithDeadline
    此函数返回其父项的派生 context,当截止日期超过或取消函数被调用时,该 context 将被取消。例如,您可以创建一个将在以后的某个时间自动取消的 context,并在子函数中传递它。当因为截止日期耗尽而取消该 context 时,获此 context 的所有函数都会收到通知去停止运行并返回。

我们来看一下源码:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
  
        return WithCancel(parent)
    }
    c := &timerCtx{
  
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    propagateCancel(parent, c)
    dur := time.Until(d)
    if dur <= 0 {
  
        c.cancel(true, DeadlineExceeded) // 已经过了截止日期
        return c, func() {
   c.cancel(false, Canceled) }
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.err == nil {
  
        c.timer = time.AfterFunc(dur, func() {
  
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() {
   c.cancel(true, Canceled) }
}

context.WithDeadline也都能创建可以被取消的计时器上下文 context.timerCtx:

context.WithDeadline 方法在创建 context.timerCtx 的过程中,判断了父上下文的截止日期与当前日期,并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel 方法同步取消信号。

  • WithTimeout
    此函数类似于 context.WithDeadline。不同之处在于它将持续时间作为参数输入而不是时间对象。此函数返回派生 context,如果调用取消函数或超出超时持续时间,则会取消该派生 context。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
  
    return WithDeadline(parent, time.Now().Add(timeout))
}

观看源码我们可以看出WithTimeout内部调用的就是WithDeadline,其原理都是一样的,上面已经介绍过了,来看一个例子吧:

func main()  {
  

    ctx,cancel := context.WithTimeout(context.Background(),10 * time.Second)
    defer cancel()
    go Monitor(ctx)

    time.Sleep(20 * time.Second)

}

func Monitor(ctx context.Context)  {
  
    select {
  
    case <- ctx.Done():
        fmt.Println(ctx.Err())
    case <-time.After(20*time.Second):
        fmt.Println("stop monitor")
    }
}

4. 总结:Context使用原则

  1. context.Background 只应用在最高等级,作为所有派生 context 的根。
  2. context取消是建议性的,这些函数可能需要一些时间来清理和退出。
  3. 不要把Context放在结构体中,要以参数的方式传递。
  4. 以Context作为参数的函数方法,应该把Context作为第一个参数,放在第一位。
  5. 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO
  6. Context的Value相关方法应该传递必须的数据,不要什么数据都使用这个传递
  7. context.Value应该很少使用,它不应该被用来传递可选参数。这使得 API 隐式的并且可以引起错误。取而代之的是,这些值应该作为参数传递。
  8. Context是线程安全的,可以放心的在多个goroutine中传递。同一个Context可以传给使用其的多个goroutine,且Context可被多个goroutine同时安全访问。
  9. Context 结构没有取消方法,因为只有派生 context 的函数才应该取消 context。

Go 语言中的 context.Context 的主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用,虽然它也有传值的功能,但是这个功能我们还是很少用到。在真正使用传值的功能时我们也应该非常谨慎,使用 context.Context 进行传递参数请求的所有参数一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。