Cpp-Taskflow  2.2.0
C1: Create a Taskflow

This chapter demonstrates how to create a task dependency graph–tf::Taskflow.

What is a Task?

A task in Cpp-Taskflow is a callable object for which the operation std::invoke is applicable. It can be either a functor, a lambda expression, a bind expression, or a class objects with operator() overloaded. All tasks are created from tf::Taskflow, the class that manages a task dependency graph and its tasks. Cpp-Taskflow provides two methods, tf::Taskflow::placeholder and tf::Taskflow::emplace to create a task.

tf::Taskflow taskflow;
tf::Task A = taskflow.placeholder();
tf::Task B = taskflow.emplace([] () { std::cout << "task B\n"; });

Debrief:

  • Line 1 creates a taskflow object, or a graph
  • Line 2 creates an empty task
  • Line 3 creates a task from a given callable object and returns a task handle

Each time you create a task including an empty one, the taskflow object adds a node to the present graph and returns a task handle of type tf::Task. A task handle is a lightweight object that wraps up a particular node in a graph and provides a set of methods for you to assign different attributes to the task such as adding dependencies, naming, and assigning a new work.

1: tf::Taskflow taskflow;
2: tf::Task A = taskflow.emplace([] () { std::cout << "create a task A\n"; });
3: tf::Task B = taskflow.emplace([] () { std::cout << "create a task B\n"; });
4:
5: A.name("TaskA");
6: A.work([] () { std::cout << "reassign A to a new task\n"; });
7: A.precede(B);
8:
9: std::cout << A.name() << std::endl; // TaskA
10: std::cout << A.num_successors() << std::endl; // 1
11: std::cout << A.num_dependents() << std::endl; // 0
12:
13: std::cout << B.num_successors() << std::endl; // 0
14: std::cout << B.num_dependents() << std::endl; // 1

Debrief:

  • Line 1 creates a taskflow object
  • Line 2-3 creates two tasks A and B
  • Line 5-6 assigns a name and a work to task A, and add a precedence link to task B
  • Line 7 adds a dependency link from A to B
  • Line 9-14 dumps the task attributes

Cpp-Taskflow uses the general-purpose polymorphic function wrapper std::function to store and invoke any callable target in a task. You need to follow its contract to create a task. For instance, the callable object must be copy constructible.

Create Multiple Tasks at One Time

Cpp-Taskflow uses C++ structured binding coupled with std::tuple to make it simple to create multiple tasks at one time.

auto [A, B, C] = taskflow.emplace( // create three tasks in one call
[](){ std::cout << "Task A\n"; },
[](){ std::cout << "Task B\n"; },
[](){ std::cout << "Task C\n"; }
);

Lifetime of A Task

A task lives with its graph and belongs to only a graph at a time, and is not destroyed until the graph gets cleaned up. The lifetime of a task refers to the user-given callable object, including captured values. As long as the graph is alive, all the associated tasks exist. It is your responsibility to keep tasks and graph alive during their execution.

Create a Task Dependency Graph

Putting everything together, the example below creates a simple task dependency graph of four dependent tasks.

1: #include <taskflow/taskflow.hpp>
2:
3: int main() {
4:
5: tf::Taskflow taskflow;
6:
7: // create a task dependency graph
8: tf::Task t0 = taskflow.emplace([] () { std::cout << "Task A\n"; });
9: tf::Task t1 = taskflow.emplace([] () { std::cout << "Task B\n"; });
10: tf::Task t2 = taskflow.emplace([] () { std::cout << "Task C\n"; });
11: tf::Task t3 = taskflow.emplace([] () { std::cout << "Task D\n"; });
12:
13: // add dependency links
14: t0.precede(t1);
15: t0.precede(t2);
16: t1.precede(t3);
17: t2.precede(t3);
18:
19: return 0;
20: }

Debrief:

  • Line 5 creates a taskflow object
  • Line 8-11 creates four tasks
  • Line 14-17 adds four task dependency links

Modify Task Attributes

This example demonstrates how to modify a task's attributes using methods defined in the task handler.

1: #include <taskflow/taskflow.hpp>
2:
3: int main() {
4:
5: tf::Taskflow taskflow;
6:
7: std::vector<tf::Task> tasks = {
8: taskflow.placeholder(), // create a task with no work
9: taskflow.placeholder() // create a task with no work
10: };
11:
12: tasks[0].name("This is Task 0");
13: tasks[1].name("This is Task 1");
14: tasks[0].precede(tasks[1]);
15:
16: for(auto task : tasks) { // print out each task's attributes
17: std::cout << task.name() << ": "
18: << "num_dependents=" << task.num_dependents() << ", "
19: << "num_successors=" << task.num_successors() << '\n';
20: }
21:
22: taskflow.dump(std::cout); // dump the taskflow graph
23:
24: tasks[0].work([](){ std::cout << "got a new work!\n"; });
25: tasks[1].work([](){ std::cout << "got a new work!\n"; });
26:
27: return 0;
28: }

The output of this program looks like the following:

This is Task 0: num_dependents=0, num_successors=1
This is Task 1: num_dependents=1, num_successors=0
digraph Taskflow {
"This is Task 1";
"This is Task 0";
"This is Task 0" -> "This is Task 1";
}

Debrief:

  • Line 5 creates a taskflow object
  • Line 7-10 creates two tasks with empty target and stores the corresponding task handles in a vector
  • Line 12-13 names the two tasks with human-readable strings
  • Line 14 adds a dependency link from the first task to the second task
  • Line 16-20 prints out the name of each task, the number of dependents, and the number of successors
  • Line 22 dumps the task dependency graph to a GraphViz Online format (dot)
  • Line 24-25 assigns a new target to each task

You can change the name and work of a task at anytime before running the graph. The later assignment overwrites the previous values.

Taskflow Composition

A powerful feature of tf::Taskflow is its composable interface. You can break down a large parallel workload into smaller pieces each designed to run a specific task dependency graph. This largely facilitates the modularity of writing a parallel task program.

1: // f1 has three independent tasks
2: tf::Taskflow f1;
3: f1.name("F1");
4: tf::Task f1A = f1.emplace([&](){ std::cout << "F1 TaskA\n"; });
5: tf::Task f1B = f1.emplace([&](){ std::cout << "F1 TaskB\n"; });
6: tf::Task f1C = f1.emplace([&](){ std::cout << "F1 TaskC\n"; });
7:
8: f1A.name("f1A");
9: f1B.name("f1B");
10: f1C.name("f1C");
11: f1A.precede(f1C);
12: f1B.precede(f1C);
13:
14: // f2A ---
15: // |----> f2C ----> f1_module_task ----> f2D
16: // f2B ---
17: tf::Taskflow f2;
18: f2.name("F2");
19: tf::Task f2A = f2.emplace([&](){ std::cout << " F2 TaskA\n"; });
20: tf::Task f2B = f2.emplace([&](){ std::cout << " F2 TaskB\n"; });
21: tf::Task f2C = f2.emplace([&](){ std::cout << " F2 TaskC\n"; });
22: tf::Task f2D = f2.emplace([&](){ std::cout << " F2 TaskD\n"; });
23:
24: f2A.name("f2A");
25: f2B.name("f2B");
26: f2C.name("f2C");
27: f2C.name("f2D");
28:
29: f2A.precede(f2C);
30: f2B.precede(f2C);
31:
32: tf::Task f1_module_task = f2.composed_of(f1).name("module");
33: f2C.precede(f1_module_task);
34: f1_module_task.precede(f2D);
35:
36: f2.dump(std::cout);
composition_static_1.png

Debrief:

  • Line 1-12 creates a taskflow of three tasks f1A, f1B, and f1C with f1A and f1B preceding f1C
  • Line 17-30 creates a taskflow of four tasks f2A, f2B, f2C, and f2D
  • Line 32 creates a module task from taskflow f1 through the method Taskflow::composed_of
  • Line 33 enforces task f2C to run before the module task
  • Line 34 enforces the module task to run before task f2D

The task created from Taskflow::composed_of is a module task that runs on a taskflow. A module task does not owns any taskflow but maintains a soft mapping to use during its execution context. You can create multiple module tasks from the same taskflow but only one module task can run at one time. For example, the following composition is valid. Even though the two module tasks module1 and module2 refer to the same taskflow F1, the dependency link prevents F1 from multiple executions at the same time.

composition_static_2.png

However, the following composition is invalid. Both module tasks refer to the same taskflow. They can not run at the same time because they are associated with the same graph.

composition_static_invalid.png