本文整理自 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.Group、Context等工具组织结构化并发; - 真正不同的是 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 状态,但它有几个边界:
- 用途非常具体,主要服务于 profile 归因;
- 标签从 Context 中取得,Context 仍然是显式载体;
- 继承规则由 API 明确规定;
- 它没有变成一个任意包都能存取任意对象的通用字典。
这反映了 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 并发模型的设计原则。