bigywater.tech
Gin · 21

c.Keys 的并发安全

c.Set 和 c.Get 是并发安全的,但 c.Keys 是个导出的 map。这篇用 -race 和多个 goroutine 验证 Set/Get 到底安全到什么程度,再把绕过锁直接写 Keys 的后果、nil map 的坑和锁本身的开销一一量出来。

10000 个键
50 个 goroutine 同时 Set+Get,没有丢失,-race 报告 0 个
3 / 3
直接并发写 c.Keys 时崩溃的次数(concurrent map writes)
+3.3 ns
c.Get 比直接读 map 多出的耗时(16.1 vs 12.8 ns)

锁在哪里

实测 · 本机执行输出
  context.go 的 Set(Gin v1.12.0):
     276 | func (c *Context) Set(key any, value any) {
     277 |     c.mu.Lock()
     278 |     defer c.mu.Unlock()
     279 |     if c.Keys == nil {
     280 |         c.Keys = make(map[any]any)
     281 |     }
     282 | 
     283 |     c.Keys[key] = value
     284 | }
  context.go 的 Get(Gin v1.12.0):
     288 | func (c *Context) Get(key any) (value any, exists bool) {
     289 |     c.mu.RLock()
     290 |     defer c.mu.RUnlock()
     291 |     value, exists = c.Keys[key]
     292 |     return
     293 | }
  context.go:  76 |     mu sync.RWMutex
  context.go:  79 |     Keys map[any]any

Set 持写锁,Get 持读锁,所以通过它们(以及基于它们的 GetString、GetInt 等)并发访问同一个 Context 是安全的。但 Keys 本身是个导出的字段,源码里没有任何东西阻止你绕过锁直接读写。

Set / Get 的安全性

实测 · 本机执行输出
  50 个 goroutine 各 Set+Get 200 次后,Keys 里有 10000 个键(期望 10000),读回不一致 0 次
  竞态检测器报告个数: 0 ;退出码 0

50 个 goroutine、每个 200 次 Set+Get,最后 10000 个键一个不少,读回的值全部一致,-race 没有任何报告。注意这是对“同一个 Context”并发访问的结果;跨请求持有 Context 的问题见上一篇。

绕过 Set 的后果

实测 · 本机执行输出
  第 1 次运行:退出码 2;第一行 'fatal error: concurrent map writes'
  第 2 次运行:退出码 2;第一行 'fatal error: concurrent map writes'
  第 3 次运行:退出码 2;第一行 'fatal error: concurrent map writes'
  -race 版本:退出码 2 ;报告个数 1 ;第一份报告里的两个访问:
    Write at 0x00c00020a360 by goroutine 17:
    main.main.func1.1()
    main.main.func1.gowrap1()
    Previous write at 0x00c00020a360 by goroutine 16:
    main.main.func1.1()
    main.main.func1.gowrap1()

直接并发写 c.Keys,Go 运行时会检测并发写 map 并让整个进程以 fatal error: concurrent map writes 退出(退出码 2)。这是 fatal error 而不是普通 panic,Recovery 中间件拦不住,服务会直接挂掉。

nil map

实测 · 本机执行输出
    进入时 c.Keys == nil: true;读 c.Keys["a"] = <nil>
    直接 c.Keys["a"] = 1 → panic: assignment to entry in nil map
    c.Set 之后 c.Keys == nil: false,len=1
    v1.12 的 Keys 是 map[any]any:结构体做键 / 整数做键

Keys 是懒创建的:进入 handler 时是 nil,读取没有问题,但直接 c.Keys["a"] = 1 会 panic。v1.12 的键类型是 any,可以用自定义类型做键来避免不同包之间的键冲突。

开销

实测 · 本机执行输出
    c.Get("k")            16.1 ns/op
    map[any]any 直接读     12.8 ns/op
    c.Set("k", i)         29.8 ns/op
    map[any]any 直接写     23.7 ns/op

读写锁在无竞争时的开销是几个纳秒:读多 3.3 ns,写多 6.1 ns。相对于 handler 里的任何 I/O 都可以忽略,不需要为了省这点开销而绕过 Set / Get。

分步演示

Keys 并发
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。并发实验用真实的 httptest.NewServer;-race 版本用 go build -race;崩溃实验重复 3 次(每次一个新进程);基准是 testing.Benchmark,9 轮中位数,用 gin.CreateTestContext 创建的 Context,单 goroutine
    • 没有验证:读写混合且有争用时的锁开销;sync.Map 或分片锁是否更合适;Get 系列包装函数(GetString 等)的类型断言开销;在 c.Copy() 之后跨 goroutine 访问副本 Keys 的行为(副本的 Keys 是独立的 map)
    京ICP备2026067502号-1京公网安备11010502063487号