- 并发编程
- 高性能计算
【免费下载链接】oneTBB
oneAPI Threading Building Blocks (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.h:
struct alignas(max_nfs_size) arena_slot_shared_state { //! The flag indicates whether the slot is used by a thread. std::atomic<bool> my_is_occupied; //! Index of the first ready task in the deque. /** Modified by thieves, and by the owner during compaction/reallocation **/ std::atomic<std::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::atomic<std::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 只在底部压入/弹出,窃取者只在顶部取走,两个方向天然错开竞争。任务池的最小容量为 64(static 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 owner":
std::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宏保护的实现:
template<typename F> d1::task* task_ptr_or_nullptr_impl(std::false_type, F&& f){ task_handle th = std::forward<F>(f)(); task_handle_task* task_ptr = task_handle_accessor::release(th); // If task has unresolved dependencies, it can't 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.h):
task_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.h
- owner 从队底取任务(LIFO):src/tbb/arena_slot.cpp
- 窃取者从队顶取任务(FIFO):src/tbb/arena_slot.cpp
- deque 的
head/tail与任务池定义:src/tbb/arena_slot.h - 旁路(Bypass)的预览实现与依赖约束:include/oneapi/tbb/task_group.h
- 调度器旁路的官方说明:Task Scheduler Bypass
- 并发编程
- 高性能计算
【免费下载链接】oneTBB
oneAPI Threading Building Blocks (oneTBB)
相关推荐
mold 中的 oneTBB 任务调度器工作原理:从 work-stealing 设计到链接器并行加速实践
mold 中的 oneTBB 任务调度器工作原理:从 work stealing 设计到链接器并行加速实践 本文聚焦 oneTBB(oneAPI Threadi
开发工具构建工具系统编程yuzu Switch 模拟器完整指南:安装、配置与三档调优一次讲清
yuzu Switch 模拟器完整指南:安装、配置与三档调优一次讲清 yuzu 是一款开源的任天堂 Switch 模拟器,用 C++ 编写,维护 Windows
虚拟化桌面应用图形学oneTBB 任务调度器(Task Scheduler)深入解析:任务式编程、工作原理与执行引导
oneTBB 任务调度器(Task Scheduler)深入解析:任务式编程、工作原理与执行引导 导读 本文围绕 oneAPI Threading Buildi
并发编程高性能计算
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考