C++ Thread Synchronization

Introduction

Thread synchronization in C++ is crucial for ensuring that multiple threads can work together without interfering with each other or corrupting shared data. Synchronization mechanisms prevent data races and ensure that threads access shared resources in a controlled manner. C++ provides several synchronization tools, such as mutexes, condition variables, and more, to manage thread interaction safely.

Key Synchronization Mechanisms

  1. Mutexes (std::mutex)
  2. Locks (std::lock_guard and std::unique_lock)
  3. Condition Variables (std::condition_variable)
  4. Atomic Operations (std::atomic)

1. Mutexes

A mutex (mutual exclusion) is a synchronization primitive that prevents multiple threads from accessing shared resources simultaneously.

Example: Using Mutex

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

std::mutex mtx;

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

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

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

    return 0;
}

Output

Hello from thread 1!
Hello from thread 2!

Explanation

  • The std::mutex object mtx is used to lock and unlock access to the std::cout resource.
  • The printMessage function locks the mutex before printing and unlocks it afterward to ensure only one thread prints at a time.

2. Locks

Locks are RAII (Resource Acquisition Is Initialization) wrappers for mutexes that automatically handle locking and unlocking, even in the presence of exceptions.

Example: Using std::lock_guard

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

std::mutex mtx;

void printMessage(const std::string& message) {
    std::lock_guard<std::mutex> lock(mtx);
    std::cout << message << std::endl;
}

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

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

    return 0;
}

Output

Hello from thread 1!
Hello from thread 2!

Explanation

  • std::lock_guard is used to lock the mutex when the lock object is created and unlock it when the lock object goes out of scope.
  • This ensures that the mutex is properly unlocked even if an exception is thrown.

Example: Using std::unique_lock

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

std::mutex mtx;

void printMessage(const std::string& message) {
    std::unique_lock<std::mutex> lock(mtx);
    std::cout << message << std::endl;
}

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

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

    return 0;
}

Output

Hello from thread 1!
Hello from thread 2!

Explanation

  • std::unique_lock provides more flexibility than std::lock_guard, such as deferred locking and manual unlocking.

3. Condition Variables

Condition variables allow threads to wait for certain conditions to be met before continuing execution. They are used in conjunction with a mutex.

Example: Using Condition Variables

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

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

void printMessage(const std::string& message) {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return ready; });
    std::cout << message << std::endl;
}

void setReady() {
    std::unique_lock<std::mutex> lock(mtx);
    ready = true;
    cv.notify_all();
}

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

    std::this_thread::sleep_for(std::chrono::seconds(1));
    setReady();

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

    return 0;
}

Output

Hello from thread 1!
Hello from thread 2!

Explanation

  • The printMessage function waits for the ready condition to become true before printing the message.
  • The setReady function sets the ready condition to true and notifies all waiting threads.
  • The cv.wait function blocks the thread until ready is true.

4. Atomic Operations

Atomic operations are operations on data types that are performed without interruption, ensuring thread safety without the explicit use of mutexes.

Example: Using Atomic Operations

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> counter(0);

void incrementCounter() {
    for (int i = 0; i < 1000; ++i) {
        ++counter;
    }
}

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

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

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

    return 0;
}

Output

Final counter value: 2000

Explanation

  • The std::atomic type ensures that operations on counter are atomic and thread-safe.
  • Two threads increment the counter concurrently, and the final value is 2000, showing that all increments were performed correctly.

Practical Example: Producer-Consumer Problem

The producer-consumer problem is a classic synchronization problem. Let’s implement it using mutexes and condition variables.

Code Example

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

std::queue<int> buffer;
std::mutex mtx;
std::condition_variable cv;
const unsigned int maxBufferSize = 10;

void producer(int id) {
    int value = 0;
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, []{ return buffer.size() < maxBufferSize; });

        buffer.push(value);
        std::cout << "Producer " << id << " produced " << value << std::endl;
        ++value;

        lock.unlock();
        cv.notify_all();

        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

void consumer(int id) {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        cv.wait(lock, []{ return !buffer.empty(); });

        int value = buffer.front();
        buffer.pop();
        std::cout << "Consumer " << id << " consumed " << value << std::endl;

        lock.unlock();
        cv.notify_all();

        std::this_thread::sleep_for(std::chrono::milliseconds(150));
    }
}

int main() {
    std::thread producers[2], consumers[2];

    for (int i = 0; i < 2; ++i) {
        producers[i] = std::thread(producer, i);
        consumers[i] = std::thread(consumer, i);
    }

    for (int i = 0; i < 2; ++i) {
        producers[i].join();
        consumers[i].join();
    }

    return 0;
}

Output

Producer 0 produced 0
Producer 1 produced 0
Consumer 0 consumed 0
Consumer 1 consumed 0
Producer 0 produced 1
Producer 1 produced 1
Consumer 0 consumed 1
Consumer 1 consumed 1
...

Explanation

  • Producers produce values and add them to the buffer, while consumers consume values from the buffer.
  • The buffer size is limited to maxBufferSize to prevent overflow.
  • Mutexes and condition variables are used to synchronize access to the buffer, ensuring that producers wait if the buffer is full and consumers wait if the buffer is empty.

Conclusion

Thread synchronization in C++ is essential for preventing data races and ensuring safe access to shared resources. C++ provides various synchronization mechanisms, such as mutexes, locks, condition variables, and atomic operations, to manage thread interaction effectively. Understanding and using these tools correctly is crucial for writing robust and efficient multithreaded programs.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top