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
& in int& x) feel shaky, revisit the previous lesson first — threads lean on them heavily.💡 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.
#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.
#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.
#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.
#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.
#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::asyncfor results: it handles the thread lifecycle and propagates exceptions through.get(). - 💡 Reach for
std::atomicbefore a mutex when you only share one simple value — it's lock-free and faster. - 💡 C++20 has
std::jthreadwhich auto-joins on destruction — no more forgottenjoin()calls.
Common Errors (and the fix)
- Forgetting
join()→terminate called: astd::threaddestroyed while still joinable callsstd::terminateand kills your program. Alwaysjoin()(ordetach()) 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 astd::lock_guard<std::mutex>or make itstd::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_lockto 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
| Task | Code |
|---|---|
| Create a thread | std::thread t(func, args...); |
| Wait for it | t.join(); |
| Run it independently | t.detach(); |
| Pass by reference | std::thread t(f, std::ref(x)); |
| Protect shared data | std::lock_guard<std::mutex> g(mtx); |
| Run task, get result | auto f = std::async(launch::async, fn); |
| Read the result | auto v = f.get(); |
| Lock-free counter | std::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.
#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 mustjoin()ordetach()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_guardprotect a critical section safely - ✅
std::asyncreturns astd::future;.get()redeems the result - ✅
std::atomicshares 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.