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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
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:
When you explicitly join a subflow with tf::Subflow::join, you can catch exceptions thrown by its child tasks at the join point:
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::async behaves like std::async: the exception is stored in the returned std::future and rethrown on get():
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::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:
The same applies to tf::Runtime::corun:
If the exception is not caught at the corun call site, it propagates to the parent task and then to the parent taskflow:
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:
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:
This removes all try- from the Taskflow runtime, producing a leaner binary and potentially faster execution.catch blocks