使用 Go 开发后台服务程序,经常会用到上下文 (context.Context)。这是因为远程服务调用,往往会受网络环境等因素的影响,产生耗时长、丢包无返回等异常状况。因此,无论是我们自身提供的服务,还是调用下游的服务,都需要在一定的时间内有完成或取消,并释放资源。

很多人有用了 context.Context,但是对于其具体的功能或者原理不甚清楚,甚至觉着这是个比较复杂的东西。实际上,Context 是个非常简单,以 1.19 为例,context.go 源代码只有不到 600 行。

在本文中,会先强调一下关于 context.Context 的几点基本认识,然后将 context 包按照功能进行了拆解以介绍使用方法、示例以及实现原理,最后抓包展示进程间如何传递 Context。

看完本文,一定能透彻理解 Go 的上下文。

一、基本认识

在介绍具体之前,先列几点关于 Context 接口以及 context 包的基本认识,文章后续也会不断的提及:

  1. Context 只有两个简单的功能:跨 API 或在进程间 1)携带键值对、2)传递取消信号(主动取消、时限/超时自动取消) !!!
  2. Context 是接口,可以通过两种方式获得上下文:
BackGround()TODO()With


  1. 函数间传递的 Context 实际是某结构的地址,是高效的
  2. 相同的 Context 可以多个 goroutine 中使用,是并发安全的

另外,实践中还应该遵循一些规范,这些在 Go 的官方文档中有提及:

ctx
nilcontext.TODO()

二、空的上下文的创建

空的上下文的创建有两种方式:

context.TODO()context.BackGround()
context.emptyCtx

尽管本质上是一样的,但是区分两个函数是为了在编写代码时,更清晰地表明开发人员在创建这个上下文的意图

TODO()BackGround()
emptyCtxDone()Err()Value()nil

三、携带键值对

WithValue()
WithValue()Contextparent

举一个简单的例子:

上述代码打印的内容:

WithValue()

另外,这里的值可以是任何类型,拿出来使用的时候,需要转换成具体的类型。

实现原理

WithValue()*valueCtx
valueCtx
Context.Value()
WithValue()Value()emptyCtx

打印上下文的所有 key/val

String()

我把打印 key-value 的小函数放在 GitHub Gist。这里先不展开说明:

好玩的问题:是否可以将 Context 作为 val 设置进去?

四、传递取消信号

Value()
context.Context

这种处理方式是高效的:虽然可能因为超时或主动取消,没有得到预期结果,但可以及时停止后续操作、释放出资源来处理别的请求,而不必等待每个函数都有返回。

Done() —— 确定上下文是否完成

Done()chan struct{}

【channel 基础知识】通道有一种常见的用法:不会往通道里写入任何东西,在需要发送信号的时候关闭通道,此时接收操作符(receive operator)会立马收到一个管道类型的零值,在 Go 规范中有详细描述:

select
Done()
case <- ctx.Done()<- ctx.Done()

Cancel() —— 取消上下文

context.WithCancelCancelFuncfunc()
WithCancel()cancelCtxcancel()

后边实现原理会介绍一下 cancelCtx 类型的具体内容。

取消上下文的示例

Done()Cancel()

其中代码具体的执行,做一下简单的说明:

genchan intBackGround()WithCancel()gen(ctx)gen()chan intdst


forrange


ctx.Done()

这个示例,使用取消上下文的目的是为了防止 goroutine 的泄漏:如果没有上下文的结束信号,外部的for循环退出之后,goroutine 运行的函数会会一直阻塞在 select,对应的资源也不会被释放。

实现原理

WithCancel()cancelCtx
BackGround()mudoneDone()atomic.Value
Done()
Cancel()

五、带时限/超时的取消

WithCancel()
context.WithDeadline()context.WithTimeout()
WithTimeout()WithDeadline()

超时取消示例

这里仍然使用 context 官方文档上的一个示例,做了一点修改:

对上边的代码做一下简单解释:

WithTimeout()selectfmt.Println(ctx.Err())

如注释:尽管上下文会在超时结束取消,但是作为一种良好实践,在任何场景中,都应该调用一下 cancel 函数

CancelFunc()

Err() —— 获取上下文错误

<-ctx.Done()ctx.Err()
context deadline exceeded
Err()
Done()Done()Err()
CanceledDeadlineExceededWithDeadline()time.AfterFunc

实现原理

*timerCtxtimerCtxcancelCtx
WithDeadline()

六、Context 树与 Cancel 传播

BackGround()

context 包文档中的这段话也是如此的描述,其中提到的是 optionally,这意味着并不是每个函数都需要派生上下文,而是在确实需要的情况下可以派生,不然可以直接使用当前的上下文

The chain of function calls between them must propagate the Context, optionally replacing it with a derived Context created using WithCancel, WithDeadline, WithTimeout, or WithValue. When a Context is canceled, all Contexts derived from it are also canceled.
BackGround()
ctx = WithValue(ctx, key, val)

虽然前边提到过,通过 With 开头的函数,可以派生出新的上下文,但是实际使用中,并不需要每个中间派生的上下文都需要存到一个变量中

下边的代码也很常见:

ctx = WithValue(ctx, key, val)

为什么需要传播 cancel

这里的传播和之前提到的传递信号是两个概念:

  • 传递信号是指创建 goroutine 调用函数,使用同一个上下文,调用方和被调函数都能够主动结束、或感知到结束
  • 传播是指由派生关系的上下文,当父上下文结束之后,会将派生的上下文也 cancel 掉
Calling the CancelFunc cancels the child and its children, removes the parent’s reference to the child, and stops any associated timers. Failing to call the CancelFunc leaks the child and its children until the parent is canceled or the timer fires. The go vet tool checks that CancelFuncs are used on all control-flow paths.

上下文通过直接调用 Cancel 或者通过定时/截止时间间接调用 Cancel 来结束上下文,都会传递给该上下文的所派生出的所有上下文,使得这些上下文结束。

这个功能非常容易理解:以一个 HTTP 请求处理函数为例,假设会多次查询 MySQL,如果我们整个请求处理的超时设置为1秒(会创建一个上下文 parent,WithTimeout 1s),每次 MySQL 请求的超时设置为 800ms(会派生出上下文 childN,WithTimeout 800ms),当第N个 MySQL 请求还没有达到超时,但是总的超时时间已经达到1秒时,parent 会被自动 cancel 掉,这时候当前的 MySQL 查询(以及后续未执行的查询,但是还没有派生上下文),都没有意义,因此的这个派生的上下文childN 会在 parent canel 的过程中也被 cancel 掉。

Cancel 传播示例

以上边解释为什么要是传递给派生上下文的场景为例,写一个简单的例子:

运行上边代码,会在 1秒钟之后结束,其屏幕输出结果是:

**将for移入default会是什么情况?**上边的代码中,每执行完一次查询,都去检查上下文是否完成,如果为未完成走 default 分支。如果将 for 循环移入 default 或者移入 slowFunc 中,结果会有什么异同呢?

slowFunc<-ctx.Done()

实现原理

cancelCtx
  • 通过 cancelCtx.Context 能获得自己父 Context 的信息
  • cancelCtx.children 记录自己的派生的 Context,*cancelCtx 实现了 canceler 接口
cancel()

七、进程间 Context 间传递

context 包文档中第一句就介绍,Context 可以携带信息在跨越 API 边界或在进程间传递。前面的例子大多是说在 API 调用的时候传递 ctx 的例子,接下来介绍一下进程间传递信息的示例和实现。

在进程间传递上下文,需要 RPC 协议的支持。比如较流行的 gRPC 就支持上下文的传递,包括:

  • 默认的 context 中部分信息,比如超时时间
  • 用户自己需要传递的元数据信息 metadata

gRPC 传递上下文的示例

我这里修改 gRPC 中的例子,来演示一下,进程间传递的以上两种信息。

代码地址: https://github.com/panzhongxian/context-helloworld 。这个代码在 helloworld 的基础上做了两点修改:

  • 在 greeter_client/main.go 中增加一个 metadata (weburl: [https://wlbcoder.com])
  • 在 greeter_server/main.go 中增加打印 ctx、ctx.Deadline() 以及 metadata 到标准输出

构建并运行上述代码中的 server 和 client。server 上打印的屏幕输出为:

上面的日志可以看到:1) 上下文中有调用方传来的超时信息(997ms超时);2) metadata 中包含自己注入的信息 weburl: [https://wlbcoder.com] ,同时还有别的一些默认的信息(user-agent、content-type等)

上边这些信息,我们通过 Wireshark 抓包,并过滤出 client 请求 server 的 HTTP2 包,也能找到是如何传输的:

content-typegrpc-grpc-timeoutgrpc-weburl



OpenTelemetry 示例

如果对调用跟踪有了解,对于我们对上下文中携带和传递信息,会更有帮助:在函数之间、在服务之间,传递着 TraceID、SpanID 的,而其传递过程,也是依赖于上下文。

我这里写了一个简单的例子,展示的是 Context 在进程间传递:

func1()func1()

上述程序运行一次,打印的结果如下:

另外,opentelemetry 中的 TraceID 和 SpanID 也是会在进程间进行传递,但如前文提到的,这是依赖于 RPC 框架和通信协议的。接下来,看看 OpenTelemetry 在基于官方 HTTP 库和 gRPC 框架进行进程间传递的示例。

后边再提到 OpenTelemetry 会简称 OTEL。

OTEL x HTTP Client

如果作为 HTTP Client 请求,要携带 Trace 信息,则需要将上下文中的 Trace 信息,通过 HTTP 协议携带过去。

otelhttp.NewTransporthttp.NewRequestWithContext

而 W3C 的规范中,对调用跟踪有支持,也就是明确规定了可以使用哪些 Header 可以携带的调用的上下文:https://www.w3.org/TR/trace-context/

OTEL x gRPC Client

如果使用 gRPC 框架,和直接使用 HTTP Client 不同,无法让 OTEL 直接操作 HTTP 的拼装,而是通过满足 gRPC 要求的 UnaryInterceptor 或者 StreamInterceptor 来实现从上下文中读取或往上下文中写入 Trace 信息:

在 Server 端:

在 Client 端:

我们观察一下 Client 和 Server 中的 Trace 信息的传递,Server 中的 Span 会将 Client 的 Span 作为 Parent Span:

同时,我们可以在抓包中看到的这个 Trace 的一些信息:


结语

本文由浅入深介绍了 context.Context,中间举了很多示例,并且通过抓包的等方式清晰的说明了用法和原理。理解了 Context 之后,得在使用的过程中不断的体会,才能不断的将理解加深。

另外,还有另外一些细节没有介绍,这里提一下,有兴趣等同学可以自行再多探索一下:Go 1.20 新增的 Cause/ WithCancelCause/CancelCauseFunc;源码依赖 “internal/reflectlite”、“sync”、“sync/atomic” 等库,涉及锁的使用,原子变量的使用

参考资料