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.