Concurrency(3) Understanding Mutex

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 using try_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:

  1. Supports explicit lock() and unlock() operations allowing for more refined control over the mutex.
  2. It has an internal state indicating whether it currently owns the lock on the mutex.
  3. 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:

  1. 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.
  2. 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 and std::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 .
© 2023 🐸 Fanxiang Zhou 🐸