OSC++.037: std::packaged_task — Move-Only Tasks, Futures, Exceptions, and Controlled Execution

Black female infrastructure programmer sitting at a dual-monitor workstation in a data center, with syntax-highlighted code and a task workflow visualization

OSC++.037 explains std::packaged_task, a movable C++ standard-library wrapper that pairs an executable callable with a std::future for retrieving the result or an exception. When a program needs to transfer ownership of a job into a worker thread, the wrapper supplies a simple connection between the work and the eventual outcome. A packaged task does not start its own thread and does not schedule work automatically; the application decides when and where to invoke it.

The lesson follows OSC++.036 on std::move_only_function. That chapter showed how to own callbacks that cannot be copied. Here the move-only callable also produces a typed future result. Earlier lessons on move semantics, smart pointers and ownership, and callable objects provide the prerequisites.

Learning Objectives and Prerequisites

  • Define std::packaged_task<R(Args...)> and identify its <future> header and C++11 introduction.
  • Explain the callable, the shared result state, the associated std::future<R>, and explicit invocation.
  • Move a task into a std::thread without copying a std::unique_ptr captured by its callback.
  • Distinguish normal result delivery, stored exceptions, abandonment as broken_promise, and the reset() operation.
  • Compile and execute a complete program, compare observed output with predicted output, and diagnose blocking or invalid-state errors.
  • Describe why a future is not a cancellation mechanism and why a task queue still needs synchronization and shutdown policies.

Prerequisites include functions and lambdas, C++ references, std::move, std::unique_ptr, and an introductory understanding of threads. Hardware requirements are minimal: a local desktop, laptop, or virtual machine with a C++20-capable compiler and a thread-enabled standard library. The APIs shown were introduced in C++11; the verified lab uses C++20 for a consistent compiler configuration.

From Callable to Shared State

std::packaged_task is a class-template specialization with a function signature, for example std::packaged_task<int(int)>. It owns a callable compatible with that signature. The packaged task’s get_future() method returns a std::future<int> tied to the same shared state. Invoking the task runs the callable and stores its return value or thrown exception. A waiting future becomes ready when its shared state is ready and may then retrieve that outcome through get(). The C++ working draft describes the state and callable ownership model.

Flow showing packaged task creation, future acquisition and explicit worker-thread invocation
Original Open Source Tech Cert diagram. Creating the future does not run the task; it observes a shared result state that becomes ready only after execution or abandonment.

A std::future owns access to the result channel, but it does not own the same responsibilities as the execution wrapper. The task is move-only: it cannot be copied. The future is also move-only, and a conventional future.get() retrieves its result at most once. These constraints help define where work and its result are owned. A valid future is not proof that its task has been scheduled.

The Difference Between packaged_task, function and async

  • std::function: copyable type-erased callable wrapper, suitable for copyable targets, without a built-in future result channel.
  • std::move_only_function: C++23 move-only callable wrapper whose signature and qualifiers govern invocation, but which does not automatically create a future.
  • std::packaged_task: move-only executable wrapper that owns a callable and provides a future for its returned value or exception; a scheduler is still needed.
  • std::async: launches or defers callable execution according to a launch policy and returns a future; the caller does not directly handle a packaged task.
  • std::promise: allows code to set a value or exception for a shared result state explicitly, without packaging a callable itself.

A Microsoft standard-library reference describes packaged_task as an asynchronous provider and documents its members. Asynchronous retrieval means the result can be observed later; it does not imply the packaged task must run on a separate thread. It can be invoked immediately in the same thread, dispatched through a queue, or moved into a worker.

Jeevan Pant, “Modern C++: Multi-threading, packaged task, future” (March 17, 2024). The video demonstrates single-threaded and multithreaded std::packaged_task computation and moving tasks into std::thread. Source: creator’s publicly listed YouTube tutorial.

A Complete Compiled Lab

The following program demonstrates four independently verifiable behaviors. A lambda takes exclusive ownership of a heap-allocated integer; a worker computes its value; another task deliberately throws an exception; a never-run task produces a broken promise; and a final task is reset and executed again. The code avoids shared mutable data, detached threads, unbounded waits and file access.

#include <chrono>
#include <future>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <thread>
#include <utility>

int main() {
    auto owned = std::make_unique<int>(40);
    std::packaged_task<int(int)> task(
        [resource = std::move(owned)](int increment) {
            return *resource + increment;
        });
    std::future<int> result = task.get_future();

    const bool pending =
        result.wait_for(std::chrono::seconds(0)) == std::future_status::timeout;
    std::thread worker(std::move(task), 2);
    worker.join();
    std::cout << "pending before invocation: " << std::boolalpha << pending << '\n';
    std::cout << "worker result: " << result.get() << '\n';

    std::packaged_task<int()> failing([]() -> int {
        throw std::runtime_error("planned failure");
    });
    auto failure_future = failing.get_future();
    failing(); // Exception is captured in the future, not thrown to this caller.
    try {
        (void)failure_future.get();
    } catch (const std::runtime_error& e) {
        std::cout << "captured exception: " << e.what() << '\n';
    }

    std::future<int> abandoned;
    {
        std::packaged_task<int()> never_run([] { return 1; });
        abandoned = never_run.get_future();
    } // Destroyed without invoking: broken_promise state.
    try {
        (void)abandoned.get();
    } catch (const std::future_error& e) {
        std::cout << "broken promise: "
                  << (e.code() == std::make_error_code(std::future_errc::broken_promise))
                  << '\n';
    }

    std::packaged_task<int()> reusable([] { return 7; });
    auto first = reusable.get_future();
    reusable();
    std::cout << "first run: " << first.get() << '\n';
    reusable.reset(); // New shared state, same stored callable.
    auto second = reusable.get_future();
    reusable();
    std::cout << "after reset: " << second.get() << '\n';
}

Save the source as oscpp_037_packaged_task.cpp, then compile it with the following commands in a POSIX shell with GCC and threading support. On Windows, select the equivalent C++20 language option and supported thread runtime in the IDE or compiler toolchain.

g++ --version
g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread oscpp_037_packaged_task.cpp -o oscpp_037_packaged_task
./oscpp_037_packaged_task

The program was compiled and executed with GCC 14.2.0 using the command shown. The observed output was:

pending before invocation: true
worker result: 42
captured exception: planned failure
broken promise: true
first run: 7
after reset: 7

The first future is checked before thread creation, so wait_for(0) reports a timeout predictably. The owned integer begins as 40, and the worker adds 2. The calculation 40 + 2 = 42 is deterministic, even though the operating system chooses when the worker actually executes. The main thread joins the worker before reading the returned result, avoiding an unjoined-thread shutdown error.

Exceptions Are Delivered Through the Future

When a packaged callable throws, operator() captures that exception in the associated shared state. The exception is rethrown when the consumer invokes future.get(). In the tested example, failing() does not throw std::runtime_error directly to its caller; the later failure_future.get() does. An application must still handle standard-library misuse errors, allocation failures and system failures where applicable.

Diagram comparing a returned value, an exception stored for future.get, and an unexecuted task causing broken_promise
Original Open Source Tech Cert outcome diagram. A ready future may hold a value, an exception, or a broken-promise error; readiness alone does not mean success.

There is a critical difference between an exception raised by the callable and a std::future_error raised because the shared state was misused or abandoned. The C++ packaged-task member specification identifies errors such as future_already_retrieved, promise_already_satisfied, and no_state. If the packaged task is destroyed without running and a future still depends on its shared state, the future becomes ready with future_errc::broken_promise, not a successful default value.

Ownership and Lifetime Safety

A task can capture a std::unique_ptr by move, as the lab does. A separate std::thread can then take ownership of the task by another move. No code should dereference the original unique pointer after it has been moved from; the program intentionally uses only the captured pointer. In contrast, capturing references to short-lived local variables can produce dangling references if a task is queued beyond the variables’ lifetimes. Designing the lifetime boundary before launching work is essential.

A std::thread object must be joined or detached before destruction; the example uses join() because task completion is required. A production queue could use a std::jthread or explicit task-worker lifecycle manager. Neither packaged tasks nor futures automatically serialize access to user-owned shared objects; use std::mutex, message passing or another appropriate synchronization strategy when resources are shared.

Why get_future, operator() and reset Can Fail

  • Calling get_future() twice for the same shared state: the second call throws std::future_error with future_already_retrieved.
  • Invoking an already-completed task a second time: the task reports promise_already_satisfied unless it has been reset to a new shared state.
  • Using a moved-from task: the source task no longer has a shared state after move construction. Check valid() before operations that require one.
  • Forgetting to invoke work: the future can block indefinitely while the task still exists but has not been scheduled. Destroying an unsatisfied task makes a waiting future receive a broken promise.
  • Resetting an unfinished task: reset() creates a new state and abandons the old one; consumers holding the old future may receive broken_promise. It is not a cancellation protocol.
  • Reusing a future after get(): a normal std::future is single-consumption; use std::shared_future when a result must be observed by multiple consumers under its documented rules.

The cppreference overview and its reset reference detail the contract. Do not use reset merely to “retry” a failed network or database operation without checking the semantics of the callable, ownership of its arguments, duplicate side effects, and whether the previous attempt actually completed.

Mike Shah, “std::future and std::async in Modern cpp | Introduction to Concurrency in C++” (November 20, 2021). Background lecture on futures and deferred result retrieval; it covers std::async rather than presenting a packaged-task-specific lab.

Engineering Application: A Small Work Queue

In a server application, a scheduler might accept tasks from producers, store them in a move-aware queue, then let worker threads run them. The submission side returns futures to callers; consumers wait only when their results are needed. One simple option is std::queue<std::packaged_task<void()>> for a homogeneous set of void-returning jobs. If multiple results have distinct types, the application may need a separate type-erasure strategy or task adapter rather than assuming arbitrary task signatures can inhabit one queue without conversion.

A correct worker queue also requires a mutex and condition variable for shared access, a carefully defined stop signal, policies for rejected submissions, exception reporting, and shutdown behavior for unexecuted work. Some workloads require backpressure, deadlines and cancellation. A std::future does not provide built-in cancellation; the producer and worker must cooperate through a separate mechanism. Thread count, latency and throughput cannot be inferred from this demonstration, because it only exercises one worker and no benchmark suite.

Practical Exercises

  1. Change the owned value from 40 to 58 and the worker argument from 2 to 7. Predict the new worker result before compiling, then verify that it prints 65.
  2. Write a simple task whose callable returns std::string. Change the signature and matching future type; verify that the returned text arrives through get().
  3. Add an explicit test for get_future() called twice. Catch std::future_error and check for future_errc::future_already_retrieved. Do not treat an exception message’s English wording as a stable machine-readable error code.
  4. Use a small bounded delay inside a disposable lab task and inspect wait_for() before and after execution. Explain why timing-dependent output should not be considered deterministic evidence of thread speed.
  5. Explain how a program can avoid indefinite waiting when a queue stops accepting work and how a caller should be notified that submission failed.

Knowledge Check and Answers

  1. Does creating a packaged task create a worker thread? No. Invocation or dispatch must be explicitly arranged.
  2. Which header declares std::packaged_task? <future>, introduced in C++11.
  3. What does get_future() return? A future sharing the packaged task’s result state.
  4. What happens when the callable throws? The exception is stored and rethrown by the associated future’s get().
  5. Why does the example’s worker compute 42? The owned integer is 40 and the worker supplies 2.
  6. What happens when the unexecuted task is destroyed? The future receives a broken-promise error because the promised result is abandoned.
  7. Can the same task’s existing shared state be executed repeatedly? No. Calling reset() constructs a new shared state for another invocation.
  8. What extra mechanisms are needed for a production task queue? Safe synchronization, lifecycle management, backpressure and an explicit shutdown/error policy.

Official Technical References

The core semantics were cross-checked against the C++ working draft, packaged_task general and member functions; Microsoft Learn, packaged_task class; cppreference, std::packaged_task; get_future reference; and reset reference. Compiler execution occurred on November 6, 2026, using GCC 14.2.0 on a local Debian-based environment. Results describe that test configuration rather than every compiler implementation.

Conclusion

The value of std::packaged_task is precise ownership: a movable callable supplies a result state while an independently managed execution mechanism determines when the work runs. Futures make eventual outcomes observable, including failure. Engineers who understand the difference between owning work, scheduling work and consuming its result can build safer background processing systems without confusing asynchronous interfaces with automatic concurrency.

Editor’s Note: OSC++.037 follows the verified OSC++.036 chapter. The complete source example was compiled with GCC 14.2.0 in C++20 mode and executed; all six output lines were checked. The main example does not benchmark concurrency or demonstrate production system performance. Featured image is original AI-generated editorial art; the two diagrams are original educational illustrations, not screenshots of compiled software. Source video titles and publishers were checked; iframe rendering and live playback are separately audited.

Original BitcoinVersus.Tech 5,000-satoshi donation QR code
BitcoinVersus.Tech original 5,000-sat donation QR code. Verify the destination and amount before sending.

Mobile scanning: display the original QR code on a computer or second screen and scan it using a compatible Bitcoin wallet. Where supported, use the wallet’s saved-image scan option on the same phone. Verify the destination and 5,000-sat amount before approving.

We volunteer daily to ensure the information on this platform is Verifiably True. If you would like to support the research, please scan the QR code.

Leave a Reply