[ Multithreading C++ ]
I. Critical section
In C++, a critical section is a segment of code that is executed by multiple concurrent threads or processes and which accesses shared resources. A critical section can be any section of code where shared resources are accessed, and it typically consists of two parts: the entry section and the exit section. The entry section is where a process requests access to the critical section, and the exit section is where it releases the resources and exits the critical section.
To prevent data races in C++, critical sections can be protected using synchronization mechanisms such as mutexes, locks, and thread-safe data structures.
-
Mutexes can be used to protect any shared resource, including variables, data structures, and I/O streams.
-
Locks are synchronization objects that can be used to protect shared resources from being accessed simultaneously by multiple threads.
-
Thread-safe data structures are data structures that are designed to be accessed by multiple threads simultaneously without causing data races
II. Mutex (MUTual EXclusion )
A mutex is a synchronization primitive that can be used to protect shared data from being simultaneously accessed by multiple threads. A mutex is a lockable object that is designed to signal when critical sections of code need exclusive access, preventing other threads with the same access from modifying the shared data. In C++, a mutex is implemented as a class, std::mutex
, which provides two member functions: lock()
and unlock()
.
-
When a thread locks a mutex, no other thread can access the shared data until the mutex is unlocked. If a thread tries to lock a mutex that is already locked by another thread, the thread will block until the mutex is unlocked.
-
Mutexes can be used to protect shared resources from simultaneous access by multiple threads or processes. Mutexes are used to prevent data races and ensure that only one thread can access the shared data at a time. Mutexes can be expensive to use, and it is essential to use them judiciously to avoid performance degradation.
-
A thread locks the mutex when it enters the critical section.
-
A thread unlocks the mutex when it leaves the critical section.
Thread Synchroniztion with Mutex
- Thread A locks the mutex
- Thread A enters the critical section
- Thread B, C,.. wait until they can lock the mutex
- Thread A leaves the critical section
- Thread A unlock the mutex
- One of threads B,C,.. can now lock the mutex and enter the critical section.
std::mutex Class
The std::mutex
class is a synchronization primitive in C++ that can be used to protect shared data from being simultaneously accessed by multiple threads. The class provides member functions, lock()
, try_lock()
and unlock()
, which can be used to lock, tries to lock the mutex -> returns immediately if not successful and unlock the mutex, respectively.
Example of multiple threads with mutex in C++
Example unscramble.cpp
bellow:
unscramble.cpp
// Use a mutex to avoid scrambled output
#include <iostream>
#include <mutex>
#include <thread>
#include <string>
// Global mutex object
std::mutex task_mutex;
void task(const std::string& str)
{
for (int i = 0; i < 5; ++i) {
// Lock the mutex before the critical section
task_mutex.lock();
// Start of critical section
std::cout << str[0] << str[1] << str[2] << std::endl;
// End of critical section
// Unlock the mutex after the critical section
task_mutex.unlock();
}
}
int main()
{
std::thread thr1(task, "abc");
std::thread thr2(task, "def");
std::thread thr3(task, "xyz");
thr1.join();
thr2.join();
thr3.join();
}
Output
abc
abc
abc
abc
abc
def
def
def
def
def
xyz
xyz
xyz
xyz
xyz
std::mutex::trylock()
The std::mutex::try_lock()
function is a member function of the std::mutex class in C++ that attempts to lock the mutex without blocking. The function returns immediately and tries to acquire the lock. If the lock is acquired successfully, the function returns true, and if the lock is not acquired, the function returns false. The std::mutex::try_lock()
function can be used to avoid blocking when acquiring a lock on a mutex.
// Keep trying to get the lock
while(!the_mutex.try_lock()){
// Could not lock the mutex
// Try again later
std::this_thread::sleep_for(100ms);
}
// Finally locked the mutex
// Can now execute in the critical section
Example try_lock() the mutextry_lock_mutex.cpp
bellow:
try_lock_mutex.cpp
// Example of calling try_lock() in a loop until the mutex is locked
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
using namespace std::literals;
std::mutex the_mutex;
void task1()
{
std::cout << "Task1 trying to lock the mutex" << std::endl;
the_mutex.lock();
std::cout << "Task1 has locked the mutex" << std::endl;
std::this_thread::sleep_for(500ms);
std::cout << "Task1 unlocking the mutex" << std::endl;
the_mutex.unlock();
}
void task2()
{
std::this_thread::sleep_for(100ms);
std::cout << "Task2 trying to lock the mutex" << std::endl;
while (!the_mutex.try_lock()) {
std::cout << "Task2 could not lock the mutex" << std::endl;
std::this_thread::sleep_for(100ms);
}
std::cout << "Task2 has locked the mutex" << std::endl;
the_mutex.unlock();
}
int main()
{
std::thread thr1(task1);
std::thread thr2(task2);
thr1.join();
thr2.join();
}
Output
Task1 trying to lock the mutex
Task1 has locked the mutex
Task2 trying to lock the mutex
Task2 could not lock the mutex
Task2 could not lock the mutex
Task2 could not lock the mutex
Task2 could not lock the mutex
Task1 unlocking the mutex
Task2 has locked the mutex
III. Internally Synchronized Class
When multiple threads access the same memory location concurrently, and at least one of them modifies that memory location, we need to synchronize these accesses to prevent a data race.
-
The containers in the C++ standard library need to be synchronized and we can lock a mutex before calling any of the member functions on a shared C++ library container to prevent a data race.
-
We can also write classes that provide their synchronization, where the class takes the responsibility for preventing the data race. One way to do that is to have a mutex as a data member, and the member functions of this class will lock the mutex before they access any of the class’s internal data and then unlock it afterward.
Wrapper for std::vector
+ std::vector acts as a memory location
- We may need to lock a mutex before calling its member function
+ Alternatively, we could write an internally synchronized wrapper for it.
+ A class which
- Has an std::vector data member
- Has an std::mutex data member
- Member functions which lock the mutex before accessing the std::vector
- Then unlock the mutex after accessing it
+ An internally synchronized class
//Very simplistic thread-safe vector class
class Vector{
std::mutex mut; // Mutex as private class data member
std::vector<int> vec; // Shared data -mutex protects access to it
public:
void push_back(const int& i){
mut.lock(); //Lock the mutex
vec.push_back(i); //Critical section
mut.unlock(); //Unlock the mutex
}
};
Example A class which is internally without mutex bellow:
without_mutex.cpp
// A class which is internally synchronized
// The member functions lock a mutex before they access a data member
#include <thread>
#include <mutex>
#include <vector>
#include <iostream>
#include <chrono>
using namespace std::literals;
class Vector {
std::mutex mut;
std::vector<int> vec;
public:
void push_back(const int& i)
{
//mut.lock();
// Start of critical section
vec.push_back(i);
// End of critical section
//mut.unlock();
}
void print() {
//mut.lock();
// Start of critical section
for (auto i : vec) {
std::cout << i << ", ";
}
// End of critical section
//mut.unlock();
}
};
void func(Vector& vec)
{
for (int i = 0; i < 5; ++i) {
vec.push_back(i);
std::this_thread::sleep_for(50ms);
vec.print();
}
}
int main()
{
Vector vec;
std::thread thr1(func, std::ref(vec));
std::thread thr2(func, std::ref(vec));
std::thread thr3(func, std::ref(vec));
thr1.join(); thr2.join(); thr3.join();
}
Scramble Ouput
0, 0,
end print section
0, 0,
0, 0, 1,
end print section
end print section
start print section
0, 0, 1, 1, 1,
end print section
start print section
0, 0, 1, 1, 1, 2,
end print section
start print section
0, 0, 1, 1, 1, 2, 2,
end print section
start print section
0start print section,
0, 0, 1, 1, 1, 2, 2, 2,
0, end print section
1, 1, 1, 2, 2, 2,
end print section
start print section
0, 0, 1073744080, 32544, 1, 2, 2, 2, 3,
end print section
start print section
0, 0, 1073744080, 32544, 1, 2, 2, 2, 3, 3,
end print section
start print section
0, 0, 1073744080, 32544, 1, 2, 2, 2, 3, 3, 4,
end print section
start print section
0, 0, 1073744080, 32544, 1, 2, 2, 2, 3, 3, 4, 4,
end print section
start print section
0, 0, 1073744080, 32544, 1, 2, 2, 2, 3, 3, 4, 4, 4,
end print section
start print section
start print section0, 0, 1073744080, 32544, 1, 2, 2, 2, 3, 3, 4, 4, 4,
end print section
0, 0, 1073744080, 32544, 1, 2, 2, 2, 3, 3, 4, 4, 4,
end print section
Example A class which is internally synchronize bellow:
internal_sync_class.cpp
// A class which is internally synchronized
// The member functions lock a mutex before they access a data member
#include <thread>
#include <mutex>
#include <vector>
#include <iostream>
#include <chrono>
using namespace std::literals;
class Vector {
std::mutex mut;
std::vector<int> vec;
public:
void push_back(const int& i)
{
mut.lock();
// Start of critical section
vec.push_back(i);
// End of critical section
mut.unlock();
}
void print() {
mut.lock();
// Start of critical section
for (auto i : vec) {
std::cout << i << ", ";
}
// End of critical section
mut.unlock();
}
};
void func(Vector& vec)
{
for (int i = 0; i < 5; ++i) {
vec.push_back(i);
std::this_thread::sleep_for(50ms);
vec.print();
}
}
int main()
{
Vector vec;
std::thread thr1(func, std::ref(vec));
std::thread thr2(func, std::ref(vec));
std::thread thr3(func, std::ref(vec));
thr1.join(); thr2.join(); thr3.join();
}
Stable Ouput
start print section
0, 0, 0,
end print section
start print section
0, 0, 0,
end print section
start print section
0, 0, 0, 1, 1,
end print section
start print section
0, 0, 0, 1, 1, 1,
end print section
start print section
0, 0, 0, 1, 1, 1,
end print section
start print section
0, 0, 0, 1, 1, 1, 2,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4,
end print section
start print section
0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4,
end print section
IV. Lock guard
Mutex problem with the exception is thrown
To protect a critical section, we can use a mutex or a lock. We lock the mutex before entering the critical section and unlock it afterwards. However, if an exception is thrown while the thread is in the critical section, the code will jump out of the try block into the catch block, and the code that follows will not be executed. This means that the unlock function is NEVER called, and the mutex is left in a locked state.
try{
task_mutex.lock(); //Lock the mutex before the critical section
//Critical section thrown an exception
task_mutex.unlock(); // NEVER get called
}
catch(std::exeption&e ){
....
}
// -> MUTEX will be left locked state -> Error: device or resource busy
So, when the exception is thrown, we have the stack unwinding process:
-
The destructors are called, for all objects in scope.
-
The program flow jumps into the catch handler.
-
The unlock() call is never executed, and
-
The mutex remains in a locked state.
When a thread locks a mutex, any other thread that wants to lock that mutex will wait indefinitely, causing the threads to be blocked. If any code has called join() on those blocked threads, such as themain()
function, then that code will also be blocked. As a result, the entire program will be blocked, and the threads will not be able to proceed.
- Calling lock() requires corresponding call unlock() If NOT the mutex remaining locked after the thread exits
- Unlock must always be called, even ifThere are multiple paths through the critical section, An exception thrown
- Relies to the programmer to get it right
- Due to these reasons, we do not normally use std::mutex
So what should we use? -> Mutex Wrapper Class
A mutex wrapper class is a class that holds a mutex and an associated object, providing a convenient way to lock and unlock the mutex when accessing the object. the wrapper classes for mutexes in C++ provide the following benefits:
- They have a mutex object as a private member and are defined in the same header as the mutex class
- They use the RAII idiom to manage resources, where the resource is a mutex that is locked
- The constructor acquires the resource by locking the mutex, and the destructor releases the resource by unlocking the mutex
- Objects of this class are created on the stack, and when the object goes out of scope, the destructor is called, and the mutex is unlocked
- This guarantees that objects are destroyed when the scope in which they were declared ends, and it is possible to acquire the resource in the constructor and release it in the destructor
- This is even more useful in the presence of exceptions, whose unusual control flow is often the source of resources not being released under exceptional flows
std::lock_guard
std::lock_guard
is a C++ class that provides a convenient RAII-style mechanism for owning a mutex for the duration of a scoped block. std::lock_guard
is a useful tool for synchronizing access to shared resources in a multithreaded environment. It provides a convenient way to lock and unlock a mutex when accessing an object, ensuring that only one thread can access the object at a time. It is simple to use and has less likelihood for incorrect use than other mutex wrapper classes.
Example use std::lock_guard to avoid scrambled output:
lock_guard.cpp
// Use std::lock_guard to avoid scrambled output
#include <iostream>
#include <mutex>
#include <thread>
#include <chrono>
#include <string>
using namespace std::literals;
std::mutex print_mutex;
void task(std::string str)
{
for (int i = 0; i < 5; ++i) {
try {
// Create an std::lock_guard object
// This calls print_mutex.lock()
std::lock_guard<std::mutex> lck_guard(print_mutex);
// Start of critical section
std::cout << str[0] << str[1] << str[2] << std::endl;
// Critical section throws an exception
throw std::exception();
// End of critical section
std::this_thread::sleep_for(50ms);
} // Calls ~std::lock_guard
catch (std::exception& e) {
std::cout << "Exception caught: " << e.what() << '\n';
}
}
}
int main()
{
std::thread thr1(task, "abc");
std::thread thr2(task, "def");
std::thread thr3(task, "xyz");
thr1.join(); thr2.join(); thr3.join();
}
Output
abc
Exception caught: std::exception
def
Exception caught: std::exception
def
Exception caught: std::exception
def
Exception caught: std::exception
def
Exception caught: std::exception
def
Exception caught: std::exception
xyz
Exception caught: std::exception
abc
Exception caught: std::exception
xyz
Exception caught: std::exception
xyz
Exception caught: std::exception
xyz
Exception caught: std::exception
xyz
Exception caught: std::exception
abc
Exception caught: std::exception
abc
Exception caught: std::exception
abc
Exception caught: std::exception
The exceptions are being caught and different threads are executing. Other threads are able to lock the mutex and perform their own critical section without scrambled output.
std::unique_lock()
In general, std::lock_guard is simpler and easier to use, while std::unique_lock is more flexible and provides more functionality. If the mutex needs to be locked for the entire scope of a block, std::lock_guard is preferred. If the mutex needs to be locked for only part of the scope of a block or if more advanced functionality is needed, std::unique_lock is preferred.
-
It is used to ensure that a mutex is locked for the duration of a critical section, and it is automatically released when the unique_lock object goes out of scope.
-
It is more flexible than
std::lock_guard
because it can be constructed with or without taking the mutex immediately, and it can adopt a current lock that is already locked by a thread. -
It is useful for synchronizing access to shared resources in a multithreaded environment, and it can be used to avoid deadlocks and complexity that can arise from using too many mutexes.
Example use std::unique_lock() to avoid scrambled output:
unique_lock.cpp
// Use std::unique_lock to avoid scrambled output
#include <iostream>
#include <mutex>
#include <thread>
#include <chrono>
#include <string>
using namespace std::literals;
std::mutex print_mutex;
void task(std::string str)
{
for (int i = 0; i < 5; ++i) {
// Create an std::unique_lock object
// This calls print_mutex.lock()
std::unique_lock<std::mutex> uniq_lck(print_mutex);
// Start of critical section
std::cout << str[0] << str[1] << str[2] << std::endl;
// End of critical section
// Unlock the mutex
uniq_lck.unlock();
std::this_thread::sleep_for(50ms);
} // Calls ~std::unique_lock
}
int main()
{
std::thread thr1(task, "abc");
std::thread thr2(task, "def");
std::thread thr3(task, "xyz");
thr1.join(); thr2.join(); thr3.join();
}
Output
abc
def
xyz
abc
def
xyz
abc
def
xyz
abc
def
xyz
abc
def
xyz
The std::unique_lock
class has several constructors that allow for different ways of locking a mutex. The second argument of the constructor is optional and specifies the locking strategy. Here are some of the available options:
-
std::defer_lock:
The mutex is not locked on construction. The caller must lock the mutex manually using the lock() method. -
std::try_to_lock:
The mutex is locked if possible, but the constructor does not block if the mutex is already locked by another thread. If the mutex is not locked, it is locked by the constructor. -
std::adopt_lock:
The mutex is assumed to be already locked by the calling thread. The constructor does not lock the mutex, but instead adopts the lock.
- std::unique_lock is much more flexible, but: Slower and requires slighly more storage
- Recommendationuse lock_guard to lock mutex for entire scope, unique_lock for unlock wihin the scope, use unique_lock if you need more extra features.
References
- https://learn.microsoft.com/en-us/cpp/cppcx/wrl/criticalsection-class?view=msvc-170
- https://en.cppreference.com/w/cpp/thread/mutex/try_lock
- https://cplusplus.com/reference/mutex/mutex/try_lock/
- James Raynard, Learn Multithreading with Modern C++ Udemy.
- https://blog.andreiavram.ro/cpp-channel-thread-safe-container-share-data-threads/