Multithreading and Concurrency in C++

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!

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();
}

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: Use std::lock to lock multiple mutexes
void thread_safest() {
    std::lock(mutex1, mutex2);  // Locks both atomically
    std::lock_guard<std::mutex> lk1(mutex1, std::adopt_lock);
    std::lock_guard<std::mutex> lk2(mutex2, std::adopt_lock);
    // work...
}

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();
}

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

Don'ts

Next Steps

You've learned the fundamentals of multithreading in C++:

Multithreading is like conducting an orchestra - it requires careful coordination, but when done right, the performance is magnificent. In the next lesson, we'll explore move semantics and rvalue references, which are crucial for efficient resource management in modern C++.