CodeGym /Courses /C++ SELF /std::async — launch policies

std::async — launch policies

C++ SELF
Level 71, Lesson 0
Available

1. Why use std::async and std::future?

If you've only just started working with threads, a natural reaction to std::async sounds like: "Is this yet another thing to do the same thing but differently?". The answer is both yes and no. A thread is about "run execution somewhere in parallel". And std::async is about "start a computation and give me a handle that I can use later to retrieve the result". That "handle" is called std::future.

Imagine an everyday scene: you ordered a pizza. A thread is like "I went to the kitchen to cook the pizza". std::async is like "I ordered a pizza and got an order number: with that number I can later check the status and collect the result". The order number is the future. You don't need to think how the kitchen is organized inside — what's important is that there will be a result and you can obtain it in a controlled way.

One more important difference: std::async can defer execution. That is, you can say: "don't cook the pizza right now — cook it when I actually come to pick it up". And that is the launch policy.

Asynchrony is a contract, not magic

The word "async" sounds like everything will necessarily be parallel, fast, and neat. In practice in C++ it's not "magic" but a contract between you and the standard library: you launch a task, and the library decides when and where to execute it — depending on the chosen launch policy.

In the history of standardization around std::async and related facilities there were many discussions about subtle behavior and wording — up to questions whether some parts of the specification can be implemented identically in all standard libraries. That's a good sign: the topic is non-trivial, and you shouldn't "guess" behavior from the name.

So the practical goal here is simple: learn to write code that doesn't rely on guessing, and understand when std::async truly runs work in parallel and when it does not.

2. Basic concepts and syntax

Mini-glossary: callable, task, result, future

Before writing code, let's agree on terms in plain words.

Callable is "something you can call". A regular function is callable. A lambda is callable. Sometimes even an object with operator() is callable (but we won't dive into that depth today).

A task is your callable plus its arguments: what you want to execute.

Result is the value the callable returns (for example, int), or simply the fact of completion (void).

std::future<T> is an object associated with a "result cell" (often called the shared state). You will obtain the value later when it's ready.

For std::async remember one idea: it starts a callable and returns a future. How it starts it depends on the policy.

(By the way, even in editorial reports and standard patches you can see that std::async has its own "corner" in the futures section — it's not a side utility but a full part of the result model.)

Basic syntax of std::async: "started and got a future"

Let's start with a minimal example: a function adds two numbers, but we run it through std::async.

#include <future>
#include <iostream>

int add(int a, int b) {
    return a + b;
}

int main() {
    std::future<int> f = std::async(std::launch::async, add, 2, 3);
    std::cout << f.get() << '\n'; // 5
}

Note three things.

  • First — include <future>, because std::async and std::future live there.
  • Second — we passed the policy std::launch::async. Why did we do that explicitly if we "want async"? Because without a policy you can easily be mistaken in your expectations, and this is the central idea of today's lecture.
  • Third — get() looks like "alright, I took the value". We won't deeply analyze the future lifetime today, but it's important to understand: get() is the point where you actually "receive the result", and if the result is not ready yet you will wait.

3. Launch policies: std::launch

Now — the most important part.

std::async accepts launch policies specified via std::launch.

std::launch::async

This policy means: "execute the task asynchronously". In typical terms this will be a separate thread of execution (or an equivalent "asynchronous activity" inside the implementation).

That means your callable will start right away, and the main thread continues.

std::launch::deferred

This policy means: "defer execution until someone asks for the result".

The key point: with deferred the task runs in the thread that calls the wait point (get() or wait()), typically the main thread. This is not parallelism. It's a "lazy launch".

Why might this be useful? For example, you want to describe a computation but only run it if it is actually needed. This is like "I'll prepare the report, but only if you press the 'show report' button".

Demonstration: deferred is "later and in the same thread"

Let's make an example that prints the order of events. We don't even need a separate result — the main thing is to see the moment of execution.

#include <future>
#include <iostream>

int main() {
    auto f = std::async(std::launch::deferred, [] {
        std::cout << "work\n";
        return 10;
    });

    std::cout << "before get\n";
    std::cout << f.get() << '\n';
}

Expected output:

// before get
// work
// 10

The idea is: until you call get(), the "work" hasn't even started.

When it's really async: the default-policy trap

Here is where beginners often say: "I called std::async, so it's async!". And the standard library replies: "Ha-ha. Cute naive person".

If you call std::async without an explicit policy, you usually get "both are allowed": the implementation may run the task asynchronously, or it may defer. In practice this means: on one platform you'll see parallelism, on another — no, and on a third — "sometimes yes, sometimes no".

That's why it's useful to follow a rule: if parallelism matters — write std::launch::async explicitly.

Let's illustrate this feeling with an "artificially slow" job.

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slow() {
    std::this_thread::sleep_for(std::chrono::seconds{1});
    return 7;
}

int main() {
    auto f = std::async(std::launch::async, slow);
    std::cout << "doing something...\n";
    std::cout << f.get() << '\n'; // 7
}

If the policy here were deferred, "doing something..." might print, but slow() would start only on get(), and you'd get a "pseudo-asynchrony": you appear to "do something", but nothing was actually computed in parallel.

Visual scheme: async vs deferred

To make this less "magical", let's fix it as a simple diagram.

flowchart TD
    A[main: calls std::async] --> B{launch policy}
    B -->|launch::async| C[task starts immediately in another thread/activity]
    B -->|launch::deferred| D[task does NOT start immediately]
    D --> E["main: later calls get()/wait()"]
    E --> F[task executes in main thread]
    C --> G["main: later calls get()/wait()"]
    G --> H[main receives the result]
    F --> H

This diagram is your "anti-mysticism". If you understand it, you won't write code thinking "it should probably run in parallel".

4. Results, lifetimes, and design

std::future<void>: when the value isn't needed but completion is

Sometimes you don't need a value. You only need to wait for the task to finish. For example: "save a log", "flush a cache", "close a connection gracefully".

Then it's convenient to return void — and obtain a std::future<void>.

#include <future>
#include <iostream>

void work() {
    // do something useful
}

int main() {
    std::future<void> f = std::async(std::launch::async, work);
    f.get(); // wait for completion; no value
    std::cout << "done\n"; // done
}

Note that get() for future<void> is "wait and check that everything is OK". We won't dive into exceptions in future today, but the moral is: future<void> is also a "result channel", only the result is the fact of successful completion.

Arguments and lifetimes: why async sometimes "crashes"

Now an important point that trips up beginners more often than commas in cout.

std::async starts work "somewhere later" (or "somewhere in parallel"). That means everything referenced inside the task must live long enough.

If you captured a reference to a local variable and the function already returned — the reference becomes dangling. This can manifest as "random crash", "random garbage", or "it works on my machine".

Dangerous style — capturing by reference without lifetime guarantees:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [&] {
        return name.size();
    });

    return 0; // risk: the task may still read name
}

Safer style — capture by value:

#include <future>
#include <string>

int main() {
    std::string name = "Alice";

    auto f = std::async(std::launch::async, [name] {
        return name.size();
    });

    (void)f.get();
}

Yes, it's a copy. But for a string the copy is the price of safety. We will optimize later, when the code is correct (and when there's a clear need).

Design nuance: std::async is not "fire-and-forget"

Beginners quickly get tempted: "Oh! I'll sprinkle std::async all over the program and everything will fly!". That's like handing a radio to every employee and thinking the company became more efficient.

std::async implies you keep a future, i.e. you have a point where you either retrieve the result or at least wait for completion. It's a discipline: if you "lose" the future, you lose control over the task.

Good style looks like this: create a future → store it in a variable → later consciously call get() (or wait(), which we'll cover later).

Thought for the future: future and the "shared result cell"

We deliberately don't go deep into the future lifetime today, but it's useful to know that internally there's a "result cell", and the standard library polished wording for years about how this cell is created and released. Even for future::get there was discussion about how explicitly the shared state should be detached and freed.

This is not to scare you. It's to make you understand: future is not "just an int in a box". It's an object with a lifetime, and you should treat it with discipline.

5. Practical example: console report generator

So examples are not a set of "spherical horse in a vacuum", let's build a small application that is easy to extend.

The idea is simple: we have a list of "tasks" (not threads, but project tasks), and we want to build a "report" — a costly operation (in reality: reading, parsing, aggregating). While the report is building, the main thread can continue doing something (in the demo — at least print messages).

Step 1: data model and the "expensive function"

#include <chrono>
#include <string>
#include <thread>
#include <vector>

struct Task {
    std::string title;
    int estimateHours;
};

int buildReport(const std::vector<Task>& tasks) {
    std::this_thread::sleep_for(std::chrono::milliseconds{300}); // simulate work
    int total = 0;
    for (const auto& t : tasks) total += t.estimateHours;
    return total;
}

The function returns an int: the total estimated hours.

Step 2: start the report via std::async with an explicit policy

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

int main() {
    std::vector<Task> tasks{{"Parse logs", 5}, {"Generate PDF", 2}};

    auto futureTotal = std::async(std::launch::async, buildReport, std::cref(tasks));
    std::cout << "report started...\n";

    int total = futureTotal.get();
    std::cout << "total hours = " << total << '\n'; // total hours = 7
}

A new detail appears here: std::cref(tasks). It's a convenient way to pass a reference as an argument (instead of copying the whole vector). If you're unsure, you can pass a copy — for demo-size data that's fine. I show std::cref because it appears often and helps avoid "hauling a train" of data around.

Important: we explicitly specified std::launch::async because today we're learning not to guess whether it will run in parallel.

Another demo: two calculations in parallel

Let's make an example that launches two calculations. Even without timing you'll see the difference in approach.

#include <chrono>
#include <future>
#include <iostream>
#include <thread>

int slowCalc(int x) {
    std::this_thread::sleep_for(std::chrono::milliseconds{200});
    return x * 2;
}

int main() {
    auto a = std::async(std::launch::async, slowCalc, 10);
    auto b = std::async(std::launch::async, slowCalc, 20);

    std::cout << a.get() + b.get() << '\n'; // 60
}

If both tasks actually start in parallel, the total time will be closer to 200 ms (plus overhead) rather than 400 ms. If you used deferred, both tasks would start only on get(), and you'd almost certainly get them "one after another" (because get() is called sequentially).

6. Common mistakes when using std::async and launch policies

Mistake #1: using std::async without a policy and expecting guaranteed parallelism.
Such code may "accidentally" run in parallel on your machine and suddenly be deferred on another. If parallelism is part of the logic (for example, you expect to overlap disk/network/CPU waiting), it's better to write std::launch::async explicitly so the contract is clear to you and the reader.

Mistake #2: thinking std::launch::deferred is "start after some time".
deferred does not mean "wait 2 seconds and start". It means "don't start at all until I ask for the result". The start time is determined not by a timer but by the moment of get()/wait().

Mistake #3: capturing local variables by reference that may die before the task.
This is a classic lambda trap in concurrent code. You look at a variable and it's alive and friendly, and a millisecond later main finished, the variable vanished, and the task still tries to access it. If there's no ironclad lifetime guarantee — capture by value or pass data so the owner lives longer.

Mistake #4: "losing" the future and assuming the task now runs on its own.
std::async is not detach. If you don't keep the future, you lose the normal synchronization and observation point for the result. Consider future an essential part of the contract: start a task — keep the handle — later consciously wait for or retrieve the result.

Mistake #5: stuffing everything into std::async, including work on shared data.
std::async starts computation but does not cancel concurrency rules. If a task touches shared data, you still need agreements: who owns it, who reads it, where are mutexes, where condition variables live. std::async is a way to get a result "later", not a universal amulet against data races.

1
Task
C++ SELF, level 71, lesson 0
Locked
Asynchronous Squares
Asynchronous Squares
1
Task
C++ SELF, level 71, lesson 0
Locked
Deferred delivery
Deferred delivery
1
Task
C++ SELF, level 71, lesson 0
Locked
Two Boosters
Two Boosters
1
Task
C++ SELF, level 71, lesson 0
Locked
Background logging
Background logging
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION