cpp-taskflow 源码:https://github.com/cpp-taskflow/cpp-taskflow
(后面简称taskflow)
taskflow一个写的比较好的基于task有向无环图(DAG)的并行调度的框架,之所以说写的比较好,个人觉得有几点原因:
1.是一个兼具学术研究和工业使用的项目,并非一个玩具
2.现代C++开发,风格简洁
(源码要求编译器支持C++17,也比较容易改成C++11)
3.文档全面
4.注释丰富
因此,学习和研究了taskflow的代码,并写此文作为学习笔记。
通读代码之后,个人的感觉是开发一个通用的DAG调度框架重要在三个方面:
1.拓扑结构的存储和表达
taskflow存储和表达拓扑结构的方式还是比较简单的,用Node类表示DAG中的每个结点,Graph类存储所有的Node对象,Topology类表示一个拓扑结构,后面再详细说。
2.拓扑结构的调度执行
taskflow的调度逻辑中为提高性能做了较多优化:通过WorkStealingQueue提高线程使用率,通过Notifier类(from Eigen库)减少生产者-消费者模式中加锁的频率等。
3.辅助工具或功能
taskflow提供方法可以比较简单的监视每个线程的活动、分析用户程序的性能。
类Node最重要的是保存了当前节点的执行函数,所有前继节点的指针、后续节点的指针,子图(如果有的话),每个前继节点完成后需要修改的计数器(当所有的前继节点都完成时,当前节点就可以执行了)。
class Node {
//static节点的执行函数是返回值和参数都是void的函数
using StaticWork = std::function;
//dynamic节点的执行函数是返回值为void、参数为Subflow的函数,
//在代码中通过模板元编程判断参数是否是Subflow来区分静态节点还是动态节点
using DynamicWork = std::function;
//节点的status
constexpr static int SPAWNED = 0x1;
constexpr static int SUBTASK = 0x2;
public:
Node() = default;
//可以通过构造函数传入节点的执行函数,可以不传入(default构造函数),后续再set
template
Node(C&&);
//传入当前节点的一个后续节点
void precede(Node&);
private:
std::string _name;
//用了C++17的std::variant函数,可以认为就是union类型,未设置、static work、dynamic work
std::variant _work;
//所有的后续节点
tf::PassiveVector _successors;
//所有的前继节点
tf::PassiveVector _dependents;
//子图,正如github上的文档所说,每个节点可以在执行期动态的创建子图
//这里用了C++17的std::optional函数,多了”未设置“状态,好处是如果未设置不会调用Graph的构造函数
std::optional _subgraph;
//若有值,表示此节点实际一个Taskflow,通过组合成为了一个Node
Taskflow* _module {nullptr};
//以下两个是弱引用
Topology* _topology {nullptr};
Taskflow* _module {nullptr};
int _status {0};
//前继节点的个数,在调度执行过程会多线程修改,所以是std::atomic类型。
std::atomic _num_dependents {0};
};
重点说下precede函数的实现
inline void Node::precede(Node& v) {
//1.将v添加到当前节点的后续节点vector中
_successors.push_back(&v);
//2.将当前节点添加到v的前继节点vector中
v._dependents.push_back(this);
//3.将v的前继节点的计数+1,调度执行时每个前继节点完成时-1,等于0时即可执行当前节点
v._num_dependents.fetch_add(1, std::memory_order_relaxed);
}
类Task很简单,就是Node指针的包装,弱引用一个Node对象指针,并不管理其内存。提供了一些public函数的实现也是直接调用类Node对应的函数。根据注释,用类Task而不是直接使用Node指针的目的,是为了防止使用方直接操作内部存储数据。
class Task {
public:
template
Task& work(C&& callable);
template
Task& precede(Ts&&... tasks);
Task& precede(std::vector& tasks);
Task& precede(std::initializer_list tasks);
template
Task& succeed(Ts&&... tasks);
Task& succeed(std::vector& tasks);
Task& succeed(std::initializer_list tasks);
template
Task& gather(Ts&&... tasks);
Task& gather(std::vector& tasks);
Task& gather(std::initializer_list tasks);
private:
//弱引用,不负责管理其内存
Node* _node {nullptr};
};
类Graph负责管理Node对象内存。
class Graph {
public:
void clear();
bool empty() const;
size_t size() const;
template
Node& emplace_back(C&&);
Node& emplace_back();
std::vector>& nodes();
const std::vector>& nodes() const;
private:
//强引用,负责Node对象内存的管理
std::vector> _nodes;
};
重点看下emplace_back函数的实现:
template
Node& Graph::emplace_back(C&& c) {
//1.创建一个新的Node节点
//2.传入c作为新节点的执行函数
//3.将新节点指针插入到vector中
//4.返回新节点的引用
_nodes.push_back(std::make_unique(std::forward(c)));
return *(_nodes.back());
}
inline Node& Graph::emplace_back() {
//同上,但没有传执行函数,后续再set
_nodes.push_back(std::make_unique());
return *(_nodes.back());
}
class Topology {
public:
//Taskflow是弱引用,P是判断函数、传给_pred,C是回调函数、传给_call
template
Topology(Taskflow&, P&&, C&&);
private:
Taskflow& _taskflow;
//Executor的各种run函数返回的就是这个promise对应的future
//可以异步的等待taskflow执行完毕
std::promise _promise;
//DAG的所有入口节点指针(即graph中所有前继节点为0的节点)
PassiveVector _sources;
//DAG的所有出口节点的个数(出口只需要知道个数就行了)
int _cached_num_sinks {0};
//DAG的出口节点的完成计数器(当所有的出口都执行完毕,整个DAG就执行完毕了),多线程写,因此为 std::atomic类型
std::atomic _num_sinks {0};
//判断函数,当DAG执行完毕后执行这个函数,若返回false,则再执行一遍
std::function _pred;
//回调函数,taskflow执行完成后执行此回调函数
std::function _call;
void _bind(Graph& g);
void _recover_num_sinks();
};
重点看下_bind函数:
inline void Topology::_bind(Graph& g) {
_num_sinks = 0;
_sources.clear();
//遍历graph中的每个node,找到所有的入口节点,计算所有的出口节点个数
for(auto& node : g.nodes()) {
node->_topology = this;
if(node->num_dependents() == 0) {
_sources.push_back(node.get());
}
if(node->num_successors() == 0) {
_num_sinks++;
}
}
_cached_num_sinks = _num_sinks;
}
FlowBuilder是Taskflow的父类,因此先看下FlowBuilder。
class FlowBuilder {
FlowBuilder(Graph& graph);
//插入一个执行函数,实现中将在graph中创建一个新节点,参数callable作为新节点的执行函数
template
Task emplace(C&& callable);
//同上,区别是插入多个执行函数
template 1), void>* = nullptr>
auto emplace(C&&... callables);
//对一个容器,从迭代器[beg,end)进行分片,并行度为p(如果为0则设为CPU核数)分片,分片中对每个成员执行callable操作。创建一个占位的开始节点和结束节点(都没有实际的执行函数),返回的就是pair<开始节点,结束节点>
template
std::pair parallel_for(I beg, I end, C&& callable, size_t partitions = 0);
//同上,多了一个step参数
template , void>* = nullptr >
std::pair parallel_for(I beg, I end, I step, C&& callable, size_t partitions = 0);
//并行执行reduce操作(bop),先分片,每个分片并行执行bop,在对每个分片的结果执行bop。例如求容器中的min或max,可以用这种方法提高效率。
template
std::pair reduce(I beg, I end, T& result, B&& bop);
template
std::pair reduce_min(I beg, I end, T& result);
template
std::pair reduce_max(I beg, I end, T& result);
//基本同上,区别在于执行bop之前先对容器中的每个元素执行uop操作。
template
std::pair transform_reduce(I beg, I end, T& result, B&& bop, U&& uop);
template
std::pair transform_reduce(I beg, I end, T& result, B&& bop1, P&& bop2, U&& uop);
//创建一个空的task,无执行体
Task placeholder();
//A是B的前继节点
void precede(Task A, Task B);
//vector中的第i个task是第i+1个task的前继节点,即后续会串行执行
void linearize(std::vector& tasks);
void linearize(std::initializer_list tasks);
//A是所有others的前继节点
void broadcast(Task A, std::vector& others);
void broadcast(Task A, std::initializer_list others);
// A是所有others的后续节点
void gather(std::vector& others, Task A);
void gather(std::initializer_list others, Task A);
private:
Graph& _graph;
};
类Taskflow继承自类FlowBuilder,大部分功能FlowBuilder都已经实现了,看下不一样的:
class Taskflow : public FlowBuilder {
public:
//Taskflow之间是可以组合的,一个Taskflow作为其他的Taskflow的其中一个节点
tf::Task composed_of(Taskflow& taskflow);
private:
std::string _name;
//Taskflow保存Graph,而FlowBuilder只是弱引用Graph
Graph _graph;
std::mutex _mtx;
//这里很有意思,Graph只有一个而Topology是一个列表,也就是说一个taskflow可以执行多个拓扑结构,而节点却是可以复用的
std::list _topologies;
};
类Executor是负责执行taskflow的类。
class Executor {
//每个工作线程处理一个Worker
struct Worker {
std::mt19937 rdgen { std::random_device{}() };
//这个queue需要理解:说明每个工作线程有自己单独的queue
WorkStealingQueue queue;
std::optional cache;
};
//作为thread local data在每个工作线程中都有一个副本
struct PerThread {
Executor* pool {nullptr};
int worker_id {-1}; //这个id作为索引来访问对应的Worker
};
public:
explicit Executor(unsigned n = std::thread::hardware_concurrency());
~Executor();
std::future run(Taskflow& taskflow);
template
std::future run(Taskflow& taskflow, C&& callable);
std::future run_n(Taskflow& taskflow, size_t N);
template
std::future run_n(Taskflow& taskflow, size_t N, C&& callable);
template
std::future run_until(Taskflow& taskflow, P&& pred);
//上面几个run函数最终都是调用此函数,下文重点看下此函数
template
std::future run_until(Taskflow& taskflow, P&& pred, C&& callable);
void wait_for_all();
size_t num_workers() const;
template
Observer* make_observer(Args&&... args);
void remove_observer();
private:
std::condition_variable _topology_cv;
std::mutex _topology_mutex;
std::mutex _queue_mutex;
unsigned _num_topologies {0};
// scheduler field
std::vector _workers;//每个工作现场固定访问其中一个
std::vector _waiters;
std::vector _threads;//N个工作线程
//这个queue需要理解:这是调用线程(不是工作线程)的queue。也就是说有N+1个queue
WorkStealingQueue _queue;
std::atomic _num_actives {0};
std::atomic _num_thieves {0};
std::atomic _done {0};
Notifier _notifier;
std::unique_ptr _observer;
};
Executor在构造函数中做了这几件事:
1.创建了N个工作线程,N为并发度、默认为CPU核数
2.工作线程中初始化tls数据PerThread
3.然后就是一个死循环:
std::optional t;
while(1) {
// i是工作线程的索引;执行task
_exploit_task(i, t);
// 等待可用的task
if(_wait_for_task(i, t) == false) {
break;
}
}
注意上面先执行_exploit_task,再执行_wait_for_task,而通常确实相反的操作。这里这么做的原因是,执行到_wait_for_task说明当前工作线程自己的queue已经全部执行完、为空了。
_wait_for_task中用了一个Eigen中Notifier类,相对于通常实现生产者-消费者模式直接使用信号量性能更好,理解上可以当做信号量理解。
_wait_for_task原理:
1.生成[0,N)之间的随机数作为索引,尝试从对应的工作线程的queue中偷一个task(如果随机到本线程id,则到调用线程queue中偷)。偷到了则进行下一步,偷不到则继续随机,直到随机了Y(Y=100)次还是没偷到,也进行下一步。
2.偷到了则返回,没偷到则继续下一步
3.再次尝试从调用线程queue中偷任务,偷到了则返回
4.阻塞等待
可以看到,在阻塞等待前通过work stealing提高线程使用率。
_exploit_task原理:
1.对于取到的任务(实际是Node*),进行执行:
A 对于static节点,执行该组合节点
B 对于dynamic节点,执行节点的node->_work,并根据_subgraph创建子图的拓扑结构,开始调度这个子图拓扑结构
2. 对于当前节点的所有后继节点的_num_dependents - 1,如果减到0,则加入调度队列
3. 如果当前节点是一个最终节点,那么对拓扑结构的_num_sinks - 1,如果减到0,则到tear_down逻辑
4. 执行拓扑结构的_pred函数(如果有的话),如果返回false,则再执行一遍打当前拓扑结构,否则:
A 调用回调函数_call
B 设置_promise
C 执行拓扑结构队列中的下一个拓扑结构,全部都执行完成后设置
cpp-taskflow中有一些很有趣的代码:
1.Work Stealing Queue
2.来源于Eigen中的Notifier类
这块再单独写文档。
以上。