Concurrency
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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free C++ course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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.
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.
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.
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:
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.
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.
Practice quiz
What does calling join() on a std::thread do?
- Starts the thread
- Kills the thread immediately
- Makes the calling thread wait until that thread finishes
- Detaches the thread
Answer: Makes the calling thread wait until that thread finishes. join() blocks until the thread completes, then cleans it up.
What happens if a joinable std::thread is destroyed without join() or detach()?
- The program calls std::terminate
- Nothing, it cleans up
- It silently leaks
- It auto-joins
Answer: The program calls std::terminate. Every thread must be joined or detached before destruction, or std::terminate is called.
By default, how are arguments passed to a std::thread's function?
- By reference
- By pointer
- By move only
- They are copied
Answer: They are copied. Arguments are copied; to let a thread modify your variable, wrap it in std::ref so it's passed by reference.
What is a data race?
- Two threads finishing at the same time
- Two+ threads accessing the same memory with at least one writing, and no synchronization
- A thread that runs too fast
- A loop that never ends
Answer: Two+ threads accessing the same memory with at least one writing, and no synchronization. Unsynchronized concurrent access with at least one writer is a data race — undefined behaviour in C++.
Why does std::lock_guard make protecting shared data safer than locking by hand?
- It locks on construction and automatically unlocks when it goes out of scope, even on exceptions
- It is faster
- It never needs a mutex
- It allows many threads in at once
Answer: It locks on construction and automatically unlocks when it goes out of scope, even on exceptions. RAII: lock_guard unlocks automatically at the end of the scope, so you can't forget to unlock.
What does std::async return so you can collect a task's result later?
- A std::thread
- A std::mutex
- A std::future you redeem with .get()
- void
Answer: A std::future you redeem with .get(). async returns a std::future; calling .get() blocks until the result is ready and returns it (rethrowing any exception).
How many times can you call .get() on a single std::future?
- Unlimited
- Exactly once
- Twice
- Once per thread
Answer: Exactly once. A future delivers its value once; calling get() again is invalid.
When is std::atomic enough instead of a std::mutex?
- Always
- When you must update several values together
- Never
- For a single shared value like a counter or flag
Answer: For a single shared value like a counter or flag. atomic makes individual operations on one value indivisible; use a mutex when several related values must stay consistent.
Why does multi-threaded cout output come out in a different order each run?
- A compiler bug
- The OS schedules threads independently, so the order they reach cout is non-deterministic
- cout is broken
- Threads always reverse order
Answer: The OS schedules threads independently, so the order they reach cout is non-deterministic. Thread scheduling is non-deterministic; never rely on thread output ordering.
Two threads each hold one mutex and wait for the other's. What is this called, and what prevents it?
- A data race; use atomic
- A spin; use detach()
- A deadlock; always lock multiple mutexes in the same order (or use scoped_lock)
- A leak; use join()
Answer: A deadlock; always lock multiple mutexes in the same order (or use scoped_lock). Circular waiting is a deadlock; consistent lock ordering or std::scoped_lock taking them together avoids it.