← ALL ARTICLES

任务系统

全库目录221
本文目录9

动画写姿态,渲染准备要读它,两项工作不能仅因线程池有空位就同时运行。任务系统把读写关系变成依赖,工作者只执行已就绪任务。粒度、等待与长尾决定并行是否值得。

任务图

任务系统把一帧工作拆成小任务,并显式记录依赖。A 写入位置、B 读取位置时,B 必须等待 A;两个只读任务可以并行。任务图的边表达数据依赖,执行顺序因此可以沿数据的生产与消费关系确定。

任务太大时可并行的块数不足,任务太小又会让调度和同步成本占主导。常见做法是把批量循环分块,并设置最小粒度。调度器可以给每个工作线程一个双端队列:本线程从一端取任务,空闲线程从另一端窃取,减少争用。

纤程(Fiber)或协程(Coroutine)可以在等待依赖时让出执行权,让工作线程处理其他就绪任务。挂起的纤程保留自身栈,协程状态则由其帧保存;这些状态的寿命需要持续到恢复或取消结束。引用的风险来自被引用对象已经销毁,例如调用者返回后,其局部数组仍被挂起任务借用。调度器因此要区分任务持有的数据与任务借用的数据,并为借用关系指定结束点。

就绪计数与完成传播

依赖关系确定以后,调度器还需要知道某个任务什么时候能执行。给每个任务保存尚未完成的前驱数量,初始值就是入度。入度为零的任务进入就绪队列;一个任务完成后,将每个直接后继的计数减一,只有从一变成零的那个操作负责入队。这样两个前驱同时完成时,后继也只入队一次。

text
建立图:remaining[B] = B的不同前驱数
初始入队:所有remaining为0的任务
完成A:
    发布A的输出
    for B in A.successors:
        old = atomic_fetch_sub(remaining[B], 1, acquire_release)
        if old == 1: readyQueue.push_release(B)
工作者:B = readyQueue.pop_acquire(); 执行B

这是并发机制伪代码,要求图和计数在工作开始前建立、每条边只完成一次、队列具有正确的发布/获取语义。最后一个前驱必须能观察其他前驱发布的数据,再把就绪任务交给执行线程。计数归零只回答“前驱结束了”,不能替代数据写入的内存可见性。

动画姿态A同时供包围盒B和蒙皮C读取,B、C都完成后才能提交D。初始计数为A=0、B=1、C=1、D=2。B完成只让D变成1,C完成才使D就绪。若D等待A但漏掉C,任务图形式上仍是无环图,结果却可能读到半成品顶点;因此还要从数据读写检查边是否齐全。

环会让剩余任务永远无法归零。构建期用拓扑遍历检查,处理数量小于总任务数就报告环;运行期动态加边则需要独立的循环与取消协议。取消前驱也必须向后继传播“失败/取消”,不能仅减少计数后让后继读取无效输出。

工作者等待与长尾

设两个工作线程都在执行父任务,父任务各提交一个子任务后阻塞等待;子任务已经就绪,队列却无人可取。图没有数据依赖环,线程池仍会因执行资源被等待者占满而停住。调度器可以把父任务等待表示为可恢复状态,让工作线程返回队列;也可以在等待时帮助执行就绪任务。帮助执行会带来重入,持有外部锁时不能随意采用。纤程只是保存和恢复执行状态的一种载体,能否安全让出还取决于栈、借用数据和取消协议。

再设任务耗时为A=1、B=1、C=8,三者独立。两个线程若一开始分到[A,B]和[C],一个线程在2结束后空闲到8。把C拆成四份各2,并在完成后汇总,可以让六个就绪块更均衡,但也引入调度和汇总成本。若每个块都只有0.01而一次调度也要0.01,继续细分会把大量时间花在组织工作上。这里的时间只是模型单位,实际粒度要用任务分布与调度耗时测量。

同步与内存顺序

互斥锁适合保护较长的复合操作,原子操作适合简单状态转换。Lock-Free 只保证系统整体持续前进,Wait-Free 才保证每个参与者在有限步骤内完成。无锁结构还要处理 ABA、内存回收和正确的 内存序,不能因为“没有锁”就默认更快。

Acquire 读观察到对应 Release 发布的值或相应 release sequence 时,两者才能建立同步关系。给任意两条操作分别加 Release 与 Acquire,并不会自动让它们同步。编译器和 CPU 都可能在规则允许时重排访问。Debug 版本偶然正常不能证明并发正确。

假共享(False Sharing) 指多个线程修改同一 缓存行 上不同变量。逻辑上没有共享,硬件仍会反复转移缓存行所有权。按线程分块、填充热点计数或改为局部累积后汇总可以缓解。

显式任务依赖

下面的C++风格伪代码把动画、物理和渲染准备之间的读写关系写进任务图。动画写姿态缓冲,物理写独立刚体缓冲,frameInput只读;UpdateWorldTransforms消费两份结果,未在这里实现求解算法。Combine 只表达依赖,不主动等待 CPU:

cpp
JobHandle animation = ScheduleParallel(UpdateAnimation, entities, frameInput);
JobHandle physics = Schedule(StepPhysics, fixedStep, previousPhysics);
JobHandle transforms = ScheduleParallel(UpdateWorldTransforms,
                                        entities, Combine(animation, physics));
JobHandle visibility = ScheduleParallel(CullRenderables,
                                        renderables, transforms);
SubmitRendering.After(visibility);

每个任务需要声明读取和写入的数据范围,调度器才能判断哪些任务可并行。若两个任务写同一数组,即使代码中没有锁,也存在数据竞争。验证时记录任务开始、结束、等待原因和工作线程利用率;大量短任务会把时间耗在调度上,过大的任务又会留下长尾。

验证方法

与单线程结果对照,改变工作线程数量和调度顺序,记录开始、结束、等待与窃取次数。关闭阶段先停止入队,再等待或取消拥有的任务。原子内存顺序定义见线程同步,任务边不替代数据声明。

参考实验与证据

reference.py的ready_order在单线程执行同一入度传播,验证菱形依赖、循环拒绝与缺失前驱。运行python 知识库/examples/revision_22_26/test_reference.py,2026-10-22的21项CPU检查全部通过,其中3项归属于任务图。实验不验证工作窃取、队列实现或C++内存序;这些仍需实际并发实现与竞争检查。

相关主题

参考资料

  • Microsoft,Fibers,纤程状态、切换和栈释放说明;2026-10-22核读。支持纤程保存自身栈的条件,本文借用数据的寿命约束由此单独讨论。

  • Taskflow官方编程入口,Start Your First Taskflow Program与Run a Taskflow through an Executor,precede建立任务边、Executor执行图;2026-10-22核读。本文计数模型为独立参考推导。

  • 线程同步:原子发布、获取及共享数据的同步前提。