Skip to main content
    Courses/C++/Concurrency in C++

    Lesson 25 • Advanced

    Concurrency in C++

    By the end of this lesson you'll be able to run work on several threads at once with std::thread, pass data in safely, protect shared state from data races with a std::mutex, and collect results back with std::async and std::future — the toolkit behind every fast, responsive C++ program.

    What You'll Learn

    • Create a std::thread and pass it arguments
    • Use join() and detach() correctly (and why you must)
    • Pass variables by reference with std::ref
    • Spot a data race on shared mutable state
    • Protect shared data with std::mutex + std::lock_guard
    • Get return values back with std::async and std::future

    💡 Real-World Analogy

    Think of your program as a kitchen. A single-threaded program is one chef doing every step in order. Threads are extra chefs working at the same time — the meal comes out faster. But if two chefs reach for the same chopping board at once, they collide: that's a data race. The fix is a mutex — a single "talking stick" only one chef may hold while using the board. And std::async is like handing a chef a ticket: you walk away, and later redeem the ticket for the finished dish (a std::future).

    1. Creating Threads with std::thread

    A thread is a second line of execution that runs at the same time as your main(). You create one by handing std::thread a function plus any arguments — it starts running immediately. Two rules matter: every thread must be join()ed (wait for it to finish) or detach()ed (let it run on its own), and arguments are copied by default. To let a thread modify one of your variables, wrap it in std::ref so it's passed by reference. Read this worked example and run it.

    Worked example: create, join, pass std::ref

    Read every comment, run it, and check the output.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>   // std::thread lives here
    #include <string>
    using namespace std;
    
    // A plain function each thread will run.
    void greet(const string& name, int times) {
        for (int i = 0; i < times; i++) {
            cout << "Hello from " << name << " #" << i << "\n";
        }
    }
    
    // A thread that MODIFIES a variable owned by main().
    // It takes int& so it can write back to the caller's variable.
    void addTo(int& total, int amount) {
        total += amount;   // changes main()'s 'to
    ...

    Your turn. The program below starts one thread but is missing two things: how to pass the variable by reference, and how to wait for the thread. Fill in the blanks marked ___, then run it.

    🎯 Your turn: pass by reference and join

    Fill in the ___ blanks, then check your output against the expected line.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    using namespace std;
    
    void doubleIt(int& n) {
        n = n * 2;   // writes back through the reference
    }
    
    int main() {
        // 🎯 YOUR TURN — fill in the blanks marked with ___
    
        int value = 21;
    
        // 1) Start a thread that runs doubleIt and passes 'value' BY REFERENCE
        //    so the thread can change it. Reference wrapper = ref(...).
        thread t(doubleIt, ___);   // 👉 ref(value)
    
        // 2) Wait for the thread to finish before you read 'value'.
        t.__
    ...

    2. Data Races & std::mutex

    When two threads touch the same variable and at least one writes, you have a data race. The trap: counter++ looks like one step but is really three — read, add one, write back. Two threads can interleave those steps and lose updates, giving a wrong, random answer. The C++ standard calls a data race undefined behaviour, which means anything can happen. The fix is a std::mutex (short for "mutual exclusion"): a lock only one thread can hold at a time. Wrap it in a std::lock_guard, which locks on creation and automatically unlocks when it goes out of scope.

    Worked example: data race vs lock_guard

    See why the unsafe counter is wrong, and how the mutex fixes it.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>    // std::mutex, std::lock_guard
    using namespace std;
    
    int counter = 0;       // shared mutable state — DANGEROUS without a lock
    mutex mtx;             // the "talking stick": only one thread holds it at a time
    
    // UNSAFE: many threads do counter++ at once. counter++ is really
    // read -> add 1 -> write, and two threads can interleave those steps,
    // losing updates. This is a DATA RACE (undefined behaviour).
    void raceIncrement() {
        for (i
    ...

    3. Getting Results Back: std::async & std::future

    A raw std::thread runs a void function — it can't easily hand you a return value. std::async solves this. You give it a function and arguments; it runs them on another thread and returns a std::future, a "ticket" you redeem later by calling .get(). .get() blocks until the result is ready, then returns it (and rethrows any exception the task threw). For sharing a single value without a mutex, std::atomic makes operations like ++ indivisible at the hardware level — perfect for a shared counter or flag.

    Worked example: async, future & a std::atomic note

    Launch a task, redeem the future, and see lock-free atomics.

    Try it Yourself »
    C++
    #include <iostream>
    #include <future>   // std::async, std::future
    #include <atomic>   // std::atomic
    using namespace std;
    
    // A function that returns a value (unlike a void thread function).
    long long sumTo(long long n) {
        long long total = 0;
        for (long long i = 1; i <= n; i++) total += i;
        return total;
    }
    
    int main() {
        // std::async launches sumTo on another thread and immediately hands you
        // a std::future — a "ticket" you redeem later for the return value.
        future<long l
    ...

    Now you try. Launch a function on another thread with std::async and pull the answer back out of the future. Fill in the two blanks:

    🎯 Your turn: async + future.get()

    Launch square(9) and redeem the future, then run it.

    Try it Yourself »
    C++
    #include <iostream>
    #include <future>
    using namespace std;
    
    int square(int x) {
        return x * x;
    }
    
    int main() {
        // 🎯 YOUR TURN — fill in the blanks marked with ___
    
        // 1) Launch square(9) on another thread with std::async.
        //    Store the ticket in a future<int>.
        future<int> result = async(launch::async, ___, 9);  // 👉 square
    
        // 2) Redeem the ticket: block until the answer is ready and read it.
        int answer = result.___;   // 👉 get()
    
        cout << "9 squared = " << ans
    ...

    🔎 Deep Dive: which tool do I reach for?

    std::thread — a manual background worker. You control its life and must join() or detach() it. Best for long-running tasks that don't return a value.

    std::async + std::future — fire off a task that returns something (or might throw) and collect it later with .get(). Less bookkeeping than a raw thread.

    std::mutex + std::lock_guard — protect a block of code that touches shared data so only one thread runs it at a time.

    std::atomic — a single shared value (a counter, a flag) updated safely without a lock.

    Pro Tips

    • 💡 Lock the smallest block possible: hold the mutex only around the shared data, not the slow work, so other threads aren't blocked needlessly.
    • 💡 Prefer std::async for results: it handles the thread lifecycle and propagates exceptions through .get().
    • 💡 Reach for std::atomic before a mutex when you only share one simple value — it's lock-free and faster.
    • 💡 C++20 has std::jthread which auto-joins on destruction — no more forgotten join() calls.

    Common Errors (and the fix)

    • Forgetting join()terminate called: a std::thread destroyed while still joinable calls std::terminate and kills your program. Always join() (or detach()) before the thread object goes out of scope.
    • Data race on a shared counter: counter++ from two threads gives a wrong, random total. Guard it with a std::lock_guard<std::mutex> or make it std::atomic<int>.
    • Deadlock: two threads each hold one mutex and wait for the other's — both freeze forever. Always lock multiple mutexes in the same order everywhere, or use std::scoped_lock to take them together.
    • Capturing by reference in a detached thread: a detach()ed thread that captures a local by reference reads a dangling reference once the function returns and the variable is destroyed. Capture by value, or keep the data alive longer than the thread.

    📋 Quick Reference

    TaskCode
    Create a threadstd::thread t(func, args...);
    Wait for itt.join();
    Run it independentlyt.detach();
    Pass by referencestd::thread t(f, std::ref(x));
    Protect shared datastd::lock_guard<std::mutex> g(mtx);
    Run task, get resultauto f = std::async(launch::async, fn);
    Read the resultauto v = f.get();
    Lock-free counterstd::atomic<int> n{0};

    Frequently Asked Questions

    Q: What is the difference between join() and detach()?

    join() makes the calling thread wait until the other thread finishes, then cleans it up. detach() lets the thread run independently in the background and severs the handle. Every std::thread must be either joined or detached before it is destroyed, or the program calls std::terminate.

    Q: What is a data race and why is it dangerous?

    A data race happens when two or more threads access the same memory at the same time and at least one is writing, with no synchronisation. Operations like counter++ are not indivisible, so threads interleave and lose updates. The C++ standard calls this undefined behaviour: the program may produce wrong results, crash, or appear to work and fail later. Protect shared data with a std::mutex or use std::atomic.

    Q: When should I use std::async instead of std::thread?

    Use std::async when your task RETURNS a value or might throw — async hands you a std::future that delivers the result (or rethrows the exception) when you call get(), and it manages the thread for you. Use std::thread when you want full manual control over a long-running background worker.

    Q: Do I still need a mutex if I use std::atomic?

    Not for a single value. std::atomic<int> makes individual reads, writes, and operations like ++ indivisible without a lock, so it is perfect for a shared counter or flag. Reach for a std::mutex when you must keep several related values consistent together, or guard a larger critical section.

    Q: Why does the output from my threads come out in a different order each run?

    Because the operating system schedules threads independently, the order in which they reach a cout statement is non-deterministic — it can change from run to run and machine to machine. Never rely on thread output ordering. If you need ordering, synchronise explicitly (for example with join() or a condition variable).

    Mini-Challenge: Safe Shared Total

    No blanks this time — just a brief and an outline to keep you on track. Two threads add to the same total, so the std::mutex is what keeps the answer correct. Build it, run it, and check your output against the expected line in the comments.

    🎯 Mini-Challenge: build a thread-safe counter

    Two threads, one mutex-protected total. Print the result.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    using namespace std;
    
    int main() {
        // 🎯 MINI-CHALLENGE: Safe shared total
        // 1. Declare an int "sum" set to 0 and a std::mutex called "mtx".
        // 2. Write a lambda that loops 50000 times and adds i to "sum",
        //    protecting each add with a lock_guard<mutex>(mtx).
        // 3. Run that lambda on TWO std::threads (t1 and t2).
        // 4. join() BOTH threads, then print "sum = " << sum.
        //
        // Why the lock? Without it, t1 and 
    ...

    🎉 Lesson Complete

    • std::thread t(fn, args...) starts work in parallel; you must join() or detach() it
    • ✅ Arguments are copied — use std::ref(x) to pass by reference
    • ✅ A data race on shared mutable state is undefined behaviour
    • std::mutex + std::lock_guard protect a critical section safely
    • std::async returns a std::future; .get() redeems the result
    • std::atomic shares a single value without a lock; thread output order is non-deterministic
    • Next lesson: Mutexes & Locks — deeper protection patterns and avoiding deadlocks

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service