Thread synchronization with Mutex, Lock_guard, Unique_lock

Introduction

In earlier blog, Thread and Process in C++ – My sky (freewindcode.com), I have explained some aspects of threads that are problematic:

  • Threads can share and access same data, thus potentially give rise to Data Race issue.
  • When a new thread is created from its main thread, CPU may run threads in any order. A program has no guarantees about which thread will next be scheduled to use the CPU or according to specified synchronization techniques.

In this blog, I explain concurrency. data race issue and the tools to implement thread synchronization.

Concept: Concurrency

Concurrency: running multithreads to perform tasks. The tasks however are switched between different threads.

  • if the functions both impacted the same variable, sequential execution is preferred.

Parallelism: also running multithreads to perform tasks. However, the threads perform the same tasks together.

Concept: Race condition

Race condition describes the scenario that multiple threads can modify a value in uncontrollable, unexpected way, when no threads synchronization is implemented.

The following programs spawns 2 threads to increment the same global values in parallel.

  • Each thread is started and runs a loop to increment the values.
  • We have no control on which thread to run first .
  • We have no control if a thread accesses the value at the same time or right after one another.

The result is that after each program run, the global value differs wildly.

#include <thread>
#include <iostream>
using namespace std;

static int glob = 0;

static void threadFunc(int loop)
{

    for (int j = 0; j < loop; j++) {
        glob++;
    }

}
int main(int argc, char *argv[])
{
     
     int loops=10000000;
    
     std::thread t1 (threadFunc, loops);
     std::thread t2 (threadFunc, loops);
     
     t1.join();
     t2.join();
     
     std::cout<<glob<<endl;
     return 0;
}

Mutex

Mutex "lock" or "acquires" a part of codes, aka "critical section". We have:

  • mutex object initialized in global scope.
  • mutex locks at the beginning of the for loop and unlocks at the end of the for loop.
  • thus, the threads run concurrently: thread t1 or t2 runs the entire for loop. Then the remaining thread picks up the glob value and run the for loop.

Mutex performance

Mutexes are implemented using atomic machine-languageoperations (performed on memory locations visible to all threads) and require system calls only in case of lock contention.

Compared to using file lock in Linux to lock a critical section, mutex is faster.

The following program demonstrates the use of mutex.

#include <thread>
#include <iostream>
#include <mutex>
using namespace std;

static int glob = 0;
std::mutex mtx;


static void threadFunc(int loop)
{
    mtx.lock();
    
    const auto now = std::chrono::system_clock::now();
    const std::time_t t_c = std::chrono::system_clock::to_time_t(now);
    std::cout << "Time:  " << std::ctime(&t_c);
    std::cout<<"threadID: "<<std::this_thread::get_id()<<std::endl;
    
    for (int j = 0; j < loop; j++) {
        glob++;
    }
    
    mtx.unlock();
}
int main(int argc, char *argv[])
{
     
     int loops=10000000;
    
     std::thread t1 (threadFunc, loops);
     std::thread t2 (threadFunc, loops);
     
     t1.join();
     t2.join();
     
     std::cout<<glob<<endl;
     return 0;
}

After the program exit, the glob value is always 20000000, since each thread increments the glob value by 10000000

Time:  Fri Aug  9 04:08:34 2024
threadID: 132559802152512
Time:  Fri Aug  9 04:08:34 2024
threadID: 132559793759808
20000000

Also notes that the thread start one after another, but we don't know which thread, t1 or t2, start first. The UML activity chart for the above program could be like this:

Lock_guard

The class lock_guard is a mutex wrapper that provides a convenient RAII-style mechanism for owning a mutex for the duration of a scoped block (Cplusplus Reference)

The following program demonstrates usage of lock_guard. In the program, lock_guard only locks the critical section within the braces {}, also the scope of function threadFunc.

#include <thread>
#include <iostream>
#include <mutex>
#include <chrono>
using namespace std;

static int glob = 0;
std::mutex mtx;

static void threadFunc(int loop)
{
     std::lock_guard<std::mutex> lock(mtx);
        
     const auto now = std::chrono::system_clock::now();
     const std::time_t t_c = std::chrono::system_clock::to_time_t(now);
     std::cout << "Time:  " << std::ctime(&t_c);
     std::cout<<"threadID: "<<std::this_thread::get_id()<<std::endl;
        
     for (int j = 0; j < loop; j++) {
         glob++;
     }

     std::cout<<"glob "<<glob<<std::endl;

}
int main(int argc, char *argv[])
{
     
     int loops=10000000;
    
     std::thread t1 (threadFunc, loops);
     std::thread t2 (threadFunc, loops);
     
     t1.join();
     t2.join();
     
     std::cout<<glob<<endl;
     return 0;
}

Output

Time:  Fri Aug  9 04:02:16 2024
threadID: 140477026113088
glob 10000000
Time:  Fri Aug  9 04:02:16 2024
threadID: 140477017720384
glob 20000000
20000000

The following program demonstrates that lock_guard only lock the codes within a scope. The programs purposedly enclosed lock_guard in a brace pair, and further run a loop outside the braces. It shows that the threads runs in parallel and cause race data issue.

#include <thread>
#include <iostream>
#include <mutex>
#include <chrono>
using namespace std;

static int glob = 0;
std::mutex mtx;

static void threadFunc(int loop)
{
    {
        std::lock_guard<std::mutex> lock(mtx);
        std::cout<<"***lock critical section"<<std::endl;
        const auto now = std::chrono::system_clock::now();
        const std::time_t t_c = std::chrono::system_clock::to_time_t(now);
        std::cout<<"Time:  " << std::ctime(&t_c);
        std::cout<<"ThreadID: "<<std::this_thread::get_id()<<std::endl;
        for (int j = 0; j < loop; j++) {
            glob++;
        }
        std::cout<<"glob "<<glob<<std::endl;
        std::cout<<"***unlock critical section"<<std::endl;
    }

    std::cout<<"***threads running non-critical section in parallel, race condition occurs"<<std::endl;
    for (int j = 0; j < loop; j++) {
            glob++;
    }
    std::cout<<"glob "<<glob<<std::endl;

}
int main(int argc, char *argv[])
{
     
     int loops=10000000;
    
     std::thread t1 (threadFunc, loops);
     std::thread t2 (threadFunc, loops);
     
     t1.join();
     t2.join();
     
     return 0;
}

Output:

The glob value changes wildly in multiple runs\

***lock critical section
Time:  Fri Aug  9 04:46:42 2024
ThreadID: 123930279085632
glob 10000000
***unlock critical section
***thread running non-critical section
***lock critical section
Time:  Fri Aug  9 04:46:42 2024
ThreadID: 123930270692928
glob 21190478
***unlock critical section
***thread running non-critical section
glob 22084982
glob 31229044

Unique_lock

The class unique_lock is a general-purpose mutex ownership wrapper allowing deferred locking, time-constrained attempts at locking, recursive locking, transfer of lock ownership, and use with condition variables.

  • When a unique_lock is constructed with the following constructor, it can take in an associate mutex mtx and immediately starts locking by invoking mtx.lock
std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
  • Alternately, when provided with appropriate optional parameter, the unique_lock can defer locking associated mutex
std::mutex mtx;
std::unique_lock<std::mutex> lk_b(m_b, std::defer_lock);
  • when unique_lock's destructor is invoked, the associate mutex also unlock.
  • alternately, unique_lock can also unlock the mutex using function unlock().
#include <thread>
#include <iostream>
#include <mutex>
using namespace std;

static int glob = 0;
std::mutex mtx;


static void threadFunc(int loop)
{
    
    std::unique_lock<std::mutex> lock(mtx);
    std::cout<<"threadID: "<<std::this_thread::get_id()<<std::endl;
    for (int j = 0; j < loop; j++) {
        glob++;
    }
    std::cout<<"glob "<<glob<<std::endl;
    
    lock.unlock();  // can omit because unique_lock destructor is called
                    // when going out of scope. 
}

int main(int argc, char *argv[])
{
     
     int loops=10000000;
    
     std::thread t1 (threadFunc, loops);
     std::thread t2 (threadFunc, loops);
     
     t1.join();
     t2.join();

     return 0;
}

Leave a Reply

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