Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 58

Asynchronous Operations with `std::packaged_task` and `std::async`

Recommended resources

Hello! Welcome back to our exploration of C++ concurrency.

In the last lesson, we mastered std::promise and std::future, the fundamental tools for transferring a result from a producer thread to a consumer. We saw how they use a shared state on the heap, and how future::get() provides a simple, blocking mechanism to receive a value or a propagated exception.

However, using std::promise directly still involves some manual work: you have to create the thread, move the promise into it, and carefully wrap your function call in a try...catch block to ensure the promise is always fulfilled. Today, we'll explore two higher-level abstractions, std::packaged_task and std::async, that simplify this process, letting you focus more on the task itself and less on the mechanics of inter-thread communication.

1. std::packaged_task: Decoupling the 'What' from the 'When'

The first layer of abstraction over a std::promise is std::packaged_task. Think of it as a convenient wrapper that bundles a callable (like a function or lambda) with a promise.

When you invoke a packaged_task, it automatically:

  1. Calls the function you provided.
  2. Takes the return value and uses it to fulfill the internal promise by calling set_value().
  3. If the function throws an exception, it catches it and fulfills the promise with set_exception().

This handles all the try...catch boilerplate for you. More importantly, std::packaged_task decouples the packaging of a task from its execution. You create the task and get its associated future, but you still decide when and where to run it—on the current thread, a new std::thread, or, as we'll see, in a thread pool.

To see how this simplifies our code, let's return to the "Futures from Scratch" talk.

CppCon 2015: Arthur O'Dwyer “Futures from Scratch..."

Arthur O'Dwyer explains how std::packaged_task was designed to eliminate the repetitive boilerplate code associated with manually using std::promise.

Watch from 21:03 to 24:32. Pay close attention to how packaged_task internally stores a function and a promise, and how its call operator () wraps the logic for calling the function and setting the promise's value or exception.

Example Usage

Here’s how you would use std::packaged_task to run our Pi calculation from the previous lesson. Notice the absence of a manual try...catch block in the worker function.

#include <iostream>
#include <thread>
#include <future>
#include <cmath> // For std::pow

// The function to be executed. Simpler now!
double calculate_pi_term(int k) {
    if (k < 0) {
        throw std::runtime_error("Term cannot be negative");
    }
    return 4.0 * (std::pow(-1.0, k) / (2.0 * k + 1.0));
}

int main() {
    // 1. Wrap the function in a packaged_task. 
    // The template argument is the function signature: double(int).
    std::packaged_task<double(int)> task(calculate_pi_term);

    // 2. Get the future from the task.
    std::future<double> result_future = task.get_future();

    // 3. YOU decide how to execute the task. Here, we'll use a new thread.
    // We move the task into the thread, along with its arguments.
    std::cout << "Main thread: starting worker...\n";
    std::thread worker_thread(std::move(task), 100000); // Pass argument 'k'

    // Do other work...
    std::cout << "Main thread: waiting for the result...\n";

    // 4. Get the result.
    try {
        double term_result = result_future.get();
        std::cout << "Main thread: The calculated term is " << term_result << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "Main thread: Caught an exception from worker: " << e.what() << std::endl;
    }

    worker_thread.join();
    return 0;
}

This decoupling is incredibly powerful and is exactly what we'll need for our thread pool project. We can create packaged_task objects and put them into a queue, and our worker threads can simply pull from the queue and execute them without needing to know what function is inside.

Executor Service and Thread Pool Architecture
This diagram shows an Executor Service, a model identical to a thread pool. `std::packaged_task` is the perfect C++ tool to represent the 'Tasks' that are submitted to the 'Task Queue' and executed by the 'Threads'.

2. std::async: The High-Level "Fire and Forget"

What if you just want to run a function in the background without managing the thread yourself? For this, C++ gives us std::async. It's the highest-level abstraction in this family. In a single function call, std::async:

  1. Takes a callable and its arguments.
  2. Creates the promise/future pair and the packaged_task internally.
  3. Launches and manages the task's execution based on a launch policy.
  4. Returns a std::future that will eventually hold the result.

Launch Policies

The behavior of std::async is controlled by its first (optional) argument, the launch policy:

  • std::launch::async: Guarantees that the task will be run on a new thread, executing concurrently.
  • std::launch::deferred: Does not run the task. The task is executed lazily, in the same thread that calls .get() or .wait() on the future.
  • std::launch::async | std::launch::deferred (the default): The implementation is free to choose either policy. This can lead to surprising behavior (is your code running concurrently or not?), so it's best practice to be explicit.

Let's watch a practical example of std::async used to load data in the background.

std::async in cpp with background thread loading data example | Introduction to Concurrency in C++

Mike Shah provides a clear, real-world example of using std::async for background data loading, demonstrating both the launch policies and how to check the future's status without blocking.

Watch from 2:42 to 8:34. This segment covers: How to call std::async with an explicit std::launch::async policy. How to use future::wait_for with a zero-duration timeout to poll the future's status without blocking the main thread.


3. The Critical Difference: A Tale of Two Futures

You might think that std::async is just syntactic sugar for creating a packaged_task and putting it in a std::thread. However, there is a profound and crucial difference.

The std::future returned by std::async has a special property: its destructor will block until the asynchronous task is complete.

This is a deliberate design choice to prevent a common bug: if the main function returns while a background thread launched by std::async is still running, that thread might try to access objects that have already been destroyed, leading to a crash. The blocking destructor acts as a built-in synchronization point, ensuring safety.

In contrast, the std::future you get from std::packaged_task or std::promise has no special destructor behavior. If it goes out of scope, nothing happens. The responsibility for joining the thread and ensuring the task completes is entirely on you.

The following resource provides an excellent explanation for why this design difference exists. Your appreciation for first-principles reasoning will make this particularly insightful.

future is different returned from std::packaged_task and ...

This Stack Overflow answer masterfully explains the design philosophy behind the different behaviors of futures from std::async and std::packaged_task.

Read the top two answers. The first, by Nicol Bolas, explains that std::async knows how the task is executed, so it can guarantee completion. The second, by Sarfaraz Nawaz, breaks down the roles of std::function, std::packaged_task, and std::async to clarify the separation between 'what' is executed and 'how/where' it is executed.

In short, std::async is an "all-in-one" package that manages execution, so it can provide a strong safety guarantee. std::packaged_task is a building block that knows nothing about execution, so it cannot provide that guarantee.

Test your understanding!

Consider this code:

void long_running_task() {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "Task finished.\n";
}

int main() {
    {
        // Block A
        auto fut = std::async(std::launch::async, long_running_task);
    } // fut is destroyed here
    std::cout << "Main continues.\n";

    {
        // Block B
        std::packaged_task<void()> task(long_running_task);
        auto fut = task.get_future();
        std::thread(std::move(task)).detach();
    } // fut is destroyed here
    std::cout << "Main continues again.\n";
    
    std::this_thread::sleep_for(std::chrono::seconds(3)); // Wait to see output
    return 0;
}

What is the likely order of the print statements? Why is there a difference between Block A and Block B?

Show answer

The likely output will be:

Task finished.
Main continues.
Main continues again.
Task finished. 

Or the last two lines might be swapped.

Explanation:

  • Block A: The std::future fut from std::async has a blocking destructor. When the scope {} ends, fut is destroyed. Its destructor waits for long_running_task to complete. Therefore, "Task finished." is guaranteed to print before "Main continues.".
  • Block B: The std::future fut from std::packaged_task has a normal, non-blocking destructor. We also detached the thread. When the scope {} ends, fut is destroyed, but this has no effect on the running task. The main thread immediately prints "Main continues again." The detached thread continues running in the background and prints "Task finished." about 2 seconds later.

4. A Look at the Internals

To solidify your mental model, let's visualize how all these components relate. Given your background, seeing the class relationships can be very helpful.

Internal Structure of C++ Asynchronous Primitives
This diagram shows the internal relationships between C++ concurrency primitives. You can see how `promise`, `packaged_task`, and `async` all operate on a shared state (`_Associated_state`), which is what the `future` uses to retrieve the result. Note how `async` has specialized state types (`_Deferred_async_state`, `_Task_async_state`) to handle its launch policies, reinforcing the idea that it has special knowledge of execution.

This complex interaction of components can sometimes lead to subtle issues that are only visible with advanced tools. For example, some implementations of the standard library have optimizations that can trigger false positives in thread sanitizers like Valgrind's Helgrind. While you don't need to memorize these details, being aware of them is a mark of an advanced C++ developer. The linked Stack Overflow page (LINK) about a packaged_task data race is a fascinating case study in how libstdc++ implements notify_all() outside of a mutex lock for performance, which is safe but looks suspicious to Helgrind.


Conclusion

You've now moved up the C++ concurrency abstraction ladder. You have the full toolset to manage asynchronous tasks, from the low-level std::promise to the high-level std::async.

Key Takeaways:

  • std::packaged_task bundles a function with a promise, automating result/exception handling. It crucially decouples task definition from task execution, making it the perfect tool for a thread pool's task queue.
  • std::async is a high-level utility that bundles a function and manages its execution for you, returning a std::future.
  • The Big Difference: The std::future from std::async has a special blocking destructor that ensures task completion, a feature not present in futures from std::packaged_task or std::promise.
  • Be Explicit: Always specify a launch policy for std::async (std::launch::async or std::launch::deferred) to avoid surprising behavior.

What's Next?

We've covered several ways to manage data flow and task execution between threads. So far, our synchronization has focused on protecting shared data (std::mutex) or coordinating based on state (std::condition_variable). In the next lesson, we will explore a more specialized synchronization primitive, std::shared_mutex, which allows for a new level of optimization in scenarios with many readers and few writers—a very common pattern in system design.