c.Set 和 c.Get 是并发安全的,但 c.Keys 是个导出的 map。这篇用 -race 和多个 goroutine 验证 Set/Get 到底安全到什么程度,再把绕过锁直接写 Keys 的后果、nil map 的坑和锁本身的开销一一量出来。
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]anySet 持写锁,Get 持读锁,所以通过它们(以及基于它们的 GetString、GetInt 等)并发访问同一个 Context 是安全的。但 Keys 本身是个导出的字段,源码里没有任何东西阻止你绕过锁直接读写。
50 个 goroutine 各 Set+Get 200 次后,Keys 里有 10000 个键(期望 10000),读回不一致 0 次 竞态检测器报告个数: 0 ;退出码 0
50 个 goroutine、每个 200 次 Set+Get,最后 10000 个键一个不少,读回的值全部一致,-race 没有任何报告。注意这是对“同一个 Context”并发访问的结果;跨请求持有 Context 的问题见上一篇。
第 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 中间件拦不住,服务会直接挂掉。
进入时 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。