Go设计取舍之一: goroutine 为什么保持匿名、无状态

本文整理自 2022 年 9 月 30 日至 10 月 3 日发生在 golang-nuts 邮件列表上的讨论。共 31 封邮件,Rob Pike、Ian Lance Taylor、Axel Wagner 等人参与其中。详见:Go routine context

为什么 Go 不给 goroutine 一个身份?从一场 31 封邮件的争论说起

很多写过 Go 服务的人,都有过类似的抱怨:

go 复制代码
func Handle(ctx context.Context, req *Request) error {
    return service.Process(ctx, req)
}

func (s *Service) Process(ctx context.Context, req *Request) error {
    return s.repo.Save(ctx, req)
}

ctx 从入口一路传到底层。它可能只承载请求取消、超时、trace ID 和少量日志字段,却出现在一层又一层函数签名中。

如果用过 Java,问题自然会冒出来:为什么不把这些信息存在 ThreadLocal 里?Go 的 goroutine 也很轻量,一个请求通常由一个 goroutine 接收,那能不能提供一个 GoroutineLocal,让日志、追踪和请求上下文自动跟着当前 goroutine 走?

2022 年秋天,golang-nuts 上的一场讨论把这个问题一路追到了 Go 并发模型的根部。

讨论是怎样开始的

2022 年 9 月 30 日,Robert Engels 分享了一篇介绍 Java 虚拟线程的文章。他的判断是:当线程足够便宜,可以为每个请求创建一条线程时,线程本身就可以代表请求上下文。既然如此,就没有必要让每一层函数都显式接收 context.Context

这个观点很有吸引力。它对应的是一种非常直观的模型:

text 复制代码
一个请求
   ↓
一条虚拟线程或 goroutine
   ↓
线程本地存储中保存请求信息

日志组件需要 trace ID 时,直接读取当前执行单元的本地数据;数据库层需要租户信息时,也不必修改函数签名。

但 Ian Lance Taylor 很快指出,Go 中的请求并不天然属于某一条 goroutine。

在网络服务里,一个请求经常会派生出一组 goroutine:

go 复制代码
func Handle(ctx context.Context) error {
    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error {
        return loadUser(ctx)
    })
    g.Go(func() error {
        return loadOrders(ctx)
    })
    g.Go(func() error {
        return loadPermissions(ctx)
    })

    return g.Wait()
}

这里真正属于请求的,是 ctx 所表示的取消关系、截止时间和请求级数据,而不是某一条具体 goroutine。

反过来也存在另一种情况:一条长期运行的 goroutine 负责管理连接池、缓存或某个串行化资源,它会通过 channel 接收来自多个请求的任务。此时,一条 goroutine 同时服务很多请求,也不能把自身等同于任何一个请求上下文。

Rob Pike 的核心观点:goroutine 应该匿名、无状态

讨论第二天,Rob Pike 加入了线程。他提到,Go 很早就作出过一个关键选择:不为 goroutine 定义公开名称,也不鼓励给 goroutine 绑定可识别状态。

这句话很容易被理解成"Go 团队只是没做 goroutine ID",但它实际指向一种更深的编程约束。

一旦执行单元拥有稳定身份和本地状态,程序会逐渐产生这样的代码:

go 复制代码
func SaveOrder(order Order) error {
    tenantID := CurrentGoroutineLocal().TenantID
    traceID := CurrentGoroutineLocal().TraceID
    // ...
}

从函数签名看,SaveOrder 只依赖一个 Order;实际上,它还依赖调用它的 goroutine 已经被正确设置了租户和追踪信息。

这种依赖是隐形的。

单元测试必须先准备 goroutine-local 状态;任务被转交给另一个 goroutine 后,数据是否继承要看运行时规则;某个通用组件如果内部启动 goroutine,还必须决定复制哪些状态、何时清理,以及父子执行单元之间能否互相修改。

显式传递 Context 确实有点啰嗦,却把依赖写在了函数边界上:

go 复制代码
func SaveOrder(ctx context.Context, order Order) error

调用者一眼就知道,这个操作可能受到取消、截止时间或请求级数据影响。

Rob Pike 还强调,"一请求一线程"并不是并发模型的终点。执行单元足够便宜以后,一个请求完全可以使用多条 goroutine;某些工作也可以被多个请求共享。把计算或数据绑定到一条执行线程,会反过来限制这种拆分和组合。

context.Context 不是 goroutine 的属性

标准库对 Context 的定义很明确:它在 API 边界和进程之间传递截止时间、取消信号以及请求范围的数据。一个 Context 可以被多条 goroutine 同时使用。

这意味着它描述的是一棵工作关系,而不是线程归属。

text 复制代码
请求 Context
├── 查询用户的子 Context
├── 查询订单的子 Context
└── 查询权限的子 Context

父 Context 被取消时,派生出的子 Context 也被取消。这个传播关系不要求所有工作运行在同一条 goroutine 上。

如果改用 goroutine-local 数据,跨 goroutine 传递就会重新成为问题:

  • 创建子 goroutine 时是否自动继承?
  • 继承的是引用还是快照?
  • 子 goroutine 修改后,父 goroutine 能否看到?
  • 任务通过 channel 交给长期 worker 时,如何切换上下文?
  • goroutine 被复用或长期存活时,旧数据何时清理?

Java 的 InheritableThreadLocal 能解决其中一部分,但它不能自然表达所有权转移、多个 goroutine 共同处理一个请求,以及一条 worker goroutine 轮流处理多个请求的情况。

结构化并发并不等于线程本地上下文

讨论中,大家又转向了当时的 Java JEP 428,也就是结构化并发。

结构化并发主张:由一个任务启动的子任务,应当形成明确的生命周期树。父任务结束前需要等待子任务;失败和取消应当沿着结构传播。这个理念和 Go 中常见的 errgroup 用法很接近。

Axel Wagner 在邮件中作了一个很重要的区分:

  • Java 的底层并发原语仍然可以是非结构化的;
  • StructuredTaskScope 是建立在这些原语之上的高层模型;
  • Go 也可以用 errgroup.GroupContext 等工具组织结构化并发;
  • 真正不同的是 Java 有线程本地存储,而 Go 有意不提供通用的 goroutine-local storage。

所以,结构化并发并不能直接证明 Context 应该藏进 goroutine。恰恰相反,当任务会跨 goroutine、经过 channel 或交给共享 worker 时,显式 Context 更容易把不同并发模型连接起来。

Go 真的完全没有 goroutine-local 状态吗

讨论到这里,Ian Lance Taylor 举了一个看似反例的 API:runtime/pprof.Do

go 复制代码
pprof.Do(ctx, pprof.Labels("tenant", tenantID), func(ctx context.Context) {
    doWork(ctx)
})

执行 f 时,当前 goroutine 会带上相应的性能分析标签;在这段执行期间启动的新 goroutine,也会继承这些标签。runtime/pprof.SetGoroutineLabels 甚至直接修改当前 goroutine 的标签。

这的确是一种受限制的 goroutine-local 状态,但它有几个边界:

  1. 用途非常具体,主要服务于 profile 归因;
  2. 标签从 Context 中取得,Context 仍然是显式载体;
  3. 继承规则由 API 明确规定;
  4. 它没有变成一个任意包都能存取任意对象的通用字典。

这反映了 Go 常见的设计方式:不是先提供一个能力极强的底层机制,再要求开发者自律,而是针对明确场景提供窄接口。

sync.Pool 也是类似例子。它解决临时对象复用,却没有把通用线程本地缓存暴露给程序。

显式传递真的只是缺点吗

很多人把 ctx context.Context 看作"函数染色":一旦底层需要 Context,上层所有函数都得跟着增加参数。

这种抱怨并非没有道理。Context 很容易被滥用:

  • 把普通业务参数放入 Context.Value
  • 为了省参数,把数据库对象、配置甚至可变状态塞进去;
  • 所有函数机械地接收 ctx,却根本不处理取消;
  • 在 struct 中长期保存 Context,模糊生命周期。

但显式参数也带来几个实际好处。

第一,依赖可见。调用链中哪些操作参与请求取消,一眼可以看出来。

第二,静态工具可以检查传播。go vet 能发现部分 CancelFunc 未调用的问题;接口和代码审查也能识别 Context 是否放在第一个参数。

第三,测试不需要伪造当前 goroutine 的隐式环境:

go 复制代码
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

err := SaveOrder(ctx, order)

第四,Context 能自然跨越 goroutine 和进程边界。HTTP、RPC 和消息系统可以从显式上下文中提取 deadline、trace 信息,再传给下游。

工程里应该怎样处理日志和追踪信息

这场讨论并不意味着所有信息都必须用 Context.Value 传递。更稳妥的做法是区分数据类型。

请求取消和 deadline 使用 Context:

go 复制代码
func Query(ctx context.Context, id string) error

真正的业务参数使用普通参数或结构体:

go 复制代码
func SaveOrder(ctx context.Context, tenantID string, order Order) error

日志字段可以在边界处从 Context 中提取,构造请求级 logger:

go 复制代码
logger := baseLogger.With(
    "trace_id", traceID,
    "tenant_id", tenantID,
)

return service.Process(ctx, logger, req)

是否继续显式传 logger,要看项目规模和团队约定。关键不是"参数越少越好",而是避免让函数依赖一个看不见、又会随执行线程变化的环境。

这场争论留下的答案

Go 不公开稳定的 goroutine ID,也没有通用 GoroutineLocal,并不是因为实现不了。

它想避免的是另一套编程习惯:把请求、日志、权限、事务和各种可变状态绑定到一条执行线程,再让所有函数从隐式环境中取数据。

Go 更愿意让 goroutine 成为可以随时创建、拆分、交接和组合的执行工具。请求属于一组工作,而不是某条线程;Context 描述工作关系,而不是 goroutine 身份。

显式传递有成本。但这项成本换来的,是依赖可见、生命周期清楚,以及并发结构可以自由变化。

这也是为什么,Rob Pike 所说的"匿名、无状态",并不是一句审美判断,而是一条影响整个 Go 并发模型的设计原则。

参考链接

相关推荐
fliter1 小时前
Go设计取舍之二: maps.Keys和Values为什么返回迭代器
后端
热心市民lcj1 小时前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
Revolution611 小时前
Nest.js 是什么:怎样用它写出第一个后端接口
后端·node.js·nestjs
aiopencode1 小时前
SwiftUI Introspect生产环境完全指南:为什么它是安全可靠的选择
后端·ios
shengjk12 小时前
x86架构发展史:从8086到x86-64,一文看懂40多年CPU指令集如何改变世界
后端
JackSparrow4143 小时前
前端安全之JS混淆+请求加密+请求签名以提升爬虫难度
前端·javascript·后端·爬虫·python·安全
geovindu3 小时前
go:loghelper
开发语言·后端·golang
小满zs4 小时前
Go语言第四章(类型转换)
后端·go
人间凡尔赛5 小时前
告别冷启动!WebAssembly + Spin 实战:Serverless 延迟从 1 秒降到 1 毫秒
后端·云原生·serverless·webassembly·spin