这是一个用于启用基于任务的多线程的库。它允许执行具有任意依赖关系的任务图。依赖关系被表示为原子计数器。
在幕后,任务图是使用纤程执行的,而纤程又在工作线程池上运行(每个 CPU 核心一个线程)。这允许调度程序等待依赖项,而无需任务链或上下文切换。
该库最初是作为 Christian Gyrling 在 2015 年 GDC 演讲“使用光纤并行化顽皮狗引擎”中提出的想法的概念证明而创建的
免费 GDC Vault 演示录音
幻灯片
# include " ftl/task_scheduler.h "
# include " ftl/wait_group.h "
# include < assert.h >
# include < stdint.h >
struct NumberSubset {
uint64_t start;
uint64_t end;
uint64_t total;
};
void AddNumberSubset (ftl::TaskScheduler *taskScheduler, void *arg) {
( void )taskScheduler;
NumberSubset *subset = reinterpret_cast <NumberSubset *>(arg);
subset-> total = 0 ;
while (subset-> start != subset-> end ) {
subset-> total += subset-> start ;
++subset-> start ;
}
subset-> total += subset-> end ;
}
/* *
* Calculates the value of a triangle number by dividing the additions up into tasks
*
* A triangle number is defined as:
* Tn = 1 + 2 + 3 + ... + n
*
* The code is checked against the numerical solution which is:
* Tn = n * (n + 1) / 2
*/
int main () {
// Create the task scheduler and bind the main thread to it
ftl::TaskScheduler taskScheduler;
taskScheduler. Init ();
// Define the constants to test
constexpr uint64_t triangleNum = 47593243ULL ;
constexpr uint64_t numAdditionsPerTask = 10000ULL ;
constexpr uint64_t numTasks = (triangleNum + numAdditionsPerTask - 1ULL ) / numAdditionsPerTask;
// Create the tasks
// FTL allows you to create Tasks on the stack.
// However, in this case, that would cause a stack overflow
ftl::Task *tasks = new ftl::Task[numTasks];
NumberSubset *subsets = new NumberSubset[numTasks];
uint64_t nextNumber = 1ULL ;
for ( uint64_t i = 0ULL ; i < numTasks; ++i) {
NumberSubset *subset = &subsets[i];
subset-> start = nextNumber;
subset-> end = nextNumber + numAdditionsPerTask - 1ULL ;
if (subset-> end > triangleNum) {
subset-> end = triangleNum;
}
tasks[i] = { AddNumberSubset, subset };
nextNumber = subset-> end + 1 ;
}
// Schedule the tasks
ftl::WaitGroup wg (&taskScheduler);
taskScheduler. AddTasks (numTasks, tasks, ftl::TaskPriority::Normal, &wg);
// FTL creates its own copies of the tasks, so we can safely delete the memory
delete[] tasks;
// Wait for the tasks to complete
wg. Wait ();
// Add the results
uint64_t result = 0ULL ;
for ( uint64_t i = 0 ; i < numTasks; ++i) {
result += subsets[i]. total ;
}
// Test
assert (triangleNum * (triangleNum + 1ULL ) / 2ULL == result);
( void )result;
// Cleanup
delete[] subsets;
// The destructor of TaskScheduler will shut down all the worker threads
// and unbind the main thread
return 0 ;
}
对于纤维和总体概念的精彩介绍,我建议观看 Christian Gyrling 的演讲。 (截至撰写本文时)可以从 GDC 库免费观看。也就是说,我将在下面概述该库的工作原理:
光纤由堆栈和寄存器的小存储空间组成。它是一个在线程内运行的非常轻量级的执行上下文。您可以将其视为实际线程的外壳。
纤维的美妙之处在于您可以非常快速地在它们之间切换。最终,切换包括保存寄存器,然后交换执行指针和堆栈指针。这比完整的线程上下文切换要快得多。
为了回答这个问题,让我们与另一个基于任务的多线程库进行比较:英特尔的线程构建模块。 TBB 是一个极其完善且成功的任务库。它可以处理非常复杂的任务图,并具有出色的调度程序。然而,让我们想象一个场景:
任务A创建任务B、C和D并将它们发送到调度程序
任务 A 做了一些其他工作,但随后它遇到了依赖性:B、C 和 D 必须完成。
如果他们还没有完成,我们可以做两件事:
自旋等待/睡眠
向调度程序请求新任务并开始执行该任务
我们走第二条路
所以调度程序给我们任务G,我们开始执行
但任务 G 最终也需要依赖项,因此我们向调度程序请求另一个新任务
还有另一个,还有另一个
与此同时,任务B、C、D已完成
任务A理论上可以继续,但它被埋在我们等待时得到的任务下面的堆栈中
我们恢复 A 的唯一方法是等待整个链恢复到它,或者遭受上下文切换。
显然,这是一个人为的例子。正如我上面所说,TBB 有一个很棒的调度程序,可以努力缓解这个问题。也就是说,光纤可以通过允许任务之间廉价的切换来帮助完全消除这个问题。这使我们能够将一项任务的执行与另一项任务的执行隔离开来,从而防止上述的“链接”效应。
任务队列- 用于保存等待执行的任务的“普通”队列。在当前代码中,有一个“高优先级”队列和一个“低优先级”队列。
光纤池- 用于在当前任务等待依赖项时切换到新任务的光纤池。纤维执行任务
工作线程- 每个逻辑 CPU 核心 1 个。它们运行纤维。
等待任务- 等待依赖关系被满足的所有纤程/任务。依赖关系用 WaitGroup 表示
任务可以在堆栈上创建。它们只是一个简单的结构体,带有一个函数指针和一个可选的 void *arg 来传递给函数:
struct Task {
TaskFunction Function;
void *ArgData;
};
Task tasks[ 10 ];
for ( uint i = 0 ; i < 10 ; ++i) {
tasks[i] = {MyFunctionPointer, myFunctionArg};
}
您可以通过调用 TaskScheduler::AddTasks() 安排任务执行
ftl::WaitGroup wg (taskScheduler);
taskScheduler-> AddTasks ( 10 , tasks, ftl::TaskPriority::High, &wg);
任务被添加到队列中,其他线程(或当前线程,当当前任务完成时)可以在它们从队列中弹出时开始执行它们。
AddTasks 可以选择使用指向 WaitGroup 的指针。如果这样做,WaitGroup 的值将按排队任务的数量递增。每次任务完成时,WaitGroup 都会自动递减。您可以使用此功能在任务之间创建依赖关系。你可以用这个函数来做到这一点
void WaitGroup::Wait ();
这就是纤维发挥作用的地方。如果 WaitGroup 的值 == 0,则该函数通常会返回。如果没有,调度程序会将当前的 Fiber 移动到 WaitGroup 中的等待 Fiber 列表中,并从Fiber Pool中获取新的 Fiber。新的 Fiber 从任务队列中弹出一个任务并开始执行。
但是我们存储在 WaitGroup 中的任务/纤程呢?什么时候执行完毕?
当 WaitGroup 值从减量变为零时,我们将所有等待纤程添加回 TaskScheduler 的队列中。下次线程切换纤程时(因为当前纤程完成,或者因为它调用了 WaitGroup::Wait() ),就绪的任务将被拾取并从中断处恢复。
一般来说,您不应该在 Fiber 代码中使用互斥体,原因有两个:
如果您使用互斥体并调用 WaitGroup::Wait(),当 Wait() 恢复时,您的代码可能位于另一个线程上。互斥体解锁将是未定义的行为,并且可能导致死锁
互斥锁争用将阻塞工作线程。由于我们通常不会超额订阅内核的线程,因此这会使内核闲置。
为了解决这个问题,我们创建了 Fibtex。它实现了 std 可锁定接口,因此您可以将它与所有您喜欢的包装器(std::lock_guard、std::unique_lock 等)一起使用。它是通过 Fiber 等待在幕后实现的,因此如果 Fibtex 被锁定,服务员可以切换到另一项任务并做有价值的工作
当纤程在 WaitGroup::Wait() 或 Fibtex::lock() 之后恢复时,不能保证它将在挂起时运行的同一线程上恢复。对于大多数代码来说,这很好。然而,某些库有很强的假设。例如,在 DirectX 中,您必须从创建交换链的同一线程执行最终帧提交。因此,某些代码需要保证纤程在挂起时运行的同一线程上恢复。为此,您可以使用参数pinToCurrentThread
。当设置为true
时,调度程序将保证恢复的纤程将在同一线程上运行。此参数可用于 WaitGroup::Wait() 和 Fibtext::lock()。注意:线程固定比默认行为更昂贵,并且可能会导致相关任务的恢复速度慢得多(因为它需要固定线程来完成当前正在运行的任务)。因此,只有在确实必要时才应该使用它。
C++11 编译器
CMake 3.2 或更高版本
拱 | 视窗 | Linux | 操作系统 | iOS系统 | 安卓 |
手臂 | 需要测试 | 完全支持 | 理论上 | 理论上 | 理论上 |
手臂_64 | 需要测试 | 完全支持 | 需要测试 | 理论上 | 理论上 |
x86 | 完全支持 | 需要测试 | 需要测试 | ||
x86_64 | 完全支持 | 完全支持 | 完全支持 |
FiberTaskingLib 是标准 CMake 构建。但是,有关如何在您自己的项目中构建和包含该库的详细说明,请参阅文档页面。
该库根据 Apache 2.0 许可证获得许可。但是,FiberTaskingLib 分发并使用来自其他拥有自己许可证的开源项目的代码:
Boost Context 分支:Boost 许可证 v1.0
Catch2:Boost 许可证 v1.0
非常欢迎您的贡献。有关更多详细信息,请参阅贡献页面。
这个实现是我创建的,因为我认为 Christian 的演示非常有趣,我想自己探索一下。该代码仍在开发中,我很乐意听到您对我如何改进它的批评。我将继续致力于这个项目并尽可能地改进它。