Taskflow  2.4-master-branch
C2: Executor

After you create a task dependency graph, you need to submit it to threads for execution. In this chapter, we will show you how to execute a task dependency graph.

Create an Executor

To execute a taskflow, you need to create an executor of type tf::Executor. An executor is a thread-safe object that manages a set of worker threads and executes tasks through an efficient work-stealing algorithm. Issuing a call to run a taskflow creates a topology, a data structure to keep track of the execution status of a running graph. tf::Executor takes an unsigned integer to construct with N worker threads. The default value is std::thread::hardware_concurrency.

tf::Executor executor1; // create an executor of std::thread::hardware_concurrency worker threads
tf::Executor executor2(4); // create an executor of 4 worker threads

An executor can be reused to execute multiple taskflows. In most workloads, you may need only one executor to run multiple taskflows where each taskflow represents a part of a parallel decomposition.

Execute a Taskflow

tf::Executor provides a set of run_* methods, tf::Executor::run, tf::Executor::run_n, and tf::Executor::run_until to run a taskflow for one time, multiple times, or until a given predicate evaluates to true. All methods accept an optional callback to invoke after the execution completes, and return a std::future for users to access the execution status. The code below shows several ways to run a taskflow.

1: // Declare an executor and a taskflow
2: tf::Executor executor;
3: tf::Taskflow taskflow;
4:
5: // Add three tasks into the taskflow
6: tf::Task A = taskflow.emplace([] () { std::cout << "This is TaskA\n"; });
7: tf::Task B = taskflow.emplace([] () { std::cout << "This is TaskB\n"; });
8: tf::Task C = taskflow.emplace([] () { std::cout << "This is TaskC\n"; });
9:
10: // Build precedence between tasks
11: A.precede(B, C);
12:
13: std::future<void> fu = executor.run(taskflow);
14: fu.wait(); // block until the execution completes
15:
16: executor.run(taskflow, [](){ std::cout << "end of one execution\n"; }).wait();
17: executor.run_n(taskflow, 4);
18: executor.wait_for_all(); // block until all associated executions finish
19: executor.run_n(taskflow, 4, [](){ std::cout << "end of four executions\n"; }).wait();
20: executor.run_until(taskflow, [int cnt=0] () mutable { return (++cnt == 10); });

Debrief:

  • Line 6-8 creates a taskflow of three tasks A, B, and C
  • Line 13-14 runs the taskflow once and use std::future::wait to wait for completion
  • Line 16 runs the taskflow once with a callback to invoke when the execution finishes
  • Line 17-18 runs the taskflow four times and use tf::Executor::wait_for_all to wait for completion
  • Line 19 runs the taskflow four times and invokes a callback at the end of the forth execution
  • Line 20 keeps running the taskflow until the predicate returns true

Issuing multiple runs on the same taskflow will automatically synchronize to a sequential chain of executions in the order of run calls.

executor.run(taskflow); // execution 1
executor.run_n(taskflow, 10); // execution 2
executor.run(taskflow); // execution 3
executor.wait_for_all(); // execution 1 -> execution 2 -> execution 3

A key point to notice is a running taskflow must remain alive during its execution. It is your responsibility to ensure a taskflow not being destructed when it is running. For example, the code below can result undefined behavior.

tf::Executor executor; // create an executor
// create a taskflow whose lifetime is restricted by the scope
{
tf::Taskflow taskflow;
// add tasks to the taskflow
// ...
// run the taskflow
executor.run(f);
} // at this point, taskflow might get destructed while it is running, resulting in defined behavior

Similarly, you should avoid touching a taskflow while it is running.

tf::Taskflow taskflow;
// Add tasks into the taskflow
// ...
// Declare an executor
tf::Executor executor;
std::future<void> future = taskflow.run(f); // non-blocking return
// alter the taskflow while running leads to undefined behavior
f.emplace([](){ std::cout << "Add a new task\n"; });

A rule of thumb is to always keep a taskflow alive in your function scope while it is participating in an execution.

Thread Safety

All run_* methods are thread-safe. Touching an executor from multiple threads is acceptable. You can have multiple threads call the same executor to run different taskflows.

1: tf::Executor executor;
2:
3: for(int i=0; i<10; ++i) {
4: std::thread([i, &](){
5: // ... modify my taskflow
6: executor.run(taskflows[i]); // run my taskflow
7: }).detach();
8: }

Again, it is your responsibility to ensure all taskflows from different threads remain alive during their executions; or it can result unexpected behavior or program crash.