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

📅 发布时间:2026/8/2 15:21:26
Go设计取舍之一: goroutine 为什么保持匿名、无状态 本文整理自 2022 年 9 月 30 日至 10 月 3 日发生在golang-nuts邮件列表上的讨论。共 31 封邮件Rob Pike、Ian Lance Taylor、Axel Wagner 等人参与其中。详见Go routine context。为什么 Go 不给 goroutine 一个身份从一场 31 封邮件的争论说起很多写过 Go 服务的人都有过类似的抱怨funcHandle(ctx context.Context,req*Request)error{returnservice.Process(ctx,req)}func(s*Service)Process(ctx context.Context,req*Request)error{returns.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。这个观点很有吸引力。它对应的是一种非常直观的模型一个请求 ↓ 一条虚拟线程或 goroutine ↓ 线程本地存储中保存请求信息日志组件需要 trace ID 时直接读取当前执行单元的本地数据数据库层需要租户信息时也不必修改函数签名。但 Ian Lance Taylor 很快指出Go 中的请求并不天然属于某一条 goroutine。在网络服务里一个请求经常会派生出一组 goroutinefuncHandle(ctx context.Context)error{g,ctx:errgroup.WithContext(ctx)g.Go(func()error{returnloadUser(ctx)})g.Go(func()error{returnloadOrders(ctx)})g.Go(func()error{returnloadPermissions(ctx)})returng.Wait()}这里真正属于请求的是ctx所表示的取消关系、截止时间和请求级数据而不是某一条具体 goroutine。反过来也存在另一种情况一条长期运行的 goroutine 负责管理连接池、缓存或某个串行化资源它会通过 channel 接收来自多个请求的任务。此时一条 goroutine 同时服务很多请求也不能把自身等同于任何一个请求上下文。Rob Pike 的核心观点goroutine 应该匿名、无状态讨论第二天Rob Pike 加入了线程。他提到Go 很早就作出过一个关键选择不为 goroutine 定义公开名称也不鼓励给 goroutine 绑定可识别状态。这句话很容易被理解成“Go 团队只是没做 goroutine ID”但它实际指向一种更深的编程约束。一旦执行单元拥有稳定身份和本地状态程序会逐渐产生这样的代码funcSaveOrder(order Order)error{tenantID:CurrentGoroutineLocal().TenantID traceID:CurrentGoroutineLocal().TraceID// ...}从函数签名看SaveOrder只依赖一个Order实际上它还依赖调用它的 goroutine 已经被正确设置了租户和追踪信息。这种依赖是隐形的。单元测试必须先准备 goroutine-local 状态任务被转交给另一个 goroutine 后数据是否继承要看运行时规则某个通用组件如果内部启动 goroutine还必须决定复制哪些状态、何时清理以及父子执行单元之间能否互相修改。显式传递Context确实有点啰嗦却把依赖写在了函数边界上funcSaveOrder(ctx context.Context,order Order)error调用者一眼就知道这个操作可能受到取消、截止时间或请求级数据影响。Rob Pike 还强调“一请求一线程”并不是并发模型的终点。执行单元足够便宜以后一个请求完全可以使用多条 goroutine某些工作也可以被多个请求共享。把计算或数据绑定到一条执行线程会反过来限制这种拆分和组合。context.Context不是 goroutine 的属性标准库对Context的定义很明确它在 API 边界和进程之间传递截止时间、取消信号以及请求范围的数据。一个 Context 可以被多条 goroutine 同时使用。这意味着它描述的是一棵工作关系而不是线程归属。请求 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 举了一个看似反例的 APIruntime/pprof.Do。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 的隐式环境ctx,cancel:context.WithTimeout(context.Background(),time.Second)defercancel()err:SaveOrder(ctx,order)第四Context 能自然跨越 goroutine 和进程边界。HTTP、RPC 和消息系统可以从显式上下文中提取 deadline、trace 信息再传给下游。工程里应该怎样处理日志和追踪信息这场讨论并不意味着所有信息都必须用Context.Value传递。更稳妥的做法是区分数据类型。请求取消和 deadline 使用 ContextfuncQuery(ctx context.Context,idstring)error真正的业务参数使用普通参数或结构体funcSaveOrder(ctx context.Context,tenantIDstring,order Order)error日志字段可以在边界处从 Context 中提取构造请求级 loggerlogger:baseLogger.With(trace_id,traceID,tenant_id,tenantID,)returnservice.Process(ctx,logger,req)是否继续显式传 logger要看项目规模和团队约定。关键不是“参数越少越好”而是避免让函数依赖一个看不见、又会随执行线程变化的环境。这场争论留下的答案Go 不公开稳定的 goroutine ID也没有通用GoroutineLocal并不是因为实现不了。它想避免的是另一套编程习惯把请求、日志、权限、事务和各种可变状态绑定到一条执行线程再让所有函数从隐式环境中取数据。Go 更愿意让 goroutine 成为可以随时创建、拆分、交接和组合的执行工具。请求属于一组工作而不是某条线程Context 描述工作关系而不是 goroutine 身份。显式传递有成本。但这项成本换来的是依赖可见、生命周期清楚以及并发结构可以自由变化。这也是为什么Rob Pike 所说的“匿名、无状态”并不是一句审美判断而是一条影响整个 Go 并发模型的设计原则。参考链接golang-nutsGo routine context完整讨论context 包文档runtime/pprof 包文档Go BlogGo Concurrency Patterns: ContextGo BlogContexts and structsOpenJDK JEP 428Structured Concurrency