Context 本质上讲主要有两方面应用。一方面是关于 key-value 的使用,我们可以向 Context 中不停的添加 key-value 数据,在后面的步骤中可以读取这些数据,这样的好处在于我们可以把很多的数据封装在 Context 中,当整个流程非常长、函数调用链非常多的时候,避免太冗长的参数传递;另一方面是基于 Channel 实现超时控制,通过 Timeout() 超时机制或者 显示的调用 Cancel() 来关闭管道,当管道关闭的时候,select 中相应的 读操作 case <-ctx.Done() 就会解除阻塞,利用这种机制实现超时控制。当然,利用超时控制也可以实现任务取消功能,用户可以主动调用 cancel() 方法,使所有监听 ctx.Done() 的 Goroutine 退出,比如中断计算、停止轮询,或者并发访问数据库时,服务器压力非常大,普通方式仅能取消 HTTP 请求,而 context 可以把所有的 DB 请求、Redis 请求以及其它的 Goroutine 全部取消,实现高负载能力。

相关阅读:面试官:“连 Go Context 都没用过?” 难怪你的并发程序总失控!

一、Context 本质说明

Context 本质上是一个接口,里面有四个函数:

type Context interface {
    Deadline()(deadline time.Time, ok bool)
    Done() <-chan struct{} //Done() 函数返回一个只读管道
    Err() error
    Value(key any) any
}

这四个函数该怎么用呢?Done() 函数可以返回一个只读类型的管道;Deadline() 可以返回该管道关闭的时间;Err() 函数会保存管道关闭的原因。所以从功能上讲,Done() 函数、Deadline() 函数、Err() 函数是一起的,而 Value() 函数可以获取 Context 中的数据。 Context 可以被当成一个 map 使用,Context 中保存若干个 key-value 键值对,通过调用 Value() 函数传入 key 可以获取对应的 value 值,其中 key 和 value 都是 any 空接口类型。

在 Go 的源码中,给出了 Context 的四个函数的实现,但是 Context 和对应的方法都是空的实现。

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 any) any{
    return nil
}

除此之外,Go 的源码中给出 background 和 todo,这两个其实就是 emptyCtx 空的 Context 的对象实例,给出两个函数 Background() 和 TODO() 返回对应的 Context 对象实例。其实创建的 这两个 Context 是一模一样的,但为什么要创建两个一模一样的 Context 呢?Background() 代表明确的根节点,所有派生 Context (如 WithCancel、WithTimeout)的起点,类似 Java 中的 Object 类、Linux 系统的根目录 / 、树的根节点;TODO() 代表标记“不确定”,当前需要使用 Context,但是不确定应该传入哪个时,使用 TODO(),并在后续确定下来尽快替换,主要起到一个标记作用,类似 // TODO 注解。

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

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

一个空的 Context 接口实现有什么用呢?目的就是用于初始化一个 Context,因为在 Go 语言中,我们要自己创建一个 Context 的话需要传入一个父的 Context ,这样就在 Context 之间形成了一个继承链,但是最开始的 Context 是没有父类的,那么就需要调用 Background() 或者 TODO() 函数来生成祖先 Context。

至此,上述内容是 Go 底层源码所实现的 Context 内容,在后续学习其它的 Go 开源框架中,都会有属于自己的 Context ,但是也都会遵守 Go 语言中关于 Context 的规范定义。

二、Value() 的使用

在引入的 context 包中,可以通过 WithValue() 创建 Context,调用方式为

child := context.WithValue(ctx context.Context, key any, value any)

WithValue() 方法传入三个参数,一个是父类 Context,剩下两个是要存入的 key-value 键值对数据。返回的内容是 子 Context 对象。

示例代码:

package main

import (
    "fmt"
    "context"
)

func step1(ctx context.Context) context.Context {
    child := context.WithValue(ctx, "name", "梦塔世界")
    return child
}

func step2(ctx context.Context) context.Context {
    child := context.WithValue(ctx, "age", 25)
    return child
}

func step3(ctx context.Context){
    fmt.Printf("name %s\n", ctx.Value("name"))
    fmt.Printf("age %d\n", ctx.Value("age"))
}

func main() {
    grandPa := context.Background()
    father := step1(grandPa)
    son := step2(father)
    //打印数据
    step3(son)
}

输出结果:

说明:在主函数中 grandPa := context.Background() 创建祖先 Context ,然后将其传入 step1(),设置一对键值对数据,返回 Context father,然后再将其传入 step2(),再设置一对键值对数据,返回 Context son,最后调用 step3() 打印方法,son 继承了 grandPa 和 father 的全部数据,所以最后可以把两对键值对数据全部打印出来。

三、用 Done() 实现超时

在引入的 context 包中,还可以通过 WithTimeout() 创建 Context,调用方式为

ctx, cancel := context.WithTimeout(ctx context.Context, timeout time.Duration)

WithTimeout() 方法传入两个参数,一个是 context.Context,另一个是时间用于设置超时时间,返回的是 子 Context 和 取消函数 cancel。好习惯:立刻延迟执行这个函数 defer cancel() 。当时间达到设置的超时时间后,cancel() 函数会自动取消当前 子 Context,然后关闭管道。

一个管道如果关闭的话,那么我们再从这个管道中读取数据就会立即返回,不管这个管道中是否有数据都会返回,而且返回的值是对应管道元素的零值。

示例代码:

package main

import (
    "fmt"
    "context"
    "time"
)

func f1() {
    ctx, cancel := context.WithTimeout(context.TODO(), time.Millisecond*100)
    defer cancel()

    select {
    case <-ctx.Done():
        err := ctx.Err()
        fmt.Pritln(err)
    }
}

func main() {
    f1()
}

输出结果:

说明:在 select 中的 case 下,一直监听 <-ctx.Done() ,想从 ctx.Done() 返回的管道中读取数据,但是此时管道还没关闭,那么该 case 就会一直阻塞,直到管道关闭,<-ctx.Done() 才会读出数据,然后执行后面的逻辑,调用 ctx.Err() 打印错误信息。最终实现效果就是,100 ms 超时时间结束后,打印出超时错误信息。

四、Timeout 的继承关系

在三中讲到使用 Context.Timeout() 来创建 Context 对象,那么我们基于首次创建的 Context 再次调用 Context.Timeout() 创建 子 Context,而且父子 Context 具有不同的延迟时间,此时父子 Context 的寿命时间取最小值。

示例代码:

package main

import (
	"context"
	"fmt"
	"time"
)

func f2() {
	parent, cancel1 := context.WithTimeout(context.TODO(), time.Millisecond*1000)
	defer cancel1()
	t0 := time.Now()

	time.Sleep(time.Millisecond * 500)

	child, cancel2 := context.WithTimeout(parent, time.Millisecond*1000)
	defer cancel2()
	t1 := time.Now()

	select {
	case <-child.Done():
		err := child.Err()
		t2 := time.Now()
		fmt.Printf("父Context father 的寿命为:%d\n子Context child 的寿命为:%d\n", t2.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds())
		fmt.Println(err)
	}
}

func main() {
	f2()
}

输出结果:

说明:基于祖先 context.TODO() 创建的 father 的寿命是 1000 ms,在 father 基础上创建的 子Context child 寿命也设置为 1000 ms,但是在创建 child 之前程序睡眠 500 ms,导致 <-child.Done() 这里阻塞了 500 ms, 最终形成的效果为 child 的寿命取两者最小值,为 500 ms。

然后做个修改,将 子 Context 的寿命时长设置为 100 ms,其它不变。

child, cancel2 := context.WithTimeout(parent, time.Millisecond*100)

也就是说,当创建 child 的时候,parent 还剩下 500 ms,再过100 ms ,child 过期,此时 parent 的寿命应为 600 ms (500 + 100), child 的寿命为 100 ms。

输出结果:

五、Cancel() 的使用

前面讲到的调用 context.WithTimeout() 方法是设置了 Context 的过期时间,通过 defer cancel() 自动关闭 Context 管道,接下来使用 Cancel() 方法配合协程主动选择何时关闭 Context 管道。调用方式为:

ctx, cancel := context.WithCancel(ctx context.Context)

示例代码:

package main

import (
	"context"
	"fmt"
	"time"
)

func f3() {
    ctx, cancel := context.WithCancel(context.TODO())
    t0 := time.Now()
    //开一个协程
    go func(){
        time.Sleep(time.Millisecond * 100)
        cancel()
    }()

    select {
    case <-ctx.Done():
        err := ctx.Err()
        t1 := time.Now()
        fmt.Printf("Context ctx 的寿命为:%d\n", t1.Sub(t0).Milliseconds())
        fmt.Println(err)
    }
}

func main() {
    f3()
}

输出结果:

说明:context.WithCancel() 方法创建 Context,并且不用指定超时时间,只需传入 Context 即可,后续开启一个协程,显示指定 100 ms 之后调用 cancel() 方法关闭管道,select 监听 ctx.Done() 收到信息后执行后续逻辑,打印错误信息。

六、总结

本篇文章讲解了 Context 的常见用法,包括 Context 接口内部 Deadline()、Done()、Err() 、Value() 四个方法的含义与用法;Go 语言底层实现的空 Context 对象 background 和 todo 的作用;详解了 context.WithValue() 和 context.WithTimeout() 的用法;父子 Context 的数据传递;Done() 监听管道实现超时控制的原理;Timeout() 的继承关系以及最后显示调用 Cancel() 实现超时控制功能。

Categories:

Tags:

No responses yet

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注