Synchronization

Talking about the race condition and the synchronization methods.

Table of contents

Race condition

When using the shared memory / resources, race condition can occur. In the diagram Thread1 and Thread2 tries to increase the shared variable a, but if the data captured both Thread 1 and Thread 2 and not returned yet from those threads, in this case, we can see the unexpected behavior that a=3 not a=4. In this case, attempting to accessing the shared variable a causes this race, and this kind of operation is called as a critical section. Dealing with this race conditions safely and efficiency is the key stone of the multithreading(multiprocessing), which is called as synchronization.

#include <array>
#include <chrono>
#include <iostream>
#include <thread>

#include "Timer.h"

void IncreaseA(const int numIter, int* shared_ptr)
{
   int i = 0;
   using namespace std::chrono_literals;
   for (int i = 0; i < numIter; ++i) {
       ++(*shared_ptr);
       std::this_thread::sleep_for(10us);
   }
}

int main()
{
  
   Timer timer;
   constexpr int NUM_THREADS = 4;
   constexpr int NUM_ITERATIONS = 10'000;

   int shared_value = 0;
  
   std::array<std::thread, NUM_THREADS> threads;
  
   for(int i=0; i<NUM_THREADS; ++i)
   {
       threads[i] = std::thread(IncreaseA, NUM_ITERATIONS, &shared_value);
   }

   for(int i=0; i<NUM_THREADS; ++i)
   {
       threads[i].join();
   }
  
   std::cout << shared_value << "\n";

}

Traditional Solution: Lock

Once one Thread enters a Critical Section to perform a task, a lock prevents other Threads from performing that task.

  • Lock: An abstract concept for a critical section. It refers to a mechanism that prevents all processes from touching resources or code blocks that one or more threads are simultaneously accessing.

  • Mutex: It is one of the concrete tools that implement the Lock concept. As its name implies, it is the most representative way to allow only one thread to access at a given time by providing mutual exclusion

Modern Solution - Lockfree : Atomic operations

In modern computers, atomic operations supports in hardware / OS levels. These atomic operation ensures the un-breakable operations such as ordering memories in store and load memories, CAS (compare and swap), and TAS (test and set).

Here’s the example of Lock in C++

#include <array>
#include <chrono>
#include <iostream>
#include <thread>
#include <mutex>

#include "Timer.h"

void IncreaseA(const int numIter, int* shared_ptr, std::mutex& mtx)
{
   int i = 0;
   using namespace std::chrono_literals;
   for (int i = 0; i < numIter; ++i)
   {
       { // mtx was given by reference, lock_guard is RAII Wrapper, when the lock is out of the code block, it safely unlock the mutex.
           std::lock_guard<std::mutex> lk(mtx);
           ++(*shared_ptr);
       } // unlock occurred - RAII
       std::this_thread::sleep_for(10us);
   }
}

int main()
{
  
   std::mutex mtx;
  
   constexpr int NUM_THREADS = 4;
   constexpr int NUM_ITERATIONS = 10'000;

   int shared_value = 0;
  
   std::array<std::thread, NUM_THREADS> threads;
   {
       Timer timer;
       for(int i=0; i<NUM_THREADS; ++i)
       {
           threads[i] = std::thread(IncreaseA, NUM_ITERATIONS, &shared_value, std::ref(mtx));
       }

       for(int i=0; i<NUM_THREADS; ++i)
       {
           threads[i].join();
       }
   }
   std::cout << shared_value << "\n";

}

TAS (for Lock)

The following TAS algorithm is just for the understanding TAS (mutex). Since the following code is not actually hardware system, it causes data race.


// TAS
// This TAS is not actually working because of the following TAS not atomic operation. 
bool TestAndSet(bool *p)
{
   if (*p)
   {
       return true;
   }
   else
   {
       *p = true;
       return false;
   }

}

class Mutex
{   
public:
   void Lock() {
       while (TestAndSet(&m_IsLocked)) {
          // Since TAS, if m_IsLocked starts with false,
          // it returning false and at the same time, it locks the
          // member variable with m_IsLocked = true;
       }
       // If it is not unlocked in the same code block,
       // Because of the pointer, m_IsLocked keep as true.
       // This makes the others be keep waiting.
   }
   void Unlock() {
       // Must Lock first then Unlock 
       m_IsLocked = false;
   }

private:
   bool m_IsLocked = false; // false: unlocked, true: locked

};

In C++, we can use std::atomic stl for the atomic operation system calls.

#include <atomic>

class Mutex {
public:
   void Lock() {
       while (m_IsLocked.exchange(true)) {} // TAS
   }
   void Unlock() {
       m_IsLocked.store(false);
   }
private:
   std::atomic<bool> m_IsLocked = false;
};

for the real lock free pattern, C++ std::atomic offers std::atomic<T> for the shared variables.

#include <array>
#include <chrono>
#include <iostream>
#include <thread>
#include <atomic> // Include for atomic operations

#include "Timer.h" // Assuming Timer is defined elsewhere

// Function using std::atomic
void IncreaseAAtomic(const int numIter, std::atomic<int>* shared_atomic_ptr)
{
    using namespace std::chrono_literals;
    for (int i = 0; i < numIter; ++i) {
        (*shared_atomic_ptr)++; // Atomic increment
        std::this_thread::sleep_for(10us);
    }
}

int main()
{
    constexpr int NUM_THREADS = 4;
    constexpr int NUM_ITERATIONS = 10'000;

    std::atomic<int> shared_atomic_value = 0; // Use std::atomic

    std::array<std::thread, NUM_THREADS> threads;
    {
        Timer timer; // Assuming Timer works here
        for(int i=0; i<NUM_THREADS; ++i)
        {
            threads[i] = std::thread(IncreaseAAtomic, NUM_ITERATIONS, &shared_atomic_value);
        }

        for(int i=0; i<NUM_THREADS; ++i)
        {
            threads[i].join();
        }
    }
    std::cout << shared_atomic_value << "\n"; // Access atomic value
    return 0;
}