Golang 上下文 Context 源码解析(1): 值传递

如果在 公众号 文章发现状态为 已更新, 建议点击 查看原文 查看最新内容。

状态: 未更新

原文链接: https://typonotes.com/posts/2023/03/01/devopscamp-context-sample/

上下文 Context 应该是 Go语言 中一个极其重要的 基石 概念了。 本文将通过一个案例 着重 说明 值传递 的过程、用法和注意事项。

本文会通过 案例分析, 扩展到 源码讲解使用方式 等多方面进行 Context 讲解。

阅读完本文后, 你能

  1. 掌握标准库中的 Context 是如何实现存取值的。
  2. 掌握开源库中, 对于 Context 的封装使用。

扩展阅读

这里有一篇 Go 语言设计与实现 - 上下文 Context , 是目前我学习的资料中 完成度友善度 都很高的一篇文章。

不管你愿不愿意, 用 Go 都绕不过 Context。不管用不用, 在所有 公共方法或函数 中强迫自己自己使用 context 作为入参。 虽然有点武断,但是…(我也没有想到好的理由)

从上文中我们可以确认, context 有两个核心作用, 值传递信号传递

  1. 值传递: 将上文的中的值传递到下文。 最直观的用法可能应该链路追踪。
  2. 信号传递: 应该算 值传递 的一种特殊情况。 通过捕获信号、处理信息, 可以控制调用链流程。

值传递案例讲解: 曹操打新野

就用 context 实现一个 曹操打新野 的值传递游戏, 要求如下

1
2
3
main -> Lubei(ctx context.Context, n int)
     -> Guanyu(ctx context.Context)
     -> Zhangfei(ctx context.Context)
  1. 给刘备传递 任意数字
  2. 刘备拿到数字, 并输出 “曹操来了 n 万人”
  3. 刘备把数字传递给关羽。
  • a. 如果数字为偶数, 直接传递给张飞
  • b. 如果数字为奇数, 数字扩大10倍后传递给张飞。
  • c. 输出 “曹操来了 n 万人”。 (注意 n 的值)
  1. 张飞拿到数字, 直接输出 “曹操来了 n 万人”。

代码已经放到了 Github 上: https://github.com/tangx-labs/golang-context-with-from-demo

Context 是一个接口

为什么要单独把这个拿出来说呢? 因为最开始我以为 Conetxt 是一个 struct, 所有的 Context 都是一样的。 指导我在 gonic-gin/gin 那边踩了坑(挖个坑,以后有机会再说)。

1
2
3
4
5
6
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

而我们常用的 标准库 Conetxt, 其实背后基本上是 4种 基础 struct 构成。

  1. emptyCtx
  2. valueCtx: 这个是我们今天的重点。
  3. cancelCtx
  4. timerCtx

如果你常用他们, 应该能马上想起对应他们几个的方法或函数。

向 context 注入值

通常我们使用 context.WithValue 函数 进行变量值注入。

1
2
3
4
5
6
7
8
// 定义曹军的 key , 用于 context 的值传递
type enemy int // 定义一个新类型
var keyCaojun = enemy(0)

// 将曹军数量注入到 context 中
func WithEnemyContext(ctx context.Context, number int) context.Context {
	return context.WithValue(ctx, keyCaojun, number)
}

最后的返回值 context.Context 就是一个 valueCtx。 也就是不管你传入的 ctx 是什么, 最后得到的都是 valueCtx。 这个是无法控制的, 除非你不用这个函数而自己实现。

从代码中可以看到, 我定义了一个 函数 叫做 WithEnemyContext 专门用作 值注入, 而 keyCaojun 并不是作为参数传递进来的, 而是在定义在 包级别 的常量。

这是因为:作为一个 , 所提供功能相对固定, 不需要用户自己定义。 这样可以减少了由于输入带来的不必要问题。

这也是开源库中常见的用法。

conetxt key 的讲究

从代码中可以看到, 我为这个 key 定义了一个 新类型, 其基础类型是 int

1
2
3
// 定义曹军的 key , 用于 context 的值传递
type enemy int // 定义一个新类型
const keyCaojun = enemy(0)

掌握了基础的你应该知道, 类型 enemyint 可以互相转换, 但他们并不相同。

事实上, 在 context 注入值的时候, 应该 尽量避免 使用 基础类型。 就是因为基础类型太过于常用, 在做 key 的时候, 如果其值很简单, 很可能不知不觉就发生冲突被覆盖了。

如果一定要使用基础类型怎么办? 那就把值设置复杂一点吧

从 context 中取值

同样的, 为了从 context 中取值, 也封装了一个函数 FromEnemyContext, 同样也用到了 keyCaojun 这个常量。 这样 WithEnemyConetxt 一起, 组成了 一组 用于存取的函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 从 context 中取出曹军数量
func FromEnemyContext(ctx context.Context) int {
	val := ctx.Value(keyCaojun)

	n, ok := val.(int) // 类型断言
	if !ok {
		return 0
	}
	return n
}

在代码中, 通过 ctx.Value 取出来的值是 any 类型, 在返回之前, 我们需要将其断言成 int 类型。

不过, 由于我们这是 一组 函数, 所以 val 的类型肯定是 int, 而其 零值 就是 0。 综上这么多 约束条件, 此处代码可以简写, 不需要在判断类型断言是否成功。

1
2
3
4
5
6
7
// 从 context 中取出曹军数量
func FromEnemyContext(ctx context.Context) int {
	val := ctx.Value(keyCaojun)

	n := val.(int) // 类型断言
	return n
}

值传递源码解析

下面这段代码, 就是 “曹操打新野” 的故事的代码实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func main() {
	ctx := context.Background()

	Liubei(ctx, 9)
}

func Liubei(ctx context.Context, n int) {

	// (1) 注入到 context
	ctx = info.WithEnemyContext(ctx, n)

	fmt.Printf("刘备: 曹操来了 %d 万人\n", n)
	Guanyu(ctx)
}

func Guanyu(ctx context.Context) {
	n := info.FromEnemyContext(ctx)
	// (2) 获取消息
	fmt.Printf("关羽(1) <-: 曹操来了 %d 万人\n", n)

	if n%2 == 1 {
		// 扩大数量
		n = n * 10
		// (1) 重新注入到 context
		ctx = info.WithEnemyContext(ctx, n)
	}

	fmt.Printf("关羽(2) ->: 曹操来了 %d 万人\n", n)
	Zhangfei(ctx)
}

func Zhangfei(ctx context.Context) {
	// (2) 获取消息
	n := info.FromEnemyContext(ctx)
	fmt.Printf("张飞: 曹操来了 %d 万人\n", n)
}

在代码中, 可以注意到

  1. 在 (1) 处: 刘备和关羽都通过 WithEnemyContext 传递了军情信息。
  2. 在 (2) 处: 关羽和张飞都通过 FromEnemyContext 获得了军情信息。

虽然他们都使用了相同的函数, 相同的 key, 但是 传递或得到 的军情却是不同的。

我们现在从上到下, 一步步来理清楚。

BackgroundTODO

虽然他们都是 emptyCtx 的实例, 他们 不能被取消、 没有值、 也没有到期时间

1
2
3
4
var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

但是他们用处不同(字面赋予的意义)。

  1. Background: 用于 main 函数、 初始化、 测试以及最顶层的Context。
  2. TODO: 作为一个占位符, 表示这里还不清楚的初始化, 或待替换成其他 context。

重复向 context 传值

在传值使用 WithValue, 省略其他安全边界检查, 可以看到核心代码如下, 每次都创建了一个新的 valueCtx 对象

1
2
3
4
func WithValue(parent Context, key, val any) Context {
	// ... 省略安全边界
	return &valueCtx{parent, key, val}
}

我们再来看看 valueCtx 的结构, 非常简单, 就是三个字段。

1
2
3
4
type valueCtx struct {
	Context
	key, val any
}
  1. 不管你传入的 context 底层是什么数据结构, 出来的一定是 valueCtx
  2. 每一次调用都是一次 valueCtx 的封装。 调用的的越多, 层就越多。 就像俄罗斯套娃一样。

所以就案例来讲, 画图大概如下。

刘备在 main 上套了一层, 关羽在刘备上套了一层。 由于关羽创建新 context 是有条件的, 所以使用虚线表示。

从 context 取值

这里需要注意, 跟踪 ctx.Value() 其实是到了 Context 接口的定义的 Value 方法字段。

1
2
3
4
type Context interface {
// 省略
    Value(key any) any
}

因此, 要了解 Value 的实际行为逻辑, 还必须的到清楚 ctx 底层到底是什么结构体?

我们这里开了天眼, 知道是 valueCtx, 所以直接转到这部分代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (c *valueCtx) Value(key any) any {
	if c.key == key {
		return c.val
	}
	return value(c.Context, key)
}

func value(c Context, key any) any {
	for { // (1) 循环查找
		switch ctx := c.(type) {
		case *valueCtx:
			if key == ctx.key {
				return ctx.val
			}
			c = ctx.Context // (2) 找到 valueCtx, 但 key 不对, 同变量复制继续循环
		// 省略
		case *emptyCtx: // (3) 如果底层是 emtpyCtx
			return nil
		default:
			return c.Value(key) // (3) 如果最底层是其他自定 Context
		}
	}
}

可以看到, valueCtx.Value 的实现简单粗暴

  1. 先看自己这一层的 key 是否匹配, 匹配则返回 val 值。
  2. 如果不匹配, 那我就不管了。 派遣下面小弟 value() 函数去处理, 自己只需要对外统一返回结果就行。

value() 函数中, 做的事情就比多了。

  1. (1) 首先就创建了一个 for {} 死循环, 准备死磕, 非要找到不可。
  2. (2) 如果当前的是 valueCtx, 就使用 key 进行比对, 匹配就返回, 不匹配就就把 c 替换成 父Context, 继续死磕。
  3. (3) 不断的循环迭代, 到最后找到最底层的 Context。
    • 如果是 emptyCtx, 就直接返回 nil
    • 如果是 用户自定义 的 Context , value() 也不干了, 直接返回用户自定义的方法。 至于后面是什么, 怎么实现的,全看用户。

回到案例中, 情况就比较简单了。

  1. 关羽得到军情, 一定是刘备给的。 用绿线表示。
  2. 而张飞得到军情, 可能是关羽给的、 也可能是刘备给的。从上往下获取。 用黑线表示。

总结

本文中, 着力于 valueCtx 的讨论, 而跳过了 timerCtxcancelCtx

其一, valueCtx 只有值的操作, 用来说明 Context 的 层级结构 更简单。 其二, timerCtxcancelCtx 可以看作 valueCtx 的扩展。 当后者掌握了之后, 前两者就只需要在补充一点个字特色知识就能掌握。