Concurrency in C++: Threads and Synchronization

Understanding Threads in C++

Concurrency in C++ allows developers to execute multiple threads simultaneously, which can significantly improve application performance, particularly in I/O-bound or CPU-bound programs. Each thread represents a separate path of execution, enabling a program to perform tasks in parallel. To effectively use threads in C++, we need to use the C++11 standard or later, which introduced a robust threading library.

Creating Threads

C++ provides a straightforward way to create threads using the std::thread class. Here's a simple example:

#include <iostream>
#include <thread>

void printMessage(const std::string &message) {
    std::cout << message << std::endl;
}

int main() {
    std::thread t1(printMessage, "Hello from thread 1!");
    std::thread t2(printMessage, "Hello from thread 2!");

    // Wait for threads to finish
    t1.join();
    t2.join();

    return 0;
}

In this example, we have two threads t1 and t2 that execute the printMessage function with different messages. The join() member function is called to wait for both threads to finish before the main thread exits. This ensures that all output is printed before the program terminates.

Passing Arguments to Threads

When creating threads, you can easily pass arguments to the thread function. C++ handles this through a simple mechanism, as shown in the previous example. However, passing large objects can lead to unnecessary overhead. For performance optimization, you can use references by utilizing std::ref.

#include <iostream>
#include <thread>

void increment(int &value) {
    ++value;
}

int main() {
    int num = 0;
    std::thread t(increment, std::ref(num));
    
    t.join();
    std::cout << "Incremented value: " << num << std::endl;

    return 0;
}

Using std::ref(num) ensures that the original variable num is passed by reference, preventing a copy and allowing the thread to modify the actual value.

Synchronization in C++

While concurrency improves performance, it also introduces challenges, especially when multiple threads access shared data. To safely manage shared resources, synchronization mechanisms such as mutexes (mutual exclusions) are used.

What is a Mutex?

A mutex is a locking mechanism that ensures that only one thread can access a resource at a time. C++11 introduced std::mutex, which is essential for synchronizing access to shared variables.

Here's an example demonstrating the use of std::mutex:

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx; // Mutex for critical section
int sharedCounter = 0;

void incrementCounter() {
    for (int i = 0; i < 1000; ++i) {
        mtx.lock();         // Lock the mutex
        ++sharedCounter;    // Critical section
        mtx.unlock();       // Unlock the mutex
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

    t1.join();
    t2.join();

    std::cout << "Final counter: " << sharedCounter << std::endl;

    return 0;
}

In this code, both threads attempt to increment sharedCounter. The mtx.lock() and mtx.unlock() calls ensure that one thread enters the critical section at a time, preventing race conditions.

Using std::lock_guard

Instead of directly locking and unlocking the mutex, you can use std::lock_guard. This RAII-style lock automatically releases the mutex when it goes out of scope, which makes the code cleaner and less error-prone.

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx; // Mutex for critical section
int sharedCounter = 0;

void incrementCounter() {
    for (int i = 0; i < 1000; ++i) {
        std::lock_guard<std::mutex> lock(mtx); // Lock the mutex
        ++sharedCounter; // Critical section
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

    t1.join();
    t2.join();
    
    std::cout << "Final counter: " << sharedCounter << std::endl;

    return 0;
}

Using std::lock_guard simplifies the code and helps prevent mistakes such as forgetting to unlock the mutex.

Advanced Synchronization Techniques

Condition Variables

Sometimes, it's not enough to let threads wait for resources to become available. For this purpose, C++11 also introduces condition variables, std::condition_variable, which allow threads to wait until they are notified.

Condition variables help coordinate the activities of threads. Here's a brief example:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void printId(int id) {
    std::unique_lock<std::mutex> lck(mtx);
    cv.wait(lck, [] { return ready; }); // Wait until ready is true
    std::cout << "Thread " << id << " is running\n";
}

void go() {
    std::lock_guard<std::mutex> lck(mtx);
    ready = true; // Set to true
    cv.notify_all(); // Notify all waiting threads
}

int main() {
    std::thread threads[10];

    for (int i = 0; i < 10; ++i) threads[i] = std::thread(printId, i);

    std::cout << "10 threads ready to race...\n";
    go(); // Allow threads to run

    for (auto &th : threads) th.join();

    return 0;
}

In this example, the printId function waits for the ready flag to be set to true. The go function changes this flag and notifies all waiting threads.

Avoiding Deadlocks

Deadlocks occur when two or more threads are blocked forever, each waiting on the other. To avoid deadlocks, always acquire locks in a consistent order and consider using techniques such as std::lock to lock multiple mutexes simultaneously.

#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx1, mtx2;

void threadFunction1() {
    std::lock(mtx1, mtx2); // Lock both mutexes
    std::lock_guard<std::mutex> lk1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lk2(mtx2, std::adopt_lock);
    // Perform operations on shared resources
}

void threadFunction2() {
    std::lock(mtx1, mtx2); // Lock both mutexes
    std::lock_guard<std::mutex> lk1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lk2(mtx2, std::adopt_lock);
    // Perform operations on shared resources
}

Using std::lock to acquire multiple locks helps prevent deadlocks as it guarantees that all locks are acquired without blocking each other.

Conclusion

Concurrency in C++ plays a vital role in modern programming. Understanding threads, mutexes, and synchronization methods equips developers with the tools necessary to craft efficient and reliable software solutions. While intricacies like deadlocks and race conditions pose challenges, tools like std::mutex, std::lock_guard, and std::condition_variable make it manageable. Embrace the power of concurrency to elevate your C++ applications to new heights!