Takeaways(要点总结)
- 理解异步任务及其依赖的重要性
- 异步任务允许程序在等待 I/O 或其他长时间操作时继续执行其他任务。
- 任务之间可能存在依赖关系,需要保证依赖被满足后再执行。
- 认识现有异步任务模型的局限性
- 常规线程池、任务队列在处理复杂依赖图时效率不高。
- 对于高度不规则的任务图,调度器可能成为瓶颈。
- 引入新的动态任务图编程模型:AsyncTask
- 每个任务是图中的一个节点,依赖关系形成有向无环图(DAG)。
- 调度器动态选择可执行节点,提高并行度。
- 克服调度挑战
- 必须处理依赖解析、任务优先级和负载均衡。
- 支持成千上万甚至亿级异步任务。
- 展示 AsyncTask 的高效性
- 实际任务图(GPU 或 CPU 并行模拟)上性能提升显著。
- 总结
- 充分利用现代硬件需要高效的异步任务调度。
为什么要并行计算?
- 并行计算可以显著提升性能,尤其在多核 CPU 或 GPU 上。
- 性能提升量级:相比单核 CPU,可达 10~100 倍加速。
T single CPU ≈ 100 minutes T 8 CPU ≈ 12.5 minutes T_\text{single CPU} \approx 100 \text{ minutes} \quad T_\text{8 CPU} \approx 12.5 \text{ minutes} Tsingle CPU≈100 minutesT8 CPU≈12.5 minutes - 图示例:单核 CPU → 8 核 → 16 核 → GPU 加速训练机器学习模型时间显著下降。
现代硬件为并行设计
以 Intel Haswell 微架构为例:
- 4 个物理核心(CPU) + 集成 GPU
- 1.4 亿晶体管,22nm 工艺
- 深流水线:16 个阶段
- 超标量架构(superscalar):每周期可执行多条独立指令
- 超线程技术(HTT):每物理核心可模拟成两个逻辑处理器
注释:如果不做并行编程,硬件性能不能充分利用。
今日并行计算问题:高度不规则
GPU 并行电路仿真示例
- 电路规模:> 5 亿个门和网络
- 异步核任务:> 1000
- 依赖关系:> 1000
- 执行时间:数小时
任务图可表示为 DAG(有向无环图):
Task A ──► Task B ──► Task C
│
└────► Task D
注释:任务依赖复杂,任务之间不均匀,导致 GPU 并行利用率下降。
CPU 并行 VLSI 静态时序分析
- 将分析算法建模为任务图,每个任务对应 pipeline 中的计算阶段:
- Levelize → RCP1 → SLP1 → DLP1 → ATP1 → JMP1 → …
- 任务数量和依赖量巨大:
- 异步任务 > 10 8 10^8 108
- 依赖关系 > 1.5 × 10 8 1.5 \times 10^8 1.5×108
- 任务图调度挑战:
- 并行调度:保证每个阶段的任务按依赖执行。
- 负载均衡:CPU 核心之间工作量不均会导致空闲等待。
- 动态任务图:任务执行时依赖关系可能动态产生,需要实时调度。
公式化任务图表示
假设有
N
N
N 个异步任务
T
1
,
T
2
,
.
.
.
,
T
N
{T_1, T_2, ..., T_N}
T1,T2,...,TN 和依赖集
D
D
D:
D
=
(
T
i
,
T
j
)
∣
T
j
depends on
T
i
D = {(T_i, T_j) \mid T_j \text{ depends on } T_i}
D=(Ti,Tj)∣Tj depends on Ti
- 可执行任务集合
E
E
E:
E = T k ∣ ∀ ( T i , T k ) ∈ D , T i 已完成 E = { T_k \mid \forall (T_i, T_k) \in D, T_i \text{ 已完成} } E=Tk∣∀(Ti,Tk)∈D,Ti 已完成 - 调度器每个时间步选择 E E E 中的任务在 CPU/GPU 上执行。
Takeaway
- 不规则并行计算要求动态任务图和智能调度器。
- 简单的线程池或固定 pipeline 无法充分利用现代硬件。
- AsyncTask 是为此类问题设计的模型:
Task Graph:
Nodes = tasks
Edges = dependencies
Scheduler selects executable nodes -> executes in parallel
注:这种方法可扩展到亿级任务,适用于 VLSI 仿真、深度学习加速、GPU 核心任务调度等场景。
并行化不规则问题并不容易
并行化高度不规则的问题,挑战巨大,需要处理大量底层细节:
- 并行抽象(Parallelism Abstraction)
- 同时处理软件层(任务、线程)和硬件层(CPU/GPU核心、缓存、流水线)。
- 让程序员不必每次手动管理线程/核,依然能利用硬件。
- 并发控制(Concurrency Control)
- 保证多线程或多任务之间的状态不冲突。
- 避免竞态条件(data races)。
- 同步(Synchronization)
- 在任务有依赖关系时,需要等待前置任务完成。
- 任务与数据竞态(Task/Data Race Avoidance)
- 防止多个任务同时访问相同数据导致未定义行为。
- 依赖约束(Dependency Constraints)
- 任务执行顺序受图中依赖关系约束。
- 调度效率(Scheduling Efficiency)
- 需要平衡负载,保证 CPU/GPU 核心都充分利用。
- 程序员生产力(Programming Productivity)
- 写并行程序的难度要低,否则会影响开发效率。
- 性能可移植性(Performance Portability)
- 同一套程序能在不同硬件(多核 CPU、GPU、异构系统)上高效运行。
注释:所有这些挑战意味着底层细节非常复杂,程序员不想每次都手动管理。
权衡(Trade-offs)
- 性能 vs 开发者意图
- 我们希望有高性能,但程序员更关心如何高效地实现功能。
- 因此,需要一种高层封装方案来管理细节,让程序员只关注算法逻辑和意图,而不用处理调度、同步等低层问题。
为什么选择任务并行编程(Task-parallel Programming, TPP)?
任务并行编程(TPP)是处理不规则工作负载的有效方法:
- 表达开发者意图
- 将算法拆解为自上而下的任务图(task graph),每个任务是函数节点,边表示依赖。
- 程序员只需要描述任务和依赖,不必关心底层线程。
- 优化运行时调度
- 任务图提交给优化好的运行时(runtime),由它处理:
- 负载均衡(load balancing)
- 动态调度(dynamic scheduling)
- 依赖满足检查(dependency resolution)
- 任务图提交给优化好的运行时(runtime),由它处理:
- 现代库支持任务并行
- OpenMP 4.0:
task+depend子句 - C++26 Execution Library:
std::exec - Intel TBB:
tbb::flow::graph - Taskflow: Taskflow Control Taskflow Graph (CTFG)
- …其他库也在向任务图模型靠拢
- OpenMP 4.0:
任务图模型(Task Graph Model)
- 节点(Node):表示函数或任务
- 边(Edge):表示任务依赖
示意:
Task A ──► Task B ──► Task C
│
└────► Task D
- 公式化表示:
设有任务集合 T = T 1 , T 2 , . . . , T N T = {T_1, T_2, ..., T_N} T=T1,T2,...,TN,依赖集合 D D D:
D = ( T i , T j ) ∣ T j depends on T i D = { (T_i, T_j) \mid T_j \text{ depends on } T_i } D=(Ti,Tj)∣Tj depends on Ti - 可执行任务集合
E
E
E:
E = T k ∣ ∀ ( T i , T k ) ∈ D , , T i 已完成 E = { T_k \mid \forall (T_i, T_k) \in D, , T_i \text{ 已完成} } E=Tk∣∀(Ti,Tk)∈D,,Ti 已完成 - 运行时调度器从 E E E 中选择任务在 CPU/GPU 上执行。
注:这种模型可以自然处理不规则依赖,同时提供高度并行性。
总结
- 不规则问题并行化复杂,需要处理并发、同步、依赖、调度等细节。
- **任务并行编程(TPP)**为程序员提供了抽象:
- 高层描述任务和依赖
- 运行时自动处理调度
- 可扩展到多核 CPU 和 GPU
- 现代并行库逐步支持任务图模型,未来 C++ 标准也在朝这个方向发展。
使用 std::async 创建异步任务
std::async 是 C++ 标准库提供的高级设施,用于异步执行任务,并返回一个 std::future 对象,用于等待结果或获取返回值。
示例代码
#include <future>
#include <iostream>
// 一个简单的函数,返回传入的整数
int compute(int v) {
return v;
}
int main() {
// 使用 std::async 异步运行 compute(42)
std::future<int> fu = std::async(std::launch::async, compute, 42);
// 获取异步任务的返回值,并打印
std::cout << fu.get() << std::endl; // 输出 42
}
解释:
std::async(std::launch::async, compute, 42)std::launch::async表示任务一定在新线程中运行。- 返回一个
std::future<int>对象fu,用于获取结果。
fu.get()- 阻塞调用,直到异步任务完成,并返回
compute(42)的结果。
- 阻塞调用,直到异步任务完成,并返回
注释:这是 最简单的异步任务启动方式。
std::async会自动管理线程和同步,程序员无需手动管理线程。
std::async 的自定义实现示例
下面是一个手动实现 std::async 的示例,帮助理解底层机制:
#include <future>
#include <thread>
#include <type_traits>
#include <utility>
template <typename F, typename... Args>
auto async(F&& func, Args&&... args) {
// 推断函数返回类型
using ReturnType = std::invoke_result_t<F, Args...>;
// 创建 promise-future 对,用于线程间同步
std::promise<ReturnType> prom;
std::future<ReturnType> fu = prom.get_future();
// 启动线程执行任务
std::thread t([prom=std::move(prom),
f=std::forward<F>(func),
...args=std::forward<Args>(args)] () mutable {
if constexpr(std::is_void_v<ReturnType>) {
f(std::move(args)...); // 执行函数
prom.set_value(); // void 类型直接设置 promise
} else {
prom.set_value(f(std::move(args)...)); // 将返回值存入 promise
}
});
t.detach(); // 模拟 std::async fire-and-forget 行为
return fu; // 返回 future 以便访问异步结果
}
解析:
- 推断返回类型
using ReturnType = std::invoke_result_t<F, Args...>;
- 使用
std::invoke_result_t得到函数F在参数Args...下的返回类型 R R R。 - 若 R = v o i d R = void R=void,则在 promise 中不存储值,否则存储结果。
- promise-future 配对
std::promise<ReturnType> prom;
std::future<ReturnType> fu = prom.get_future();
std::promise用于线程间设置值。std::future用于线程间获取值。- p r o m prom prom 与 f u fu fu 一一对应,保证同步。
- 线程启动与完美转发(Perfect Forwarding)
std::thread t([prom=std::move(prom), f=std::forward<F>(func), ...args=std::forward<Args>(args)] () mutable {
// ...
});
- 使用 lambda 捕获
func和参数args。 - 完美转发保证传入的左值/右值语义正确传递。
mutable允许 lambda 内修改捕获变量。
- 处理 void 与非 void 返回类型
if constexpr(std::is_void_v<ReturnType>) {
f(std::move(args)...);
prom.set_value();
} else {
prom.set_value(f(std::move(args)...));
}
- 使用
if constexpr判断返回类型是否为void。 - 非 void 类型将返回值写入 promise,void 类型直接设置完成标志。
- 分离线程(fire-and-forget)
t.detach();
- 线程独立运行,不阻塞主线程。
- 通过
future对象仍然可以获取任务完成的结果。
异步任务的数学模型
- 假设任务函数为 f : X → Y f: X \to Y f:X→Y,输入参数 x ∈ X x \in X x∈X,返回值 y ∈ Y y \in Y y∈Y。
- 异步执行可视为一个映射:
async ( f , x ) ⟶ future y \text{async}(f, x) \longrightarrow \text{future } y async(f,x)⟶future y
- y y y 是 延迟求值(lazy evaluation)的结果,由 future 提供访问接口。
- 当调用
future.get()时:
y = f ( x ) y = f(x) y=f(x)
- 如果任务未完成,则调用会阻塞,直到完成。
✓ 总结: std::async提供了高层异步执行接口,无需手动管理线程。- 通过
promise/future机制实现线程间同步。 - 可处理
void和非void函数。 - 本质上是将同步函数 f包装为异步任务,返回
future对象以访问结果。
1. 使用 std::async + std::future 构建动态任务图
示例代码
#include <future>
#include <iostream>
int main() {
// 异步任务 A
auto A = std::async(std::launch::async, []() {
std::cout << "A\n";
});
A.wait(); // 等待 A 完成,才能继续
// 异步任务 B 和 C
auto B = std::async(std::launch::async, []() {
std::cout << "B\n";
});
auto C = std::async(std::launch::async, []() {
std::cout << "C\n";
});
B.wait(); // 等待 B 完成
C.wait(); // 等待 C 完成
// 异步任务 D
auto D = std::async(std::launch::async, []() {
std::cout << "D\n";
});
D.wait(); // 等待 D 完成
}
解释
- 任务 A → B、C → D 的依赖关系
- 任务图可以表示为:
A → B , C → D A \rightarrow {B, C} \rightarrow D A→B,C→D future.wait()用于显式同步,保证依赖任务按顺序执行。
- 动态任务图(Dynamic Task Graph)特点
- 每个任务在运行时可以动态创建后续任务。
- 不需要提前知道全部任务和依赖关系。
- 适合不规则并行问题。
2. Sender-Receiver 模型(C++26 std::exec)
示例
#include <execution>
#include <iostream>
int main() {
exec::static_thread_pool pool;
auto scheduler = pool.get_scheduler();
// 任务 A
auto sa = exec::then(exec::schedule(scheduler), []{
std::cout << "A\n";
});
exec::sync_wait(sa); // 等待 A
// 并行任务 B 和 C
auto sb = exec::then(exec::schedule(scheduler), []{
std::cout << "B\n";
});
auto sc = exec::then(exec::schedule(scheduler), []{
std::cout << "C\n";
});
exec::sync_wait(exec::when_all(sb, sc)); // 等待 B 和 C
// 任务 D
auto sd = exec::then(exec::schedule(scheduler), []{
std::cout << "D\n";
});
exec::sync_wait(sd); // 等待 D
}
解释
exec::schedule(scheduler)在线程池中调度任务。exec::then(sender, func)表示任务依赖,当sender完成后执行func。exec::sync_wait(sender)阻塞直到任务完成。- 通过
exec::when_all可以同时等待多个任务完成,实现并行分支。
3. Intel TBB 任务组
#include <tbb/task_group.h>
#include <iostream>
int main() {
tbb::task_group tg;
// 任务 A
tg.run([] { std::cout << "A\n"; });
tg.wait(); // 等待 A
// 并行任务 B 和 C
tg.run([] { std::cout << "B\n"; });
tg.run([] { std::cout << "C\n"; });
tg.wait(); // 等待 B 和 C
// 任务 D
tg.run([] { std::cout << "D\n"; });
tg.wait(); // 等待 D
}
解释
tg.run(func)提交任务到任务组。tg.wait()等待任务组中所有任务完成。- 通过多次
wait(),可以控制任务依赖顺序,实现 任务图。
4. OpenMP Tasking Model
#include <omp.h>
#include <iostream>
int main() {
#pragma omp parallel
{
int A_B, A_C, B_D, C_D;
#pragma omp single
{
// Task A
#pragma omp task depend(out: A_B, A_C)
std::cout << "A\n";
// Task B depends on A
#pragma omp task depend(in: A_B, out: B_D)
std::cout << "B\n";
// Task C depends on A
#pragma omp task depend(in: A_C, out: C_D)
std::cout << "C\n";
// Task D depends on B and C
#pragma omp task depend(in: B_D, C_D)
std::cout << "D\n";
}
}
}
解释
#pragma omp task depend(in: X; out: Y)in表示依赖于任务完成的输入。out表示该任务完成后生成的依赖标识。
- OpenMP 自动生成并行任务图,根据依赖关系调度任务。
- 不需要显式等待任务完成,依赖关系由编译器处理。
5. 总结
| 模型 | 特点 | 同步方式 | 任务依赖表示 |
|---|---|---|---|
std::async + std::future | 标准库,高度可控 | future.wait() | 手动同步,动态任务图 |
Sender-Receiver (std::exec) | 现代 C++26 异步模型 | sync_wait() | then() + when_all() 构建依赖 |
| Intel TBB | 高性能任务调度库 | task_group.wait() | 任务组,手动分阶段同步 |
| OpenMP | 编译器指令级 | depend 子句自动调度 | in/out 表示依赖,编译器生成任务图 |
数学模型
- 假设任务集合为
T
i
{T_i}
Ti,依赖关系为集合
E
E
E,则任务图为:
G = ( V , E ) , V = A , B , C , D , E = ( A , B ) , ( A , C ) , ( B , D ) , ( C , D ) G = (V, E), \quad V = {A, B, C, D}, \quad E = {(A,B),(A,C),(B,D),(C,D)} G=(V,E),V=A,B,C,D,E=(A,B),(A,C),(B,D),(C,D) - 动态任务图:某些任务(例如 B、C)在运行时才被创建,依赖关系也动态生成。
✓ 总结 - 使用
future.wait()或 Sender-Receiversync_wait()可以手动或半自动构建动态任务图。 - TBB 提供更高级的任务组抽象,OpenMP 通过依赖子句实现编译器自动调度。
- 核心思想:任务图 = 节点(任务) + 边(依赖),并行调度由运行时负责。
1. OpenCilk 异步任务示例
示例代码
#include <iostream>
#include <cilk/cilk.h> // 需要支持 OpenCilk 的编译器
void A() { std::cout << "A\n"; }
void B() { std::cout << "B\n"; }
void C() { std::cout << "C\n"; }
void D() { std::cout << "D\n"; }
int main() {
A(); // 主线程执行任务 A
cilk_spawn B(); // 在子线程异步执行 B
C(); // 主线程继续执行 C
cilk_sync; // 等待所有 cilk_spawn 的任务(这里是 B)完成
D(); // 所有依赖任务完成后执行 D
}
理解与注释
- 基本概念
cilk_spawn:将一个函数(任务)标记为异步执行,即“生成子任务”。cilk_sync:等待之前所有cilk_spawn的任务完成,相当于“同步点”。
- 任务依赖关系
- 任务图(Task Graph)可表示为:
A → B , C → D A \rightarrow {B, C} \rightarrow D A→B,C→D - 执行顺序:
- 主线程执行
A() B()在子线程异步执行- 主线程继续执行
C() cilk_sync等待B()完成- 最终执行
D()
- 主线程执行
- OpenCilk 的 fork-join 模型通过编译器扩展自动管理线程和任务调度。
- 运行要求
- 需要支持 OpenCilk 语法的编译器(如 Intel Cilk Plus 或 OpenCilk)。
- 这是一个语言级别的异步模型,和
std::async/ TBB / OpenMP 不同,需要非标准 C++ 扩展。
2. 现有异步任务模型的局限性
- 任务和依赖的解耦
- 在很多异步模型中(
std::async、TBB、OpenMP),任务创建和依赖定义是分开的。 - 如果依赖关系没有在创建任务时明确表达,运行时无法优化任务放置和负载均衡。
- 等待调用依赖程序员手动控制
- 程序员必须明确调用
wait()或sync_wait()来保证依赖完成。 - 对于有 N 个任务和 M 个依赖的复杂任务图:
最坏情况等待次数 = M \text{最坏情况等待次数} = M 最坏情况等待次数=M - 但实际上,很多应用只关心整个任务图完成,而不需要中间等待,手动同步会增加开销并易出错。
- 高度动态任务图支持有限
- 高度动态任务图指:
- 任务结构、依赖关系和任务内容在运行时才确定
- 依赖动态控制流和运行时变量
- OpenMP 这种静态依赖模型不适合高度动态场景。
- 可能需要非标准编译器
- OpenCilk 依赖编译器扩展生成并行代码,限制了可移植性。
std::async、TBB、OpenMP 相比,标准化程度不同,但各有局限。
总结
| 模型 | 优点 | 局限性 |
|---|---|---|
| OpenCilk | 简洁,编译器管理 fork-join,自动调度 | 需要非标准 C++ 编译器,任务图依赖静态分析 |
| std::async / std::future | 标准库,易用,动态任务图 | 手动同步,依赖管理完全靠程序员 |
| TBB | 高性能,任务组抽象 | 依赖管理需要分阶段 wait,复杂任务图管理麻烦 |
| OpenMP | 编译器自动并行化,依赖子句 | 不适合高度动态任务图,静态依赖限制 |
关键公式总结
- 任务图表示
G = ( V , E ) , V = A , B , C , D , E = ( A , B ) , ( A , C ) , ( B , D ) , ( C , D ) G = (V, E), \quad V = {A, B, C, D}, \quad E = {(A,B), (A,C), (B,D), (C,D)} G=(V,E),V=A,B,C,D,E=(A,B),(A,C),(B,D),(C,D) - 等待次数最坏情况
- 若程序员手动等待每个依赖:
最坏情况等待次数 = ∣ E ∣ \text{最坏情况等待次数} = |E| 最坏情况等待次数=∣E∣
- 动态任务图特点
- 某些任务和依赖在运行时才生成:
V d y n a m i c ⊆ V , E d y n a m i c ⊆ E V_{dynamic} \subseteq V, \quad E_{dynamic} \subseteq E Vdynamic⊆V,Edynamic⊆E
1. 静态任务图 (STGP) 与动态任务图 (DTGP) 对比
- 动态任务图编程 (DTGP, Dynamic Task Graph Programming)
- 任务创建和任务执行是解耦的。
- 当任务的所有依赖完成后,就可以立即执行该任务。
- 优点:可以充分利用空闲 CPU/GPU 核心,减少等待时间。
- 任务图示意(时间轴):
时间 t 任务执行 t 0 构建任务 t 1 任务 A 完成 → B、C 可执行 t 2 B、C 并行执行 t 3 B、C 完成 → D 可执行 t 4 D 执行完成 \begin{array}{c|c} \text{时间 t} & \text{任务执行} \\ \hline t_0 & \text{构建任务} \\ t_1 & \text{任务 A 完成 → B、C 可执行} \\ t_2 & \text{B、C 并行执行} \\ t_3 & \text{B、C 完成 → D 可执行} \\ t_4 & \text{D 执行完成} \\ \end{array} 时间 tt0t1t2t3t4任务执行构建任务任务 A 完成 → B、C 可执行B、C 并行执行B、C 完成 → D 可执行D 执行完成 - DTGP 最大优势:任务可以尽早执行,节省时间 t t t。
- 静态任务图编程 (STGP, Static Task Graph Programming)
- 任务图在运行前就完全构建好(construct-and-run)。
- 一旦任务图构建完成,调度器根据依赖顺序执行任务。
- 时间轴示意:
任务构建 ⟶ 任务执行 \text{任务构建} \longrightarrow \text{任务执行} 任务构建⟶任务执行 - 与 DTGP 的区别在于:任务执行必须等待整个任务图构建完成,可能浪费一些时间,但更易管理和调试。
2. Taskflow 实现静态任务图示例
代码
#include <taskflow/taskflow.hpp> // Taskflow 是 header-only
int main() {
tf::Executor executor; // 创建执行器
tf::Taskflow taskflow; // 创建任务流
// 创建四个任务 A, B, C, D
auto [A, B, C, D] = taskflow.emplace(
[] () { std::cout << "TaskA\n"; },
[] () { std::cout << "TaskB\n"; },
[] () { std::cout << "TaskC\n"; },
[] () { std::cout << "TaskD\n"; }
);
A.precede(B, C); // A 先于 B 和 C 执行
D.succeed(B, C); // D 在 B 和 C 之后执行
executor.run(taskflow).wait(); // 运行任务图并等待完成
return 0;
}
理解与注释
- 任务创建
tf::Taskflow taskflow;- 创建一个任务图对象,用于存放任务和依赖关系。
taskflow.emplace(...)- 同时创建多个任务(lambda 函数封装),返回任务句柄
[A, B, C, D]。
- 同时创建多个任务(lambda 函数封装),返回任务句柄
- 依赖关系定义
A.precede(B, C)- 表示任务 A 必须先执行,才能开始 B 和 C。
D.succeed(B, C)- 表示任务 D 必须在 B 和 C 完成之后才执行。
- 依赖关系图可表示为:
A → B , C → D A \rightarrow {B, C} \rightarrow D A→B,C→D
- 任务执行
executor.run(taskflow).wait();- 启动任务图执行,等待所有任务完成。
- Taskflow 会自动调度任务到线程池,B 和 C 可以并行执行。
- 运行结果可能顺序
TaskA
TaskC
TaskB
TaskD
- B 与 C 的执行顺序不固定,因为它们可以并行执行。
- D 始终在 B 和 C 完成之后执行。
3. 关键总结
- 静态任务图 (STGP)
- 构建任务图后执行
- 优点:易于调试、明确依赖
- 缺点:任务执行必须等待整个图构建完成
- 动态任务图 (DTGP)
- 任务可以在依赖满足时立即执行
- 优点:最大化并行性,节省时间
- 缺点:依赖关系管理复杂
- Taskflow 优势
- C++ 标准库兼容
- 轻量级 header-only
- 可支持静态和动态任务图
- 自动线程调度和负载均衡
https://godbolt.org/z/ecqzzvxGY
1. 动态任务图 (DTGP) 基本概念
- DTGP(Dynamic Task Graph Programming)允许任务在依赖满足时立即执行,无需等整个任务图构建完成。
- 每个任务可以动态指定依赖的前置任务。
- Taskflow 提供
silent_dependent_async和dependent_asyncAPI 来支持 DTGP。
2. Taskflow 动态任务图示例代码
#include <taskflow/taskflow.hpp> // Taskflow 是 header-only
int main() {
tf::Executor executor; // 创建执行器,管理线程池和任务调度
// 动态创建任务 A
auto A = executor.silent_dependent_async([](){
std::cout << "TaskA\n";
});
// 动态创建任务 B,依赖 A 完成
auto B = executor.silent_dependent_async([](){
std::cout << "TaskB\n";
}, A);
// 动态创建任务 C,依赖 A 完成
auto C = executor.silent_dependent_async([](){
std::cout << "TaskC\n";
}, A);
// 动态创建任务 D,依赖 B 和 C 完成
auto [D, Fu] = executor.dependent_async([](){
std::cout << "TaskD\n";
}, B, C);
// 等待 D 完成(同时保证 A, B, C 已完成)
Fu.wait();
return 0;
}
3. 逐行解析
tf::Executor executor;- 创建一个 Taskflow 执行器对象,用于管理线程池和调度任务。
- 执行器负责自动分配任务到空闲线程,提高并行执行效率。
auto A = executor.silent_dependent_async([](){ ... });- 创建任务 A,并立即提交到线程池执行。
silent_dependent_async表示这是一个“无返回值任务”,Taskflow 会处理依赖调度。
auto B = executor.silent_dependent_async(..., A);- 创建任务 B,同时指定依赖任务 A。
- Taskflow 保证 B 在 A 执行完成后才会开始执行。
auto C = executor.silent_dependent_async(..., A);- 类似 B,也依赖任务 A 完成后执行。
- B 和 C 可以并行执行(因为它们都依赖 A,但相互独立)。
auto [D, Fu] = executor.dependent_async(..., B, C);- 创建任务 D,依赖任务 B 和 C。
dependent_async返回一个 任务句柄 和一个std::future(这里是Fu)。- 该 future 可以用来等待 D 及其依赖任务完成。
Fu.wait();- 等待 D 任务完成。
- Taskflow 会自动确保 A, B, C 已按依赖顺序完成,无需手动管理。
4. 动态任务图执行流程图
A
│
├── B
│
└── C
│
└── D
- 任务执行顺序:
- A 执行
- A 完成 → B 和 C 并行执行
- B 和 C 完成 → D 执行
- D 完成 →
Fu.wait()返回
- 特点:依赖关系在任务创建时动态指定,Taskflow 自动调度任务,支持并行执行。
5. DTGP 优势
- 任务构建与执行解耦
- 任务可以在运行时动态创建,而不是必须提前构建完整图。
- 充分利用并行性
- B 和 C 可以同时执行,不需要手动管理线程或 future。
- 简化同步
- 只需等待最终任务完成(D),中间依赖由 Taskflow 自动处理。
- 灵活性高
- 适合高度动态或数据依赖复杂的应用,如 VLSI 分析、GPU 核心任务调度等。
https://godbolt.org/z/nqo8Mh5W5
1. 动态任务图中的拓扑顺序 (Topological Order)
在 动态任务图 (Dynamic Task Graph, DTGP) 中,任务的执行必须遵循 依赖关系,即拓扑顺序。
- 拓扑顺序 (Topological Order):一个有向无环图 (DAG) 中的节点顺序,使得每条边的起点都在终点之前执行。
- 对动态任务图,必须先创建前置任务,才能让依赖于它的任务正确执行。
2. 正确拓扑顺序示例
tf::Executor executor;
// 创建任务 A
auto A = executor.silent_dependent_async(
[](){ std::cout << "TaskA\n"; }
);
// 创建任务 B 和 C,它们依赖 A
auto B = executor.silent_dependent_async(
[](){ std::cout << "TaskB\n"; }, A
);
auto C = executor.silent_dependent_async(
[](){ std::cout << "TaskC\n"; }, A
);
// 创建任务 D,依赖 B 和 C
auto D = executor.silent_dependent_async(
[](){ std::cout << "TaskD\n"; }, B, C
);
- 解释:
- 先创建 A,作为前置任务。
- 然后创建 B 和 C,并指定它们依赖 A。
- 最后创建 D,依赖 B 和 C。
- 可能的拓扑顺序:
- A → B → C → D A \to B \to C \to D A→B→C→D
-
A
→
C
→
B
→
D
A \to C \to B \to D
A→C→B→D
这两种顺序都是正确的,因为 A 必须在 B 和 C 之前,B 和 C 必须在 D 之前,但 B 和 C 的相对顺序不固定。
3. 错误拓扑顺序示例
tf::Executor executor;
auto A = executor.silent_dependent_async([](){
std::cout << "TaskA\n";
});
// 错误示例:先创建 D,但 B 和 C 还未创建
auto D = executor.silent_dependent_async([](){
std::cout << "TaskD\n";
},
/* B-is-unavailable-yet */,
/* C-is-unavailable-yet */
);
auto B = executor.silent_dependent_async([](){
std::cout << "TaskB\n";
}, A);
auto C = executor.silent_dependent_async([](){
std::cout << "TaskC\n";
}, A);
executor.wait_for_all();
- 问题:
- 当创建 D 时,B 和 C 还未创建,依赖无法绑定。
- Taskflow 无法表达正确的动态任务图。
- 这可能导致执行顺序不正确,如 A → D → B → C A \to D \to B \to C A→D→B→C,违反依赖关系。
- 结果:
- D 可能先于它的前置任务执行。
- 导致运行时错误或逻辑错误。
- 因此,在 DTGP 中必须按拓扑顺序创建任务。
4. 总结
- 动态任务图创建规则:
- 创建任务前,确保它的所有前置任务已经存在。
- 任务可以并行创建,但依赖的前置任务必须已经被创建。
- Taskflow 会在内部保证依赖关系执行正确,但它无法修正错误拓扑顺序。
- 可行策略:
- 按层次或依赖顺序逐步构建任务图。
- 使用 variadic template 参数(如
dependent_async([]{}, B, C))指定多个依赖。 - 最后使用
Fu.wait()或executor.wait_for_all()等待所有任务完成。
1. 可变范围的任务依赖 (Variable Range of Task Dependencies)
在 Taskflow 中,任务依赖不仅可以固定写死,也可以使用 容器(如 std::vector) 来存储一组任务,并一次性创建依赖关系。
- 用途:当任务依赖关系在运行时才确定(例如从文件或网络加载)时非常方便。
- 关键特性:
- 支持运行时动态的任务集合。
- 可使用迭代器 (
begin(),end()) 指定依赖范围。 - 提供
dependent_async和silent_dependent_async两种接口:dependent_async:返回一个 future 对象,可用于等待任务完成。silent_dependent_async:不返回 future,fire-and-forget 风格。
2. 示例代码解析
#include <taskflow/taskflow.hpp> // Taskflow 是头文件库
int main(){
tf::Executor executor;
// 使用 vector 存储一组异步任务
std::vector<tf::AsyncTask> tasks = {
executor.silent_dependent_async([](){ std::cout << "TaskA\n"; }), // 任务 A
executor.silent_dependent_async([](){ std::cout << "TaskB\n"; }), // 任务 B
executor.silent_dependent_async([](){ std::cout << "TaskC\n"; }), // 任务 C
executor.silent_dependent_async([](){ std::cout << "TaskD\n"; }) // 任务 D
};
// 创建一个依赖于 tasks 中所有任务的 dependent-async 任务
executor.dependent_async(
[](){ std::cout << "Dependent Task\n"; }, // 执行任务内容
tasks.begin(), tasks.end() // 依赖于 tasks 容器中的所有任务
);
// 创建一个依赖于 tasks 中所有任务的 silent-dependent-async 任务
executor.silent_dependent_async(
[](){ std::cout << "Silent Dependent Task\n"; },
tasks.begin(), tasks.end()
);
return 0;
}
解释
tasks中存储了 4 个独立任务(A、B、C、D)。- 使用
tasks.begin(), tasks.end()将整个任务集合作为依赖。 - 创建了两个依赖任务:
- 一个返回 future (
dependent_async)。 - 一个不返回 future (
silent_dependent_async)。
- 一个返回 future (
- 好处:
- 不需要一个一个手动依赖。
- 任务数量和顺序可以在运行时动态调整。
3. 动态任务图的灵活性 (DTGP is Flexible)
动态任务图允许任务图 在运行时根据变量和控制流生成。
示例:
if (a == true) {
G1 = build_task_graph1(); // 构建任务图 G1
if (b == true) {
G2 = build_task_graph2(); // 构建任务图 G2
G1.precede(G2); // G1 的所有任务先于 G2 执行
if (c == true) {
... // 其他任务图
}
} else {
G3 = build_task_graph3();
G1.precede(G3);
}
}
- 特点:
- 任务图结构依赖于运行时变量 (
a,b,c)。 - 可以动态组合任务图 (
G1.precede(G2, G3))。 - 支持复杂的依赖条件和任务数判断 (
num_tasks(),num_dependencies())。
- 任务图结构依赖于运行时变量 (
- 优势:
- 静态任务图 (Static Task Graph Programming, STGP) 很难实现这种运行时驱动的任务调度。
- DTGP 提供了更高的灵活性,允许程序根据实时数据动态生成任务图。
4. 总结
- Taskflow 提供了灵活的 DTGP 支持:
- 可变范围依赖:使用容器或迭代器一次性指定多个任务依赖。
- 运行时驱动:任务图结构可以在程序执行过程中动态生成。
- 静态 vs 动态:
- STGP:在编译时固定任务图。
- DTGP:在运行时动态创建任务和依赖关系。
- 实践技巧:
- 尽量按拓扑顺序创建任务。
- 使用 vector 或 iterators 来批量管理依赖。
- 对复杂条件使用动态控制流生成任务图。
https://godbolt.org/z/rEKx79b1z
1. 核心思想
- AsyncTask 的定位:
- 关注 粗粒度任务并行 (coarse-grained task parallelism)。
- 不处理细粒度数据并行(比如向量加法、矩阵乘法等)。
- 目标:
- 让用户用表达式丰富的语言来描述任务以及它们之间的依赖关系。
- 不干扰用户的 数据布局和抽象,即不要求把原本的数据结构改成库特定格式。
2. 示例函数接口
template <typename F, typename... Tasks>
auto dependent_async(F&& func, Tasks&&... tasks) {
// ... 内部实现
}
解释
F是用户提供的 任务函数(通常是 lambda)。Tasks...是 依赖的任务集合(可变参数)。- 内部实现类似
std::async:- 捕获参数时使用 完美转发 (perfect forwarding)。
- 任务函数
func自己捕获所需的数据或参数。
- AsyncTask 只负责调度任务和管理依赖,而不干涉任务内部的数据结构。
3. 为什么这样设计有优势?
- 用户保留对数据的完全控制:
- 可以自由选择数据结构和内存布局。
- 对性能敏感的场景(比如高性能计算、图处理)可以优化数据存储方式。
- AsyncTask 轻量、非侵入式:
- 不需要修改原有代码来适配库特定的数据模型。
- 与库无关,使用方便。
- 对比其他库的限制:
- Fastflow、TBB pipeline 等模型要求用户:
- 重写代码以适配库特定的数据抽象。
- 否则无法充分利用任务并行。
- AsyncTask 则让任务和数据解耦,用户无需重构现有数据结构。
- Fastflow、TBB pipeline 等模型要求用户:
4. 使用示例
#include <taskflow/taskflow.hpp>
#include <vector>
#include <iostream>
int main() {
tf::Executor executor;
std::vector<int> data = {1,2,3,4,5};
// 用户可以自由捕获数据,不需要转换成库特定的数据结构
auto task = executor.dependent_async([&data](){
for(auto &x : data){
x *= 2;
}
std::cout << "Data doubled\n";
});
task.wait(); // 等待任务完成
for(auto x : data) std::cout << x << " "; // 输出: 2 4 6 8 10
return 0;
}
解释
data是用户的原生std::vector<int>。- lambda 捕获
&data,修改数据。 - AsyncTask 只负责调度任务
task,不干预data的存储或布局。 - 完全保留了用户对内存布局的控制,同时实现任务并行。
5. 总结
- 核心点:
- AsyncTask 关注任务和依赖,不管数据怎么存储。
- 保持任务调度库的 轻量性和非侵入性。
- 好处:
- 高性能程序员可以自由优化数据结构。
- 避免像 Fastflow/TBB pipeline 那样必须重写代码。
- 适用场景:
- 高性能计算。
- 动态任务图。
- 需要对数据布局敏感的应用(图算法、机器学习模型等)。
理解
- 关注点是粗粒度任务并行,而非细粒度数据并行
- AsyncTask 的设计目标是让用户描述任务(task)及其依赖关系,而不是去管理数据如何存储或如何并行访问。
- 这意味着 AsyncTask 不会要求你把数据放入特定容器或结构中去配合库的流水线或数据抽象。
- 任务表达式的语法
template <typename F, typename... Tasks> auto dependent_async(F&& func, Tasks&&... tasks) { ... }F&& func是用户传入的任务函数(通常是 lambda)。Tasks&&... tasks是依赖的先行任务列表(可变参数模板)。- **完美转发(perfect forwarding)**保证传入的函数和任务依赖被高效且类型安全地传递,不会发生不必要的拷贝或类型擦除。
- 用户保留对数据的完全控制
- AsyncTask 只负责任务调度和依赖关系,不接管数据的存储。
- 用户可以自己决定数据的布局和所有权,优化内存和缓存使用。
- 举例来说,如果你有一个大数组或者复杂结构体,AsyncTask 不会要求你改写它,也不会对它做任何封装。
- 优势
- 轻量级、非侵入式
- 不需要修改已有数据结构来适配 AsyncTask。
- 高性能优化自由
- 用户可以针对应用领域优化数据布局,例如按 cacheline 排列、使用连续内存等。
- 对比其他库
- Fastflow、TBB Pipeline 等模型要求你把数据包装到库特定的抽象中才能获得并行加速,这会增加学习成本和代码侵入性。
- 轻量级、非侵入式
示例代码
#include <iostream>
#include <vector>
#include <taskflow/taskflow.hpp> // Taskflow 作为 AsyncTask 实现示例
int main() {
// 数据完全由用户管理
std::vector<int> data = {1, 2, 3, 4, 5};
tf::Executor executor;
// 创建一个异步任务,处理数据
auto task1 = executor.silent_dependent_async([&data](){
for(auto& x : data) {
x *= 2; // 用户完全控制数据
}
std::cout << "Task1: Data doubled\n";
});
// 第二个任务依赖于 task1 完成
auto task2 = executor.silent_dependent_async([&data](){
int sum = 0;
for(auto x : data) sum += x;
std::cout << "Task2: Sum = " << sum << "\n";
}, task1); // task2 依赖 task1
// 等待所有任务完成
executor.wait_for_all();
return 0;
}
注释说明:
data完全由用户管理,AsyncTask 不会封装它。task1的 lambda 捕获了data,对它进行修改。task2依赖task1,保证先完成数据修改再计算和。- 这种方式 轻量、灵活,无需改动数据结构,符合 AsyncTask 的设计哲学。
总结
- AsyncTask 的核心设计理念是 “只管任务和依赖,不管数据”。
- 用户可以自由管理数据结构和内存布局。
- 模板 + 完美转发保证任务和依赖的高效传递。
- 对比其他依赖数据封装的库,AsyncTask 非侵入式且灵活,适合多种应用场景。
理解
1. 任务级调度 (Task-level scheduling)
- 目标:在**动态任务图(DTGP, Dynamic Task Graph Programming)**中,高效调度任务,保证:
- 活跃 worker 数量与动态生成任务的平衡
- 低延迟
- 高吞吐量
- 能耗优化
- Taskflow 的调度器是 工作窃取(Work-stealing)调度器,可以让空闲线程从忙线程窃取任务,以减少 idle 时间。
2. 任务调度流程(图示解析)
SVG 图描述了 Taskflow 中工作窃取调度器的核心逻辑。文字如下:
<?xml version="1.0" encoding="UTF-8"?>主要流程
- 队列是否为空?
Queue empty?- N(否) → 从本地队列中取出一个任务
t。 - Y(是) → 等待其他任务完成(可能从其他线程窃取任务)。
- N(否) → 从本地队列中取出一个任务
- 检查条件任务(Condition task?)
- 某些任务可能是条件任务,只有在满足条件时才执行。
- 任务出队(Dequeue a task t)
- 从工作线程的本地队列或者全局队列中取一个任务
t。
- 从工作线程的本地队列或者全局队列中取一个任务
- 执行任务(invoke(t))
r = invoke(t)- 运行任务 t,并返回子任务
r(如果 t 创建了新的依赖任务)。 - 这是动态任务图的核心——执行时可能生成新任务。
- 运行任务 t,并返回子任务
- 递减后继任务的依赖计数
Dec remaining strong dependencies of t's successors by one- 每个任务维护一个强依赖计数(remaining strong dependencies)。
- 当一个任务完成时,将其后继任务的计数减一。
- 入队后继任务
Enqueue successors of zero remaining strong dependencies- 如果某个后继任务的依赖计数减到 0,即所有依赖任务完成,入队等待执行。
- 循环执行,直到队列空
- 空闲线程可尝试从其他线程队列窃取任务(work stealing)。
3. 动态任务图调度挑战
- ABA 问题(Challenge #1)
- 动态依赖任务必须正确存在。
- 不能让依赖指向已经释放的内存地址。
- 竞争条件(Challenge #2)
- 因为任务图构建和执行可以同时发生,多线程访问后继任务可能产生 race condition。
- Taskflow 通过原子操作和锁保护关键数据结构(如任务计数和队列)。
- 同步挑战(Challenge #3)
- 应用程序可以随时发起 fine-grained wait 调用。
- 调度器必须保证 wait 操作安全,且不会阻塞其他线程过长时间。
4. 动态任务图调度伪代码
可以用伪代码理解工作原理:
while(true) {
if(worker_queue.empty()) {
task = steal_task_from_other_queue();
if(!task) break; // 队列空,退出
} else {
task = dequeue_task();
}
if(task.is_condition_task()) continue;
// 执行任务
auto children = task.invoke();
// 更新后继任务依赖
for(auto succ : task.successors) {
if(--succ.remaining_dependencies == 0) {
enqueue_task(succ);
}
}
// 把任务生成的子任务入队
for(auto& r : children) {
enqueue_task(r);
}
}
说明:
invoke():执行任务,同时可能动态生成子任务。remaining_dependencies:强依赖计数,确保任务在所有前置任务完成后才执行。enqueue_task():入队等待调度执行。- work-stealing:线程空闲时,从其他线程队列中窃取任务,保证负载均衡。
5. 总结
- Taskflow 的动态任务图调度器 结合工作窃取,实现 低延迟、高吞吐量、动态任务生成。
- 核心原理是 依赖计数 + 后继入队:
if remaining_dependencies == 0 → enqueue task \text{if remaining\_dependencies == 0 → enqueue task} if remaining_dependencies == 0 → enqueue task - 动态任务图构建与执行同时进行,带来了 ABA 问题、竞争条件、同步挑战。
- AsyncTask 的设计使得任务的依赖关系表达和执行完全动态,适合复杂运行时控制流的并行任务。
1. 问题回顾:ABA 问题
在 动态任务图调度中,任务可能会被多个线程同时访问:
worker #1 访问任务 A
worker #2 也访问任务 A
- 如果任务 A 执行完被销毁,然后内存被复用给新的任务 A’,指针地址可能仍然是
0x0010。 - worker #2 可能误以为它访问的是原来的 A,而实际上是新的对象 A’。
- 这就是经典的 ABA 问题,可能导致任务依赖错误、未定义行为甚至崩溃。
2. Taskflow 的解决方案:共享所有权
核心思路:用 shared ownership 保证任务在被使用期间不会被销毁。
- Taskflow 的
tf::AsyncTask实际上就像std::shared_ptr:- 内部引用计数确保任务对象在所有依赖任务完成之前一直存在。
- 当最后一个引用释放时,任务才会被真正销毁。
- 这样就消除了 ABA 问题,因为任务指针在其生命周期内不会被回收。
3. 示例代码与注释
#include <taskflow/taskflow.hpp> // Taskflow 头文件
int main() {
// 创建调度器
tf::Executor executor;
// 使用 silent_dependent_async 创建异步任务
// 返回的 tf::AsyncTask 对象像 shared_ptr,保证任务不会在使用期间销毁
tf::AsyncTask A = executor.silent_dependent_async([]{
std::cout << "TaskA\n";
});
tf::AsyncTask B = executor.silent_dependent_async([]{
std::cout << "TaskB\n";
}, A); // B 依赖 A
tf::AsyncTask C = executor.silent_dependent_async([]{
std::cout << "TaskC\n";
}, A); // C 依赖 A
tf::AsyncTask D = executor.silent_dependent_async([]{
std::cout << "TaskD\n";
}, B, C); // D 依赖 B 和 C
// 等待所有任务完成
executor.wait_for_all();
return 0;
}
注释说明
tf::AsyncTask A = executor.silent_dependent_async(...);- 创建任务 A,并返回一个 tf::AsyncTask 对象。
- 内部引用计数保证 A 不会被销毁,即使 B/C 依赖它的线程同时访问。
B, C 依赖 A- 当任务 B、C 被创建时,它们持有 A 的 shared ownership。
- 即使 A 执行完,也不会立即释放内存,因为 B/C 还持有引用。
D 依赖 B, C- 同理,D 持有 B、C 的引用。
- 当 B 和 C 都执行完成并释放最后的引用后,D 才能正确获取任务状态。
executor.wait_for_all()- 阻塞等待所有任务完成。
- Taskflow 会确保所有依赖计数正确处理、任务不会提前销毁。
- 避免了 ABA 问题和潜在的内存访问错误。
4. 核心原理总结
- 问题:动态任务图中任务对象可能被多线程同时访问 → ABA 问题。
- 解决方案:使用 shared ownership(类似
std::shared_ptr)管理任务生命周期。 - 好处:
- 安全:任务在所有依赖完成前不会被销毁。
- 简洁:不需要手动管理任务指针和生命周期。
- 高性能:Taskflow 内部优化了引用计数,支持低延迟调度。
Challenge #2: Data Race(数据竞争)
问题描述:
- 假设有任务 A,B 和 C 同时依赖 A。
- B 和 C 都想把自己加入 A 的 successor list(后继任务列表),同时 A 执行完成时可能会修改这个列表(比如清理完成的后继任务)。
- 如果不加同步,会出现 数据竞争(Data Race),可能导致:
- 后继列表错乱
- 内存访问错误
- 任务丢失或重复执行
解决方案:
- 使用 CAS(Compare-And-Swap) + 自旋锁(spinning)实现任务列表的原子访问。
- 原理:
- B 或 C 想加入 A 的后继列表时,先尝试原子读取列表。
- 用 CAS 更新列表,如果失败就自旋重试,直到成功。
- 优点:
- 自旋锁开销小,因为大多数任务图是稀疏的(sparse)。
- 如果任务图非常密集(dense),这种方法可能不适合,需要其他模型。
示意图(文字版):
Worker 1: B -> wants to add to A.successors
Worker 2: C -> wants to add to A.successors
A finishes -> may remove successors
使用 CAS 保证 atomic 操作
Challenge #3: Synchronization(同步)
问题描述:
- 用户可能希望在任意时刻进行 粗粒度或细粒度同步:
- 粗粒度:等待整个任务图完成
- 细粒度:只等待某个任务完成
解决方案:
- 粗粒度同步(Coarse-grained sync)
tf::Executor executor;
auto A = executor.silent_dependent_async([]{});
auto B = executor.silent_dependent_async([]{}, A);
executor.wait_for_all(); // 等待 A 和 B 完成
- 细粒度同步(Fine-grained sync)
auto C = executor.silent_dependent_async([]{}, A);
auto D = executor.silent_dependent_async([]{}, B, C);
// 只等待 C 和 D 完成
executor.wait_for_all();
- 锁和条件变量方式(传统方法)
std::mutex mtx;
std::condition_variable cv;
int num_tasks = 0;
// 等待所有任务完成
std::unique_lock lock(mtx);
cv.wait(lock, [&]{ return num_tasks == 0; });
- C++20 原子变量 + 原子等待(atomic wait)方式(高性能)
- 使用
std::atomic<int>来计数任务数量 - 使用
atomic.wait()和atomic.notify_one()/notify_all()实现 用户态同步 - 避免了频繁进入内核态,提高约 11% 性能
#include <atomic>
std::atomic<int> num_tasks = 4; // 假设有 4 个任务
// 等待任务完成
int n = num_tasks.load();
while(n != 0){
num_tasks.wait(n); // 用户态等待
n = num_tasks.load();
}
// 任务完成后,worker 会执行:
num_tasks.fetch_sub(1);
num_tasks.notify_one(); // 唤醒等待线程
原理:
fetch_sub(1)原子减少计数notify_one()唤醒等待的线程- 循环判断
num_tasks是否为 0,确保所有任务完成
核心总结
| 挑战 | 问题 | 解决方案 | 核心技术 |
|---|---|---|---|
| #2 数据竞争 | 多线程同时修改任务后继列表 | CAS + 自旋锁 | 原子操作,轻量锁 |
| #3 同步 | 用户可随时进行任务同步 | 粗粒度:wait_for_all() 细粒度: future.wait() 或 atomic wait | C++20 原子变量、条件变量、用户态等待 |
✓ 小结
- DTGP 的调度设计中,任务执行和任务图构建可能同时发生,因此必须保证:
- 任务生命周期安全 → 使用 shared ownership 避免 ABA
- 多线程访问安全 → 使用 CAS + 自旋锁解决数据竞争
- 同步灵活高效 → 使用粗粒度或细粒度同步,推荐 C++20 atomic wait 实现高性能
算法总览
该算法包含三个核心部分:
dependent_async(callable, deps)— 创建一个异步任务,并注册它的依赖。process_dependent(task, dep, num_deps)— 处理依赖关系,保证任务在依赖完成后才执行。schedule_async_task(task)— 真正调度并执行任务,同时触发其后继任务(successors)。
算法核心目标:无锁、动态、支持多线程安全的任务调度。
算法 1: dependent_async(callable, deps)
Algorithm 1 dependent_async(callable, deps)
1: Create a future
2: num_deps ← sizeof(deps) // 任务 task 的依赖数量
3: task ← initialize_task(callable, num_deps, future) // 创建任务对象
4: for all dep ∈ deps do
5: process_dependent(task, dep, num_deps) // 处理依赖
6: end for
7: if num_deps == 0 then // 没有依赖,直接调度
8: schedule_async_task(task)
9: end if
10: return(task, future) // 返回 task 对象和 future,用于同步
理解与注释:
- 第 1 行:为任务创建一个
future,用于后续等待结果或同步(细粒度同步)。 - 第 2 行:统计任务依赖数
num_deps,初始值等于依赖数组长度。 - 第 3 行:初始化任务对象
task,包括可调用对象、依赖计数和 future。 - 第 4~6 行:遍历每个依赖任务
dep,调用process_dependent()注册到依赖的后继列表。 - 第 7~9 行:如果没有依赖(
num_deps == 0),直接将任务调度执行。 - 第 10 行:返回任务对象和 future,供用户或 runtime 调用
wait()。
算法 2: process_dependent(task, dep, num_deps)
Algorithm 2 process_dependent(task, dep, num_deps)
1: dep_state ← dep.state // 获取依赖任务的状态
2: target_state ← UNFINISHED
3: if dep_state.CAS(target_state, LOCKED) then // 尝试锁定 dep
4: dep.successors.push(task) // 将 task 加入 dep 的后继列表
5: dep_state ← UNFINISHED
6: else if target_state == FINISHED then // 依赖已完成
7: num_deps ← AtomDec(task.join_counter) // 任务依赖计数减 1
8: else
9: goto line 2 // 如果 CAS 失败且不是 FINISHED,重试
10: end if
理解与注释:
- 关键问题:多线程同时访问同一个依赖任务的
successors列表可能产生数据竞争(Challenge #2)。 - CAS + 自旋:
- 第 3 行:尝试将依赖任务
dep的状态从UNFINISHED设置为LOCKED,以安全访问后继列表。 - 成功 → 安全地把
task加入dep.successors(第 4 行),然后释放锁(第 5 行)。 - 失败 → 检查依赖是否已完成:
- 已完成 → 将
task.join_counter原子减 1(表示依赖已满足)。 - 仍未完成 → 重试 CAS(第 9 行)。
- 已完成 → 将
- 第 3 行:尝试将依赖任务
注意:CAS(Compare-And-Swap)是原子操作,无需传统锁即可安全访问共享内存。
算法 3: schedule_async_task(task)
Algorithm 3 schedule_async_task(task)
1: target_state ← UNFINISHED
2: while not task.state.CAS(target_state, FINISHED) do
3: target_state ← UNFINISHED
4: end while
5: Invoke(task.callable) // 执行任务
6: for all successor ∈ task.successors do
7: if AtomDec(successor.join_counter) == 0 then
8: schedule_async_task(successor) // 依赖已满足,递归调度
9: end if
10: end for
11: if AtomDec(task.ref_count) == 0 then
12: Delete task // 任务释放
13: end if
理解与注释:
- 第 2~4 行:尝试将任务状态从
UNFINISHED原子设置为FINISHED,确保任务只执行一次。 - 第 5 行:调用任务的可执行函数
callable,执行实际逻辑。 - 第 6~10 行:处理后继任务:
- 遍历
task.successors,每个 successor 的依赖计数join_counter原子减 1。 - 如果计数为 0 → 所有依赖完成 → 调度 successor 执行。
- 递归调度确保任务按依赖关系动态执行(DTGP)。
- 遍历
- 第 11~13 行:引用计数
ref_count原子减 1,如果为 0 → 删除任务,避免内存泄漏。
算法核心特点
- 无锁设计(Lock-free)
- 只使用原子操作和 CAS 来访问共享资源(任务状态、join_counter、successors)。
- 避免了传统 mutex 锁的阻塞开销。
- 动态任务图(DTGP)
- 任务可在 runtime 动态注册依赖和后继任务。
- 支持高度动态、稀疏任务图。
- 多线程安全
- 使用 CAS + 自旋解决 Challenge #2 数据竞争
- 使用
atomic join_counter和ref_count解决 Challenge #1 ABA 问题 - 使用原子计数和
wait()机制解决 Challenge #3 同步问题
- 递归调度
- 任务完成后立即调度其后继任务,实现 fine-grained, low-latency 调度。
简化示意伪代码
auto [task, future] = dependent_async([]{
std::cout << "Task running\n";
}, dep1, dep2); // 注册依赖
void schedule_async_task(Task* task){
if(!task->state.compare_exchange_strong(UNFINISHED, FINISHED)) return;
task->callable(); // 执行
for(auto succ : task->successors){
if(--succ->join_counter == 0)
schedule_async_task(succ);
}
if(--task->ref_count==0) delete task;
}
核心思想:任务只执行一次 → 完成后递归触发后继 → 动态任务图。

719

被折叠的 条评论
为什么被折叠?



