【cpp-taskflow】源码分析

一、简介

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提供方法可以比较简单的监视每个线程的活动、分析用户程序的性能。

二、基本数据类型

1.类Node

类Node最重要的是保存了当前节点的执行函数,所有前继节点的指针、后续节点的指针,子图(如果有的话),每个前继节点完成后需要修改的计数器(当所有的前继节点都完成时,当前节点就可以执行了)。

class Node {
  //static节点的执行函数是返回值和参数都是void的函数
  using StaticWork  = std::function<void()>;
  //dynamic节点的执行函数是返回值为void、参数为Subflow的函数,
  //在代码中通过模板元编程判断参数是否是Subflow来区分静态节点还是动态节点
  using DynamicWork = std::function<void(Subflow&)>;

  //节点的status
  constexpr static int SPAWNED = 0x1;
  constexpr static int SUBTASK = 0x2;

  public:

    Node() = default;

   //可以通过构造函数传入节点的执行函数,可以不传入(default构造函数),后续再set
    template <typename C>
    Node(C&&);

    //传入当前节点的一个后续节点
    void precede(Node&);

  private:
    
    std::string _name;
    //用了C++17的std::variant函数,可以认为就是union类型,未设置、static work、dynamic work
    std::variant<std::monostate, StaticWork, DynamicWork> _work;

    //所有的后续节点
    tf::PassiveVector<Node*> _successors;
    //所有的前继节点
    tf::PassiveVector<Node*> _dependents;
   
    //子图,正如github上的文档所说,每个节点可以在执行期动态的创建子图
    //这里用了C++17的std::optional函数,多了”未设置“状态,好处是如果未设置不会调用Graph的构造函数
    std::optional<Graph> _subgraph;

    //若有值,表示此节点实际一个Taskflow,通过组合成为了一个Node
    Taskflow* _module {nullptr};

    //以下两个是弱引用
    Topology* _topology {nullptr};
    Taskflow* _module {nullptr};

    int _status {0};
    //前继节点的个数,在调度执行过程会多线程修改,所以是std::atomic类型。
    std::atomic<int> _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);
}

2.类Task

类Task很简单,就是Node指针的包装,弱引用一个Node对象指针,并不管理其内存。提供了一些public函数的实现也是直接调用类Node对应的函数。根据注释,用类Task而不是直接使用Node指针的目的,是为了防止使用方直接操作内部存储数据。

class Task {
  public:
    template <typename C>
    Task& work(C&& callable);
    
    template <typename... Ts>
    Task& precede(Ts&&... tasks);
    Task& precede(std::vector<Task>& tasks);
    Task& precede(std::initializer_list<Task> tasks);
    
    template <typename... Ts>
    Task& succeed(Ts&&... tasks);
    Task& succeed(std::vector<Task>& tasks);
    Task& succeed(std::initializer_list<Task> tasks);
    
    template <typename... Ts>
    Task& gather(Ts&&... tasks);
    Task& gather(std::vector<Task>& tasks);
    Task& gather(std::initializer_list<Task> tasks);

  private:
    //弱引用,不负责管理其内存
    Node* _node {nullptr};
};

3.类Graph

类Graph负责管理Node对象内存。

class Graph {
  public:
    void clear();

    bool empty() const;

    size_t size() const;
    
    template <typename C>
    Node& emplace_back(C&&); 

    Node& emplace_back();

    std::vector<std::unique_ptr<Node>>& nodes();

    const std::vector<std::unique_ptr<Node>>& nodes() const;

  private:
    //强引用,负责Node对象内存的管理
    std::vector<std::unique_ptr<Node>> _nodes;
};

重点看下emplace_back函数的实现:

template <typename C>
Node& Graph::emplace_back(C&& c) {
  //1.创建一个新的Node节点
  //2.传入c作为新节点的执行函数
  //3.将新节点指针插入到vector中
  //4.返回新节点的引用
  _nodes.push_back(std::make_unique<Node>(std::forward<C>(c)));
  return *(_nodes.back());
}

inline Node& Graph::emplace_back() {
  //同上,但没有传执行函数,后续再set
  _nodes.push_back(std::make_unique<Node>());
  return *(_nodes.back());
}

4. 类Topology

class Topology {
  public:
    //Taskflow是弱引用,P是判断函数、传给_pred,C是回调函数、传给_call
    template <typename P, typename C>
    Topology(Taskflow&, P&&, C&&);
    
  private:

    Taskflow& _taskflow;
    //Executor的各种run函数返回的就是这个promise对应的future
    //可以异步的等待taskflow执行完毕
    std::promise<void> _promise;

    //DAG的所有入口节点指针(即graph中所有前继节点为0的节点)
    PassiveVector<Node*> _sources;
     //DAG的所有出口节点的个数(出口只需要知道个数就行了)
    int _cached_num_sinks {0};
    //DAG的出口节点的完成计数器(当所有的出口都执行完毕,整个DAG就执行完毕了),多线程写,因此为 std::atomic类型
    std::atomic<int> _num_sinks {0};
    
    //判断函数,当DAG执行完毕后执行这个函数,若返回false,则再执行一遍
    std::function<bool()> _pred;
    //回调函数,taskflow执行完成后执行此回调函数
    std::function<void()> _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;

}

三、流数据类型

1.类FlowBuilder

FlowBuilder是Taskflow的父类,因此先看下FlowBuilder。

class FlowBuilder {
    FlowBuilder(Graph& graph);
    
    //插入一个执行函数,实现中将在graph中创建一个新节点,参数callable作为新节点的执行函数
    template <typename C>
    Task emplace(C&& callable);
    //同上,区别是插入多个执行函数
    template <typename... C, std::enable_if_t<(sizeof...(C)>1), void>* = nullptr>
    auto emplace(C&&... callables);
    
    //对一个容器,从迭代器[beg,end)进行分片,并行度为p(如果为0则设为CPU核数)分片,分片中对每个成员执行callable操作。创建一个占位的开始节点和结束节点(都没有实际的执行函数),返回的就是pair<开始节点,结束节点>
    template <typename I, typename C>
    std::pair<Task, Task> parallel_for(I beg, I end, C&& callable, size_t partitions = 0);
    //同上,多了一个step参数
    template <typename I, typename C, std::enable_if_t<std::is_arithmetic_v<I>, void>* = nullptr >
    std::pair<Task, Task> parallel_for(I beg, I end, I step, C&& callable, size_t partitions = 0);
    

    //并行执行reduce操作(bop),先分片,每个分片并行执行bop,在对每个分片的结果执行bop。例如求容器中的min或max,可以用这种方法提高效率。
    template <typename I, typename T, typename B>
    std::pair<Task, Task> reduce(I beg, I end, T& result, B&& bop);
    template <typename I, typename T>
    std::pair<Task, Task> reduce_min(I beg, I end, T& result);
    template <typename I, typename T>
    std::pair<Task, Task> reduce_max(I beg, I end, T& result);
    
   //基本同上,区别在于执行bop之前先对容器中的每个元素执行uop操作。
    template <typename I, typename T, typename B, typename U>
    std::pair<Task, Task> transform_reduce(I beg, I end, T& result, B&& bop, U&& uop);
    template <typename I, typename T, typename B, typename P, typename U>
    std::pair<Task, Task> 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<Task>& tasks);
    void linearize(std::initializer_list<Task> tasks);

   //A是所有others的前继节点
    void broadcast(Task A, std::vector<Task>& others);
    void broadcast(Task A, std::initializer_list<Task> others);

    // A是所有others的后续节点
    void gather(std::vector<Task>& others, Task A);
    void gather(std::initializer_list<Task> others, Task A);
    
  private:
    Graph& _graph;
};

2.类Taskflow

类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<Topology> _topologies;
};

四、执行

类Executor是负责执行taskflow的类。

class Executor {
  //每个工作线程处理一个Worker
  struct Worker {
    std::mt19937 rdgen { std::random_device{}() };
    //这个queue需要理解:说明每个工作线程有自己单独的queue
    WorkStealingQueue<Node*> queue;
    std::optional<Node*> 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<void> run(Taskflow& taskflow);
    template<typename C>
    std::future<void> run(Taskflow& taskflow, C&& callable);
    std::future<void> run_n(Taskflow& taskflow, size_t N);
    template<typename C>
    std::future<void> run_n(Taskflow& taskflow, size_t N, C&& callable);
    template<typename P>
    std::future<void> run_until(Taskflow& taskflow, P&& pred);
    //上面几个run函数最终都是调用此函数,下文重点看下此函数
    template<typename P, typename C>
    std::future<void> run_until(Taskflow& taskflow, P&& pred, C&& callable);

    void wait_for_all();

    size_t num_workers() const;
  
    template<typename Observer, typename... Args>
    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<Worker> _workers;//每个工作现场固定访问其中一个
    std::vector<Notifier::Waiter> _waiters;
    std::vector<std::thread> _threads;//N个工作线程

    //这个queue需要理解:这是调用线程(不是工作线程)的queue。也就是说有N+1个queue
    WorkStealingQueue<Node*> _queue;

    std::atomic<size_t> _num_actives {0};
    std::atomic<size_t> _num_thieves {0};
    std::atomic<bool>   _done        {0};

    Notifier _notifier;
    
    std::unique_ptr<ExecutorObserverInterface> _observer;
};

Executor在构造函数中做了这几件事:
1.创建了N个工作线程,N为并发度、默认为CPU核数
2.工作线程中初始化tls数据PerThread
3.然后就是一个死循环:

std::optional<Node*> 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类

这块再单独写文档。

以上。

发布了76 篇原创文章 · 获赞 220 · 访问量 155万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览