📝 Lesson 19: Multithreading and Concurrency
Harness the power of parallel execution — spin up threads, coordinate them safely with mutexes and condition variables, and hand back results with futures — to build high-performance, thread-safe C++ applications.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Manage threads using
std::threadand understand thread lifecycle management (join/detach). - Implement thread synchronization using mutexes,
lock_guard,unique_lock, and condition variables to prevent race conditions. - Analyze and resolve deadlock scenarios using consistent locking strategies.
- Utilize atomic operations,
std::future/std::promise, and thread pool patterns for efficient, lock-free concurrency.
Estimated Time: 90–120 minutes
Project: Build a parallel file processing utility or a thread-safe message queue for producer-consumer scenarios.
In This Lesson
Understanding Threads: Multiple Workers in Your Program
Imagine a restaurant kitchen. With one chef (single thread), orders are prepared sequentially. But with multiple chefs (multiple threads), several dishes can be prepared simultaneously. That's the power of multithreading!
Your First Multithreaded Program
#include <iostream>
#include <thread> // Required for std::thread
// Function to be executed by a thread
void printNumbers(int start, int end) {
for (int i = start; i <= end; i++) {
std::cout << "Thread " << std::this_thread::get_id()
<< ": " << i << std::endl;
}
}
int main() {
// Create two threads
std::thread t1(printNumbers, 1, 5); // Thread 1: prints 1-5
std::thread t2(printNumbers, 10, 15); // Thread 2: prints 10-15
// Wait for both threads to complete
t1.join(); // Main thread waits for t1
t2.join(); // Main thread waits for t2
std::cout << "All threads completed!" << std::endl;
return 0;
}
Thread Lifecycle and Management
Join vs Detach
// join() - Wait for thread to finish
std::thread worker(doWork);
worker.join(); // Main thread blocks here until worker finishes
// Safe to continue - worker is done
// detach() - Let thread run independently
std::thread background(backgroundTask);
background.detach(); // Thread continues on its own
// Main can continue immediately - background still running
⚠️ Important Rule
You MUST either join() or detach() a thread before its destructor is called. Failing to do so will terminate your program!
💡 Modern C++ (C++20): std::jthread
std::jthread (from <thread>) is a safer drop-in for std::thread: its destructor automatically calls join() for you, so you can never crash by forgetting. It also supports cooperative cancellation through a std::stop_token. Prefer it in new code.
#include <thread>
#include <iostream>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
// do a chunk of work...
}
std::cout << "Worker asked to stop.\n";
}
int main() {
std::jthread t(worker); // the stop_token is passed in automatically
// ... when t goes out of scope, request_stop() and join()
// both run automatically — no manual join() needed
return 0;
}
The Race Condition Problem
When multiple threads access shared data simultaneously, chaos can ensue. It's like two people trying to edit the same document at the same time without coordination.
Example: Race Condition in Action
#include <iostream>
#include <thread>
#include <vector>
int counter = 0; // Shared variable - DANGER!
void incrementCounter(int iterations) {
for (int i = 0; i < iterations; i++) {
counter++; // NOT thread-safe!
}
}
int main() {
const int num_threads = 4;
const int iterations = 100000;
std::vector<std::thread> threads;
// Create threads
for (int i = 0; i < num_threads; i++) {
threads.emplace_back(incrementCounter, iterations);
}
// Wait for all threads
for (auto& t : threads) {
t.join();
}
std::cout << "Expected: " << num_threads * iterations << std::endl;
std::cout << "Actual: " << counter << std::endl;
// Result is unpredictable! Often less than expected.
return 0;
}
Mutex: The Thread Traffic Light
A mutex (mutual exclusion) is like a bathroom with a lock. Only one person (thread) can use it at a time. Others must wait their turn.
Fixing Race Conditions with Mutex
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
int counter = 0;
std::mutex counter_mutex; // Protects counter
void safeIncrementCounter(int iterations) {
for (int i = 0; i < iterations; i++) {
// Lock the mutex before accessing shared data
counter_mutex.lock();
counter++; // Now thread-safe!
counter_mutex.unlock();
}
}
// Better approach using lock_guard (RAII)
void betterIncrementCounter(int iterations) {
for (int i = 0; i < iterations; i++) {
std::lock_guard<std::mutex> lock(counter_mutex);
counter++; // Automatically unlocks when lock goes out of scope
} // lock_guard destructor unlocks mutex here
}
Common Synchronization Primitives
std::lock_guard - The Automatic Lock
void updateSharedData() {
std::lock_guard<std::mutex> lock(data_mutex);
// Mutex is locked
shared_data.modify();
// Mutex automatically unlocks when lock goes out of scope
// Even if an exception is thrown!
}
std::unique_lock - The Flexible Lock
void flexibleOperation() {
std::unique_lock<std::mutex> lock(data_mutex);
// Can manually unlock and relock
process_part1();
lock.unlock(); // Release lock temporarily
do_something_else(); // Other threads can access data
lock.lock(); // Reacquire lock
process_part2();
}
std::condition_variable - Thread Communication
std::mutex m;
std::condition_variable cv;
bool ready = false;
// Producer thread
void producer() {
std::unique_lock<std::mutex> lock(m);
prepare_data();
ready = true;
cv.notify_one(); // Wake up waiting thread
}
// Consumer thread
void consumer() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, []{ return ready; }); // Wait until ready
process_data();
}
C++20 Coordination Primitives
C++20 added three lightweight tools for coordinating groups of threads without hand-rolling a condition variable:
std::latch— a single-use countdown. Threadscount_down()and otherswait()until it reaches zero.std::barrier— a reusable rendezvous point where a group of threads meet before each moves on to the next phase.std::counting_semaphore/std::binary_semaphore— limit how many threads may enter a section at once.
#include <latch>
#include <thread>
#include <vector>
#include <iostream>
std::latch startGate{1}; // opens once count_down() is called
void racer(int id) {
startGate.wait(); // every racer blocks here
std::cout << "Racer " << id << " go!\n";
}
int main() {
std::vector<std::jthread> racers;
for (int i = 0; i < 4; ++i)
racers.emplace_back(racer, i);
startGate.count_down(); // release all racers at once
return 0; // jthreads auto-join here
}
Condition Variables: Thread Communication
Condition variables are like a school bell - threads can wait for the bell to ring before proceeding!
Producer-Consumer Pattern
// Thread-safe queue with condition variables
template<typename T>
class ThreadSafeQueue {
private:
queue<T> data;
mutable mutex mtx;
condition_variable cv;
public:
void push(T value) {
{
lock_guard<mutex> lock(mtx);
data.push(move(value));
}
cv.notify_one(); // Wake up one waiting thread
}
T pop() {
unique_lock<mutex> lock(mtx);
cv.wait(lock, [this] { return !data.empty(); });
T value = move(data.front());
data.pop();
return value;
}
bool try_pop(T& value) {
lock_guard<mutex> lock(mtx);
if (data.empty()) {
return false;
}
value = move(data.front());
data.pop();
return true;
}
bool empty() const {
lock_guard<mutex> lock(mtx);
return data.empty();
}
};
// Producer-Consumer example
void producerConsumer() {
ThreadSafeQueue<int> queue;
atomic<bool> done(false);
// Producer thread
thread producer([&]() {
for (int i = 1; i <= 10; ++i) {
queue.push(i);
cout << "Produced: " << i << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
done = true;
});
// Consumer threads
auto consumer = [&](int id) {
while (!done || !queue.empty()) {
int value;
if (queue.try_pop(value)) {
cout << "Consumer " << id << " consumed: "
<< value << endl;
this_thread::sleep_for(chrono::milliseconds(150));
}
}
};
thread consumer1(consumer, 1);
thread consumer2(consumer, 2);
producer.join();
consumer1.join();
consumer2.join();
}
Deadlock: The Dining Philosophers Problem
Deadlock occurs when threads wait for each other indefinitely. Imagine two people who need both a pen and paper to work, but each grabbed one item and won't let go until they get the other.
Avoiding Deadlock
// Problem: Potential deadlock
void thread1() {
lock1.lock();
lock2.lock(); // If thread2 has lock2, DEADLOCK!
// work...
}
void thread2() {
lock2.lock();
lock1.lock(); // If thread1 has lock1, DEADLOCK!
// work...
}
// Solution 1: Always lock in the same order
void thread1_safe() {
lock1.lock();
lock2.lock();
// work...
}
void thread2_safe() {
lock1.lock(); // Same order as thread1
lock2.lock();
// work...
}
// Solution 2 (modern, C++17): std::scoped_lock locks any number of
// mutexes deadlock-free and unlocks them all via RAII
void thread_safest() {
std::scoped_lock lock(mutex1, mutex2); // Locks both without deadlock
// work...
} // Both mutexes unlocked here automatically
Atomic Operations: Lock-Free Programming
Atomic operations are like using a vending machine - the entire transaction (insert money, press button, receive item) happens as one indivisible operation.
#include <atomic>
std::atomic<int> counter{0}; // Atomic integer
void atomicIncrement(int iterations) {
for (int i = 0; i < iterations; i++) {
counter++; // Thread-safe without mutex!
}
}
// More atomic operations
std::atomic<bool> ready{false};
void producer() {
prepare_data();
ready.store(true); // Atomic write
}
void consumer() {
while (!ready.load()) { // Atomic read
// Wait...
}
process_data();
}
Future and Promise: Asynchronous Results
Futures and promises are like ordering a pizza - you get a receipt (future) immediately and can check when your pizza (result) is ready!
Async Programming with Future and Promise
// Basic future and promise
void futurePromiseBasics() {
promise<int> prom;
future<int> fut = prom.get_future();
thread t([&prom]() {
this_thread::sleep_for(chrono::seconds(1));
prom.set_value(42); // Fulfill the promise
});
cout << "Waiting for result..." << endl;
int result = fut.get(); // Blocks until ready
cout << "Got result: " << result << endl;
t.join();
}
// std::async - easier async execution
void asyncExample() {
// Launch async task
auto future = async(launch::async, []() {
this_thread::sleep_for(chrono::seconds(1));
return 42;
});
// Do other work...
cout << "Doing other work..." << endl;
// Get result when needed
int result = future.get();
cout << "Result: " << result << endl;
}
// Multiple async operations
void multipleAsync() {
vector<future<int>> futures;
// Launch multiple tasks
for (int i = 0; i < 5; ++i) {
futures.push_back(async(launch::async, [i]() {
this_thread::sleep_for(chrono::milliseconds(100 * i));
return i * i;
}));
}
// Collect results
for (int i = 0; i < 5; ++i) {
cout << "Result " << i << ": " << futures[i].get() << endl;
}
}
// Exception handling with futures
void futureExceptions() {
auto future = async(launch::async, []() {
throw runtime_error("Something went wrong!");
return 42;
});
try {
int result = future.get();
} catch (const exception& e) {
cout << "Caught exception: " << e.what() << endl;
}
}
// Shared future for multiple consumers
void sharedFutureExample() {
promise<string> prom;
shared_future<string> fut = prom.get_future().share();
// Multiple threads can get the same result
thread t1([fut]() {
cout << "Thread 1: " << fut.get() << endl;
});
thread t2([fut]() {
cout << "Thread 2: " << fut.get() << endl;
});
prom.set_value("Shared result");
t1.join();
t2.join();
}
Thread Pool Pattern
Instead of creating threads for each task, maintain a pool of worker threads. It's like having permanent employees instead of hiring contractors for each job.
#include <thread>
#include <vector>
#include <queue>
#include <functional>
#include <condition_variable>
class ThreadPool {
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable cv;
bool stop = false;
public:
ThreadPool(size_t threads) {
for (size_t i = 0; i < threads; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex);
cv.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty()) return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
}
template<class F>
void enqueue(F&& f) {
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace(std::forward<F>(f));
}
cv.notify_one();
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
cv.notify_all();
for (auto& worker : workers) {
worker.join();
}
}
};
Practical Examples
🏋️ Exercise: Parallel File Processing
Process multiple files concurrently:
void processFile(const std::string& filename) {
// Simulate file processing
std::cout << "Processing " << filename
<< " on thread " << std::this_thread::get_id() << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
int main() {
std::vector<std::string> files = {"data1.txt", "data2.txt", "data3.txt"};
std::vector<std::thread> threads;
// Process each file in a separate thread
for (const auto& file : files) {
threads.emplace_back(processFile, file);
}
// Wait for all to complete
for (auto& t : threads) {
t.join();
}
return 0;
}
🏋️ Exercise: Producer-Consumer Queue
Implement a thread-safe queue where one thread produces data and another consumes it:
template<typename T>
class ThreadSafeQueue {
private:
std::queue<T> queue;
mutable std::mutex mutex;
std::condition_variable cv;
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mutex);
queue.push(std::move(value));
cv.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [this] { return !queue.empty(); });
T value = std::move(queue.front());
queue.pop();
return value;
}
};
Best Practices and Common Pitfalls
Do's
- ✓ Use RAII (lock_guard, unique_lock) for mutex management
- ✓ Minimize time holding locks
- ✓ Use atomic operations for simple shared data
- ✓ Design for immutability when possible
- ✓ Test with thread sanitizers
Don'ts
- ✗ Don't use global variables without protection
- ✗ Don't call unknown code while holding a lock
- ✗ Don't create more threads than CPU cores for CPU-bound tasks
- ✗ Don't forget to join or detach threads
Challenge Exercise: Concurrent Web Crawler
🏋️ Advanced Threading Challenge
Build a multi-threaded web crawler that:
- Crawls websites using multiple threads
- Respects rate limiting
- Avoids duplicate URLs
- Handles network timeouts gracefully
- Produces a sitemap
💡 Design Considerations
- Use thread pool for worker threads
- Concurrent set for visited URLs
- Rate limiter with tokens/second
- Producer-consumer for URL queue
- Graceful shutdown mechanism
🎯 Quick Quiz
Question 1: You create std::thread worker(doWork); and never call worker.join() or worker.detach() before worker goes out of scope. What happens?
Question 2: Why can counter++ on a plain (non-atomic) shared int produce a wrong result when called concurrently from multiple threads?
Question 3: In a producer-consumer queue, why should the consumer use cv.wait(lock, []{ return !queue.empty(); }) instead of a plain busy-wait loop like while (queue.empty()) {}?
Summary
🎉 Key Takeaways
- Create and manage threads with
std::thread, and alwaysjoin()ordetach()before a thread object is destroyed. - Race conditions happen when unsynchronized threads read and write shared data — results become unpredictable.
std::mutexwithlock_guardorunique_lock(RAII) protects shared data and unlocks automatically, even during exceptions.- Deadlock occurs when threads wait on each other's locks forever — avoid it with consistent lock ordering,
std::lock, orstd::scoped_lock. std::condition_variablelets threads efficiently wait for and signal state changes, powering producer-consumer pipelines.std::atomicgives you lock-free, thread-safe operations on simple shared data.std::future,std::promise, andstd::asynclet you retrieve results from asynchronous work, including exceptions.- A thread pool reuses a fixed set of worker threads instead of spawning a new thread per task.
📚 Additional Resources
🚀 What's Next?
Multithreading is like conducting an orchestra — careful coordination yields a magnificent performance. Next, in Lesson 20: Design Patterns in Modern C++, you'll assemble everything you've learned into proven, reusable solutions — seeing how smart pointers, templates, and move semantics reshape classic patterns like Factory, Strategy, and Observer.
🎉 Concurrency unlocked!
You've tackled one of the hardest topics in C++ — threads, races, locks, atomics, and futures — and come out the other side. Every parallel program you write from here builds on what you just learned.