Operating System Software-Based Solution for Process Synchronization



To solve the process synchronization problem in a multitasking operating system, we can implement software-based solutions or hardware-based solutions. Software based solutions refers to the algorithms and programming techniques that are used inside processes to achieve synchronization without using any special hardware instructions.

In this chapter, we will focus on understanding the software-based solutions that can be implemented to mutual exclusion and synchronization between multiple processes. Here are some popular algorithms that provide software-based solutions for process synchronization −

Peterson's Algorithm

Peterson's Algorithm is a classic solution for achieving mutual exclusion between two processes. The idea here is to use two shared variables −

  • flag[i] − indicates whether process i wants to enter its critical section.
  • turn − indicates whose turn it is to enter the critical section.

The algorithm works as follows −

  • Change flag[i] to true, (means, process i wants to enter its critical section).
  • Set turn to j, indicating that it's the other process's turn.
  • While flag[j] is true (the other process wants to enter its critical section) and turn equals j (it's the other process's turn), wait.
  • Enter the critical section.
  • Set flag[i] to false, indicating that process i has finished its critical section.

Example

The following is the implementation of Peterson's Algorithm in C++

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

atomic<bool> flag[2] = {false, false};
atomic<int> turn;

void peterson_process(int i) {
    int j = 1 - i;
    while (true) {
        // Indicate that process i wants to enter its critical section
        flag[i] = true;
        // Set turn to the other process
        turn = j;
        // Wait until the other process is not interested or it's this process's turn
        while (flag[j] && turn == j) {
            // Busy wait
        }
        // Critical Section
        cout << "Process " << i << " in critical section" << endl;
        // Exit Section
        flag[i] = false;
        // Remainder Section
        cout << "Process " << i << " in remainder section" << endl;
    }
}

int main() {
    thread t1(peterson_process, 0);
    thread t2(peterson_process, 1);
    t1.join();
    t2.join();
    return 0;
}

The output of the above code will be −

Process 0 in critical section
Process 0 in remainder section
Process 1 in critical section
Process 1 in remainder section 
...

Dekker's Algorithm

In Dekker's Algorithm, uses two flags and a turn variable to achieve mutual exclusion between two processes. Every process have its own flag to indicate whether it wants to enter its critical section or not. The turn variable is used to indicate whose turn it is to enter the critical section.

The algorithm works as follows −

  • Create a turn variable to indicate who may go first.
  • To enter: set your flag[i] = true.
  • While the other flag[j] is true and turn == j, set flag[i] = false and wait for your turn; when turn != j set flag[i] = true again.
  • Enter the critical section.
  • After exit: set turn = j and flag[i] = false.

Example

The following is the implementation of Dekker's Algorithm in C++

#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>

std::atomic<bool> want1{false};
std::atomic<bool> want2{false};
std::atomic<int> favoured{1};
const int ITERATIONS = 10;

void thread1() {
    for (int i = 0; i < ITERATIONS; ++i) {
        // indicate intent to enter
        want1.store(true);

        // entry section: wait while thread 2 wants to enter
        while (want2.load()) {
            if (favoured.load() == 2) {
                // yield to thread 2 and wait until favoured changes
                want1.store(false);
                while (favoured.load() == 2) {
                    std::this_thread::yield();
                }
                want1.store(true);
            }
        }

        // critical section
        std::cout << "Thread 1 entering critical section (iteration " << i << ")" << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::cout << "Thread 1 leaving critical section (iteration " << i << ")" << std::endl;

        // favour the other thread and exit
        favoured.store(2);
        want1.store(false);

        // remainder section
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
}

void thread2() {
    for (int i = 0; i < ITERATIONS; ++i) {
        // indicate intent to enter
        want2.store(true);

        // entry section: wait while thread 1 wants to enter
        while (want1.load()) {
            if (favoured.load() == 1) {
                // yield to thread 1 and wait until favoured changes
                want2.store(false);
                while (favoured.load() == 1) {
                    std::this_thread::yield();
                }
                want2.store(true);
            }
        }

        // critical section
        std::cout << "Thread 2 entering critical section (iteration " << i << ")" << std::endl;
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
        std::cout << "Thread 2 leaving critical section (iteration " << i << ")" << std::endl;

        // favour the other thread and exit
        favoured.store(1);
        want2.store(false);

        // remainder section
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
}

int main() {
    std::thread t1(thread1);
    std::thread t2(thread2);

    t1.join();
    t2.join();

    std::cout << "Both threads completed." << std::endl;
    return 0;
}

The output of the above code will be −

Thread 1 entering critical section (iteration 0)
Thread 1 leaving critical section (iteration 0)
Thread 2 entering critical section (iteration 0)
Thread 2 leaving critical section (iteration 0)
Thread 1 entering critical section (iteration 1)
....
Both threads completed.

Bakery Algorithm

Bakery Algorithm is another solution for achieving mutual exclusion among multiple processes. The algorithm uses the idea of allocating tokens to customers in a bakery, to decide the order in which they will be served.

To implement the Bakery Algorithm for process synchronization, we use two shared arrays −

  • choosing[i](boolean) − Stores whether process i is in the process of choosing a number.
  • number[i](integer) − holds the number assigned to process i, This is the token to enter the critical section.

The algorithm works as follows −

  • Set the choosing[i] to true, indicating that process i is in the process of choosing a ticket number.
  • Now, set the number[i] to 1 plus the maximum ticket number among all processes.
  • Then, set choosing[i] to false, indicating that process i has finished choosing its ticket number.
  • For each process j, wait until choosing[j] is false (i.e., process j has finished choosing its ticket number).
  • For each process j, wait until either number[j] is 0 (i.e., process j is not interested in entering its critical section) or (number[j], j) is greater than (number[i], i) (i.e., process j has a higher ticket number or the same ticket number but a higher process ID).
  • Enter the critical section.
  • Set number[i] to 0, indicating that process i has finished its critical section.
  • Remainder section.

Example

The section below implements the Bakery Algorithm in C++.

#include <iostream>
#include <thread>
#include <vector>
#include <algorithm>
#include <atomic>

using namespace std;
const int NUM_PROCESSES = 5;
atomic<bool> choosing[NUM_PROCESSES];
atomic<int> number[NUM_PROCESSES];

void bakery_process(int i) {
    for (int iter = 0; iter < 5; iter++) {
        // Choosing a number
        choosing[i] = true;
        number[i] = 1 + *max_element(number, number + NUM_PROCESSES);
        choosing[i] = false;

        // Waiting for other processes
        for (int j = 0; j < NUM_PROCESSES; j++) {
            while (choosing[j]);
            while (number[j] != 0 && (number[j] < number[i] || (number[j] == number[i] && j < i)));
        }

        // Critical Section
        cout << "Process " << i << " is in its critical section." << endl;

        // Exit Section
        number[i] = 0;

        // Remainder Section
        cout << "Process " << i << " is in its remainder section." << endl;
    }
}

int main() {
    vector<thread> processes;
    for (int i = 0; i < NUM_PROCESSES; i++) {
        choosing[i] = false;
        number[i] = 0;
    }
    for (int i = 0; i < NUM_PROCESSES; i++) {
        processes.push_back(thread(bakery_process, i));
    }
    for (auto &process : processes) {
        process.join();
    }
    return 0;
}

The output of the above code will be −

Process 0 is in its critical section.
Process 0 is in its remainder section.
Process 1 is in its critical section.
Process 1 is in its remainder section.
...

Peterson's Algorithm vs Bakery Algorithm vs Dekker's Algorithm

Here is a comparison between Peterson's Algorithm, Bakery Algorithm, and Dekker's Algorithm based on various factors −

Factor Peterson's Algorithm Bakery Algorithm Dekker's Algorithm
Idea Behind the Algorithm Uses two flags and a turn variable to achieve mutual exclusion between two processes. Uses a ticketing system to allocate numbers to processes, similar to customers in a bakery. Uses two flags and a turn variable. But more complex than Peterson's algorithm.
Number of Processes Two processes only Multiple processes (N processes) Two processes only
Complexity Simple and easy to implement More complex due to ticketing mechanism Complex due to multiple flags and turn variable
Fairness Fair, as it uses turn variable Fair, as it uses ticket numbers Fair, but can lead to starvation in some cases
Performance Efficient for two processes Less efficient due to overhead of ticketing Less efficient due to busy waiting and complexity
Use Cases Suitable for two-process synchronization scenarios Suitable for multi-process synchronization scenarios Primarily of theoretical interest; rarely used in practice

Conclusion

There are three popular algorithm to achieve process synchronization. These are also called as software-based solutions for process synchronization. Peterson's Algorithm is classical algorithm that is simple and efficient for two processes. Dekker's Algorithm is the more complex version of Peterson's algorithm. Bakery Algorithm is more general solution that can be used for more than two processes.

Advertisements