bigywater.tech
Go · 84

context.Value:key 类型冲突与查找成本

context.WithValue 看起来像一个随处可用的“全局变量”,但它有两个陷阱:key 类型冲突和线性查找。这篇用实验复现它们,量层数对查找耗时的影响,并核对文档的建议。

覆盖
两个包都用字符串 "id" 作 key 时,先写入的值被后写入的覆盖
3 vs 1331 ns
1000 层的 ctx 链上,查最外层的 key 与查最里层的 key
1 次
每次 WithValue 的分配次数,查找 0 次

key 要用自己的类型

实测 · 本机执行输出
  都用字符串 "id" 作 key:包 A 取回的是 "来自包 B 的用户ID"(被包 B 覆盖)
  各自定义 struct{} 类型作 key:包 A 取回 "来自包 A 的请求ID",包 B 取回 "来自包 B 的用户ID"
  自定义类型 type S string 作 key:ctx.Value("id") = <nil>;ctx.Value(S("id")) = "自定义字符串类型"

两个包都用字符串 "id",后写入的值把前面的盖住。各自定义一个 struct{} 类型当 key,就互不干扰;连 type S string 也和普通字符串是不同的 key。这就是文档要求不要用内置类型作 key 的原因。

线性查找

实测 · 本机执行输出
  链长    1 层:查最早加入的 key      3 ns/次;查最后加入的 key   3 ns/次
  链长   10 层:查最早加入的 key     12 ns/次;查最后加入的 key   3 ns/次
  链长  100 层:查最早加入的 key    136 ns/次;查最后加入的 key   3 ns/次
  链长 1000 层:查最早加入的 key   1331 ns/次;查最后加入的 key   9 ns/次

value 函数沿着父链一层一层比较 key。查最外层的 key 只需 3 ns,查最里层的:10 层 12 ns、100 层 136 ns、1000 层 1331 ns。链一般不长,但如果每个中间件都往 ctx 里塞东西,又在深处取外层的值,就会感觉到。

每次 WithValue 一次分配

实测 · 本机执行输出
  WithValue(ctx, struct{} 类型的 key, 字符串值):每次 1 次分配
  WithValue(ctx, 指针 key, 字符串值):每次 1 次分配
  WithValue(ctx, struct{} key, 整数值 12345):每次 1 次分配
  WithValue(ctx, 自定义 int 类型 key = 3, 字符串值):每次 1 次分配
  WithValue(ctx, 字符串 key "id", 字符串值):每次 1 次分配
  ctx.Value(key) 查找一层:每次 0 次分配

每个 WithValue 分配一个节点(1 次分配);查找不分配。这组测量里换 key 的类型(struct{}、指针、自定义 int、字符串)分配次数都是 1,没看出区别。

取回的是 any

实测 · 本机执行输出
  取回 42(类型 int)
  ctx.Value 取回 any,断言成错误类型:interface conversion: interface {} is int, not string

断言错类型会 panic(interface conversion: interface {} is int, not string),所以包里最好封装 FromContext 函数用带 ok 的断言。

文档怎么说

实测 · 本机执行输出
  Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
  The provided key must be comparable and should not be of type string or any other built-in type to avoid collisions between packages using context. Users of WithValue should define their own types for keys. To avoid allocating when assigning to an interface{}, context keys often have concrete type struct{}. Alternatively, exported context key variables' static type should be a pointer or interface.
  context.go 的 value 函数(节选):
    func value(c Context, key any) any {
        for {
            switch ctx := c.(type) {
            case *valueCtx:
                if key == ctx.key {
                    return ctx.val
                }
                c = ctx.Context
            case *cancelCtx:

文档的建议是只放跨进程、跨 API 的请求范围数据(请求 ID、认证信息),不要拿来给函数传可选参数,后者会让依赖变成隐式的,难以测试;key 要自定义类型,常用 struct{}。源码里 value 函数的循环就是前面看到的逐层比较。

分步演示

context.Value
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。第二节的链用 context.WithValue 反复嵌套,key 为 struct{} 类型加不同的 int 类型 key,查最里层/最外层各一次基准;分配用 testing.AllocsPerRun;文档与源码取自本机 go doc context.WithValue 与 src/context/context.go 的 value 函数
    • 没有验证:cancelCtx/timerCtx 混在链中的查找开销、链很深时对 GC 的影响、不同 key 类型对装箱分配的影响(本测法没有看出差别)、第三方 ctx 实现(走 default 分支调用 Value 方法)
    京ICP备2026067502号-1京公网安备11010502063487号