【从零单排Golang】第十话:快速理解并上手context的基础用法

原文摘自本人CSDN博客:【从零单排Golang】第十话:快速理解并上手context的基础用法

Golang的各种用法当中,context可谓是最能够体现Golang语言特性的模块之一。context的本意为情境、上下文,而在Golang程序当中,则可以被用于描述一次调用会话或者任务的状态信息。关于context网上有很多语法以及源码分析的文档,但是里面很多却不能从实战场景体现context的作用,导致这个概念难以理解。因此这一回,经由踩坑context后,笔者将结合自己的理解,给大家讲述contextGolang怎么用来最为方便,怎么理解最为实用。

首先来了解一下什么是context。我们先走源码:

go 复制代码
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

在源码定义中可以看到,context模块给开发者定义了一个接口约定Context。在先前关于接口的文章中有提到,接口本身定义的是一个实体可以做的行为,那么我们初步理解context的时候,就可以通过Context的定义,知道一个Context可以干什么。

假设一个Context实例ctx,关联到了一次会话,作为当次会话情境。根据代码定义,Context可以做以下几种行为:

  • Deadline -> 透出两个信息:本次会话的DDL是否有设置(ok),设置到了什么时候(deadline
  • Done -> 通过调用<- ctx.Done(),可以阻塞等待本次会话情境结束
  • Err -> 当情境结束时,可以知道本次会话结束掉情境的原因
    • 这个原因是程序性质的,比如超时或者程序主动调用cancel,不具备业务性质
    • 要给到具备业务性质的情境结束原因,需要用到context.Cause,具体用法见下文
  • Value -> 透出当前情境设定的某一个字段的值

可以看到,Context实例具备共享值信息(ValueDeadline)以及共享状态信息(DoneErr)的作用,定义上非常轻量实用。在实战场景里,context也有两个最为典型的应用场景,分别是:

  • 单次会话里,在相互配合的goroutine之间,共享当次会话的值、状态等情境信息
  • 长链路调用里,透传调用信息,覆盖到整个调用链路,使得每单个调用链路信息都可回溯

这两种应用场景,通过context模块的预置功能,加以组合,就可以充分实现。

Golang的设计里,每一个context.Context实例生成,都必须关联到一个父级的Context实例,这样的设计下,比如父级的情境结束了,那么子级的情境也会递归结束,从而能够满足情境之间的关联关系。Golang为开发者提供了两个最根部的Context实例:context.Background()context.TODO(),均是单纯实现了Context接口定义,返回零值。在状态层面,这两个Context不可结束,因为没有等待结束的chanDone接口里实现。

业务如果要自己定义Context实例,就必须继承这两个Context实例,或者他们的子Context实例。这两个根部Context的业务含义是:

  • context.Background():业务层面需要起一个最根部的Context实例,继承这个
  • context.TODO():业务还不清楚继承什么Context时,继承这个

上一段代码案例:

go 复制代码
func TestCtxBase(t *testing.T) {
    ctxBg := context.Background()
    ctxTodo := context.TODO()
    t.Logf("context.Background: %s, %d", ctxBg, ctxBg)
    t.Logf("context.TODO: %s, %d", ctxBg, ctxTodo)
    select {
    case <-ctxTodo.Done():
        t.Logf("context.TODO is done")
    case <-time.After(1 * time.Second):
        t.Logf("timeout")
    }
}

由于context.Background()context.TODO()不可取消,显然地,这段代码会1秒之后打印timeout

接下来就来看一下,context怎么在不同goroutine之间共享会话情境信息。Golang默认定义了context.WithCancelcontext.WithCancelCausecontext.WithDeadline以及context.WithValue等几个Context实例构造器,构造出来的内容里,除了新创建的Context实例之外,也会给一些回调函数,用来修改新Context实例的状态信息。

首先来看context.WithCancelcontext.WithCancelCause,两者作用相似:

  • context.WithCancel(parent Context) (ctx Context, cancel CancelFunc)
    • 输入:父Context
    • 输出:新Context、用于结束掉新Context的回调,签名为func()
  • context.WithCancelCause(parent Context) (ctx Context, cancel CancelFunc)
    • 输入:父Context
    • 输出:新Context、用于结束掉新Context的回调,签名为func(cause error)

context.WithCancelCause相对于context.WithCancel,唯一的不同点是可以输入一个cause信息,来声明是因为什么业务性质的原因从而取消整个Context,而程序写法上大致相似。

假设我们针对一次会话,建立起这样的goroutine协作模式:

  • goroutine决定某个会话要不要继续做下去
  • goroutine处理业务逻辑,但期间还要关注主goroutine的决策,来决定继不继续做

那么从程序角度,就可以写这么一个例子:

go 复制代码
func TestCtxWithCancel(t *testing.T) {
    cancelCause := errors.New("debug")
    ctxCancel, cancel := context.WithCancelCause(context.Background())
    t.Logf("context.WithCancel: %v, %p -> cause: %v", ctxCancel, cancel, cancelCause)

    sleepTimeout := 1 * time.Second  // 主goroutine觉得这个工作该完成的用时
    waiterTimeout := 2 * time.Second  // 子goroutine觉得这个工作该完成的用时

    // 子goroutine
    join := make(chan string)
    go func(ctx context.Context, timeout time.Duration, retChan chan string) {
        var ret string
        select {
        case <-ctx.Done():  // 主goroutine都不干了,那就摆烂吧,返回done
            t.Logf("ctx done! -> err: %v, cause: %v", ctx.Err(), context.Cause(ctx))
            ret = "done"
        case <-time.After(waiterTimeout):  // 返回timeout
            t.Logf("waiter timeout")
            ret = "timeout"
        }
        retChan <- ret
    }(ctxCancel, waiterTimeout, join)

    // 主goroutine再等待sleepTimeout就不干了
    time.Sleep(sleepTimeout)
    cancel(cancelCause)
    t.Logf("cancel done!")

    // join waiter
    ret := <-join
    t.Logf("waiter ret: %s", ret)
}

这种场景下,如果sleepTimeout小于waiterTimeout,由于主goroutine先调用cancel,那么子goroutineselect里就会先监听到ctx.Done,从而直接返回一个done字符串结束掉。反之如果sleepTimeout大于waiterTimeout,子goroutine会等到waiterTimeout之后,再返回一个timeout字符串。但不管怎么说,ctxCancel实例在主goroutine和子goroutine之间是有效共享的,主goroutine通过cancel方法操作ctxCancel实例的结果,子goroutine是可以感知到的。

接下来看一下context.WithDeadline,和context.WithCancel一样,也是返回新的Context实例和主动结束情境的cancel函数。但有所不同的是,业务需要输入一个自动结束掉情境的deadline时刻,这样到了deadline的时候,新的Context实例会自动地cancel掉整个情境。有兴趣的同学,可以看下context.WithDeadline怎么通过源码实现的,本文不做源码解析,只看用法。

假设和刚才一样,对于一次业务会话的协作关系,主goroutine决定做不做,子goroutine做牛马,那么如果用到context.WithDeadline的话,可以这样描述:

go 复制代码
func TestCtxWithDeadline(t *testing.T) {
    timeout := 3 * time.Second

    deadline := time.Now().Add(timeout)
    ctxDeadline, cancel := context.WithDeadline(context.Background(), deadline)
    t.Logf("context.WithDeadline: %v, %p", ctxDeadline, cancel)

    // deadline/cancel detector
    join := make(chan string)
    go func(ctx context.Context, retChan chan string) {
        var ret string
        select {
        case <-ctx.Done():
            ddl, ok := ctx.Deadline()
            if !ok {
                t.Logf("ctx deadline not set")
                ret = "nothing"
            } else if time.Now().After(ddl) {
                t.Logf("ctx reached deadline: %v -> err: %v", ddl, ctx.Err())
                ret = "deadline"
            } else {
                t.Logf("ctx early canceled! -> err: %v", ctx.Err())
                ret = "cancel"
            }
        }
        retChan <- ret
    }(ctxDeadline, join)

    // manually cancel after cancelTimeout
    cancelTimeout := 1 * time.Second
    time.Sleep(cancelTimeout)
    cancel()
    t.Logf("cancel done!")

    ret := <-join
    t.Logf("ret: %s", ret)
}

当子goroutine有监听到整个情境结束时,就有几种可能性:

  • 情境没有设置deadline,因为其它原因被结束掉
  • 情境设置了deadline,并且到了deadline时间
  • 情境设置了deadline,但还没到deadline时间就被其它原因取消掉了

那么业务层面,就可以根据这几种可能性,来分配不同的业务逻辑了。

最后,我们来看context.WithValue的作用。context.WithValue,本质是为继承的Context实例,新增一对keyvalue的映射。用法非常简单:

go 复制代码
func TestCtxWithValue(t *testing.T) {
    key1, value1 := "hello", "world"
    ctxValue1 := context.WithValue(context.Background(), key1, value1)
    key2, value2 := "foo", "bar"
    ctxValue2 := context.WithValue(ctxValue1, key2, value2)

    t.Logf("ctxValue1: %s", ctxValue1)
    t.Logf("ctxValue1.%s = %v", key1, ctxValue1.Value(key1))  // world
    t.Logf("ctxValue1.%s = %v", key2, ctxValue1.Value(key2))  // nil
    t.Logf("ctxValue2: %s", ctxValue2)
    t.Logf("ctxValue2.%s = %v", key1, ctxValue2.Value(key1))  // world
    t.Logf("ctxValue2.%s = %v", key2, ctxValue2.Value(key2))  // bar
}

在长链路调用的场景下,RPC/日志框架层面,可以约定一组携带调用信息的keys。以此为基准,RPC框架在收到请求时,可以创建一次调用的Context,通过context.WithValue为这些keys赋值,然后再把包含调用信息的Context实例给到业务handler。业务handler需要利用到这个Context实例,不仅调用下游的时候需要带上,而且在日志打印逻辑中,也需要输入Context实例,从而使得调用信息可以在日志中被打印出。这样一来,调用信息就可以覆盖到整条链路。当我们需要排查调用逻辑问题的时候,就可以把调用信息里某个key的值作为日志关键字,从而查到整条链路的日志了。

相关推荐
向前看-6 小时前
验证码机制
前端·后端
超爱吃士力架8 小时前
邀请逻辑
java·linux·后端
AskHarries10 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion11 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp11 小时前
Spring-AOP
java·后端·spring·spring-aop
我是前端小学生11 小时前
Go语言中的方法和函数
go
TodoCoder11 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
凌虚12 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心13 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
.生产的驴14 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven