C++
std::async
scope
lifetime
loops

Understanding Scope and Lifetime of References in stdasync within a Loop

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When using std::async inside a loop, references to loop variables can become dangling if the variable goes out of scope or is modified before the async task reads it. The core issue is that std::async with std::ref captures a reference to the variable, not a copy of its value. If the loop variable changes on the next iteration or is destroyed when the loop ends, the async task reads garbage or causes undefined behavior. The fix is to capture by value, use std::shared_ptr, or ensure the referenced data outlives all futures.

The Dangerous Pattern

cpp
1#include <future>
2#include <vector>
3#include <iostream>
4
5int main() {
6    std::vector<std::future<int>> futures;
7
8    for (int i = 0; i < 5; ++i) {
9        // DANGEROUS: captures reference to loop variable i
10        futures.push_back(std::async(std::launch::async, [&i]() {
11            return i * 2;  // i may have changed or been destroyed
12        }));
13    }
14
15    for (auto& f : futures) {
16        std::cout << f.get() << " ";
17    }
18    // Possible output: 10 10 10 10 10 (all see final value of i)
19    // Or worse: undefined behavior
20}

The lambda captures i by reference. By the time the async tasks execute, the loop may have finished and i equals 5, or the tasks may race with the loop increment, producing unpredictable results.

Fix 1: Capture by Value

cpp
1#include <future>
2#include <vector>
3#include <iostream>
4
5int main() {
6    std::vector<std::future<int>> futures;
7
8    for (int i = 0; i < 5; ++i) {
9        // SAFE: captures a copy of i
10        futures.push_back(std::async(std::launch::async, [i]() {
11            return i * 2;
12        }));
13    }
14
15    for (auto& f : futures) {
16        std::cout << f.get() << " ";
17    }
18    // Output: 0 2 4 6 8
19}

Capturing i by value ([i]) copies the current value into the lambda. Each async task gets its own snapshot of i at the time the lambda was created, regardless of when the task runs.

Fix 2: Pass as Function Argument

cpp
1#include <future>
2#include <vector>
3
4int compute(int value) {
5    return value * 2;
6}
7
8int main() {
9    std::vector<std::future<int>> futures;
10
11    for (int i = 0; i < 5; ++i) {
12        // SAFE: i is copied into the async call
13        futures.push_back(std::async(std::launch::async, compute, i));
14    }
15
16    for (auto& f : futures) {
17        std::cout << f.get() << " ";
18    }
19    // Output: 0 2 4 6 8
20}

std::async copies its arguments by default. Passing i directly (not wrapped in std::ref) creates a copy that the task owns.

The std::ref Trap

cpp
1#include <future>
2#include <vector>
3#include <string>
4
5void process(const std::string& data, int id) {
6    std::cout << id << ": " << data << std::endl;
7}
8
9int main() {
10    std::vector<std::future<void>> futures;
11
12    for (int i = 0; i < 5; ++i) {
13        std::string data = "item_" + std::to_string(i);
14
15        // DANGEROUS: data is destroyed at end of loop iteration
16        futures.push_back(std::async(std::launch::async,
17            process, std::ref(data), i));
18    }
19    // data from each iteration is already destroyed
20    // async tasks hold dangling references — undefined behavior
21
22    for (auto& f : futures) {
23        f.get();  // May crash or print garbage
24    }
25}

std::ref(data) passes a reference to the local data string. When the loop iteration ends, data is destroyed, leaving the async task with a dangling reference.

Fix 3: Move Data into the Task

cpp
1#include <future>
2#include <vector>
3#include <string>
4
5int main() {
6    std::vector<std::future<void>> futures;
7
8    for (int i = 0; i < 5; ++i) {
9        std::string data = "item_" + std::to_string(i);
10
11        // SAFE: data is moved into the lambda
12        futures.push_back(std::async(std::launch::async,
13            [d = std::move(data), i]() {
14                std::cout << i << ": " << d << std::endl;
15            }));
16    }
17
18    for (auto& f : futures) {
19        f.get();
20    }
21}

Using d = std::move(data) in the lambda capture transfers ownership of the string into the lambda. The task owns its data and there is no lifetime issue.

Fix 4: Ensure Data Outlives All Futures

cpp
1#include <future>
2#include <vector>
3#include <string>
4
5void process(const std::string& data, int id) {
6    std::cout << id << ": " << data << std::endl;
7}
8
9int main() {
10    // Data lives outside the loop — outlives all futures
11    std::vector<std::string> all_data;
12    for (int i = 0; i < 5; ++i) {
13        all_data.push_back("item_" + std::to_string(i));
14    }
15
16    std::vector<std::future<void>> futures;
17    for (int i = 0; i < 5; ++i) {
18        // SAFE: all_data[i] lives until after futures are joined
19        futures.push_back(std::async(std::launch::async,
20            process, std::cref(all_data[i]), i));
21    }
22
23    for (auto& f : futures) {
24        f.get();
25    }
26    // all_data destroyed here, after all futures complete
27}

If the data is stored in a container that outlives all async tasks, references are safe. The key constraint is that the container must not be modified (no push_back, resize) after launching tasks, because reallocation invalidates references.

Fix 5: Use shared_ptr for Shared Ownership

cpp
1#include <future>
2#include <vector>
3#include <memory>
4
5int main() {
6    std::vector<std::future<void>> futures;
7
8    for (int i = 0; i < 5; ++i) {
9        auto data = std::make_shared<std::string>("item_" + std::to_string(i));
10
11        // SAFE: shared_ptr is copied — data lives until last reference is gone
12        futures.push_back(std::async(std::launch::async,
13            [data, i]() {
14                std::cout << i << ": " << *data << std::endl;
15            }));
16    }
17
18    for (auto& f : futures) {
19        f.get();
20    }
21}

std::shared_ptr ensures the data lives as long as any task holds a copy of the pointer. This is the safest approach when ownership is unclear.

Common Pitfalls

  • Capturing loop variables by reference: [&i] or std::ref(i) captures a reference to i, which changes on each iteration. By the time the async task runs, i may hold a different value or be out of scope. Always capture loop variables by value.
  • Local variables destroyed before task runs: Variables declared inside the loop body are destroyed at the end of each iteration. Any std::ref to them becomes a dangling reference. Use std::move, copy by value, or shared_ptr instead.
  • Forgetting that std::async may defer execution: With std::launch::deferred, the task runs when .get() is called, which may be long after the referenced data is gone. Use std::launch::async for immediate execution or ensure data lifetime covers the .get() call.
  • Modifying the container after launching tasks: Pushing to a std::vector while async tasks hold references to its elements can cause reallocation, invalidating all references. Populate the container fully before launching tasks.
  • Not calling .get() on all futures: If a std::future is destroyed without calling .get(), the destructor blocks until the task completes (for std::async futures). In a loop with exceptions, unjoined futures can cause unexpected blocking during stack unwinding.

Summary

  • Never capture loop variables by reference ([&i], std::ref) in std::async — they change or get destroyed
  • Capture by value ([i]) or use init-capture ([d = std::move(data)]) for safe ownership
  • std::async copies its arguments by default — only std::ref creates a reference
  • Use std::shared_ptr when multiple tasks need shared ownership of the same data
  • Ensure referenced containers are fully populated and not modified after launching async tasks
  • Always call .get() on every std::future to avoid blocking destructors

Course illustration
Course illustration

All Rights Reserved.