Go 中的上下文控制

Go 开发者在权衡如何负责任地使用 `context` 和 goroutine,重点关注取消、资源生命周期,以及避免在库中隐藏并发。许多人倾向于让 API 看起来是同步的,由调用者决定是否并发执行,同时也承认库内部 goroutine 和长时间运行的工作线程有其合理场景。讨论还批评 `context` 既承载取消又承载请求作用域数据的混合属性,将其与 Rust 和 C# 中的模式进行比较,并提到了结构化并发以及 Go“没有 async 着色”理想的局限。

库中的 Goroutine 与 API 设计

  • 一个强烈的主题是:库 API 应该看起来是同步的;是否需要并发应由调用者自己通过启动 goroutine 来决定。
  • 库内部的 goroutine 是可以接受的,前提是它们:
    • 明确属于该抽象的一部分(例如爬虫、并行 map、批处理 pub/sub、指标刷新器)。
    • 有界、文档完备且可调(限制、速率、批量处理、in-flight 上限)。
    • 在公共函数返回之前被妥善清理(结构化并发风格,通常通过 errgroupWaitGroup)。
  • 反对“库里永远不要启动 goroutine”的人认为这种说法过于僵硬;对某些工作负载来说,让库负责调度会更简单也更安全。
  • 后台 goroutine 中的 panic 是一个令人担忧的问题:除非调用者拥有 goroutine 边界,否则他们无法用 recover 把它包住。

Context:目的、模式与痛点

  • 主要有两个角色:
    • 取消 / 截止时间 / “你现在可以停止了”。
    • 请求作用域数据(日志器、链路追踪、认证等)。
  • 许多人不喜欢它像个“装东西的袋子”这一点;很难知道为什么一个函数需要 context,或其中到底有什么。
  • 另一些人则为显式的 ctx 参数辩护:
    • 使可中断性和日志需求变得可见。
    • 比隐式的线程局部存储更容易审查和推理。
  • 讨论中的建议:
    • 常见规则:不要存储 context;把它向下传递。
    • 更细致地说:在本质上就是参数的 struct 中存储它是可以的;而在标准库中,为了向后兼容也有这种做法(例如 net/http),有时会使用 NewRequestWithContext
    • 当需要存储“值但不要截止时间”时,context.WithoutCancel 被视为一个有用的折中方案。
  • 摩擦点:
    • 无法静态知道被调用者是否会遵守 context。
    • 需要把 ctx 一路传到“几乎每个函数”中的压力,尤其是在日志场景下。

Context 与函数着色 / 异步模型

  • 一些人把 ctx 看作一种温和的“着色”(有 context 的函数与没有 context 的函数),这在某种程度上削弱了 Go “没有 async 关键字” 的叙事。
  • 另一些人则认为它远没有 async/await 那么受限:
    • 你总可以使用 context.Background(),或者用超时对其进行包装。
    • 它不会像其他语言那样制造严格的同步 / 异步分离。

错误处理与零值(旁支话题)

  • 围绕 (T, error) 模式的争论:
    • 一派认为:即使 error 被设置,零值 / nil 值也应该是“有用的”。
    • 另一派认为:在大多数真实 API 中,零值没有意义或很危险;缺少 sum type 迫使人们采用笨拙的约定。