多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

oneTBB 任务调度器工作原理:fork-join 并行、深度优先执行与工作窃取(Work Stealing)全解析

oneTBB 任务调度器工作原理:fork-join 并行、深度优先执行与工作窃取(Work Stealing)全解析 并发编程高性能计算【免费下载链接】oneTBBoneAPI Threading Building Blocks (oneTBB)项目地址https://gitcode.com/gh_mirrors/on/oneTBB点击查看免费下载导读本文基于 oneTBB 官方用户指南中的《How Task Scheduler Works》展开系统讲解 oneTBB 任务调度器Task Scheduler的设计动机与核心执行机制它如何为 fork-join 这类大量分叉的并行算法提供高效调度如何在深度优先与广度优先两种执行策略之间取得平衡以及如何借助每线程双端队列 随机工作窃取把潜在的并行转化为真实的多核并行。读完本文你将理解parallel_for这类算法背后的任务分发逻辑、三条任务获取规则的优先级关系以及调度器旁路Task Scheduler Bypass这一优化的原理并能在 src/tbb 的源码中找到每一处机制对应的实现证据。任务调度器的设计出发点面向大量分叉的 fork-join 并行oneTBB 的任务调度器并不绑定于某一种特定的并行模式但它的设计目标非常明确——高效支撑 fork-join 并行尤其是包含大量分叉fork的场景。所谓 fork-join是指一个计算任务反复地被拆分成若干子任务fork等待所有子任务完成后合并结果join再继续下一轮拆分。这种模式在 oneTBB 的并行算法中随处可见最典型的代表就是 oneapi::tbb::parallel_for// 定义于头文件 oneapi/tbb/parallel_for.h tbb::parallel_for(first, last, f); // 按 [first, last) 整数范围迭代 tbb::parallel_for(range, body); // 按 Range 对象迭代parallel_for会把一个连续区间递归地切成小块chunk每一块对应一个待执行的任务切分过程本身就是一个不断分叉的过程。任务调度器需要承接这种高扇出high fan-out的任务图并在多核上把它摊开执行。在 doc/main/tbb_userguide/How_Task_Scheduler_Works.rst 中文档把调度器的工作描述为同时追求三个目标目标含义实现途径最大化实际并行创建足够多的任务job让尽可能多的线程同时处于工作状态靠窃取把任务分发到空闲线程保持数据局部性让单个线程的执行更高效减少缓存未命中优先执行本线程刚创建的热任务最小化开销同时压低内存占用与跨线程通信深度优先执行控制同时存在的任务节点数量这三个目标之间存在张力把任务推给所有线程可以最大化并行但会破坏数据局部性并增加同步开销。调度器解决这一矛盾的思路是在深度优先与广度优先两种执行策略之间寻找平衡。深度优先 vs 广度优先为何顺序执行偏爱越深越好假设任务图是有限的即所有任务最终都会执行完毕文档指出对于单线程顺序执行而言深度优先策略明显优于广度优先理由有两条趁缓存还热时出手strike when the cache is hot最深的deepest任务往往是最近才创建的任务因此也是缓存中最热的数据。执行它时刚写进缓存的数据能立即被复用。而且一旦这些深层任务完成那些依赖它们的父任务就能继续执行——这些父任务虽不如最深层任务热但比起队列中更早创建的旧任务它们依然更暖。最小化空间占用minimize space如果总是执行最浅shallowest的任务任务图会以广度优先的方式展开同时存在的节点数量会呈指数级增长内存压力巨大。反过来深度优先执行虽然最终也会创建同样多的节点但由于它总是沿着一条链深入下去同一时刻存在的就绪任务只构成一个线性规模的栈因此内存占用被牢牢压在线性级别。换句话说深度优先用线性空间 缓存友好换取了顺序执行的效率而把摊开并行这件事留给了多线程场景下的任务窃取。每线程双端队列deque任务池的物理载体为了实现上述策略调度器为每一个线程维护一个独立的双端队列deque里面存放该线程当前可执行ready的就绪任务。当线程派生spawn一个新任务时它会把该任务推入自己队列的底部bottom。在 oneTBB 源码中这个 deque 由arena_slot承载——每一个 arena任务竞技场槽位对应一个工作线程。参见 src/tbb/arena_slot.hstruct alignas(max_nfs_size) arena_slot_shared_state { //! The flag indicates whether the slot is used by a thread. std::atomicbool my_is_occupied; //! Index of the first ready task in the deque. /** Modified by thieves, and by the owner during compaction/reallocation **/ std::atomicstd::size_t head; }; struct alignas(max_nfs_size) arena_slot_private_state { //! Index of the element following the last ready task in the deque. /** Modified by the owner thread. **/ std::atomicstd::size_t tail; //! Capacity of the primary task pool (number of elements - pointers to task). std::size_t my_task_pool_size; //! Task pool of the scheduler that owns this slot d1::task** task_pool_ptr; };注意head与tail的注释措辞非常关键head指向队列中第一个就绪任务由窃取者thieves修改owner 只在压缩/重分配时动它tail指向最后一个就绪任务之后的位置只由 owner 线程修改。这一两端由不同角色操作的设计正是工作窃取算法无锁化的基础owner 只在底部压入/弹出窃取者只在顶部取走两个方向天然错开竞争。任务池的最小容量为 64static constexpr std::size_t min_task_pool_size 64见 src/tbb/arena_slot.h并按max_nfs_size非完全共享缓存行大小对齐分配。三条取任务规则调度的近乎等价规则集当一个线程参与任务求值evaluation时它会持续执行按第一条命中的规则取任务的循环。文档给出的规则集如下优先级从高到低取上一个任务返回的那个任务如果有——即调度器旁路Task Scheduler Bypass对应 Task Scheduler Bypass从自己队列的底部取一个任务如果有从随机选中的另一个队列的顶部窃取一个任务。若选中的队列为空则反复尝试本规则直到成功。这条规则集在 src/tbb/task_dispatcher.h 的主调度循环local_wait_for_all()中有近乎一一对应的实现// 主调度循环 do { // 规则 1执行内层循环——处理嵌套循环产生的任务以及 // 刚执行完的任务返回的任务bypassing spawn or enqueue calls。 while (t ! nullptr) { ... if (ed.context-is_group_execution_cancelled()) { t t-cancel(ed); } else { t t-execute(ed); // 返回值 t 成为下一个候选任务规则 1 } ... } // 规则 2从本地任务池取任务LIFO取底部最年轻的任务 if (t || (slot.is_task_pool_published() (t slot.get_task(ed, isolation)))) { ... continue; } // 规则 3从全局源取任务窃取或收件箱等 t receive_or_steal_task( *m_thread_data, ed, waiter, context_guard, isolation, dl_guard.old_properties.fifo_tasks_allowed, critical_allowed ); } while (t ! nullptr); // main dispatch loop规则 2 的本质LIFO深度优先规则 2 的整体效果是线程总是执行自己 spawn 的最年轻任务。因为新任务被推到队底而 owner 从队底弹出这构成一个 LIFO后进先出栈——一直顺着最新任务往下钻直到本线程没有可做的工作为止。这正是前文深度优先策略的落地方式。源码印证位于 src/tbb/arena_slot.cpp 的arena_slot::get_task()它被注释明确限定为 Called only by the pool ownerstd::size_t T0 tail.load(std::memory_order_relaxed); ... do { // The full fence is required to sync the store of tail with the load of head (write-read barrier) T --tail; // 从队尾bottom递减取出任务 ... } while (/*!result */ !all_tasks_checked);T --tail就是从底部弹出配合tail 只由 owner 修改的约定owner 侧取任务完全无需与其他线程竞争。规则 3 的本质FIFO 窃取把潜在并行转为实际并行当线程的本地队列空了规则 3 生效它随机挑选另一个线程的队列从其顶部top窃取最老的任务。由于最老的任务在队列顶部窃取按 FIFO先进先出进行而这会触发临时的广度优先执行——被窃走的任务往往处于任务图较浅的位置它的执行会把整棵任务树撑开从而把原本只存在于理论上的并行potential parallelism转化为真实的多核并行actual parallelism。源码印证位于 src/tbb/arena_slot.cpp 的arena_slot::steal_task()std::size_t H head.load(std::memory_order_relaxed); // mirror std::size_t H0 H; do { // The full fence is required to sync the store of head with the load of tail (write-read barrier) H head; // 从队首top递增取出 ... result victim_pool[H-1]; // 取到的是最老的任务 ... } while (!result);H head与 owner 的T --tail正好相反——窃取者从另一端推进head。如果head追平了tail即队列已空窃取尝试失败窃取者把head回滚到原值head.store(/*dead: H */ H0, ...)注释中称这套往返为 victim/thief arbitration algorithm受害者/窃取者仲裁算法保证空队列不被错误消耗。至于随机挑选另一个队列src/tbb/arena.cpp 中可以看到用线程局部随机数选择槽位的代码if ( index lower || index upper ) index tls.my_random.get() % (upper - lower) lower;随机化是为了避免多个空闲线程同时扑向同一个热门受害队列造成热点竞争。规则 1 详解Task Scheduler Bypass调度器旁路规则 1 引用自 Task Scheduler Bypass。它是一条性能优化路径由用户代码直接指定下一个应该执行的任务而不是把它 spawn 进队列。为什么需要它文档对比了正常 spawn 的完整流程把新任务压入线程的 deque继续执行当前任务直到完成再从 deque 取一个任务除非它已被别的线程偷走。步骤 1 和步骤 3 引入了不必要的 deque 入队/出队操作更糟的是压入队列的任务可能被其他线程偷走从而破坏数据局部性却没有带来有意义的并行度提升。调度器旁路正是为了规避这两点任务执行完毕时直接把下一个要执行的任务作为返回值交还给调度器。调度器循环拿到这个返回值t t-execute(ed)后它就成为下一轮规则 1 的候选——几乎可以保证由当前线程执行而不会被任何其他线程抢走。旁路与深度的关系也值得注意它天然契合趁缓存热时继续往下钻的深度优先精神——父子任务在同一个线程上背靠背执行缓存与执行状态得以连续复用。当前唯一的启用途径task_group 的预览特性文档特别说明目前使用该优化的唯一方式是oneapi::tbb::task_group的预览特性preview feature。在 include/oneapi/tbb/task_group.h 中可以看到由__TBB_PREVIEW_TASK_GROUP_EXTENSIONS宏保护的实现templatetypename F d1::task* task_ptr_or_nullptr_impl(std::false_type, F f){ task_handle th std::forwardF(f)(); task_handle_task* task_ptr task_handle_accessor::release(th); // If task has unresolved dependencies, it cant be bypassed if (task_ptr task_ptr-has_dependencies() !task_ptr-release_dependency()) { task_ptr nullptr; } return task_ptr; }关键限制在注释里如果任务还有未解析的依赖unresolved dependencies它就不能被旁路。此外function_task::execute()的返回值会区分旁路的下一任务与后继任务successor task若两者同时存在则旁路当前 body 返回的任务并把后继任务正常 spawn 出去见 include/oneapi/tbb/task_group.htask_handle_task* successor_task this-complete_and_try_get_successor(); if (next_task ! nullptr) { // If there are both task returned from the body and the successor task // Bypassing the body task and spawning the successor one if (successor_task ! nullptr) d1::spawn(*successor_task, successor_task-ctx()); } else { next_task successor_task; }从源码结构看task_group的旁路链路为任务体body执行完毕后返回一个可选的下一任务 →function_task::execute把它作为返回值 → 调度器主循环 src/tbb/task_dispatcher.h 的while (t ! nullptr)直接接着执行它注释明确写道 bypassing spawn or enqueue calls从而绕开 deque 与窃取。这也是为什么文档说旁路几乎保证任务留在当前线程。三规则的协作从任务图到真实多核执行把三条规则串起来一次典型parallel_for的执行轨迹大致是入口线程创建根任务并 spawn随后进入调度循环规则 2 从队底取到它根任务执行时递归切分区间子任务被压入本线程 deque 底部规则 2 让本线程一路 LIFO 深入最年轻的分支保持缓存热度并控制内存占用当其他线程队列空转时它们通过规则 3 随机窃取某个线程 deque 顶部的最老任务把任务树撑开实现真正的多核并行若任务执行完返回了下一任务旁路规则 1 优先于规则 2/3 立即执行它保持执行连续性。值得强调的是规则 1 到规则 3 并不是互相竞争而是互补的分工规则 1 保住局部性规则 2 维持深度优先规则 3 在并行度不足时兜底转化为广度展开。三者共同服务前文列出的三个调度目标最大并行、数据局部性、低开销。从实现层面看这套机制还包含一些值得了解的细节隔离isolation约束get_task()与steal_task()都会检查任务的 isolation 标签见 src/tbb/arena_slot.cpp不匹配的任务会被跳过并留在池中避免破坏并发数据结构的隔离保证任务流task stream部分任务如 starvation-resistant 任务、FIFO 任务走独立的task_stream通道使用位图population_t跟踪非空 lane并用random_lane_selector选择插入位置见 src/tbb/task_stream.h代理任务proxy task通过邮箱mailbox实现的任务亲和affinity机制会在队列中存放代理任务get_task/steal_task都会尝试从中提取真实任务src/tbb/arena_slot.cpp。小结oneTBB 任务调度器的核心设计可以概括为一句话以 fork-join 并行如parallel_for为目标场景用每线程 deque 随机工作窃取同时实现深度优先的缓存友好与广度优先的实际并行并用调度器旁路为关键路径省去队列开销。如果你想进一步验证文中的每一处机制可以按下面的路径深入源码调度主循环与规则 1/2/3 的编排src/tbb/task_dispatcher.howner 从队底取任务LIFOsrc/tbb/arena_slot.cpp窃取者从队顶取任务FIFOsrc/tbb/arena_slot.cppdeque 的head/tail与任务池定义src/tbb/arena_slot.h旁路Bypass的预览实现与依赖约束include/oneapi/tbb/task_group.h调度器旁路的官方说明Task Scheduler Bypass赞分享并发编程高性能计算【免费下载链接】oneTBBoneAPI Threading Building Blocks (oneTBB)项目地址https://gitcode.com/gh_mirrors/on/oneTBB点击查看免费下载相关推荐mold 中的 oneTBB 任务调度器工作原理从 work-stealing 设计到链接器并行加速实践mold 中的 oneTBB 任务调度器工作原理从 work stealing 设计到链接器并行加速实践 本文聚焦 oneTBBoneAPI Threadi开发工具构建工具系统编程yuzu Switch 模拟器完整指南安装、配置与三档调优一次讲清yuzu Switch 模拟器完整指南安装、配置与三档调优一次讲清 yuzu 是一款开源的任天堂 Switch 模拟器用 C 编写维护 Windows虚拟化桌面应用图形学oneTBB 任务调度器Task Scheduler深入解析任务式编程、工作原理与执行引导oneTBB 任务调度器Task Scheduler深入解析任务式编程、工作原理与执行引导 导读 本文围绕 oneAPI Threading Buildi并发编程高性能计算上一篇FanControl 实用指南Windows 风扇控制与静音散热一次讲清下一篇bitvec 项目常见问题解决方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表