Concurrency(3) Understanding Mutex
All codes in are MVSC C++20 Enviromment
When programming with multiple threads in C++, itβs often necessary to read from
and write to shared data objects. To ensure data consistency and avoid data races,
one fundamental approach is to use locks. In C++, one of the primary ways to lock
threads is by using the
std::mutex
. Built on the
foundation of
std::mutex
, C++ offers numerous safety
measures and enhanced wrappers, such as
std::lock_guard
and
std::unique_lock
.
Why should we use Locks?
Critical Section
-
What
is this?
- A βcritical sectionβ is a concept related to code, not data. It refers to the part of a program that accesses or modifies shared resources. When multiple threads access the critical section unprotected at the same time, it can lead to data races, inconsistencies, or other problems.
-
Why
is this important?
- Data Integrity and Consistency : In a multi-threaded environment, if multiple threads are modifying shared resources simultaneously, it can lead to data inconsistency or corruption. To ensure data integrity and consistency, it is essential that only one thread can access the shared resource at any given moment.
-
Avoid Data Race
: A data race can occur when multiple threads access and modify data without proper synchronization, leading to unpredictable and irreproducible program behavior.
- A critical section represents code that will cause a data race unless the threads executing within it are synchronized. There are some solutions to implement synchronization for the critical section. This ensures that operations on shared data are atomic, thus preventing data corruption or data races. This article mainly introduces the mutex and its usage in C++.
What is Mutex?
Mutex
(MUTual EXclusion object), is a type of
synchronization primitive
used to guard access to shared resources.
It has two states:
locked
and
unlocked
.
And In most of C++ situations, the
mutex
object itself represents βlockβ, it represented by
std::mutex
.
We will see many implemnetations of
locking protocols
in STL, which simply refers how to use locks in different scenarios.
But the core concept all of them is
std::mutex
, is about how to setup βlockβ and βunlockβ status for a βmutexβ.
Hereβs a snippet demonstrates how to use mutexes in lock and unlock status, which can be employed to ensure only one thread enters the critical section at a given time:
std::mutex mtx;
void task(const std::string& str) {
for (int i = 0; i < 5; ++i) {
mtx.lock(); // start of critical section
std::cout << str << std::endl;
mtx.unlock(); // end of critical section
}
}
int main() {
std::jthread thr1(task, "abc");
std::jthread thr2(task, "def");
}
-
lock()
tries to acquire the lock on the mutex. If the mutex is already locked, it blocks until the mutex is unlocked and then locks it. -
unlock()
releases the lock on the mutex. -
try_lock()
attempts to lock the mutex but returns immediately if itβs already locked. Below is an example usingtry_lock()
.
std::mutex mtx;
void task() {
std::this_thread::sleep_for(100ms);
std::cout << "task trying to lock the mutex" << std::endl;
while (!mtx.try_lock()) {
std::cout << "task could not lock the mutex" << std::endl;
std::this_thread::sleep_for(100ms);
}
std::cout << "task has locked the mutex" << std::endl;
mtx.unlock();
}
int main() {
std::jthread thr2(task);
}
Here we use
try_lock()
in a while loop to make sure locking the mutex.
Characteristics of Mutex
- Locking : When a thread locks a mutex, any other thread attempting to lock the same mutex will block until the first thread unlocks it.
- Exclusivity : The mutex ensures that only one thread can access the critical section at a given time.
- Mutuality : Threads collectively agree to respect the locked status of the mutex.
- Real Behavior : If the critical section is unlocked, threads can enter it. However, if itβs locked, threads must wait until itβs unlocked.
- Exclusive Access : Once a thread locks a mutex and enters the critical section, it gets exclusive access to this area, ensuring no data races and a consistent state of data.
- Usage : Mutexes can be associated with a particular critical section . Before entering the critical section, each thread must lock the mutex and unlock it upon leaving. As long as every thread adheres to this agreement, only one thread can access the critical section at any given time. This method of using mutexes effectively prevents data races.
Internal and External Synchronization
- Internal synchronization : A class protects its data against races , its member functions donβt need to provide any additional synchronization .
-
External synchronization
: A class
doesnβt protect its data against races
, its member functions
need to synchronize their accesses
, like
std::vector
.
template<typename T>
class MTVector {
std::mutex mtx;
std::vector<T> vec;
public:
template<typename E>
void push_back(E &&e) {
std::lock_gurad<std::mutex> lck_guard(mtx);
vec.push_back(e);
}
void print() {
std::lock_gurad<std::mutex> lck_guard(mtx);
for (auto& i: vec) {
std::cout << i << ", ";
}
std::cout << "\n ";
}
};
Here is a simple wrapper of vector for multithreads. And we use
std::lock_guard
. We will introduce it after.
Lock Guard and
std::lock_guard
lock
and unlock
is a pair, like new
and delete
.
Sometimes we may forget them, because we are human.
std::lock_guard
just a RAII wrapper to execute lock()
in Constructor unlock()
in its Destructor.
using namespace std::literals;
std::mutex mtx;
try {
std::lock_guard<std::mutex> lck_guard(mtx);
throw std::exception();
std::this_thread::sleep_for(50ms);
do_task(1);
} // Calls ~std::lock_guard
catch (std::exception &e) {
std::cout << "Exception caught: " << e.what() << '\n';
}
Meantime, it works well with exception situtaions. So, IMO, the wise option is to use std::lock_guard
most of time.
Unique Lock and
std::unique_lock
std::unique_lock
is also a RAII lock wrapper. Differences are std::unique_lock
:
-
Supports explicit
lock()
andunlock()
operations allowing for more refined control over the mutex. - It has an internal state indicating whether it currently owns the lock on the mutex.
- Support adopt an existing lock or defer locking until explicitly requested.
Recursive Lock with
std::recursive_mutex
std::recursive_mutex
allows multiple lock acquisitions in the same thread without unlocking.
But its need usually indicates design flaws.
However, situations like a recursive function requiring lock before calling itself might necessitate its use.
Timeout Lock and
std::timed_mutex
It offers functionalities with added members for timed waits, which are:
-
try_lock_for()
, waits for a specified duration to acquire the lock. -
try_lock_until()
, until a specific time point to acquire the lock.
So, this protocol requirs manual operations to control. We must use
std::unique_lock
and std::timed_mutex
.
Here are examples of try_lock_for
and try_lock_until
.
std::timed_mutex mtx;
void task2() {
std::this*thread::sleep_for(100ms);
std::cout << "Task2 trying to lock the mutex" << std::endl;
while (!mtx.try_lock_for(100ms)) {
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;
mtx.unlock();
}
/* 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
Task1 unlocking the mutex
Task2 has locked the mutex
*/
void task2()
{
std::this_thread::sleep_for(100ms);
std::cout << "Task2 trying to lock the mutex" << std::endl;
using std::chrono::system_clock;
auto timeout_point = system_clock::now() + std::chrono::milliseconds(100ms);
while (!the_mutex.try_lock_until(timeout_point)) {
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();
}
/* 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
Task1 unlocking the mutex
Task2 has locked the mutex
*/
With
std::timed_mutex
, we can enhance the granularity of lock acquisition attempts. In scenarios where you donβt want to block indefinitely, this comes in handy, allowing repeated timed attempts to lock or waiting until a certain time to lock.
For instance, consider two tasks. Task1 locks the mutex first. Meanwhile, Task2 keeps trying to lock the same mutex. With a regular
try_lock()
, if Task2 fails, it has no idea when to retry. But with
std::timed_mutex
, Task2 can try to lock it for a specific duration or until a certain time, providing more control.
Multiple Reader, Single Writer Model
-
What
is this?
- It is a synchronization pattern where many threads access shared data concurrently, but only a limited number (often just one) modify it.
- Selective Locking : Only certain threads, typically those performing write operations, obtain an exclusive lock, while multiple reader threads can access data simultaneously.
-
Why
use this model?
- Shared data must be protected to avoid data races.
- When a thread accesses the data, it gains exclusive access to ensure it is not interfered with by other threads.
- Multiple threads can safely interleave read access only when there are no potential conflict-causing writer threads.
- Indiscriminately granting every thread an exclusive lock can lead to unnecessary performance degradation.
-
Applications
οΌ
- Financial data stream for stocks that are infrequently traded.
- Audio/video buffering in multimedia players.
A Sample using
std::mutex
-
A
writerTask
that modifies shared data. -
A
readerTask
that accesses but doesnβt modify shared data.
std::mutex dataMutex;
int sharedData = 0;
void readerTask() {
dataMutex.lock();
std::cout << "Reader Reading Data: " << sharedData << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
dataMutex.unlock();
}
void writerTask() {
dataMutex.lock();
sharedData++;
std::cout << "Writer Writing Data: " << sharedData << std::endl;
dataMutex.unlock();
}
int main() {
std::vector<std::jthread> threads;
// create 20 reader threads
for (int i = 0; i < 20; i++)
threads.emplace_back(readerTask);
// create 2 writer threads
for (int i = 0; i < 2; i++)
threads.emplace_back(writerTask);
// create 20 reader threads again
for (int i = 0; i < 20; i++)
threads.emplace_back(readerTask);
return 0;
// await all threads
}
The given code effectively turns into a single-threaded program due to the mutex, which slows down execution considerably.
Shared Mutexes
The C++ Standard Library has implemented this read-write model, known as Shared Mutexes . A shared mutex allows multiple threads to read a shared resource concurrently without blocking each other but ensures exclusive access for modifications.
It has two kinds of locks:
-
Exclusive Lock :
- A thread holding an exclusive lock prevents other threads from acquiring any lock on the shared mutex.
- No other thread can acquire a lock until the current lock holder releases its exclusive lock.
- This can be thought of as a write lock.
-
Shared Lock :
- A thread with a shared lock allows other threads to also obtain shared locks simultaneously.
- Threads wishing to acquire an exclusive lock must wait until all shared lock holders release their locks.
- This can be thought of as a read lock.
A Sample using
std::shared_mutex
- Writer Task : Modifies shared data.
- Reader Task : Accesses shared data without modification.
std::shared_mutex sharedMtx; // Shared mutex
std::mutex dataMutex;
int sharedData = 0;
void readerTask() {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // Simulate some activity
{
std::shared_lock lock(sharedMtx); // Acquire shared lock
{
std::lock_guard<std::mutex> printLock(dataMutex);
std::cout << "Reader accessed data: " << sharedData << std::endl;
}
} // Release shared lock
}
void writerTask() {
{
std::unique_lock lock(sharedMtx); // Acquire exclusive lock
++sharedData;
{
std::lock_guard<std::mutex> printLock(dataMutex);
std::cout << "Writer updated data to: " << sharedData << std::endl;
}
} // Release exclusive lock
}
Observations :
-
With the use of
std::mutex
, reader threads operate sequentially within their critical sections. -
On the other hand, using
std::shared_mutex
, reader threads execute concurrently within their critical sections unless blocked by an exclusive writer lock. This leads to reduced blocking and faster program execution.
Avoiding Data Races with Shared Mutexes
Utilizing shared mutexes effectively prevents data races:
- A writer thread can only acquire an exclusive lock after all other threads have exited their critical sections.
- Once the writer thread acquires an exclusive lock, it bars all other threads from obtaining any kind of lock, ensuring exclusive access to its critical section.
- Reader threads can simultaneously get shared locks as long as no writer thread holds an exclusive lock.
- No circumstance allows both a reader and a writer thread to run concurrently in a critical section.
- Therefore, the prerequisites for a data race are completely eliminated.
Lazy Initialization
-
What is this?
- Lazy initialization is a technique where a variable is initialized only when itβs first accessed.
-
Why should we use it?
- It is especially useful when the instantiation process is costly, like when allocating large memory or connecting to a network. It can also be valuable in environments with many threads.
-
TODO
: Manual Thread Pool
-
How to implemnet in modern C++?
-
std::call_once
andstd::once_flag
-
std::call_once
-
This function ensures that given a
std::once_flag
, a callable object (like a function or lambda) is called only once, regardless of how many threads attempt to call it.
std::once_flag
-
It is a flag object used to ensure that a function is called only once when used with
std::call_once
.