Go 的秘密生活:Context 包
Source: Dev.to
如何停止失控的 goroutine 并防止内存泄漏
如何停止失控的 goroutine 并防止内存泄漏。
Ethan: “我有内存泄漏。”
Eleanor: “真的有吗?” (不抬头)
Ethan: “嗯,严格来说不是泄漏。但看看我的仪表盘。” (指向显示锯齿形上升曲线的监视器) “这是我的 API。每当用户搜索商品时,我就会启动一个 goroutine 去查询数据库和推荐引擎。如果用户感到无聊并关闭浏览器,我的服务器仍然在工作。”
Eleanor: “因为你没有让它停止。” (走过去)
Ethan: “我以为 HTTP 处理器会处理这个?”
Eleanor: “它只处理连接。你的 goroutine 是独立的。你启动它们后就走开了。它们仍在运行,为已经离开的用户消耗 CPU 周期。你对服务器很不礼貌。”
Ethan: “那我该怎么告诉它们用户已经离开了?”
Eleanor: “你传递 Context。”
第一个参数
Eleanor 拉起一把椅子。
“在 Go 中,
context.Context是在 API 边界上传递截止时间、取消信号和请求范围值的标准方式。它几乎总是函数的第一个参数。”
她打开了 Ethan 的代码。
// Bad: No way to stop this function once it starts
func SlowDatabaseQuery(id string) string {
time.Sleep(5 * time.Second) // Simulate work
return "Product Details for " + id
}
func HandleSearch(w http.ResponseWriter, r *http.Request) {
// We ignore the request context!
result := SlowDatabaseQuery("12345")
fmt.Fprintln(w, result)
}
“如果我访问这个端点并在一秒后取消请求,你的
SlowDatabaseQuery仍然会睡满五秒。把这乘以一千个用户,你的服务器就会崩溃。”
她重构了代码。
// Good: We accept a Context
func SlowDatabaseQuery(ctx context.Context, id string) (string, error) {
// Use a select statement to listen for cancellation
select {
// …
}
}
看
select代码块。我们在竞争两件事:工作完成 或 上下文完成。ctx.Done()是一个在上下文被取消时会关闭的通道。如果用户断开连接,ctx.Done()会立即触发,函数随即返回。这样可以省下四秒的工作时间。
超时(设定边界)
“那处理了取消,” Ethan 说。“但是如果数据库坏了并且一直卡住怎么办?用户一直等,连接保持打开状态……”
“那就设定一个截止时间,” Eleanor 回答。“永远不要让进程无限运行。我们使用
context.WithTimeout。”
她创建了一个新示例。
func CallExternalAPI() error {
// 1. Create a derived context that dies after 100 ms
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
// 2. ALWAYS defer the cancel function to release resources
defer cancel()
// 3. Pass this strict context to the worker
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://slow-api.com", nil)
client := &http.Client{}
_, err := client.Do(req)
return err
}
“这很激进。”
“如果外部 API 比这更慢,我们就不想要答案,” Eleanor 坚定地说。“这可以保护你的系统。如果他们的服务器卡住,你的服务器就不会积累成千上万的等待连接。你要快速失败并继续前进。”
“那
defer cancel()呢?”
“至关重要。如果工作在 10 ms 内完成,计时器仍在后台计时。调用cancel()可以停止计时器并立即释放内存。一定要defer cancel()。”
值(谨慎使用)
“我在文档里看到
context.WithValue。我可以用它把用户对象和配置设置传递给我的函数吗?”
Eleanor皱眉。
“你可以,但通常不应该。
WithValue用于请求范围的数据——比如请求 ID 或认证令牌。它是无类型且不可见的。如果你用它来传递必需的函数参数,你的代码会变得不透明。”
“不透明?”
“如果函数需要数据库连接,应该把它作为参数传递:
func(db *DB)。不要把它隐藏在ctx中,让人看不见。显式总比隐式好。”
指挥链
Ethan 看着他的仪表盘。内存使用率趋于平稳。
“它会传播,”他恍然大悟。“如果我在
HandleSearch中取消父上下文,它会取消SlowDatabaseQuery中的子上下文,从而取消对数据库的 HTTP 请求……”
“正是如此,”Eleanor 说。“这是一条指挥链。当最高层说‘停止’时,指令会一直传递到树的底部。每个函数都会清理自己的混乱并返回。”
她站起身,拿起她那堆穿孔卡片,说道:
“服务器不是垃圾桶,Ethan。不要把它塞满被遗弃的进程。要有礼貌。当用户离开时,停止工作。”
第16章关键概念
| 概念 | 作用 |
|---|---|
context.Context | 携带截止时间、取消信号以及请求范围的值。 |
| First argument | 执行工作的函数应将 Context 作为第一个参数。 |
select on ctx.Done() | select 在 ctx.Done() 上,可在上下文被取消时提前停止工作。 |
context.WithTimeout / WithDeadline | 强制对工作运行时间设定硬性上限。 |
defer cancel() | 在工作完成后立即释放资源(计时器、goroutine 泄漏)。 |
context.WithValue | 谨慎使用——仅用于元数据,不用于必需参数。 |
| Propagation | 取消父上下文会自动取消所有派生的子上下文。 |
通过始终传递并遵守 Context,可以防止 goroutine 失控、避免内存泄漏,并保持服务器响应。
Source: …
Go 上下文概述
一个 context(上下文)携带截止时间、取消信号以及请求范围的值。它是不可变且线程安全的。
ctx.Done()
- 当上下文被取消或超时时关闭的通道。
- 在
select语句中使用它,以便在工作不再需要时提前返回。
context.Background()
- 根上下文。
- 当你在启动主函数或顶层进程且没有可继承的上下文时使用它。
context.WithCancel(parent)
ctx, cancel := context.WithCancel(parent)
- 返回一个带有新
Done通道的父上下文的副本。 - 调用返回的
cancel()函数会关闭该通道。
context.WithTimeout(parent, duration)
ctx, cancel := context.WithTimeout(parent, duration)
- 返回一个在指定持续时间后自动取消的父上下文的副本。
最佳实践:
始终使用 defer cancel(),在函数返回时立即释放资源,即使超时尚未触发。
context.TODO()
- 占位上下文。
- 当你正在重构代码但尚未决定上下文应来自何处时使用它。
context.WithValue
ctx = context.WithValue(parent, key, value)
- 用于传递请求范围的数据(例如 Trace ID)。
- 不要将其用于可选参数或核心依赖(如日志记录器或数据库句柄)。显式参数更清晰。
下一章节:JSON 与标签。Ethan 试图解析配置文件,并发现 Go 的结构体标签是语言中最接近魔法的东西。
Aaron Rose 是一名软件工程师和技术作家,活跃于 tech-reader.blog,也是《Think Like a Genius》的作者,链接见 https://amazon.com/author/aaron.rose。