在Golang的各种用法当中,context可谓是最能够体现Golang语言特性的模块之一。context的本意为情境、上下文,而在Golang程序当中,则可以被用于描述一次调用、会话或者任务的状态信息。关于context网上有很多语法以及源码分析的文档,但是里面很多却不能从实战场景体现context的作用,导致这个概念难以理解。因此这一回,经由踩坑context后,笔者将结合自己的理解,给大家讲述context在Golang怎么用来最为方便,怎么理解最为实用。
首先来了解一下什么是context。我们先走源码:
1 | type Context interface { |
在源码定义中可以看到,context模块给开发者定义了一个接口约定Context。在先前关于接口的文章中有提到,接口本身定义的是一个实体可以做的行为,那么我们初步理解context的时候,就可以通过Context的定义,知道一个Context可以干什么。
假设一个Context实例ctx,关联到了一次会话,作为当次会话的情境。根据代码定义,Context可以做以下几种行为:
Deadline-> 透出两个信息:本次会话的DDL是否有设置(ok),设置到了什么时候(deadline)Done-> 通过调用<- ctx.Done(),可以阻塞等待本次会话的情境结束Err-> 当情境结束时,可以知道本次会话结束掉情境的原因- 这个原因是程序性质的,比如
超时或者程序主动调用cancel,不具备业务性质 - 要给到具备业务性质的
情境结束原因,需要用到context.Cause,具体用法见下文
- 这个原因是程序性质的,比如
Value-> 透出当前情境设定的某一个字段的值
可以看到,Context实例具备共享值信息(Value、Deadline)以及共享状态信息(Done、Err)的作用,定义上非常轻量实用。在实战场景里,context也有两个最为典型的应用场景,分别是:
- 单次会话里,在相互配合的
goroutine之间,共享当次会话的值、状态等情境信息 - 长链路调用里,透传调用信息,覆盖到整个调用链路,使得每单个调用链路信息都可回溯
这两种应用场景,通过context模块的预置功能,加以组合,就可以充分实现。
在Golang的设计里,每一个context.Context实例生成,都必须关联到一个父级的Context实例,这样的设计下,比如父级的情境结束了,那么子级的情境也会递归结束,从而能够满足情境之间的关联关系。Golang为开发者提供了两个最根部的Context实例:context.Background()和context.TODO(),均是单纯实现了Context接口定义,返回零值。在状态层面,这两个Context不可结束,因为没有等待结束的chan在Done接口里实现。
业务如果要自己定义Context实例,就必须继承这两个Context实例,或者他们的子Context实例。这两个根部Context的业务含义是:
context.Background():业务层面需要起一个最根部的Context实例,继承这个context.TODO():业务还不清楚继承什么Context时,继承这个
上一段代码案例:
1 | func TestCtxBase(t *testing.T) { |
由于context.Background()和context.TODO()不可取消,显然地,这段代码会1秒之后打印timeout。
接下来就来看一下,context怎么在不同goroutine之间共享会话情境信息。Golang默认定义了context.WithCancel、context.WithCancelCause、context.WithDeadline以及context.WithValue等几个Context实例构造器,构造出来的内容里,除了新创建的Context实例之外,也会给一些回调函数,用来修改新Context实例的状态信息。
首先来看context.WithCancel和context.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的决策,来决定继不继续做
那么从程序角度,就可以写这么一个例子:
1 | func TestCtxWithCancel(t *testing.T) { |
这种场景下,如果sleepTimeout小于waiterTimeout,由于主goroutine先调用cancel,那么子goroutine的select里就会先监听到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的话,可以这样描述:
1 | func TestCtxWithDeadline(t *testing.T) { |
当子goroutine有监听到整个情境结束时,就有几种可能性:
- 情境没有设置
deadline,因为其它原因被结束掉 - 情境设置了
deadline,并且到了deadline时间 - 情境设置了
deadline,但还没到deadline时间就被其它原因取消掉了
那么业务层面,就可以根据这几种可能性,来分配不同的业务逻辑了。
最后,我们来看context.WithValue的作用。context.WithValue,本质是为继承的Context实例,新增一对key和value的映射。用法非常简单:
1 | func TestCtxWithValue(t *testing.T) { |
在长链路调用的场景下,RPC/日志框架层面,可以约定一组携带调用信息的keys。以此为基准,RPC框架在收到请求时,可以创建一次调用的Context,通过context.WithValue为这些keys赋值,然后再把包含调用信息的Context实例给到业务handler。业务handler需要利用到这个Context实例,不仅调用下游的时候需要带上,而且在日志打印逻辑中,也需要输入Context实例,从而使得调用信息可以在日志中被打印出。这样一来,调用信息就可以覆盖到整条链路。当我们需要排查调用逻辑问题的时候,就可以把调用信息里某个key的值作为日志关键字,从而查到整条链路的日志了。