Go 的秘密生活:Context 包

发布: (2026年1月20日 GMT+8 12:08)
9 min read
原文: Dev.to

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()selectctx.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

Back to Blog

相关文章

阅读更多 »

Go的秘密生活:并发

将混乱的 race condition 变得有序。第15章:通过通信共享。那天星期二档案异常嘈杂。不是来自声音,而是来自 t...