Skip to main content

📝 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::thread and 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

graph LR A[Thread Created] --> B[Thread Running] B --> C{Work Complete?} C -->|No| B C -->|Yes| D[Thread Finished] D --> E[join called] E --> F[Resources Cleaned Up] G[detach called] --> H[Thread Independent] B --> G

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.

graph TD A[Thread wants resource] --> B{Is mutex locked?} B -->|Yes| C[Wait in queue] B -->|No| D[Lock mutex] D --> E[Use resource safely] E --> F[Unlock mutex] F --> G[Next thread can proceed] C --> B

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. Threads count_down() and others wait() 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!

graph TD A[Producer Thread] --> B[Produce Data] B --> C[Lock Mutex] C --> D[Add to Queue] D --> E[Notify Waiting Threads] E --> F[Unlock Mutex] G[Consumer Thread] --> H[Lock Mutex] H --> I{Queue Empty?} I -->|Yes| J[Wait on Condition] J --> K["Automatically Unlock & Sleep"] K --> L[Wake on Notification] L --> H I -->|No| M[Process Data] M --> N[Unlock Mutex] style A fill:#E3F2FD,color:#111827 style G fill:#E8F5E9,color:#111827 style E fill:#FFE4B5,color:#111827 style L fill:#FFE4B5,color:#111827

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:

  1. Crawls websites using multiple threads
  2. Respects rate limiting
  3. Avoids duplicate URLs
  4. Handles network timeouts gracefully
  5. 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 always join() or detach() before a thread object is destroyed.
  • Race conditions happen when unsynchronized threads read and write shared data — results become unpredictable.
  • std::mutex with lock_guard or unique_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, or std::scoped_lock.
  • std::condition_variable lets threads efficiently wait for and signal state changes, powering producer-consumer pipelines.
  • std::atomic gives you lock-free, thread-safe operations on simple shared data.
  • std::future, std::promise, and std::async let 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.