Skip to content

6. Async, Futures & Coroutines

What & why

Threads and mutexes are the low-level building blocks. Most code wants a higher-level question answered: "run this somewhere, and give me the result later". This lesson covers futures (a handle to a result that doesn't exist yet), thread pools (reuse threads instead of creating one per task), and coroutines (functions that can suspend and resume — the foundation for generators and async I/O).

std::async and std::future

#include <future>
#include <iostream>
#include <numeric>
#include <vector>

long long sum_range(const std::vector<int>& v, std::size_t begin, std::size_t end) {
    return std::accumulate(v.begin() + begin, v.begin() + end, 0LL);
}

int main() {
    std::vector<int> data(10'000'000, 1);
    auto mid = data.size() / 2;

    std::future<long long> first =
        std::async(std::launch::async, sum_range, std::cref(data), 0, mid);
    long long second = sum_range(data, mid, data.size());   // this thread does half

    std::cout << first.get() + second << '\n';   // get() waits, then returns (or rethrows)
}
  • std::launch::async — run on a new thread. Always specify it. The default policy may defer execution until get(), silently making your code sequential.
  • future.get() — blocks until ready; returns the value or rethrows the task's exception. Callable once.
  • future.wait_for(timeout) — returns std::future_status::ready, timeout, or deferred.
  • A std::future from std::async blocks in its destructor until the task finishes. Discarding it (std::async(...); alone) makes the call synchronous.

std::shared_future

Several consumers waiting on the same result: auto sf = fut.share(); — get() can then be called many times, from many threads.

std::promise: fulfill a future manually

std::promise<std::string> promise;
std::future<std::string> result = promise.get_future();

std::jthread worker{[p = std::move(promise)]() mutable {
    try {
        p.set_value(fetch_config());
    } catch (...) {
        p.set_exception(std::current_exception());
    }
}};

std::cout << result.get();

Use promises to bridge callback-style APIs into futures.

std::packaged_task

Wraps a callable so its result goes into a future — the building block of thread pools:

std::packaged_task<int(int, int)> task{[](int a, int b) { return a + b; }};
std::future<int> f = task.get_future();
std::jthread t{std::move(task), 2, 3};
f.get();   // 5

A simple thread pool

Creating a thread per task is expensive. A pool keeps N workers pulling tasks from a queue:

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

class ThreadPool {
public:
    explicit ThreadPool(unsigned n = std::thread::hardware_concurrency()) {
        for (unsigned i = 0; i < n; ++i) {
            workers_.emplace_back([this](std::stop_token st) { run(st); });
        }
    }

    ~ThreadPool() {
        {
            std::lock_guard lock{mutex_};    // under the lock, so no worker misses the wakeup
            for (auto& w : workers_) w.request_stop();
        }
        cv_.notify_all();
    }   // jthreads join here

    template <typename F>
    auto submit(F f) -> std::future<decltype(f())> {
        using R = decltype(f());
        auto task = std::make_shared<std::packaged_task<R()>>(std::move(f));
        auto fut = task->get_future();
        {
            std::lock_guard lock{mutex_};
            tasks_.emplace([task] { (*task)(); });
        }
        cv_.notify_one();
        return fut;
    }

private:
    void run(std::stop_token st) {
        while (true) {
            std::function<void()> job;
            {
                std::unique_lock lock{mutex_};
                cv_.wait(lock, [&] { return st.stop_requested() || !tasks_.empty(); });
                if (tasks_.empty()) return;          // stop requested and nothing left
                job = std::move(tasks_.front());
                tasks_.pop();
            }
            job();
        }
    }

    std::mutex mutex_;
    std::condition_variable cv_;
    std::queue<std::function<void()>> tasks_;
    std::vector<std::jthread> workers_;              // declared last: destroyed (joined) first
};
ThreadPool pool;
std::vector<std::future<int>> results;
for (int i = 0; i < 100; ++i) {
    results.push_back(pool.submit([i] { return i * i; }));
}
int total = 0;
for (auto& r : results) total += r.get();

(std::packaged_task is move-only but std::function requires copyable callables — hence the shared_ptr wrapper. C++23's std::move_only_function removes that need.)

Member declaration order matters: workers_ is last so it's destroyed first, joining the threads while the mutex and queue still exist.

For production, use a battle-tested library (e.g. Intel oneTBB, BS::thread_pool, or your platform's executor).

Coroutines

A coroutine is a function that can suspend and later resume, keeping its local state. A function is a coroutine if it uses any of:

  • co_await expr — suspend until expr is ready
  • co_yield value — produce a value and suspend
  • co_return value — finish

C++20 provides the language machinery but only a minimal library. C++23 adds std::generator.

Generators with std::generator (C++23)

#include <generator>   // GCC 14+, MSVC 17.8+
#include <iostream>

std::generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        a = std::exchange(b, a + b);
    }
}

int main() {
    for (auto x : fibonacci() | std::views::take(10)) {
        std::cout << x << ' ';
    }
}

The generator computes lazily — each iteration resumes the coroutine until the next co_yield.

What's under the hood: a minimal generator

If your standard library lacks std::generator, here's the machinery. A coroutine's return type must have a nested promise_type that tells the compiler how to behave:

#include <coroutine>
#include <exception>
#include <utility>

template <typename T>
class Generator {
public:
    struct promise_type {
        T current;
        std::exception_ptr error;

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }   // lazy start
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T value) {
            current = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { error = std::current_exception(); }
    };

    explicit Generator(std::coroutine_handle<promise_type> h) : handle_{h} {}
    Generator(Generator&& o) noexcept : handle_{std::exchange(o.handle_, {})} {}
    Generator(const Generator&) = delete;
    ~Generator() { if (handle_) handle_.destroy(); }   // RAII for the coroutine frame

    // Advance to the next value; returns false when finished.
    bool next() {
        handle_.resume();
        if (handle_.promise().error) std::rethrow_exception(handle_.promise().error);
        return !handle_.done();
    }
    const T& value() const { return handle_.promise().current; }

private:
    std::coroutine_handle<promise_type> handle_;
};

Generator<int> count_to(int n) {
    for (int i = 1; i <= n; ++i) {
        co_yield i;
    }
}

// auto g = count_to(3);
// while (g.next()) std::cout << g.value() << ' ';   // 1 2 3

The flow:

  1. Calling count_to(3) allocates a coroutine frame (locals + promise), calls get_return_object(), and suspends at initial_suspend.
  2. next() resumes it; it runs to co_yield i, which calls yield_value and suspends.
  3. When the body ends, it suspends at final_suspend; done() becomes true.
  4. The Generator destructor destroys the frame.

Async tasks with co_await

co_await expr works with any awaitable — a type with await_ready, await_suspend, and await_resume. Libraries build async I/O on this:

// With a coroutine library (e.g. Asio, cppcoro, libunifex / std::execution in C++26):
asio::awaitable<void> echo(tcp::socket socket) {
    char data[1024];
    while (true) {
        std::size_t n = co_await socket.async_read_some(asio::buffer(data), asio::use_awaitable);
        co_await asio::async_write(socket, asio::buffer(data, n), asio::use_awaitable);
    }
}

The code reads sequentially, but each co_await suspends without blocking a thread — thousands of connections on a handful of threads. Writing your own task type and scheduler is possible but intricate; use a library (Asio is the de facto standard for networking).

Coroutine pitfalls

  • Dangling references: parameters taken by reference may die while the coroutine is suspended. Coroutine parameters should usually be by value.
  • Capturing lambdas as coroutines: the lambda object (and its captures) may be destroyed before the coroutine finishes.
  • Frame allocation — each coroutine call typically heap-allocates its frame (compilers can elide it in simple cases).
  • Who destroys the frame? Your return type must own the coroutine_handle (RAII), or it leaks.

Choosing a model

Workload Tool
A few independent CPU-heavy computations std::async(std::launch::async, ...)
Many small CPU tasks thread pool / parallel algorithms
Data-parallel loops std::for_each(std::execution::par, ...), OpenMP, TBB
Lazy sequences std::generator / ranges
Many concurrent I/O operations (network servers) coroutines + an async I/O library (Asio)

Common mistakes

  • std::async without std::launch::async.
  • Ignoring the returned future — its destructor blocks, serializing your "parallel" code.
  • Calling get() twice on a std::future.
  • Forgetting that exceptions travel through futures — always get() so they surface.
  • Thread pool member order — workers must be destroyed before the queue they use.
  • Reference parameters in coroutines that dangle after suspension.
  • Hand-rolling async I/O frameworks instead of using a library.

Exercises

  1. Parallel word count: split a list of files across std::async tasks, each returning a std::unordered_map<std::string, int>, then merge. Make one file unreadable and confirm the exception reaches main via get().
  2. Show the deferred-launch trap: run 4 slow tasks with the default policy vs std::launch::async and compare wall time.
  3. Build the ThreadPool and submit 10,000 tiny tasks. Compare against one std::jthread per task. Run it under TSan.
  4. Add wait_all() and exception propagation tests to the pool.
  5. Implement Generator<T> above, then write range(start, stop, step) and lines(std::istream&) generators.
  6. Make Generator<T> work with range-for by adding an iterator type with begin()/end().

Recap

  • std::async(std::launch::async, f, args...) → std::future; get() returns the value or rethrows.
  • std::promise fulfills a future manually; std::packaged_task wraps callables for pools.
  • Thread pools amortize thread creation; order members so workers join first.
  • Coroutines suspend/resume with co_await, co_yield, co_return; the return type's promise_type defines behavior.
  • std::generator (C++23) for lazy sequences; Asio-style libraries for async I/O.
  • Coroutine parameters by value; frames must be owned (RAII).

Next: Performance & profiling →