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 untilget(), 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)— returnsstd::future_status::ready,timeout, ordeferred.- A
std::futurefromstd::asyncblocks 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 untilexpris readyco_yield value— produce a value and suspendco_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:
- Calling
count_to(3)allocates a coroutine frame (locals + promise), callsget_return_object(), and suspends atinitial_suspend. next()resumes it; it runs toco_yield i, which callsyield_valueand suspends.- When the body ends, it suspends at
final_suspend;done()becomes true. - The
Generatordestructor 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::asyncwithoutstd::launch::async.- Ignoring the returned future — its destructor blocks, serializing your "parallel" code.
- Calling
get()twice on astd::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¶
- Parallel word count: split a list of files across
std::asynctasks, each returning astd::unordered_map<std::string, int>, then merge. Make one file unreadable and confirm the exception reachesmainviaget(). - Show the deferred-launch trap: run 4 slow tasks with the default policy vs
std::launch::asyncand compare wall time. - Build the
ThreadPooland submit 10,000 tiny tasks. Compare against onestd::jthreadper task. Run it under TSan. - Add
wait_all()and exception propagation tests to the pool. - Implement
Generator<T>above, then writerange(start, stop, step)andlines(std::istream&)generators. - Make
Generator<T>work with range-forby adding an iterator type withbegin()/end().
Recap¶
std::async(std::launch::async, f, args...)→std::future;get()returns the value or rethrows.std::promisefulfills a future manually;std::packaged_taskwraps 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'spromise_typedefines behavior. std::generator(C++23) for lazy sequences; Asio-style libraries for async I/O.- Coroutine parameters by value; frames must be owned (RAII).