C++ PROGRAMMING ยท 11 MIN STUDY

Async tasks, futures, and atomic operations

Coordinate independent work with std::async and futures, and understand when atomic variables are appropriate.

Learning objectives

  • Launch a task with std::async and retrieve its result with std::future.
  • Explain why get() waits and can be called only once on a future.
  • Use an atomic counter to avoid a data race on one shared integer.
  • Distinguish atomicity from synchronization of a multi-variable invariant.

Prerequisites: functions, exceptions, and the earlier lesson on threads and mutexes. Examples require C++11 or later.

Choose the right concurrency tool

Use std::thread when you need direct thread ownership and lifecycle control. Use std::async when a task produces a result that can be collected through a future. Use atomics for simple shared state that must be read or updated atomically.

Launch a task and collect its result

#include <future>
#include <iostream>
int calculate() { return 6 * 7; }
int main() {
    auto result = std::async(std::launch::async, calculate);
    std::cout << result.get() << '\n';
}

std::future represents a result that may become available later. Calling get() waits if necessary and retrieves the result; it can be called only once on that future. Specifying std::launch::async requests asynchronous execution rather than leaving the launch policy to implementation choice.

Build note: on many GCC and Clang Linux toolchains, compile programs using threads or futures with -pthread, for example g++ -std=c++11 -pthread main.cpp -o app. Follow the platform-specific instructions for MSVC and other toolchains.

Atomic state

#include <atomic>
#include <thread>
std::atomic<int> counter{0};
void add() { for (int i = 0; i < 10000; ++i) counter.fetch_add(1); }
int main() {
    std::thread a(add), b(add);
    a.join(); b.join();
}

Each fetch_add is an atomic read-modify-write operation. This protects the counter update from a data race. It does not automatically make a group of several variables or operations one indivisible transaction.

Memory ordering: start simple

The default atomic operations use sequentially consistent ordering. This is the easiest model to reason about and is a sensible starting point. Weaker orders such as relaxed, acquire, and release can be useful, but correctness depends on the relationships between operations and the data they publish. Do not change memory order for speed without a documented proof and tests.

Important distinctions

  • Atomicity prevents a data race on that atomic object; it does not guarantee a whole algorithm is logically correct.
  • A future communicates completion and a result; it is not a replacement for every shared-state lock.
  • Do not assume std::async always creates a new operating-system thread unless you request the async launch policy.
  • Always design task lifetimes and exception propagation deliberately.

Common mistakes

  • Calling future::get() more than once on the same future.
  • Assuming a default std::async launch always creates a new thread.
  • Replacing a mutex with one atomic variable even though several fields must change together.
  • Reading shared non-atomic data without a synchronization relationship.

Practice

  1. What does future::get do?
  2. Why is atomic counter safer than a plain int shared between threads?
  3. Why should beginners retain sequential consistency until they understand weaker memory orders?
Show answers
  1. Waits for and retrieves the result, propagating a stored exception if one occurred.
  2. Its operations are atomic and do not create a data race on the counter.
  3. Weaker ordering requires reasoning about synchronization and visibility guarantees.

Summary

Use futures to receive task results, atomics for simple independently shared state, and mutexes for compound invariants. Prefer clear synchronization over premature memory-order optimization.

References