Continuing from the last article, which utilizes Mutex, let’s delve into the major synchronization usages and approaches. Also, you may check out my course notes on synchronization from online OS course delivered by PKU.

Outline
(1) Race Condition & Solution
(2) Synchronization Approaches: Mutex, Atomic Operation, & Condition Variable
(3) References

(1) Race Condition & Solution

1–1 Race condition

Figure: Illustration of race condition. From Race Condition and Deadlock
// 6_raceCondition_01.cpp
#include <iostream>
#include <thread>
#include <chrono>

int sharedVariable = 0;

void increment() {
for (int i = 0; i < 100000; ++i) {
int currentValue = sharedVariable;
std::this_thread::sleep_for(std::chrono::microseconds(1)); // Introduce a delay
sharedVariable = currentValue + 1;
}
}

int main() {
std::thread thread1(increment);
std::thread thread2(increment);

thread1.join();
thread2.join();

std::cout << "Expected value: 200000" << std::endl;
std::cout << "Actual value: " << sharedVariable << std::endl;

return 0;
}

The output:

Figure: The output of the race condition example (on my Ubuntu VPS)
root@DemoYuChengKuo:~/3_multithreading_cpp/code_02_synchronization# ./6_raceCondition_01
Expected value: 200000
Actual value: 100000

1–2 Solution to the race condition example: Mutex

// 7_raceCondition_02.cpp
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>

int sharedVariable = 0;
std::mutex mtx; // Mutex for synchronization

void increment() {
for (int i = 0; i < 100000; ++i) {
mtx.lock(); // Lock the mutex
int currentValue = sharedVariable;
std::this_thread::sleep_for(std::chrono::microseconds(1)); // Introduce a delay
sharedVariable = currentValue + 1;
mtx.unlock(); // Unlock the mutex
}
}

int main() {
std::thread thread1(increment);
std::thread thread2(increment);

thread1.join();
thread2.join();

std::cout << "Expected value: 200000" << std::endl;
std::cout << "Actual value: " << sharedVariable << std::endl;

return 0;
}

The output:

Figure: The output of the race condition example (on my Ubuntu VPS)
root@DemoYuChengKuo:~/3_multithreading_cpp/code_02_synchronization# ./7_raceCondition_02 
Expected value: 200000
Actual value: 200000

(2) Synchronization Approaches: Mutex, Atomic Operation, & Condition Variable

We’ll see use cases with 3 types of synchronization approaches: Mutex, Atomic Operation, & Condition Variables.

  • [1] Mutex: Mutexes are used to protect shared resources by ensuring that only one thread can access the resource at a time.
  • [2] Atomic operation: Atomic operations are a type of synchronization that can be used to avoid race conditions without using locks. They ensure that operations on a variable are completed without interruption.

Let’s view an instance with Mutex & Atomic Operation.

// 8_raceCondition_03.cpp
#include <iostream>
#include <thread>
#include <chrono>
#include <mutex>
#include <atomic>

// (1) Race Condition 01: The race condition may not happen if the CPU is faster enough
int sharedVariable_race_01 = 0;

void increment_race_01() {
for (int i = 0; i < 100000; ++i) {
sharedVariable_race_01++;
}
}

// (2) Race Condition 02: Introduce delay to make race condition happen definitely
int sharedVariable_race_02 = 0;
int currentValue_02 = sharedVariable_race_02;

void increment_race_02() {
for (int i = 0; i < 100000; ++i) {
int currentValue_02 = sharedVariable_race_02;
std::this_thread::sleep_for(std::chrono::microseconds(1)); // Introduce a delay
sharedVariable_race_02 = currentValue_02 + 1;
}
}

int sharedVariable_mutex = 0;
std::mutex mtx;

void increment_mutex() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx); // Automatically locks and unlocks
sharedVariable_mutex++;
}
}
/*
std::lock_guard is a safer alternative to manual locking and unlocking because it
automatically unlocks the mutex when it goes out of scope.
*/

std::atomic<int> sharedVariable_atomic(0);

void increment_atomic() {
for (int i = 0; i < 100000; ++i) {
sharedVariable_atomic++;
}
}
/*
In this example, sharedVariable is declared as std::atomic<int>.
Atomic operations are indivisible, ensuring that the variable is incremented
correctly even when multiple threads access it concurrently.
*/


int main() {
std::thread thread1(increment_race_01);
std::thread thread2(increment_race_01);
std::thread thread3(increment_race_02);
std::thread thread4(increment_race_02);

std::thread thread5(increment_mutex);
std::thread thread6(increment_mutex);
std::thread thread7(increment_atomic);
std::thread thread8(increment_atomic);

thread1.join();
thread2.join();
thread3.join();
thread4.join();
thread5.join();
thread6.join();
thread7.join();
thread8.join();

std::cout << "Expected value: 200000" << std::endl;

std::cout << "Actual value of Race Condition: " << sharedVariable_race_01 << std::endl;
std::cout << "Actual value of Race Condition: " << sharedVariable_race_02 << std::endl;

std::cout << "Actual value of Mutex: " << sharedVariable_mutex << std::endl;
std::cout << "Actual value of Atomic: " << sharedVariable_atomic.load() << std::endl;

return 0;
}

The output:

Figure: The output on my laptop (with Intel i7–1165G7)
Figure: The output on my Ubuntu VPS (with AMD EPYC-Rome)

The output on my laptop (with Intel i7–1165G7) & my Ubuntu VPS (with AMD EPYC-Rome) are different. Still, we can see the usages of Mutexes & Atomic Operations.

Now, let’s see a more complex application of Mutex: Condition Variable

  • [3] Condition Variable: Condition variables are used for complex synchronization problems, particularly when a certain condition needs to be met before a thread can proceed. They are often used in conjunction with mutexes.
// 9_conditionVariable.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void workerThread() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; });

// Perform work after the condition is met
std::cout << "Worker thread is processing data." << std::endl;
}

int main() {
std::thread worker(workerThread);

{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one();

worker.join();
std::cout << "Back in main." << std::endl;

return 0;
}

The output:

Figure: The output of condition variable (on my Ubuntu VPS)
root@DemoYuChengKuo:~/3_multithreading_cpp/code_02_synchronization# ./9_conditionVariable 
Worker thread is processing data.
Back in main.

(3) References

[1] cloudxlab | Race Condition and Deadlock
[2] Yu-Cheng Kuo | Hands-On Multithreading with C++ 01 — Overview
[3] Yu-Cheng Kuo | OS Walkthrough 02 — Synchronization
[4] ChatGPT-4

--

--

Yu-Cheng (Morton) Kuo
Nerd For Tech

CS/DS blog with C/C++/Embedded Systems/Python. Embedded Software Engineer. Email: yc.kuo.28@gmail.com