| Taskflow: A General-purpose Task-parallel Programming System

Loading...
Searching...
No Matches
Exception Handling

Taskflow provides first-class support for exception handling in parallel programs — a capability that most task-parallel libraries deliberately omit. This page explains how exceptions propagate through task graphs, subflows, async tasks, and corun loops, and how to retrieve or suppress them.

# Why Parallel Exception Handling is Hard

In a sequential program, exception handling is straightforward: an exception thrown in a function propagates up the call stack until it reaches a `catch` block or terminates the program.

try {
do_something();
} catch(std::exception e) {
std::cerr << e.what();
}

However, in a parallel runtime, this mechanism breaks down entirely. Worker threads execute tasks independently of the application thread. When a task on worker thread 3 throws an exception, there is no call stack connection back to the `try` block the application wrote. The exception cannot simply propagate upward, as it will terminate the worker thread if left unhandled, corrupting the entire executor. A correct parallel runtime must therefore intercept every exception thrown inside a task, store it safely across thread boundaries, cancel the appropriate downstream tasks to avoid operating on invalid state, and re-raise the exception at the right point, either synchronously on a waiting thread or asynchronously via a future handle.

Getting this right without sacrificing parallel efficiency is non-trivial, which is why most task-parallel libraries simply ignore the problem and leave exception safety entirely to the user. Taskflow handles it transparently.

# How Taskflow Handles Exceptions

When a task throws an exception, Taskflow immediately cancels the execution of its parent taskflow — all subsequent tasks that depend on the throwing task are skipped. The exception itself is then routed to the appropriate reporting context according to the following three scenarios.

When multiple scenarios apply, Taskflow prioritises them in order: Scenario 1 first, then Scenario 2, then Scenario 3.

## Scenario 1: Synchronous Propagation

If the taskflow is executed in a blocking context — such as tf::Executor::corun or tf::Subflow::join — the exception is immediately rethrown to the calling thread as soon as it is detected. The executor does not defer or aggregate it.

try {
executor.corun(taskflow);
}
catch (const std::exception& e) {
std::cerr << e.what();
}

## Scenario 2: Asynchronous Propagation

When tasks are launched via tf::Executor::run or tf::Executor::async, the exception is captured and stored in the shared state of the returned tf::Future or `std::future`. It is rethrown when the application calls `get()` on that future.

tf::Future<void> fu = executor.run(taskflow);
try {
fu.get();
}
catch (const std::exception& e) {
std::cerr << e.what();
}
tf::Future
class to access the result of an execution
Definition taskflow.hpp:630

## Scenario 3: Contextual Propagation

This scenario is reached only when no blocking context and no observable shared state exist — for example, a task launched via tf::Executor::silent_async. In this case Taskflow first attempts to propagate the exception to the nearest parent execution context. If no such parent exists, the exception is silently suppressed and stored locally within the throwing task.

// No parent context: exception is silently suppressed
executor.silent_async([&]() {
throw std::runtime_error("this exception is suppressed");
});
// Parent context exists: exception propagates to the task group
executor.silent_async([&]() {
tf::TaskGroup tg = executor.task_group();
tg.silent_async([]() {
throw std::runtime_error("this propagates to the parent task group");
});
try {
tg.corun();
}
catch (const std::runtime_error& e) {
std::cerr << e.what();
}
});
tf::TaskGroup
class to create a task group from a task
Definition task_group.hpp:61
tf::TaskGroup::corun
void corun()
corun all tasks spawned by this task group with other workers
Definition task_group.hpp:725
tf::TaskGroup::silent_async
void silent_async(F &&f)
runs the given function asynchronously without returning any future object
Definition task_group.hpp:756

## Algorithm Flow

The figure below illustrates the full decision process. When a task throws, the runtime walks up the task hierarchy to mark the execution path as exceptional and identifies two kinds of anchor nodes: an explicit anchor (a blocking context such as `corun` or `join`) and an implicit anchor (a parent task group). If an explicit anchor is found, the exception is captured and rethrown on the waiting application thread (Scenario 1). If no explicit anchor exists but a shared state is associated, the exception is stored for asynchronous retrieval (Scenario 2). Otherwise the runtime propagates to the nearest implicit anchor if one exists, or suppresses the exception locally (Scenario 3). This algorithm ensures exceptions are reported whenever a reporting context is available while preserving safe and efficient parallel execution.

# Catch an Exception from a Running Taskflow

The most common case is catching an exception thrown by a task inside a taskflow submitted with tf::Executor::run. The executor captures the exception and stores it in the shared state of the returned tf::Future. Calling `get()` rethrows it:

tf::Executor executor;
tf::Taskflow taskflow;
taskflow.emplace([]() { throw std::runtime_error("exception"); });
try {
executor.run(taskflow).get();
}
catch (const std::runtime_error& e) {
std::cerr << e.what() << '\n';
}
tf::Executor
class to create an executor
Definition executor.hpp:62
tf::Executor::run
tf::Future< void > run(Taskflow &taskflow)
runs a taskflow once
tf::FlowBuilder::emplace
Task emplace(C &&callable)
creates a static task
Definition flow_builder.hpp:1571
tf::Taskflow
class to create a taskflow object
Definition taskflow.hpp:64

Note
  tf::Future is derived from std::future and inherits all standard exception-handling behaviours defined by the C++ standard.

When a task throws, the executor immediately cancels the rest of the taskflow — all tasks that depend on the throwing task are skipped. The following example shows this with a two-task chain where `B` depends on `A:`

tf::Executor executor;
tf::Taskflow taskflow;
tf::Task A = taskflow.emplace([]() {
throw std::runtime_error("exception on A");
});
tf::Task B = taskflow.emplace([]() {
std::cout << "Task B\n"; // never printed
});
A.precede(B);
try {
executor.run(taskflow).get();
}
catch (const std::runtime_error& e) {
std::cerr << e.what() << '\n';
}
tf::Task
class to create a task handle over a taskflow node
Definition task.hpp:569
tf::Task::precede
Task & precede(Ts &&... tasks)
adds precedence links from this to other tasks
Definition task.hpp:1258
exception on A

When multiple tasks throw exceptions concurrently, Taskflow propagates exactly one to the `catch` block. The others are silently caught and stored within their respective tasks (see Retrieve the Exception Pointer of a Task). In the diamond taskflow below, both `B` and `C` may throw simultaneously — only one exception reaches the application:

tf::Executor executor;
tf::Taskflow taskflow;
auto [A, B, C, D] = taskflow.emplace(
[]() { std::cout << "Task A\n"; },
[]() {
std::cout << "Task B\n";
throw std::runtime_error("Exception on Task B");
},
[]() {
std::cout << "Task C\n";
throw std::runtime_error("Exception on Task C");
},
[]() { std::cout << "Task D\n"; } // skipped
);
A.precede(B, C);
D.succeed(B, C);
try {
executor.run(taskflow).get();
}
catch (const std::runtime_error& e) {
std::cerr << e.what() << '\n'; // either B's or C's exception
}
tf::Task::succeed
Task & succeed(Ts &&... tasks)
adds precedence links from other tasks to this
Definition task.hpp:1266

# Catch an Exception from a Subflow

When you explicitly join a subflow with tf::Subflow::join, you can catch exceptions thrown by its child tasks at the join point:

tf::Executor executor;
tf::Taskflow taskflow;
taskflow.emplace([](tf::Subflow& sf) {
tf::Task A = sf.emplace([]() {
std::cout << "Task A\n";
throw std::runtime_error("exception on A");
});
tf::Task B = sf.emplace([]() {
std::cout << "Task B\n"; // skipped
});
A.precede(B);
try {
sf.join();
}
catch (const std::runtime_error& e) {
std::cerr << "caught at join: " << e.what() << '\n';
}
});
executor.run(taskflow).get();
tf::Subflow
class to construct a subflow graph from the execution of a dynamic task
Definition flow_builder.hpp:1735
tf::Subflow::join
void join()
enables the subflow to join its parent task
Task A
caught at join: exception on A

If you do not catch the exception at the join point, it propagates up to the parent taskflow and is rethrown when the application calls `get()`:

tf::Executor executor;
tf::Taskflow taskflow;
taskflow.emplace([](tf::Subflow& sf) {
tf::Task A = sf.emplace([]() {
std::cout << "Task A\n";
throw std::runtime_error("exception on A");
});
tf::Task B = sf.emplace([]() { std::cout << "Task B\n"; });
A.precede(B);
sf.join(); // exception propagates upward
});
try {
executor.run(taskflow).get();
}
catch (const std::runtime_error& e) {
std::cerr << "caught at taskflow: " << e.what() << '\n';
}
Task A
caught at taskflow: exception on A

# Catch an Exception from an Async Task

tf::Executor::async behaves like `std::async:` the exception is stored in the returned `std::future` and rethrown on `get()`:

tf::Executor executor;
auto fu = executor.async([]() {
throw std::runtime_error("exception");
});
try {
fu.get();
}
catch (const std::runtime_error& e) {
std::cerr << e.what() << '\n';
}
tf::Executor::async
auto async(P &&params, F &&func)
creates a parameterized asynchronous task to run the given function

tf::Executor::silent_async returns no future, so an exception thrown inside it is handled contextually (Scenario 3): propagated to the nearest parent context if one exists, or silently suppressed otherwise:

tf::Taskflow taskflow;
tf::Executor executor;
// no parent context — exception is silently suppressed
executor.silent_async([]() {
throw std::runtime_error("suppressed");
});
// inside a Runtime task — exception propagates to the parent taskflow
taskflow.emplace([&](tf::Runtime& rt) {
rt.silent_async([]() {
throw std::runtime_error("propagated to taskflow");
});
});
try {
executor.run(taskflow).get();
}
catch (const std::runtime_error& e) {
std::cerr << e.what() << '\n';
}
tf::Executor::silent_async
void silent_async(P &&params, F &&func)
similar to tf::Executor::async but does not return a future object
tf::Runtime
class to create a runtime task
Definition runtime.hpp:47
tf::Runtime::silent_async
void silent_async(F &&f)
runs the given function asynchronously without returning any future object
Definition runtime.hpp:671

# Catch an Exception from a Corun Loop

tf::Executor::corun and tf::Runtime::corun run a graph to completion on the calling thread, so any exception thrown inside is immediately rethrown (Scenario 1). You can catch it directly at the `corun` call site:

tf::Executor executor;
tf::Taskflow taskflow1, taskflow2;
taskflow1.emplace([]() {
throw std::runtime_error("exception");
});
taskflow2.emplace([&]() {
try {
executor.corun(taskflow1);
}
catch (const std::runtime_error& e) {
std::cerr << "caught at corun: " << e.what() << '\n';
}
});
executor.run(taskflow2).get();
tf::Executor::corun
void corun(T &target)
runs a target graph and waits until it completes using an internal worker of this executor

The same applies to tf::Runtime::corun:

tf::Executor executor;
tf::Taskflow taskflow1, taskflow2;
taskflow1.emplace([]() {
throw std::runtime_error("exception");
});
taskflow2.emplace([&](tf::Runtime& rt) {
try {
rt.corun(taskflow1);
}
catch (const std::runtime_error& e) {
std::cerr << "caught at corun: " << e.what() << '\n';
}
});
executor.run(taskflow2).get();
tf::Runtime::corun
void corun()
corun all tasks spawned by this runtime with other workers
Definition runtime.hpp:646

If the exception is not caught at the `corun` call site, it propagates to the parent task and then to the parent taskflow:

tf::Executor executor;
tf::Taskflow taskflow1, taskflow2;
taskflow1.emplace([]() {
throw std::runtime_error("exception");
});
taskflow2.emplace([&](tf::Runtime& rt) {
rt.corun(taskflow1); // not caught here
});
try {
executor.run(taskflow2).get();
}
catch (const std::runtime_error& e) {
std::cerr << "caught at taskflow: " << e.what() << '\n';
}

# Retrieve the Exception Pointer of a Task

When multiple tasks throw simultaneously, Taskflow propagates exactly one exception to the application. The remaining exceptions are stored inside their respective tasks and can be inspected via tf::Task::exception_ptr, which returns a non-null `std::exception_ptr` if the task threw, or `nullptr` if it completed normally.

This is useful for post-mortem analysis: after catching the propagated exception, you can walk the task graph and check which other tasks also threw:

tf::Executor executor(2);
tf::Taskflow taskflow;
std::atomic<size_t> arrivals{0};
// Both B and C throw; the barrier ensures they execute truly concurrently
// so neither is cancelled before it gets a chance to throw.
auto [B, C] = taskflow.emplace(
[&]() {
++arrivals; while (arrivals != 2);
throw std::runtime_error("exception from B");
},
[&]() {
++arrivals; while (arrivals != 2);
throw std::runtime_error("exception from C");
}
);
try {
executor.run(taskflow).get();
}
catch (const std::runtime_error& e) {
std::cerr << "propagated: " << e.what() << '\n';
}
// Exactly one task holds a stored exception; the other was propagated
assert((B.exception_ptr() != nullptr) != (C.exception_ptr() != nullptr));
// Inspect the stored exception
if (auto ep = B.exception_ptr()) {
try { std::rethrow_exception(ep); }
catch (const std::runtime_error& e) {
std::cerr << "stored in B: " << e.what() << '\n';
}
}
if (auto ep = C.exception_ptr()) {
try { std::rethrow_exception(ep); }
catch (const std::runtime_error& e) {
std::cerr << "stored in C: " << e.what() << '\n';
}
}
tf::Task::exception_ptr
std::exception_ptr exception_ptr() const
retrieves the exception pointer of this task
Definition task.hpp:1423

# Disable Exception Handling at Compile Time

In performance-critical applications without exception safety requirements, you can disable Taskflow's exception handling entirely at compile time by defining `TF_DISABLE_EXCEPTION_HANDLING:`

g++ -DTF_DISABLE_EXCEPTION_HANDLING your_program.cpp

This removes all `try-`catch` blocks` from the Taskflow runtime, producing a leaner binary and potentially faster execution.

Attention
  With exception handling disabled, any exception thrown inside a task is unchecked and will propagate uncontrolled through the runtime, likely terminating the program or causing undefined behaviour. Use this option only if your application guarantees that no task will throw.