Terraform 内部 promising 包:基于二部图的无死锁 Promise 与 Task 并发模型
【免费下载链接】terraformTerraform enables you to safely and predictably create, change, and improve infrastructure. It is a source-available tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned.项目地址: https://gitcode.com/GitHub_Trending/te/terraform
本文基于 Terraform 仓库的 internal/promising/README.md 及其配套源码,完整解析promising包如何用"Promise(承诺)+ Task(任务)"两个核心抽象构建一个保证不会因自依赖或未解决而死锁的并发协调机制,并深入其环检测算法、责任转移(responsibility transfer)、Once工具与PromiseID错误报告策略的源码实现。读完后,你将掌握在 Go 中设计类似无死锁 Promise 系统的方法,并理解 Terraform Stacks 运行时为何选择它作为内部求值的底层支撑。
包定位:为 Stacks 运行时而生,但并非 Stacks 专属
promising是 Terraform 仓库中的一个内部 Go 包,位于 internal/promising,README 明确说明:
该包创建的目的是支持 Terraform Stacks 运行时的内部求值,但其中没有任何 Stacks 专属的东西,因此未来可能吸引其他调用者。
包声明文件 doc.go 进一步交代了其学术渊源:整个模型基于 Georgia Institute of Technology 的 Caleb Voss 与 Vivek Sarkar 在论文 "An Ownership Policy and Deadlock Detector for Promises"(arXiv:2101.01312v1)中提出的结构与算法。从源码结构看,包的核心设计规则可以概括为三点:
- 每个 Promise 有唯一的一个负责(responsible)解决它的 Task;
- 只有显式的 Task 才能与 Promise 交互(创建、等待、解决);
- 系统利用这条规则来检测两类错误状态:相互依赖(mutual dependency,一个 Task 阻塞在另一个 Task 拥有的 Promise 上,反之亦然)与未解决(failure to resolve,负责解决的 Task 在解决之前就已退出)。
这两类情况在包中被赋予了不同的语义定位:相互依赖被视为用户输入无效(两个对象互相依赖对方的结果),因此需要以能向最终用户解释的方式报告;而未解决则永远是实现错误——Task 要么解决自己拥有的全部 Promise,要么在退出前把责任转移给别的 Task。
核心模型:Task、Promise 与二部图
包的功能围绕两个关键概念构建:
- Promise:一个占位符,代表一个可能尚未产生的结果,之后可以由某个 Task 异步地以具体结果来解决(resolve)。
- Task:能够产生和解决 Promise、也可以等待其他 Promise 被解决执行的代码路径。
两者组合出的系统结构是一张二部图(bipartite graph):一方是 Task,另一方是 Promise,中间由两种边连接。README 中用一张图直观展示了这一结构:
两种边的含义是:
- 责任边(responsibility edge):每个 Promise 有且仅有一条出边,指向负责解决它的那一个 Task;
- 等待边(awaiting edge):每个 Task 有零条或一条出边,指向它正在等待(阻塞)的那个 Promise。
由此产生几条关键规则:
- 任何 Task 都可以创建新 Promise;创建时,负责解决它的 Task 就是创建者本身;
- Task 还可以派生新的异步 Task,并在派生时把自己负责的零个或多个 Promise 的解决责任委托给新 Task;
- 任何 Task 都可以等待某个 Promise 的结果,等待期间该 Task 完全阻塞、不做任何进展。
在源码中,这两个概念分别由 promise.go 和 task.go 中的结构体落地:
// internal/promising/promise.go type promise struct { name string responsible atomic.Pointer[task] // 指向当前负责解决它的 Task(责任边) result atomic.Pointer[promiseResult] traceSpan trace.Span waiting []chan<- struct{} // 所有正在等待它的通道 waitingMu sync.Mutex } // internal/promising/task.go type task struct { awaiting atomic.Pointer[promise] // 指向当前正在等待的 Promise(等待边),同一时刻至多一个 responsible promiseSet // 本 Task 负责解决的所有 Promise 集合 }可以看到,README 描述的"每个 Promise 恰好一条责任出边、每个 Task 零或一条等待出边"在实现中严格成立:responsible是单指针、awaiting也是单指针,而 Task 反向维护的responsible promiseSet(一个map[*promise]struct{},见 ptr_set.go)用于快速枚举该 Task 欠下的所有 Promise。
两类防死锁错误:主动"炸开"而不是挂起
与大多数典型 Promise 实现相比,promising提供的额外保证是:绝不会因为 Task 等待了自己负责解决的 Promise、或忘记解决某个 Promise 而永久死锁。README 指出系统通过返回错误来规避两种本会导致死锁的情形:
- 未解决(failure to resolve):如果代表某 Task 的函数在解决其负责的全部 Promise 之前就返回,所有这些 Promise 会立即以错误形式解决,从而解除所有等待者的阻塞;
- 自依赖(self-dependency):如果某个 Task 开始等待某个 Promise 会在图中形成一个绕回自身的环(沿责任边和等待边交替反向追踪),那么链上所有Promise 立即以错误解决,解除链上所有等待者的阻塞。
对应的错误类型定义在 errors.go:
// ErrUnresolved:Task 未解决其负责的 Promise 就返回。 // 底层类型是 []PromiseID,列出全部未解决的 Promise。 type ErrUnresolved []PromiseID func (err ErrUnresolved) Error() string { return "promise unresolved" } // ErrSelfDependent:Task 试图读取自己(直接或间接)负责解决的 Promise。 // 底层类型同样是 []PromiseID,记录从该 Task 绕回自身的那条环链。 type ErrSelfDependent []PromiseID func (err ErrSelfDependent) Error() string { return "task is self-dependent" }两种错误都是[]PromiseID切片,调用方可以取出链上所有相关 Promise 的标识用于生成用户可读的错误信息。
从源码看,"未解决"场景在 Task 退出路径上统一兜底。task.go 中MainTask与AsyncTask都在实现函数返回后做了同一件事:遍历该 Task 名下仍未转移、仍未解决的 Promise,逐个以ErrUnresolved强制解决,并把 ID 累积进最终返回的错误中:
// internal/promising/task.go —— MainTask 的兜底逻辑(AsyncTask 中同样存在) for unresolved := range mainT.responsible { oneErr := ErrUnresolved{unresolved.promiseID()} resolvePromise(unresolved, nil, oneErr) unresolvedErr = append(unresolvedErr, unresolved.promiseID()) }而"自依赖"场景则发生在等待入口,即 Promise 的 getter 内部。promise.go 中NewPromise生成的PromiseGet[T]闭包在执行等待前,会先做一次"干跑"式的环检测:从目标 Promise 出发,交替沿"责任边 → 等待边"反向走,看能否绕回请求者自身:
// internal/promising/promise.go —— 等待前的自依赖检测(简化示意) checkP := p checkT := p.responsible.Load() steps := 1 for checkT != reqT { steps++ if checkT == nil { break } nextCheckP := checkT.awaiting.Load() if nextCheckP == nil { break } if checkP.responsible.Load() != checkT { break } checkP = nextCheckP checkT = checkP.responsible.Load() } if checkT == reqT { // 发现自依赖:重走一遍环链,收集全部受影响的 PromiseID, // 然后对链上每个 Promise 执行强制失败 for _, affected := range affectedPromises { resolvePromiseInternalFailure(affected, err) } }注意这里有一个实现细节:resolvePromiseInternalFailure是 promise.go 中专门用于内部故障的解决路径,它与正常的resolvePromise有两点关键差异:
- 它在结果上打
forced: true标记,且不撤销责任数据——这样责任 Task 后续仍可照常调用Resolve,会被安全地当作 no-op 处理(因为 Task 自己通常不会再调用 getter,不会感知到失败); - 它只影响"受影响的环链"上的 Promise,而不是整个系统。
这种"宁可提前失败也不挂起"的策略,加上对责任边/等待边的原子操作(atomic.Pointer+CompareAndSwap),保证了并发竞争下的行为可预测。
自依赖检测算法:为什么不用 Tarjan
README 专门用一节讨论了环检测的算法选型,这是本包最值得借鉴的设计决策之一:
- Terraform 既有的 internal/dag 包(服务于 Terraform Core 模块运行时使用的通用图)采用Tarjan 强连通分量算法做环检测,时间复杂度与边数和节点数成线性关系;
- 但对于 Promise 场景,结果必须在每次 Task 等待或解决 Promise 时重新计算,用 Tarjan 过于昂贵且复杂;
- 由于本图的特殊性——每个 Promise 恰好一个责任 Task、每个 Task 至多等待一个 Promise——环检测退化为一条单向链:只需交替沿这两类单边反向走,直到没有节点可走,或者绕回到 Task 试图等待的那个 Promise 为止。
不在链上的节点和边完全不需要访问,因此这些图遍历通常很短、很快。
从源码结构看,这一特性正体现在前述 getter 内的检测循环:每次迭代只前进一步(一个 Promise + 一个 Task),没有递归、没有栈、没有全局遍历,最坏情况也只是链长本身。这与 internal/dag 中面向通用依赖图的 Tarjan 实现形成鲜明对照——一个是为通用图准备的线性算法,一个是为"单边链"图特化的最简路径追踪。
任务身份:借 context 传递的"非典型"设计
README 坦承了一个与 Go 惯例略有偏离的设计:Go 不给 goroutine 分配程序可见的身份(这是有意为之的合理设计),因此单靠 goroutine 不足以在运行时表示 Task。promising的方案是利用context.Context的值捆绑(value-bundle)机制,把 Task 身份沿调用栈传播:
// internal/promising/task.go type taskContextKeyType int const taskContextKeyType_ = ... // 实为 const taskContextKey taskContextKeyType = 0 func contextWithTask(ctx context.Context, t *task) context.Context { return context.WithValue(ctx, taskContextKey, t) } func mustTaskFromContext(ctx context.Context) *task { ret, ok := ctx.Value(taskContextKey).(*task) if !ok { panic("cannot interact with promises or tasks from non-task context") } return ret }doc.go 对这一取舍的评价很中肯:这个隐式行为"并不理想,但它是为了把 Task 身份与 logger、分布式追踪客户端等同样随 context 传播的横切关注点对齐而做的务实权衡"。
由此衍生出的使用契约是:
- 创建 Promise(
NewPromise)、等待 Promise(getter)、创建异步 Task(AsyncTask)都必须传入携带 Task 身份的 context,否则直接 panic; - 作为 Task 实现体的函数以 context 为第一个参数,该 context 携带该 Task 的身份,按常规方式继续向下传播即可;
- 调用者写出来的仍是典型的 Go 代码——只要始终在请求之间传播 context。
两个 Task 入口:MainTask 与 AsyncTask
README 说明创建 Task 有且仅有两种方式,均以闭包(或任意函数)作为 Task 的实现体:
MainTask:阻塞式同步入口
MainTaskT把子系统的主入口包裹成"主 Task",调用不会在impl函数返回之前返回。它是进入 Promise/Task 世界的唯一顶层入口,其传入的 context 无需携带 Task 身份(由它自己创建)。
以 public_test.go 中的TestPromiseResolveSimple为例,一个完整的最小闭环长这样:
ctx := context.Background() gotVal, err := promising.MainTask(ctx, func(ctx context.Context) (string, error) { resolver, get := promising.NewPromisestring promising.AsyncTask( ctx, resolver, func(ctx context.Context, resolver promising.PromiseResolver[string]) { resolver.Resolve(ctx, wantVal, nil) }, ) return get(ctx) // 主 Task 等待它已委托出去的 Promise })主 Task 先创建 Promise(此时自己是责任人),随即通过AsyncTask把解决责任委托给子 Task,然后调用 getter 等待——由于责任已转移,这次等待不构成自依赖,最终拿到子 Task 解决的值。
AsyncTask:派生并发 Task 并委托责任
AsyncTaskP PromiseContainer在另一个 Task 内部启动异步并发 Task,可选地通过传入实现PromiseContainer接口的对象来委托一组 Promise 的责任。内部实现(见 task.go)做了三件事:
- 遍历容器中宣告的每个 resolver,校验其责任当前确实在调用者名下(否则 panic
"promise responsibility mismatch"),然后把责任从调用者转移到新 Task(更新newT.responsible、callerT.responsible、p.responsible三处); - 启动新 goroutine 承载该 Task,立即返回;
- 新 Task 的 context 携带其自己的 Task 身份,
impl必须用这个 context 来调用 getter 和 resolver。
调用者 Task 此后可以等待它委托出去的 Promise 或任何其他 Promise,只要不产生自依赖。
关于 goroutine 与 Task 的边界,README 与源码注释给出了明确而重要的限制:
- 一个 Task可以创建不被视为独立 Task 的 goroutine,也可以执行不经过 Promise 的阻塞操作;
- 但无死锁保证只覆盖 Task 与 Promise 之间的关系。如果调用者使用其他同步原语,则确保这些原语不会以导致死锁的方式干扰 Promise 解决,是调用者自己的责任;
- 每个 Task 同一时刻只允许等待一个 Promise(对应
awaiting单指针设计),违反此不变量会导致未定义行为——PromiseGet闭包内部正是用CompareAndSwap(nil, p)来强制这一点,冲突时 panic"racing promise get"。
责任转移的容器:PromiseContainer
AsyncTask委托责任的载体是 promise_container.go 中的接口:
type PromiseContainer interface { AnnounceContainedPromises(func(AnyPromiseResolver)) }包内提供了三种现成实现:
| 类型 | 用途 |
|---|---|
NoPromises | 空容器,派生不携带任何责任的新 Task |
PromiseResolver[AType](单个 resolver 本身) | 单个 resolver 自身即实现了该接口 |
PromiseResolverPair[AType, BType] | 一次性传递两个不同类型的 resolver,免去自定义结构体 |
PromiseResolverList[T] | 传递任意多个同一类型的 resolver |
注释里也解释了限制原因:Go 的类型系统不支持变长泛型,无法用一个类型收集任意多个不同类型的 resolver——需要更复杂的组合时就自己写一个实现PromiseContainer的结构体。
PromiseResolver与PromiseGet:一对句柄
NewPromiseT返回两个句柄(见 promise.go):
PromiseResolver[T]:代表"解决责任"的对象,可以随 Task 委托转移,最终用于提供 Promise 的最终结果。其Resolve(ctx, v, err)方法会先校验当前 Task 确实是责任方(否则 panic"promise resolved by incorrect task"),再写入结果并唤醒所有等待者。二次Resolve会被 panic"promise resolved more than once"拦截——除非先被内部机制强制解决过(forced结果会被安静地忽略,见前文)。PromiseGet[T]:一个函数签名func(ctx context.Context) (T, error),阻塞直至 Promise 解决并返回结果。它只能在 Task 内部、用派生自该 Task 的 context 调用。
等待的底层机制是"每个等待者一个通道":getter 先登记一个chan struct{}到p.waiting切片,然后<-ch挂起;解决时resolvePromise统一 close 所有通道并清空切片。此外,若等待时结果已就绪则直接返回,无需挂起。
值得强调的是,README 中"被阻塞的 Task 不做任何进展"这一句在实现中是字面成立的:阻塞就是纯粹的<-ch通道等待,没有轮询、没有超时代码路径(context 的取消/截止时间仍会按 doc.go 的提示由调用者自行设计响应,例如 Task 应能响应 context 取消/截止)。
Once:基于 Promise 的sync.Once替代
once.go 提供了更高层的Once[T],README 将其描述为"Go 标准库sync.Once的 Promise 化异步替代":
- 它把多次调用取同一结果的请求合并(coalesce)成一次异步 Task 解决一个 Promise的过程;
- 所有调用者都阻塞在该 Promise 的解决上,之后统一返回首次调用的结果;
- 因为内部会创建 Promise 和异步 Task,
Do必须传入携带 Task 身份的 context,否则 panic。
其Do的实现(once.go)结构非常清晰:
func (o *Once[T]) Do(ctx context.Context, name string, f func(ctx context.Context) (T, error)) (T, error) { AssertContextInTask(ctx) o.mu.Lock() if o.get == nil { // 第一次调用:创建 Promise,并把解决责任随 AsyncTask 转移出去 resolver, get := NewPromiseT o.get = get o.promiseID = resolver.PromiseID() o.mu.Unlock() AsyncTask( ctx, resolver, func(ctx context.Context, resolver PromiseResolver[T]) { v, err := f(ctx) resolver.Resolve(ctx, v, err) }, ) } else { o.mu.Unlock() } // 无论是否由本次调用发起,都阻塞等待 Promise 的结果 return o.get(ctx) }这里有一个精妙的正确性要点,源码注释直接点明:责任在AsyncTask中已从发起者转移到异步 Task,因此发起者(以及后续所有调用者)等待这个 Promise 是合法的、不构成自依赖。文档中还列出两条重要语义差异(与标准库sync.Once相比):
- 两个
Once实例互相依赖时,Do会返回ErrSelfDependent而不是死锁——这是本包"永不死锁"保证在高层抽象上的体现; - 若
fpanic,内部 Promise 无法被解决,所有Do调用将得到ErrUnresolved;由于 panic 发生在独立 goroutine 中,包内没有内置的捕获/恢复机制,需要调用者自行兜底。
once_test.go 中的TestOnce验证了合并效果:5 次Do调用后callCount恰好为 1,且 5 个返回值指向同一个结果对象。
PromiseID与用户友好的自依赖错误报告
为了让动态检测到的自依赖能产生用户可读的错误信息,每个 Promise 都带有一个可比较的唯一标识PromiseID(promise.go 中为持有*promise指针的 comparable 结构体),并随责任在 Task 间转移而跟随同一个 Promise 不变。其配套方法FriendlyName()返回创建时给出的name。
README 给出了两种调用方策略:
- 通用策略:需要该能力的调用方,在自己状态中维护一张"PromiseID → 面向用户的含义"的映射表。当 getter 的
error结果是ErrSelfDependent时,将其类型断言为[]PromiseID,逐个查表,把每个 Promise 的用户友好描述全部列进错误信息; - Stacks 运行时的特殊策略(性能权衡):不显式维护 ID 表,而是把 Promise 本身记忆在运行时状态结构的各个位置,仅当某个 Promise 返回自依赖错误时,才对整棵运行时数据结构做一次树遍历,即时构建 ID→名称表来生成错误信息。这样在无自依赖的正常路径上完全不需要追踪 Promise 用途,零额外开销。
从源码结构看,这一策略与ErrSelfDependent携带完整环链 ID 的设计是自洽的:错误本身就给出了"需要解释哪些 Promise"的精确清单,调用方无需猜测范围。
测试对核心保证的验证
public_test.go 用一组命名清晰的测试把 README 承诺的每个保证都钉死为可执行的事实:
TestPromiseUnresolvedMainWithoutGet/TestPromiseUnresolvedAsync:验证"任务返回前未解决责任 Promise"时,等待者收到的是携带正确PromiseID的ErrUnresolved,且返回值为类型零值(getResolvedPromiseResult中类型断言失败即回退零值的行为);TestPromiseUnresolvedMainWithGet:同一个 Promise 既由主 Task 负责、又被主 Task 自己读取——这被归类为自依赖而非未解决,收到长度为 1 的ErrSelfDependent。这个用例直观展示了"检测发生在 getter 入口,先于任何阻塞";TestPromiseSelfDependentSibling:两个兄弟 Task 互相等待对方负责的 Promise(A 等 p2、B 等 p1、主 Task 又等 p1/p2),无论哪个 Task 的 getter 先"踩线",两条链上的全部Promise 都立即失败,主 Task 收到的两个错误都是长度为 2 的ErrSelfDependent——精确验证了"链上所有 Promise 立即以错误解决"的规则;TestPromiseSelfDependentNested:责任经PromiseResolverPair跨两层 Task 转移后形成环,依然被完整检测并整体失败。
这些测试与 README 的文字承诺一一对应,可以作为理解"强制失败"语义的权威参照。
可观测性:内建 OpenTelemetry 追踪
README 没有展开但源码中存在的另一块能力是遥测。telemetry.go 初始化了名为github.com/hashicorp/terraform/internal/promising的 tracer;包内关键事件都会打点:
- Promise 创建(
new promise,并记录责任方的 Span ID); - Task 开始等待(
waiting for promise result,带等待者计数); - 检测到自依赖(
task is self-dependent); - 责任转移(
delegated to new task/inherited promise responsibility,含从哪个 Span 委托到哪个 Span); - 内部强制失败(
internal promise failure,带错误信息); - 正常解决(
resolved/resolved a promise)。
异步 Task 在追踪上被刻意视为与调用者"断开"的新根 Span,仅用 Span link 保留因果链——这与每个 Task 拥有独立生命周期的语义一致。
实际调用方:Stacks 运行时
README 声明的用途在仓库中得到印证:internal/stacks/stackruntime/internal/stackeval目录下大量文件(如change_exec.go、component.go、component_config.go、main_plan.go、main_apply.go、main_inspect.go等)直接调用promising.MainTask、promising.AsyncTask、promising.NewPromise等 API 来组织 Stacks 的配置与变更求值。这正对应 README 中"运行时的内部求值"这一创建目的,也是"Promise 本身被记忆在运行时状态各处、出错时即时树遍历生成名称表"这一策略的落地场景。
适用边界与工程实践小结
最后汇总使用promising时应当记住的约束(均来自 README、doc.go 与源码注释):
- 保证范围有限:系统无法检测与 Promise/Task 关系无关的死锁。例如某 Task 因非 Promise 原因永久阻塞,仍会造成整体事实死锁——调用者应让 Task 响应 context 取消/截止;
- context 契约:所有 Promise/Task 相关操作必须使用包提供的(或派生自其的)context;
AssertContextInTask可用于显式标注"只能在 Task 内调用"的函数; - 单一等待约束:每个 Task 同一时刻至多等待一个 Promise,跨 goroutine 并发调用 getter 会 panic;
- 责任必须闭环:Task 退出前,其名下每个 Promise 必须已被解决或已转移责任,否则会被兜底逻辑强制以
ErrUnresolved解决; - 错误即信息:
ErrUnresolved与ErrSelfDependent都携带[]PromiseID,配合PromiseID.FriendlyName()或自建映射表即可生成面向用户的错误报告; - 选型理由可复用:如果你的系统图满足"每个 Promise 恰好一个责任者、每个 Task 至多等待一个"的约束,就不必引入 Tarjan 级别的通用环检测,README 与 promise.go 展示的"沿单链反向追踪"是最简且足够快的方案。
promising的价值在于把一个并发难题(死锁检测)转化为一条简单的所有权规则(每个 Promise 有且只有一个责任 Task),再用最小的图遍历把规则的执行成本压到接近链长本身。对任何需要在"结果按需异步汇聚"的系统中(Stacks 运行时是其一)兼顾可解释性与永不挂起特性的 Go 项目而言,这套 API 与算法设计都提供了可直接参照的范本。
【免费下载链接】terraformTerraform enables you to safely and predictably create, change, and improve infrastructure. It is a source-available tool that codifies APIs into declarative configuration files that can be shared amongst team members, treated as code, edited, reviewed, and versioned.项目地址: https://gitcode.com/GitHub_Trending/te/terraform
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考