C++ packaged_task: Deferred Tasks, Futures, and Thread Pools

Key takeaways

std::packaged_task is a C++11 feature that wraps a function or callable object and allows you to receive the result as a std::future. Unlike std::async, you can manually control execution timing.

What is packaged_task?

std::packaged_task is a C++11 feature that allows you to wrap a function or callable object and receive the result as a std::future. Unlike std::async, you can manually control execution time, making it useful in work queues or thread pools.

#include <future>

std::packaged_task<int(int)> task([](int x) {
    return x * x;
});

std::future<int> future = task.get_future();
task(10);  // execution

int result = future.get();  // 100

Why do you need it?:

  • Execution Control: Decide when to run
  • Task Queue: Store tasks in a queue and run them later.
  • Thread Pool: Distribute work to worker threads
  • Exception propagation: Propagate exception to future

The mental model that helps most is to see a packaged_task as a callable glued to a std::promise. Calling task(args...) invokes the stored callable and then does what you would otherwise write by hand: set_value(result) on success or set_exception(std::current_exception()) if the callable throws. That glue is the whole point. Whoever runs the task (a worker thread, an event loop, a timer) needs to know nothing about the result type or about error handling; it just calls operator(). The code that submitted the work holds the future and decides when to wait.

Note that task(10) itself returns void. The result only comes out through the future, which is why forgetting get_future() before handing the task away is a real mistake: after std::move(task), the original object is empty and calling get_future() on it throws std::future_error with no_state.

// std::async: execute immediately (or delay)
auto f1 = std::async([] { return 42; });

// packaged_task: Manual execution
std::packaged_task<int()> task([] { return 42; });
auto f2 = task.get_future();
// Run whenever you want
task();

Wrapping a function and collecting its future

// Specify function signature
std::packaged_task<int(int, int)> task([](int a, int b) {
    return a + b;
});

auto future = task.get_future();
task(3, 4);  // execution
int result = future.get();  // 7

Threads, job queues, exceptions, and one-shot tasks

Running a task on a std::thread

Here is the compute implementation:

#include <thread>
#include <future>

int compute(int x) {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    return x * x;
}

int main() {
    std::packaged_task<int(int)> task(compute);
    std::future<int> future = task.get_future();
    
    std::thread t(std::move(task), 10);
    
std::cout << "Calculating..." << std::endl;
    int result = future.get();
std::cout << "Result: " << result << std::endl;
    
    t.join();
}

std::thread copies or moves its arguments into the new thread, and because packaged_task is move-only, std::move(task) is required; passing task directly fails to compile with a long error about a deleted copy constructor. The main thread prints immediately and then blocks in future.get() for about a second. t.join() is still needed after get() returns: the future becoming ready means the result was stored, not that the thread has finished, and destroying a joinable std::thread calls std::terminate.

A job queue of packaged_task<void()>

#include <queue>
#include <mutex>

class TaskQueue {
    std::queue<std::packaged_task<void()>> tasks;
    std::mutex mtx;
    
public:
    template<typename F>
    auto enqueue(F&& f) -> std::future<decltype(f())> {
        using ReturnType = decltype(f());
        
        std::packaged_task<ReturnType()> task(std::forward<F>(f));
        auto future = task.get_future();
        
        {
            std::lock_guard<std::mutex> lock(mtx);
            tasks.emplace(std::move(task));  // wrap packaged_task<R()> in packaged_task<void()>
        }
        
        return future;
    }
    
    void process() {
        std::packaged_task<void()> task;
        
        {
            std::lock_guard<std::mutex> lock(mtx);
            if (tasks.empty()) return;
            
            task = std::move(tasks.front());
            tasks.pop();
        }
        
        task();
    }
};

The queue stores tasks of one type, packaged_task<void()>, while each submission may return a different type. The line that makes this work is tasks.emplace(std::move(task)): packaged_task<void()> has an explicit constructor that accepts any callable, including a move-only packaged_task<int()>, so the typed task is wrapped inside an untyped one. Running the outer task runs the inner one, which fulfils the typed future the caller holds. push would not compile here, because it needs an implicit conversion and the constructor is explicit. Many thread-pool examples instead wrap the task in std::make_shared<std::packaged_task<R()>> and store a std::function<void()> that calls it; that is needed only because std::function requires copyable callables (C++23 adds std::move_only_function, which removes that restriction).

Also notice that task() is called after the lock is released. Running user code while holding the queue mutex would serialize all workers and deadlock if the task itself tries to enqueue more work.

Exceptions stored in the future

std::packaged_task<int()> task([] {
throw std::runtime_error("error");
    return 42;
});

auto future = task.get_future();
task();

try {
    int result = future.get();  // rethrow exception
} catch (const std::exception& e) {
std::cout << "Exception: " << e.what() << std::endl;
}

A task runs only once

std::packaged_task<int(int)> task([](int x) {
    return x * 2;
});

auto f1 = task.get_future();
task(10);
int r1 = f1.get();  // 20

// ❌ Not reusable
// task(20);  // error

// ✅ Create new
task = std::packaged_task<int(int)>([](int x) {
    return x * 2;
});

“Not reusable” deserves precision: the commented-out task(20) compiles, but at run time it throws std::future_error with the code promise_already_satisfied, because the shared state already holds a result. If you really want to rerun the same callable, task.reset() abandons the old shared state and creates a fresh one, after which you must call get_future() again. Constructing a new task, as shown, is usually clearer.

async vs packaged_task

// std::async: autorun
auto f1 = std::async([] { return 42; });

// packaged_task: Manual execution
std::packaged_task<int()> task([] { return 42; });
auto f2 = task.get_future();
task();  // explicit execution

Comparison table:

Featuresstd::asyncstd::packaged_task
When to runAutomatic (immediate or delayed)passive (explicit call)
create threadautomaticManual
Ease of useSimpleComplex
control levellowHigh
Main useSimple asynchronous operationwork queue, thread pool

Practical Selection Guide:

int expensiveComputation();  // Assuming it's defined somewhere

// ✅ Use std::async
// - Simple asynchronous operations
// - No need for thread management
auto result = std::async([] {
    return expensiveComputation();
});

// ✅ Use packaged_task
// - Save to task queue
// - Control when to run
// - Thread pool implementation
std::packaged_task<int()> task(expensiveComputation);
taskQueue.push(std::move(task));
// Later the worker thread runs

Unrun tasks, move-only handling, and repeated get_future

A task that never runs

std::packaged_task<int()> task([] { return 42; });
auto future = task.get_future();

// ❌ Do not execute task
// int result = future.get();  // wait forever

// ✅Task execution
task();
int result = future.get();

In this snippet the wait is indeed endless, because the task object is still alive and could in theory still run. The more common production variant behaves differently: the task is sitting in a queue, the pool shuts down and destroys the queue without running it. Destroying an unexecuted packaged_task stores a std::future_error with the code broken_promise in the shared state, so the waiting future.get() throws instead of hanging. When I first ran into this, the exception looked like a bug inside the task, when in fact the task had never run at all. A pool’s destructor should either drain the queue, as the ThreadPool below does, or document that pending futures will report broken_promise.

Trying to copy a move-only task

std::packaged_task<int()> task([] { return 42; });

// ❌ No copying allowed
// auto task2 = task;

// ✅ Move
auto task2 = std::move(task);

Calling get_future() twice

std::packaged_task<int()> task([] { return 42; });

auto f1 = task.get_future();
// auto f2 = task.get_future();  // error

// get_future only happens once

The second call compiles and throws std::future_error with future_already_retrieved at run time. If several consumers need the result, call get_future().share() once and copy the resulting std::shared_future.

Moving a task into a thread

std::packaged_task<int()> task([] { return 42; });
auto future = task.get_future();

// ✅ Get the future first, then move the task into the thread
std::thread t(std::move(task));
t.join();

int result = future.get();

A thread pool, timeouts, and cancellation

A simple thread pool

#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>

class ThreadPool {
    std::vector<std::thread> workers_;
    std::queue<std::packaged_task<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
    
public:
    ThreadPool(size_t numThreads) {
        for (size_t i = 0; i < numThreads; ++i) {
            workers_.emplace_back([this]() {
                while (true) {
                    std::packaged_task<void()> task;
                    
                    {
                        std::unique_lock<std::mutex> lock(mtx_);
                        cv_.wait(lock, [this]() { 
                            return stop_ || !tasks_.empty(); 
                        });
                        
                        if (stop_ && tasks_.empty()) return;
                        
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    
                    task();
                }
            });
        }
    }
    
    template<typename F>
    auto submit(F&& f) -> std::future<decltype(f())> {
        using ReturnType = decltype(f());
        
        std::packaged_task<ReturnType()> task(std::forward<F>(f));
        auto future = task.get_future();
        
        {
            std::lock_guard<std::mutex> lock(mtx_);
            tasks_.emplace(std::move(task));
        }
        
        cv_.notify_one();
        return future;
    }
    
    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto& worker : workers_) {
            worker.join();
        }
    }
};

// use
ThreadPool pool(4);
auto f1 = pool.submit([] { return 42; });
auto f2 = pool.submit([] { return 100; });

std::cout << f1.get() + f2.get() << '\n';  // 142

The worker loop uses the predicate form of cv_.wait, which handles spurious wakeups and the case where notify_one fires before a worker starts waiting. The exit condition stop_ && tasks_.empty() means the destructor drains every queued task before joining, so no future is left with broken_promise. The trade-off is that destroying the pool can take as long as the remaining work. submit after the destructor has started is not handled here; a production pool would check stop_ in submit and throw or reject.

This pool is fine for CPU-bound work sized to std::thread::hardware_concurrency(). It is a poor fit for tasks that wait on each other: if every worker is blocked in future.get() on a task that is still in the queue, nothing can make progress. That deadlock is easy to create by submitting subtasks from inside a task and waiting for them.

Running with a timeout

Here is the runWithTimeout implementation:

template<typename F>
auto runWithTimeout(F&& f, std::chrono::milliseconds timeout) 
    -> std::optional<decltype(f())> {
    
    std::packaged_task<decltype(f())()> task(std::forward<F>(f));
    auto future = task.get_future();
    
    std::thread t(std::move(task));
    t.detach();
    
    if (future.wait_for(timeout) == std::future_status::ready) {
        return future.get();
    }
    
    return std::nullopt;  // time out
}

// use
auto result = runWithTimeout([] {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 42;
}, std::chrono::seconds(1));

if (result) {
std::cout << "Result: " << *result << '\n';
} else {
std::cout << "Timeout\n";
}

Cancelling work that has not started

class CancellableTask {
    std::packaged_task<int()> task_;
    std::atomic<bool> cancelled_{false};
    
public:
    CancellableTask(std::function<int()> f) 
        : task_([this, f]() {
            if (cancelled_) {
                throw std::runtime_error("Cancelled");
            }
            return f();
        }) {}
    
    std::future<int> getFuture() {
        return task_.get_future();
    }
    
    void run() {
        task_();
    }
    
    void cancel() {
        cancelled_ = true;
    }
};

This only cancels work that has not started: the flag is checked once, before f() runs. A caller that cancels in time gets the “Cancelled” exception from get(), which is a reasonable way to tell waiters that no result is coming. Two further caveats: the lambda captures this, so the object must not be moved or destroyed before run() executes (the std::atomic member already makes it non-movable, which helps), and cancelling a task that is already running requires the task body itself to poll a flag or a C++20 std::stop_token.

Relationship to std::promise / std::future

There are three main standard configurations for chaining asynchronous results:

ComponentsRole
std::promiseManually set the value/exception seen by the future (set_value, set_exception)
std::packaged_taskExecutes a callable object once and automatically writes the results to the associated future
std::asyncConvenience API that bundles function execution and threading policy (whether thread pool reuse is non-standard depending on implementation)

packaged_task has shared state internally and returns a future on the consumer side with get_future(). On the other hand, promise is used by the producer to fill in values that are “still being calculated.” In a task queue, if you wrap the “execution body” in packaged_task, the worker only needs to call operator(), shortening the connection code.

Fire-and-forget vs result-required tasks

  • fire-and-forget: If you don’t need the result, you can just set the std::thread + join policy and be done with it, but exception propagation is difficult. To raise results/errors to the top, use one of packaged_task/async/promise.
  • Result Required: Receives value or exception with one future.get(). Consider shared_future for multiple subscriptions.
  • Backpressure·Queue Length Limit: If the producer only submits but consumption cannot keep up, memory increases. Design queue caps, blocking queues, or rejection policies together.

Error handling strategies for packaged tasks

  • future.get(): Exceptions thrown within a task are saved and rethrown at the time of get(). Therefore it is common to have a try/catch on the calling thread.
  • Timeout: Avoid infinite waiting with wait_for / wait_until, and choose logging/retry/cancel flags on failure. The above runWithTimeout is a demo, and in reality, without cancellation cooperation (periodic flag check), the thread may continue to run, so caution is required in production.
  • std::current_exception: Useful for throwing exceptions into promises at a low level, but for most cases, automatic handling by packaged_task will suffice.
std::packaged_task<int()> task([] {
    if (!validateInput()) {
        throw std::invalid_argument("bad input");
    }
    return compute();
});
auto fut = task.get_future();
std::thread(std::move(task)).detach();

try {
    use(fut.get());
} catch (const std::exception& e) {
    log_error(e.what());
}

FAQ

Q1: Why does my future.get() throw broken_promise?

A: The packaged_task was destroyed without ever being called, typically because it was still in a queue when the queue or thread pool was destroyed, or because an exception skipped the code that would have run it. Make sure every queued task runs or is deliberately abandoned during shutdown.

Q2: What is the difference from std::async?

A:

  • std::async: automatic execution (immediate or delayed), automatic creation of threads.
  • packaged_task: Manual execution, manual creation of threads
// async: simple
auto f = std::async(compute);

// packaged_task: control
std::packaged_task<int()> task(compute);
auto f2 = task.get_future();
std::thread t(std::move(task));
t.join();

Q3: Can packaged_task be reused?

A: Not as-is. A second call throws std::future_error (promise_already_satisfied). Either create a new task or call task.reset() and then get_future() again.

std::packaged_task<int()> task([] { return 42; });
task();
// task();  // throws std::future_error at run time

// create new
task = std::packaged_task<int()>([] { return 42; });

Q4: Can packaged_task be copied?

A: Impossible. Only movement is possible.

std::packaged_task<int()> task1([] { return 42; });
// auto task2 = task1;  // error
auto task2 = std::move(task1);  // OK

Q5: When should I use it?

A:

  • Implement task queue
  • Thread pool implementation
  • When you need to directly control execution timing
  • When you need to save your work and run it later

Q6: Can get_future() be called multiple times?

A: No. get_future() can be called only once per shared state; the second call throws future_already_retrieved. Use shared_future for several consumers.

std::packaged_task<int()> task([] { return 42; });
auto f1 = task.get_future();
// auto f2 = task.get_future();  // throws std::future_error

Q7: How are exceptions handled?

A: Exceptions that occur during task execution are stored in future and are rethrown when calling future.get().

std::packaged_task<int()> task([] {
    throw std::runtime_error("Error");
    return 42;
});

auto f = task.get_future();
task();

try {
    f.get();  // rethrow exception
} catch (const std::exception& e) {
    std::cout << e.what() << '\n';
}

Q8: Does the future from a packaged_task block in its destructor like std::async?

A: No. Only the future returned by std::async with the async policy waits for the task in its destructor. A future obtained from packaged_task::get_future() can be dropped at any time; the task still runs later if someone calls it, and its result is simply discarded.

References: cppreference.com - std::packaged_task, and Anthony Williams, C++ Concurrency in Action, which builds a thread pool on the same idea.

packaged_task wraps a function so that it can receive the result as a future, allowing manual execution control.