【从零单排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的值作为日志关键字,从而查到整条链路的日志了。

相关推荐
努力的小郑37 分钟前
MySQL索引(三):字符串索引优化之前缀索引
后端·mysql·性能优化
IT_陈寒1 小时前
🔥3分钟掌握JavaScript性能优化:从V8引擎原理到5个实战提速技巧
前端·人工智能·后端
程序员清风2 小时前
贝壳一面:年轻代回收频率太高,如何定位?
java·后端·面试
考虑考虑2 小时前
Java实现字节转bcd编码
java·后端·java ee
AAA修煤气灶刘哥2 小时前
ES 聚合爽到飞起!从分桶到 Java 实操,再也不用翻烂文档
后端·elasticsearch·面试
爱读源码的大都督2 小时前
Java已死?别慌,看我如何用Java手写一个Qwen Code Agent,拯救Java
java·人工智能·后端
星辰大海的精灵3 小时前
SpringBoot与Quartz整合,实现订单自动取消功能
java·后端·算法
天天摸鱼的java工程师3 小时前
RestTemplate 如何优化连接池?—— 八年 Java 开发的踩坑与优化指南
java·后端
一乐小哥3 小时前
一口气同步10年豆瓣记录———豆瓣书影音同步 Notion分享 🚀
后端·python
LSTM973 小时前
如何使用C#实现Excel和CSV互转:基于Spire.XLS for .NET的专业指南
后端