169 lines
5.4 KiB
Markdown
169 lines
5.4 KiB
Markdown
|
|
---
|
|||
|
|
tags:
|
|||
|
|
- 后端
|
|||
|
|
- Go
|
|||
|
|
- Gin
|
|||
|
|
- 并发
|
|||
|
|
create time: 2026-04-27 10:05
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Context 池化与常见陷阱
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
`gin.Context` 通过 `sync.Pool` 复用以减少 GC 压力,这带来了性能红利,也埋下了并发陷阱。本文深入剖析池化机制、保存 Context 引用会引发的数据错乱问题,以及如何正确使用。
|
|||
|
|
|
|||
|
|
## 正文
|
|||
|
|
|
|||
|
|
### 1. Context 的复用流程
|
|||
|
|
|
|||
|
|
Gin 在每次请求的完整生命周期中复用同一个 `*gin.Context` 对象:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// gin/engine.go — ServeHTTP 简化版
|
|||
|
|
func (engine *Engine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
|
|
c := engine.getContext() // 从 sync.Pool 取 Context(可能是旧的)
|
|||
|
|
c.Reset() // 清空所有字段,洗白板
|
|||
|
|
c.Request = r
|
|||
|
|
c.writermem.reset(w)
|
|||
|
|
|
|||
|
|
handlers, params, _ := engine.tree.match(r.URL.Path, r.Method)
|
|||
|
|
c.handlers = handlers
|
|||
|
|
c.params = params
|
|||
|
|
c.index = -1
|
|||
|
|
|
|||
|
|
c.Next() // 执行中间件链 + 用户 handler
|
|||
|
|
|
|||
|
|
engine.freeContext(c) // 归还到 sync.Pool,等待下次复用
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
关键链路:**从 pool 取 → Reset 清空 → 填充新数据 → 执行完毕 → 归还 pool**。
|
|||
|
|
|
|||
|
|
这意味着 `sync.Pool` 里存的是**被洗过白板的对象**,不是干净的新对象。
|
|||
|
|
|
|||
|
|
### 2. 陷阱:保存 c 到全局变量
|
|||
|
|
|
|||
|
|
如果在 handler 中把 `c` 保存到全局变量,**下次请求读到的是新请求的上下文**——因为对象被复用,字段被覆盖。
|
|||
|
|
|
|||
|
|
#### 错误示范
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
var globalC *gin.Context
|
|||
|
|
|
|||
|
|
func SaveHandler(c *gin.Context) {
|
|||
|
|
globalC = c // 保存的是对象引用,不是副本
|
|||
|
|
c.String(200, "saved")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func GetHandler(c *gin.Context) {
|
|||
|
|
// 期望读到上次请求的数据,实际读到的是某个并发请求的数据!
|
|||
|
|
req := globalC.Request
|
|||
|
|
fmt.Println(req.URL.Path) // ← 可能是任意请求的路径
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 字段被覆盖对照表
|
|||
|
|
|
|||
|
|
| 字段 | 请求 A 写入 | 请求 B 到来后(`c.Reset()`) |
|
|||
|
|
|------|-----------|--------------------------|
|
|||
|
|
| `globalC.Request` | A 的 `*http.Request` | **变成 B 的 Request** |
|
|||
|
|
| `globalC.keys` | `map[string]any{"user": "alice"}` | **被置为 nil,请求 B 重新 Set 后恢复** |
|
|||
|
|
| `globalC.Params` | A 的路径参数 | **变成 B 的路径参数** |
|
|||
|
|
| `globalC.writermem` | A 的响应缓冲 | **被重置,A 的已写入响应数据丢失** |
|
|||
|
|
|
|||
|
|
#### 并发场景下的灾难
|
|||
|
|
|
|||
|
|
当多个并发请求到来时,问题会进一步恶化:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
时间线:
|
|||
|
|
T1: 请求 A 进入 → globalC = ctx(指向 pool 中的对象 X)
|
|||
|
|
T2: 请求 B 进入 → ctx 归还 pool → 新请求 C 从 pool 取出同一个对象 X
|
|||
|
|
T3: 请求 C 的 c.Reset() → globalC 指向的对象被清空 → 请求 A 的数据丢失!
|
|||
|
|
T4: GetHandler 读取 globalC → 读到的是请求 C 的数据,不是 A 的
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**这不是简单的数据竞争,而是数据错乱**——你读到的既不是上次请求的数据,也不是当前请求的数据,而是**某个并发请求正在使用的数据**。
|
|||
|
|
|
|||
|
|
### 3. 正确做法
|
|||
|
|
|
|||
|
|
#### 方案一:拷贝数据而非保存引用
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
var uid string
|
|||
|
|
|
|||
|
|
func SaveHandler(c *gin.Context) {
|
|||
|
|
uid = c.GetString("user_id") // 拷贝值,不是保存 c
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func GetHandler(c *gin.Context) {
|
|||
|
|
_ = uid // 安全:拷贝的是基本类型值
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 方案二:c.Set + c.Copy(推荐用于 goroutine)
|
|||
|
|
|
|||
|
|
如果需要在异步 goroutine 中使用请求数据:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func MyHandler(c *gin.Context) {
|
|||
|
|
c.Set("user_id", "123")
|
|||
|
|
|
|||
|
|
// ✅ 正确:c.Copy() 创建独立副本
|
|||
|
|
go func() {
|
|||
|
|
c2 := c.Copy() // 深拷贝 Context,keys 被浅拷贝到新 map
|
|||
|
|
time.Sleep(1 * time.Second)
|
|||
|
|
// c2 在这里是安全的,不受 pool 复用影响
|
|||
|
|
fmt.Println(c2.GetString("user_id")) // "123"
|
|||
|
|
}()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`c.Copy()` 的核心实现:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func (c *Context) Copy() *Context {
|
|||
|
|
copy := &Context{
|
|||
|
|
writermem: c.writermem.Clone(), // 克隆响应缓冲
|
|||
|
|
Params: c.Params, // Params 是 []Param,线程安全
|
|||
|
|
engine: c.engine,
|
|||
|
|
}
|
|||
|
|
// 浅拷贝 keys map
|
|||
|
|
if c.keys != nil {
|
|||
|
|
copy.keys = copyMap(c.keys) // 创建新 map,拷贝所有键值对
|
|||
|
|
}
|
|||
|
|
copy.Request = c.Request // *http.Request 本身是只读的,共享安全
|
|||
|
|
return copy
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **重点理解**:`c.Copy()` 做浅拷贝——`keys` map 本身是新的(键值对也是副本),但 `Request` 共享同一个 `*http.Request` 指针(标准库保证了 handler 执行期间 Request 不会被修改)。
|
|||
|
|
|
|||
|
|
### 4. 设计原则总结
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Context 池化 = 性能红利 + 并发陷阱
|
|||
|
|
|
|||
|
|
✅ 安全:
|
|||
|
|
- 在 handler 同步代码中使用 c
|
|||
|
|
- 传给 goroutine 前先用 c.Copy()
|
|||
|
|
- 只拷贝需要的数据值(c.GetString 等)
|
|||
|
|
|
|||
|
|
❌ 危险:
|
|||
|
|
- 保存 c 的引用到全局/包级变量
|
|||
|
|
- 在 handler 中启动 goroutine 直接使用 c
|
|||
|
|
- 跨请求传递 c 的引用
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5. 思考题
|
|||
|
|
|
|||
|
|
1. `c.Copy()` 是深拷贝还是浅拷贝?如果 `c.Set("user", userObj)` 存了一个指针类型,拷贝后两个 Context 里的 `userObj` 指向同一个对象吗?修改其中一个会互相影响吗?
|
|||
|
|
2. `sync.Pool` 的 Get/Put 是线程安全的,那从 pool 取出的 Context 在并发请求场景下,有没有可能两个请求同时拿到同一个对象?为什么不会?
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[1-gin-architecture]] — Gin 整体架构
|
|||
|
|
- [[middleware]] — 中间件机制
|
|||
|
|
- [[engine-handler]] — Gin 与 http.Handler 的关系
|