Creating a tiny C++ Task Library

C++
Author

Quasar

Published

November 18, 2025

Introduction

A future is an object that represents some undetermined result of a task that will be completed sometime in the future. A promise is the provider of that result.

The std::promise and std::future pair implements a one-short producer-consumer channel with the promise as the producer and the future as the consumer. The consumer (std::future) can can block until the result of the producer (std::promise) is available.

Many modern programming languages provide similar asynchronous approaches, such as Python(with the asyncio library), Scala(in the scala.concurrent library), Rust(in its standard library std or crates such as promising_future).

The basic principle behind achieving asynchronous execution using promises and futures is that a function we want to run to generate a result is executed in the background, using a new thread or the current one, and a future object is used by the initial thread to retrieve the result computed by the function. This result value will be stored when the function finishes, so meanwhile, the future acts as a placeholder. The asynchronous function will use a promise object to store the result in the future with no need for explicit synchronization mechanisms between the initial thread and the background one. When the value is needed by the initial thread, it will be retrieved from the future object. If the value is still not ready, the initial thread of execution will be blocked until the future becomes ready.

Using promises and futures improves responsiveness by offloading computations and provides a structured approach to handling asynchronous operations compared to threads and callbacks.

The Basic Mechanics

Promises

A promise holds a shared state. The shared state is a memory area that stores the completion status, synchronization mechanisms, and a pointer to the result. It ensures proper communication and synchronization between a promise and a future by enabling the promise to store either a result or an exception, signal when it’s complete and allowing the future to access the result, blocking if the promise is not yet ready. The promise can update its shared state using the following operations:

  • Make ready. The promise stores the result in the shared state and makes the state of the promise to become ready unblocking any thread waiting on a future associated with the promise.

  • Release. The promise releases its reference to the shared state, which will be destroyed if this is the last reference.

  • Abandon. The promise stores an exception of type std::future_error with error code std::future_errc::broken_promise making the shared state ready and then releasing it.

The value of a promise can be set using the std::promise function set_value() and an exception by using the set_exception() function. The result is stored atomically in the promise’s shared state, making its state ready. Let’s see an example:

// Ref: Asynchronous programming with C++
//Javier Reguara Salgado
auto threadFunc = [](std::promise<int> prom){
    try{
        int result = func();
        prom.set_value(result);
    }catch(std::exception& ex){
        prom.set_exception(ex);
    }
}

std::promise<int> prom;
std::jthread t(thread_func, std::move(prom));

set_value() can throw a std::future_error exception if the promise has no shared state (error code set to no_state) or the shared state has already a storedd result.

set_value() can also be used without specifying a value. In that case, it simply makes the state ready. That can be used as a barrier, as we will see later in this blog post.

Futures

Futures are defined in the <future> header file as std::future.

As we saw earlier, a future is the consumer side of the communication channel. It provides access to the result stored by the promise.

A std::future object must be created from std::promise object by calling get_future() or through a std::packaged_task object or a call to the std::async function.

std::promise<int> prom;
std::future<int> fut = prom.get_future();

Like promises, futures can be moved but not copied for the smae reasons. To reference the same shared state from multiple futures, we need to use shared futures.

The get() method can be used to retrieve the result. If the shared state is still not ready, this call will block internally calling wait(). When the shared state becomes ready, the result value is returned. If an exceptionwas sttored in the shared state, that exception will be retrhrown:

try{
    int result = fut.get()
    std::cout << "Result from thread:" << result;
}catch(const std::exception& ex){
    std::cerr << "Exception : " << ex.what() << "\n";
}

After calling the get() method, valid() will return false. If for some reason get() is called when valid() is false, the behavior is undefined, but the C++ standard recommends that a std::future_error exception is thrown with the std::future_errc::no_state error code.

When a future is destroyed, it releases it shared state reference. If that were the last reference, the shared state would be destroyed.

A quick working example

#include <algorithm>
#include <chrono>
#include <future>
#include <thread>
#include <vector>
#include <print>

using namespace std::chrono_literals;

int main(){
    std::promise<std::vector<int>> user_list_promise;
    std::promise<std::vector<int>> orders_promise;

    auto fetch_users_ready = user_list_promise.get_future();
    auto fetch_orders_ready = orders_promise.get_future();

    std::jthread user_list_thread([&](){
        std::vector<int> userList{};
        for(int i{1}; i<=5; ++i){
            userList.push_back(i);
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
        user_list_promise.set_value(userList);
    });

    std::jthread orders_thread([&](){
        std::vector<int> orders{};
        for(int i{1}; i<=10; ++i){
            orders.push_back(i);
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
        orders_promise.set_value(orders);
    });

    fetch_users_ready.wait();
    fetch_orders_ready.wait();

    auto users = fetch_users_ready.get();
    auto orders = fetch_orders_ready.get();

    std::println("Users = {}", users);
    std::println("Orders = {}", orders);
    return 0;
}

Compiler Explorer

Shared Futures

As we saw earlier, std::future is only moveable, thus only one future object can refer to a particular asynchronous result. On the other hand, std::shared_future is copyable, so several shared future objects can refer to the same shared state.

There, std::shared_future allows thread-safe access from different threads to the same shared state. Shared futures can be useful for sharing the result of a computationally intensive task among multiple consumers or interested parties, reducing computation. Also, they can be used to notify events or as a synchronization mechanism where multiple threads must wait for the completion of a single task. The interface of std::shared_object is the same as the one for std::future. A shared_future object is created using the .share() function on the future.

Creating a tiny C++ task library

Promises and futures can be chained together to perform multiple asynchronous operations sequentially. We can have elaborate data-processing pipelines where one future’s result becoimes the input for the next operation’s promise. This allows for composing complex asynchronous workflows where the output of one task feeds into the next.

Also, we can allow branching in the pipeline and keep some tasks switched off until needed. This can be done using futures with deferred execution.

Generally speaking tasks make up a DAG(Directed acyclic graph). Thus, a task library must consist of two parts : an API to specify the task DAG and a scheduler that actually executes the DAG.

Let’s try to create the following task graph:

graph LR
  Task-1[Task-1] --> Task-2[Task-2]
  Task-2 --> Task-3[Task-3]
  Task-2 --> Task-4[Task-4]
  Task-3 --> Aggregate[Aggregate results]
  Task-4 --> Aggregate

We start by defining a template class called Task that accepts a callable as a template argument, defining the function to execute. This class will also allow us to create tasks that share a future with dependent ones.

#include <vector>
#include <cstdint>
#include <print>

#define sync_cout std::osyncstream(std::cout)

class GraphWalker;

template<typename Func>
class Task{

    public:
    Task(int id, Func& func)
    : m_id{ id }
    , m_func{ func }
    {
        sync_cout << "\n"
                  << "Task " << id << " constructed ";
    }

    Task(const Task& other)
    : m_id{ other.m_id }
    , m_func{ other.m_func }
    {}

    uint64_t get_id(){
        return m_id;
    }

    private:
    u64_t m_id;
    Func m_func;
    std::promise<void> m_promise;

};

struct Node{
    Task task;
    std::vector<Node*> child_nodes;
};