In C++, "thread synchronization" involves implementing techniques and mechanisms to coordinate the activities performed by multiple threads, guaranteeing their seamless coexistence and careful monitoring. Within a multi-threaded application, several threads can run concurrently, interacting with common resources and potentially leading to issues such as race conditions, data inconsistencies, and deadlocks.
Thread synchronization involves controlling access to shared resources to ensure that only one thread can utilize a resource at any given time. It also entails ensuring that threads pause until certain conditions are met before proceeding. The primary goals are to uphold data integrity and prevent conflicts that can arise when multiple threads modify shared data simultaneously.
Why We Need Thread Synchronization?
Thread synchronization is commonly needed in the following situations:
- Critical Sections: Sections of code that access and alter common resources. A crucial area can only have one thread present at a time, thanks to effective synchronization, which avoids conflicts.
- Shared Data: Synchronisation is required to prevent data corruption or inconsistent states when several threads access the same data structures or variables.
- Communication: Threads frequently need to coordinate their actions through communication. Threads can notify each other and wait for each other's activities thanks to synchronization methods.
Example for the Thread Synchronization
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // Mutex for synchronization
int globalCounter = 0; // Shared counter among threads
// Function that increments the global counter
void incrementGlobalCounter(int threadID, int iterations) {
for (int i = 0; i < iterations; ++i) {
std::lock_guard<std::mutex> lock(mtx); // Lock the mutex to ensure exclusive access
++globalCounter; // Increment the shared counter
std::cout << "Thread " << threadID << " incremented globalCounter to " << globalCounter << std::endl;
}
}
int main() {
const int numIterations = 3;
// Create two threads to increment the global counter
std::thread thread1(incrementGlobalCounter, 1, numIterations);
std::thread thread2(incrementGlobalCounter, 2, numIterations);
// Wait for both threads to finish
thread1.join();
thread2.join();
// Display the final value of the global counter
std::cout << "Final globalCounter value: " << globalCounter << std::endl;
return 0;
}
Output:
Explanation:
This C++ code illustrates the utilization of a synchronization technique to enable two threads to securely access a shared counter simultaneously. The globalCounter is only modified by one thread at a time, facilitated by the mutex-protected access control within the incrementGlobalCounter function. To mimic concurrent operations, the main function spawns two threads that invoke the incrementing function.
The code demonstrates the globalCounter's ultimate value once both threads complete their operations. This example showcases the mitigation of race conditions and maintenance of data consistency in scenarios involving multiple threads through the utilization of mutex synchronization. This technique, referred to as Thread synchronization, is straightforwardly implemented in the provided code snippet.
Techniques of Thread Synchronization in C++
Various methods are accessible in C++ for coordinating threads:
1. Threads Synchronization Using Mutex (Std::mutex (mutex)):
In C++, a mutex serves as a synchronization mechanism to effectively control the access to shared resources among multiple threads. It plays a crucial role in preventing data races and ensuring the safety of threads by allowing only one thread to access a critical section of the code at any given time. By acquiring and releasing the mutex, threads coordinate their tasks, minimizing conflicts and fostering a structured execution flow. The presence of mutexes in multi-threaded applications enhances their reliability and predictability by mitigating race conditions and facilitating controlled concurrent access to shared data.
Because threads can execute in a non-deterministic order, race conditions may arise when multiple threads access and modify shared data concurrently. Mutexes address this issue by permitting only a single thread to access a critical section of code at any given time, thereby averting simultaneous access by multiple threads to the same shared resource.
The std::mutex class is included in the C++ Standard Library and is designed for synchronization using mutexes. If a thread attempts to acquire a lock on a mutex using the lock method, it will succeed only if the mutex was not already locked. In case another thread has already acquired the lock, the current thread will be halted until the mutex is released. This blocking characteristic ensures that only one thread can execute the critical section at any given time.
It's essential to keep in mind that mutexes have the potential to lead to problems such as deadlocks and contention when not utilized correctly. Deadlocks occur when multiple threads are waiting for each other to release resources, causing the program to come to a standstill. On the other hand, contention arises when threads frequently compete for the same mutex, potentially slowing down the program as a result of excessive locking and unlocking operations.
Modern C++ provides more advanced abstractions such as std::lockguard and std::uniquelock, which simplify the handling of mutexes and reduce the chances of errors in dealing with them. These RAII (Resource Acquisition Is Initialization) classes automatically acquire and release the mutex when they are instantiated and destroyed, ensuring proper synchronization even when exceptions occur.
Example:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // Declare a mutex
void printNumbers() {
mtx.lock(); // Lock the mutex before accessing shared resources
std::cout << "Printing numbers: ";
for (int num = 0; num < 10; ++num) {
std::cout << num << ' ';
}
std::cout << "End of numbers." << std::endl;
mtx.unlock(); // Unlock the mutex after accessing shared resources
}
int main() {
std::cout << "Demonstration of Mutex in C++" << std::endl;
// Create multiple threads
std::thread thread1(printNumbers);
std::thread thread2(printNumbers);
std::thread thread3(printNumbers);
// Wait for all threads to finish
thread1.join();
thread2.join();
thread3.join();
return 0;
}
Output:
Explanation:
This C++ example illustrates the synchronization of threads to ensure proper concurrent access to shared resources. Initially, the code imports headers for input-output, threading, and mutex functionalities. To manage shared resources, a std::mutex named mtx is defined. The printNumbers function represents a critical section that multiple threads will concurrently interact with.
The function displays a series of numbers ranging from 0 to 9, with each number being spaced apart, following the application of mtx.lock to secure the mutex. Subsequently, the mutex is released for other threads to access by utilizing mtx.unlock, thereby releasing the lock. To showcase multithreading, the script initiates three threads (thread1, thread2, and thread3) that concurrently execute the printNumbers function. This serves as a practical illustration of how the mutex ensures that only one thread enters the critical section at any given time, preventing any overlap in the output. Finally, prior to the program's termination, the join methods are employed to wait for each thread to finish its execution.
In summary, the provided code illustrates the fundamental concept of using a mutex to coordinate threads, avoid race conditions, and ensure consistent and orderly outcomes when accessing shared resources simultaneously.
2. Lock Guard (std::lock_guard) in C++:
The std::lock_guard utility in C++ serves as a reliable and efficient RAII (Resource Acquisition Is Initialization) wrapper, simplifying the utilization of mutexes for thread synchronization. This feature provides a safe and automated approach to manage mutex locking and unlocking, aiding in the prevention of typical threading errors such as neglecting to release mutexes or encountering exceptions within critical code segments.
When creating a std::lockguard instance, the constructor takes a mutex as a parameter and locks it. As the std::lockguard instance goes out of scope (such as at the end of a function), the mutex is automatically unlocked. This ensures proper release of the mutex at all times, even in the presence of exceptions.
Example:
#include<iostream>
#include<thread>
#include<string>
#include<mutex>
std::mutex outputMutex; //Mutex to guard std::cout
void printFunction(const std::string& threadName){
for (int i = 0;i < 3;i++){
std::lock_guard<std::mutex> lock(outputMutex); //Lock the mutex using lock_guard
std::cout <<"Executing function from "<< threadName<< '\n';
} // lock_guard goes out of scope here and unlocks the mutex
}
int main() {
std::thread thread1{ printFunction, "Thread 1" };
std::thread thread2{ printFunction, "Thread 2" };
thread1.join (); //Wait for thread1 to finish
thread2.join (); //Wait for thread2 to finish
return 0;
}
Output:
Explanation :
This example illustrates the utilization of mutexes and std::lock_guard to ensure synchronized output in a scenario involving multiple threads.
The process starts by including the necessary headers and declaring an outputMutex mutex. This specific mutex is established to ensure that multiple threads do not mix their outputs when interacting with the standard output (std::cout).
Multiple threads can execute the printFunction concurrently. Inside a loop that runs ten times, a std::lock_guard named lock is created. This lock ensures that the outputMutex is guarded, allowing only one thread to access std::cout at any given moment. As a result, the threads' outputs remain orderly and not intermingled.
The main function initiates two threads, namely thread1 and thread2, both executing the printFunction function. By utilizing the mutex to control access to std::cout, the threads' output is synchronized and consistent. The join functions guarantee that the main program halts until both threads complete their execution before terminating.
3. Unique lock (std::unique_lock)
In C++, the std::uniquelock serves as a robust synchronization tool for handling mutexes and facilitating controlled synchronization between threads. Similar to std::lockguard, std::uniquelock enables secure and coordinated utilization of shared resources, but it offers enhanced versatility. Unlike std::lockguard, std::uniquelock supports deferred locking, allowing for multiple lock and unlock operations within the same scope, as opposed to the single lock and unlock behavior of std::lockguard.
The std::uniquelock class includes condition variables, enabling the implementation of intricate synchronization patterns. These patterns involve waiting until specific conditions are met before continuing with the execution. This feature makes it a valuable choice for scenarios demanding precise management of mutex operations like locking, unlocking, and waiting. Moreover, std::uniquelock supports both exclusive (unique) and shared (multiple) ownership of the mutex. This flexibility facilitates secure sharing of resources among threads in complex scenarios.
The advantages of using std::uniquelock for managing thread synchronization in C++ consist of its enhanced versatility and advanced features. Unlike std::lockguard, std::unique_lock enables postponed locking and unlocking, providing a more flexible approach to controlling mutex access within a specific scope. This versatility proves particularly beneficial in scenarios involving intricate synchronization requirements or instances where mutex possession needs to alter during program execution.
Example:
#include<mutex>
#include<thread>
#include<iostream>
struct Account {
explicit Account(int balance) : balance{balance} {}
int balance;
std::mutex m;
};
void transfer( Account &from, Account &to, int amount) {
// Create unique_locks without locking yet
std::unique_lock lock1{from.m, std::defer_lock};
std::unique_lock lock2{to.m, std::defer_lock};
// Lock both unique_locks without risking deadlock
std::lock(lock1, lock2);
from.balance -= amount;
to. balance += amount;
// 'from.m' and 'to.m' mutexes automatically unlocked in 'unique_lock' destructors
}
int main() {
Account account1{100};
Account account2{50};
std::thread thread1{transfer, std::ref(account1), std::ref(account2), 10};
std::thread thread2{transfer, std::ref(account2), std::ref(account1), 5};
thread1.join();
thread2.join();
std::cout << "Account 1 balance: " << account1.balance << "\n"
"Account 2 balance: " << account2.balance << '\n';
return 0;
}
Output:
Explanation:
This example illustrates the process of securely transferring funds between two accounts concurrently by employing multiple threads. It ensures the prevention of potential deadlocks and guarantees synchronized access to common resources by utilizing std::unique_lock along with std::lock.
The Account structure embodies a bank account containing a balance and a mutex (m) to safeguard against unauthorized access to the account's balance. This mutex ensures that only a single thread can interact with the balance at any given moment, thereby averting potential race conditions.
The responsibility of transferring funds between two accounts is assigned to the transfer method. This function establishes locks without directly locking the associated mutexes by employing std::uniquelock instances with the std::deferlock parameter. This approach avoids immediate locking, enabling the locks to be acquired concurrently with std::lock. By following this technique, the occurrence of deadlocks is prevented even in scenarios where multiple threads strive to lock the mutexes simultaneously.
Two separate threads, namely thread1 and thread2, are instantiated within the main function to execute the transfer operation simultaneously. These threads facilitate the movement of money between the two accounts in opposite directions. The implementation of join ensures that the main program pauses until both threads complete their respective tasks before proceeding further.
Once both threads have completed their tasks, the ultimate account balances are displayed, showcasing the impact of the simultaneous transfers. Mutexes, std::unique_lock, and std::lock are employed to ensure the accurate and race-free execution of the transfers.
4. Recursive Mutex (std::recursive_mutex) :
A std::recursive_mutex is a form of mutex utilized in C++ for thread synchronization, enabling a single thread to acquire the lock multiple times without risking deadlock.
The C++ std::recursive_mutex proves to be an invaluable asset when dealing with intricate synchronization challenges. Ensuring the prevention of race conditions and maintaining a reliable and sequential execution of multiple tasks concurrently is essential in the realm of multi-threaded programming.
The recursive mutex introduces an interesting capability: a single thread is able to acquire the same mutex multiple times without causing a deadlock, unlike the standard mutex (std::mutex) that allows only one thread to hold the lock at any given time.
This distinguishing characteristic proves to be quite beneficial in situations where a function might call other functions needing to be locked with a single mutex, thus avoiding potential deadlocks that could arise with a standard mutex. Along with offering versatility, the std::recursive_mutex also satisfies the criteria for thread synchronization by ensuring that only one thread holds the mutex at any specific time.
This method operates by monitoring the thread responsible for locking it and granting that thread permission to lock it repeatedly, essentially keeping count of how many times the thread has locked the mutex. Due to the risk of misuse and potential performance setbacks, it is crucial to only employ this type of mutex when absolutely necessary. In general, the std::recursive_mutex proves to be a valuable tool for scenarios where functions or routines within the same thread need to access shared resources protected by a mutex, facilitating the development of more adaptable and sustainable multi-threaded applications.
Example:
#include<iostream>
#include<thread>
#include<mutex>
class SharedResource {
std::recursive_mutex mtx; // Declare a recursive mutex
std::string sharedData;
Public:
void writeData(const std::string& data) {
std::lock_guard<std::recursive_mutex> lock(mtx);
sharedData = data;
std::cout << "Writing data: " << sharedData << " (inside writeData)" << '\n';
}
void readData() {
std::lock_guard<std::recursive_mutex> lock(mtx);
std::cout << "Reading data: " << sharedData << " (inside readData)" << '\n';
}
void updateData() {
std::lock_guard<std::recursive_mutex> lock(mtx);
sharedData = "updated";
std::cout << "Updating data: " << sharedData << " (inside updateData)" << '\n';
readData(); // recursive lock becomes useful here
std::cout << "Back in updateData: " << sharedData << '\n';
}
};
int main() {
SharedResource resource;
std::thread thread1(&SharedResource::writeData, &resource, "Hello");
std::thread thread2(&SharedResource::updateData, &resource);
thread1.join();
thread2.join();
return 0;
}
Output:
Explanation:
In this example, a recursive locking mechanism, particularly std::recursivemutex, is employed to achieve synchronized access to shared resources within a multi-threaded environment. The code creates an instance of the SharedResource class, incorporating a recursivemutex named mtx to ensure exclusive access to the shared data attribute sharedData.
5. Read-Write Mutex (std:: shared_mutex):
A read-write mutex, known as std::shared_mutex in C++, serves as a synchronization mechanism providing a higher level of control over concurrent access to shared data compared to standard mutexes. It ensures exclusive access for writing operations while allowing multiple threads to read from the shared resource concurrently. This functionality is particularly advantageous in scenarios where there is a need for concurrent write access, combined with frequent and simultaneous read operations.
There are two locking modes available with std::shared_mutex: shared and exclusive.
The shared lock allows multiple threads to access the shared resource simultaneously, facilitating concurrent reading.
In contrast, the exclusive lock grants exclusive write permission to one thread for the shared resource, preventing any other threads from reading or writing concurrently.
When the number of readers surpasses writers, the std::shared_mutex proves to be beneficial in enhancing concurrency and overall performance. It mitigates conflicts and boosts speed by allowing multiple threads to read concurrently. Conversely, the exclusive lock ensures data integrity when write operations are necessary.
To make use of this synchronization technique, it is necessary to have an appropriate compiler and library since the std::shared_mutex is exclusively available starting from C++17.
Example:
#include<iostream>
#include<thread>
#include<mutex>
int sharedValue = 0;
std::mutex sharedMutex; // Mutex to protect sharedValue
// Reads the sharedValue and sets 'readResult' to that value
void readSharedValue(int& readResult) {
sharedMutex.lock(); // Acquire the lock to read the sharedValue
// Simulate some latency
std::this_thread::sleep_for(std::chrono::seconds(1));
readResult = sharedValue;
sharedMutex.unlock(); // Release the lock
}
// Sets sharedValue to 'newValue'
void setSharedValue(int newValue) {
sharedMutex.lock(); // Acquire the lock to write to the sharedValue
// Simulate some latency
std::this_thread::sleep_for(std::chrono::seconds(1));
sharedValue = newValue;
sharedMutex.unlock(); // Release the lock
}
int main() {
int readResult1;
int readResult2;
int readResult3;
// Create threads to read from sharedValue and to set sharedValue
std::thread readThread1(readSharedValue, std::ref(readResult1));
std::thread readThread2(readSharedValue, std::ref(readResult2));
std::thread readThread3(readSharedValue, std::ref(readResult3));
std::thread setThread(setSharedValue, 1);
// Wait for all threads to finish
readThread1.join();
readThread2.join();
readThread3.join();
setThread.join();
// Display the read results and the final sharedValue
std::cout << "Read Result 1: " << readResult1 << "\n";
std::cout << "Read Result 2: " << readResult2 << "\n";
std::cout << "Read Result 3: " << readResult3 << "\n";
std::cout << "Final Shared Value: " << sharedValue << "\n";
return 0;
}
Output:
Explanation:
This code demonstrates how a mutex can be used in a multi-threaded setting to ensure synchronized access to a shared resource called sharedValue. It illustrates how multiple threads can both read from and modify this shared variable concurrently, all the while maintaining proper synchronization to prevent data races and ensure consistent outcomes.
At the outset of the programs, declarations for sharedValue and sharedMutex are made to safeguard the shared variable from unauthorized access. Both readSharedValue and setSharedValue functions are implemented. Upon locking the mutex, reading the shared value, releasing the lock, and simulating a delay using sleep, the former assigns the value to the readResult variable. The latter updates the shared value after locking the mutex, simulating a delay again, and assigning the new value to sharedValue.
Three threads, named readThread1, readThread2, and readThread3, are initiated within the main function to access the sharedValue concurrently, while another thread, setThread, is created to modify it. In order to ensure that the program halts until all threads finish their tasks, they are all synchronized.
The code then displays the ultimate value of sharedValue along with the read outcomes from every thread. To prevent data inconsistencies and race conditions, the mutex guarantees that multiple threads cannot simultaneously access and modify the shared element.
6. Condition Variable (std::condition_variable):
A std::condition_variable is essential for synchronization in C++ as it allows threads to pause until particular conditions are met. This component works alongside a mutex to manage thread synchronization and parallel execution. Unlike a mutex that safeguards shared resources, a condition variable is employed when a thread needs to halt its execution until a specific condition turns true.
In scenarios like producer-consumer setups, where one thread generates data while another processes it, the std::condition_variable plays a crucial role in constructing intricate synchronization mechanisms. It prevents the need for continual checking, known as busy-waiting, which can be inefficient and resource-heavy when a thread repeatedly verifies a condition without relinquishing control.
Example:
include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mutex;
std::condition_variable conditionVar;
std::string sharedData;
bool dataReady = false;
bool dataProcessed = false;
void workerThread() {
// Wait until the main thread sends data
std::unique_lock lock(mutex);
conditionVar.wait(lock, []{ return dataReady; });
// After the wait, we own the lock.
std::cout << "Worker thread is processing data\n";
sharedData += " after processing";
// Send data back to the main thread
dataProcessed = true;
std::cout << "Worker thread signals data processing completed\n";
// Manual unlocking is done before notifying to avoid waking up
// the waiting thread only to block again (see notify_one for details)
lock.unlock();
conditionVar.notify_one();
}
int main() {
std::thread worker (workerThread);
sharedData = "Example data";
// Send data to the worker thread
{
std::lock_guard lock(mutex);
dataReady = true;
std::cout << "main () signals data ready for processing\n";
}
conditionVar.notify_one ();
// Wait for the worker thread to finish processing
{
std::unique_lock lock(mutex);
conditionVar.wait(lock, [] { return dataProcessed; });
}
std::cout << "Back in main(), data = " << sharedData << '\n';
worker.join();
return 0;
}
Explanation:
The primary thread and a secondary thread are demonstrated to communicate and coordinate through the utilization of the provided code, leveraging a condition variable. The primary thread dispatches signals to commence data preparation and processing to the secondary thread. Upon receiving these signals, the secondary thread handles the data processing tasks and subsequently informs the primary thread upon completion of the processing.
Before finalizing the processed data, the primary thread pauses for this signal. This approach enhances performance in a multi-threaded environment by allowing the worker thread to handle data only when it is prepared, thereby eliminating the need for busy waiting. The reliability of multi-threaded applications is enhanced through the employment of mutexes and condition variables, facilitating synchronized and coordinated communication among the threads.
7. Semaphore (std::counting_semaphore):
The std::counting_semaphore synchronization primitive made its debut in the C++20 standard. This versatile tool manages a permit count, allowing a specific number of threads to access a resource concurrently. Threads pause until they receive permission to proceed; upon receiving it, they can move forward with their operations.
This is beneficial in situations where there is a restriction on the availability of a group of worker threads, such as for the purpose of maximizing resource efficiency and handling conflicts. By controlling simultaneous access and reducing contention for common resources, it serves as a flexible alternative to binary semaphores.
8. Atomic Operations (std::atomic):
By avoiding locks, atomic operations provide a way to perform specific actions on shared variables atomically. They are particularly beneficial for simple tasks such as increasing counts.
Advantages of Thread Synchronization in C++
- Data Integrity: Synchronisation processes ensure that shared resources are accessed in a controlled way and prevent data races. As a result, data integrity is preserved, and unexpected behavior brought on by concurrent access is avoided.
- Orderly Execution: By establishing a predetermined order of thread execution, developers may make sure that crucial actions take place in the intended order.
- Consistency: Synchronisation procedures ensure that changes made by one thread are anticipated to be visible to other threads. For the shared data to be seen consistently, this is essential.
- Avoiding deadlocks: Synchronisation methods that are properly built assist in avoiding situations where threads are trapped, waiting for one another to release resources. This guarantees efficient program execution.
- Resource Sharing: Synchronisation facilitates safe resource sharing of memory, files, and databases without introducing conflicts. This encourages effective concurrent application resource utilization.
- Performance and Parallel: By enabling many threads to operate on various tasks concurrently, synchronization promotes parallelism, which can enhance performance on multicore computers.