ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Terraform 内部 promising 包:基于二部图的无死锁 Promise 与 Task 并发模型

Terraform 内部 promising 包:基于二部图的无死锁 Promise 与 Task 并发模型 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/promisingREADME 明确说明该包创建的目的是支持 Terraform Stacks 运行时的内部求值但其中没有任何 Stacks 专属的东西因此未来可能吸引其他调用者。包声明文件 doc.go 进一步交代了其学术渊源整个模型基于 Georgia Institute of Technology 的 Caleb Voss 与 Vivek Sarkar 在论文 An Ownership Policy and Deadlock Detector for PromisesarXiv: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// ErrUnresolvedTask 未解决其负责的 Promise 就返回。 // 底层类型是 []PromiseID列出全部未解决的 Promise。 type ErrUnresolved []PromiseID func (err ErrUnresolved) Error() string { return promise unresolved } // ErrSelfDependentTask 试图读取自己直接或间接负责解决的 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.PointerCompareAndSwap保证了并发竞争下的行为可预测。自依赖检测算法为什么不用 TarjanREADME 专门用一节讨论了环检测的算法选型这是本包最值得借鉴的设计决策之一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 传播的横切关注点对齐而做的务实权衡。由此衍生出的使用契约是创建 PromiseNewPromise、等待 Promisegetter、创建异步 TaskAsyncTask都必须传入携带 Task 身份的 context否则直接 panic作为 Task 实现体的函数以 context 为第一个参数该 context 携带该 Task 的身份按常规方式继续向下传播即可调用者写出来的仍是典型的 Go 代码——只要始终在请求之间传播 context。两个 Task 入口MainTask 与 AsyncTaskREADME 说明创建 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校验其责任当前确实在调用者名下否则 panicpromise 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)来强制这一点冲突时 panicracing promise get。责任转移的容器PromiseContainerAsyncTask委托责任的载体是 promise_container.go 中的接口type PromiseContainer interface { AnnounceContainedPromises(func(AnyPromiseResolver)) }包内提供了三种现成实现类型用途NoPromises空容器派生不携带任何责任的新 TaskPromiseResolver[AType]单个 resolver 本身单个 resolver 自身即实现了该接口PromiseResolverPair[AType, BType]一次性传递两个不同类型的 resolver免去自定义结构体PromiseResolverList[T]传递任意多个同一类型的 resolver注释里也解释了限制原因Go 的类型系统不支持变长泛型无法用一个类型收集任意多个不同类型的 resolver——需要更复杂的组合时就自己写一个实现PromiseContainer的结构体。PromiseResolver与PromiseGet一对句柄NewPromiseT返回两个句柄见 promise.goPromiseResolver[T]代表解决责任的对象可以随 Task 委托转移最终用于提供 Promise 的最终结果。其Resolve(ctx, v, err)方法会先校验当前 Task 确实是责任方否则 panicpromise resolved by incorrect task再写入结果并唤醒所有等待者。二次Resolve会被 panicpromise 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 和异步 TaskDo必须传入携带 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 都带有一个可比较的唯一标识PromiseIDpromise.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 互相等待对方负责的 PromiseA 等 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 IDTask 开始等待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 相关操作必须使用包提供的或派生自其的contextAssertContextInTask可用于显式标注只能在 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),仅供参考
返回列表