Parallel programming in Java (4/4): Philosophers dining (Liveness)

Intro

This article is depending on this course on LinkedIn Learning

This is part 4 out of 4, to scratch the surface of parallel programming in Java, and how we can make use of threads using Java 11+

Part 1: Parallel programming in Java (1/4): In the kitchen (Threading)

Part 2: Parallel programming in Java (2/4): Ordering the ingredients (Data Race)

Part 3: Parallel programming in Java (3/4): Let's cook (Locks)

In each article, we will have a real-life example to align with the concepts, then we will elaborate on the code.

Philosophers dining

We have a gathering for some philosophers who do nothing but thinking and eating, eating and thinking.

We have served delicious sushi for them, each plate has its own two chopsticks.

Philosopher(A) and Philosopher(B) are sitting together and sharing the same plate and two chopsticks

Dead Lock

Philosopher (A) has a chopstick close (Chop(A)) and Philosopher(B) has another chopstick close (Chop(B)).

Philosopher(A) wanted to eat, grabbed the close chopstick(A) then grabbed the far one (B) then started to eat, then released them in the same order (B) then (A)

Philosopher(B) then wanted to eat, grabbed the close chopstick(B) then grabbed the far one (A) then started to eat, then released them in the same order (A) then (B)

At a specific moment, both of them wanted to eat. Philosopher(A) grabbed chop(A), and Philosopher(B) grabbed chop(B) and they both waited for the other chopstick.

After hours of waiting, Philosopher(C) suggested, the chopsticks shall be in the middle of the table, so each of them wants to eat, first, grab chopstick(A), and then chopstick(B) to avoid such scenarios during the night.

Abandoned Lock

After the agreement, Philosopher(A) and Philosopher(B) continued eating. Philosopher(A) grabbed chop(A), then chop(B) and then suddenly the mobile rang. Philosopher(A) seemed very anxious and left the dining, taking the two chopsticks away.

Philosopher(B) wanted to eat, but the chopsticks are gone.

Starvation

Philosopher(C) has noticed that Philosopher(B) can't eat since Philosopher(A) took the chopsticks away. so, generously, Philosopher(C) invited Philosopher(B) to the plate.

Philosopher(C) held the chopsticks and kept eating, and eating, and eating. Each time philosopher(B) tries to have the chopsticks, they are in the hands of Philosopher(C).

Philosopher(C) finally left the chopsticks, and hungry philosopher(B) tried to grab them, Philosopher(D) came and took them, and then Philosopher(E).

Finally, Philosopher(C) noticed that Philosopher(B) hasn't grabbed the chopsticks for too long. After a long time of thinking, Philosopher(C) suggested that there shall be a priority according to the importance of the philosopher on the table.

Live Lock

There is only one piece of sushi left on the plate, Philosopher(C) gave the chopsticks to Philosopher(B) saying "You shall be hungry, sir. Please have the last piece" Philosopher(B) replied. "That can't be. You shall take the last piece" and gave the chopsticks back. The chopsticks have been given and taken for too long and the last piece of sushi is still there.

A brilliant idea came to Philosopher(C). Philosopher(C) gave the chopsticks to Philosopher(B) and then went to the restroom. So, Philosopher(B) had no choice but to eat the last sushi piece.

In the code

The locks may cause serious problems in your program, so you have to be very careful using them.

There are some scenarios we are mentioning here you have to take care of

Dead Lock

This occurs when a shared resource is having two or more locks (let's say lock(I) and lock(II)), each thread is locking the resource but in a different order, they may come to a situation where thread(A) is locking (lock(I)) and thread(B) is locking (lock(II)) and they both are waiting. the application is stuck with almost zero CPU usage.

Dead locks are tricky to find as they may work fine with low numbers. However, with a slight increase in the number, the dead lock occurs (for example, this code works fine at 5, but gives dead lock at 50)

import java.util.concurrent.locks.ReentrantLock;

class Philosopher extends Thread {
    private final ReentrantLock firstChopStick, secondChopStick;
    private static int sushiCount = 50;


    Philosopher(String name, ReentrantLock firstChopStick, ReentrantLock secondChopStick) {
        this.setName(name);
        this.firstChopStick = firstChopStick;
        this.secondChopStick = secondChopStick;
    }

    public void run() {
        while (sushiCount > 0) {
            firstChopStick.lock();
            secondChopStick.lock();

            if (sushiCount > 0) {
                sushiCount--;
                System.out.println(this.getName() + " took a piece, remaining: " + sushiCount);
            }

            secondChopStick.unlock();
            firstChopStick.unlock();

        }
    }
}

public class DeadLock {
    public static void main(String[] args) {
        ReentrantLock chopStickA = new ReentrantLock();
        ReentrantLock chopStickB = new ReentrantLock();
        ReentrantLock chopStickC = new ReentrantLock();
        // Each philosopher is using different chopstick as start
        new Philosopher("Ph-A", chopStickA, chopStickB).start();
        new Philosopher("Ph-B", chopStickB, chopStickC).start();
        new Philosopher("Ph-C", chopStickC, chopStickA).start();

    }
}

The CPU usage is low in the dead lcok

We can have two solutions for this, one is to prioritize the locks so that the locks are always passed in order. Another one is to unlock the current lock.

1- Prioritizing the locks

// We can prioritize the locks as following
// chopStickA -> chopStickB -> chopStickC

new Philosopher("Ph-A", chopStickA, chopStickB).start();
new Philosopher("Ph-B", chopStickB, chopStickC).start();
new Philosopher("Ph-C", chopStickA, chopStickC).start();

2- Giving up the lock in case the other lock is not available

// The run method in the thread shall be as following
import java.util.Random;
private final Random rand = new Random();

public void run() {
    while (sushiCount > 0) {
        firstChopStick.lock();
        // Adding the tryLock method here
        // Pass if the lock is unavailable
        if (! secondChopStick.tryLock()) {
            firstChopStick.unlock();
            try {
                Thread.sleep(rand.nextInt(3));
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            continue
        }

        if (sushiCount > 0) {
            sushiCount--;
            System.out.println(this.getName() + " took a piece, remaining: " + sushiCount);
        }

        secondChopStick.unlock();
        firstChopStick.unlock();

    }
}

Abandoned Lock

This occurs when a thread is throwing an exception while it implements the lock. Other threads can't access the critical section.

The solution for this is to add a try-catch exception around the critical section, and have the unlock part in the (finally) block

import java.util.concurrent.locks.ReentrantLock;

class PhilosopherAbandonedLock extends Thread {
    private final ReentrantLock firstChopStick, secondChopStick;
    private static int sushiCount = 50_000;


    PhilosopherAbandonedLock(String name, ReentrantLock firstChopStick, ReentrantLock secondChopStick) {
        this.setName(name);
        this.firstChopStick = firstChopStick;
        this.secondChopStick = secondChopStick;
    }

    public void run() {
        while (sushiCount > 0) {
            firstChopStick.lock();
            secondChopStick.lock();
            // Solve by adding try-catch block around the critical section
            // Add the unlock part in the "finally" block
            try {
                if (sushiCount > 0) {
                    sushiCount--;
                    System.out.println(this.getName() + " took a piece, remaining: " + sushiCount);
                }

                if (sushiCount == 10)
                    // This throws exception
                    // Without try-catch block will cause abandonment lock
                    System.out.println(1/0);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                secondChopStick.unlock();
                firstChopStick.unlock();
            }
        }
    }
}

public class AbandonedLock {
    public static void main(String[] args) {
        ReentrantLock chopStickA = new ReentrantLock();
        ReentrantLock chopStickB = new ReentrantLock();
        ReentrantLock chopStickC = new ReentrantLock();

        // Abandonment Lock
        new PhilosopherAbandonedLock("Ph-A", chopStickA, chopStickB).start();
        new PhilosopherAbandonedLock("Ph-B", chopStickB, chopStickC).start();
        new PhilosopherAbandonedLock("Ph-C", chopStickA, chopStickC).start();
    }
}

Starvation

This scenario occurs in three cases:

  1. The thread is having a low priority (Note that priorities are handled by the operating system)

  2. The locks in the threads are not the same

     // Thread (Ph-B) is most-likely to have the highest opportunities
     // Because it doesn't share the lock with any other thread
     new PhilosopherAbandonedLock("Ph-A", chopStickA, chopStickB).start();
     new PhilosopherAbandonedLock("Ph-B", chopStickB, chopStickC).start();
     new PhilosopherAbandonedLock("Ph-C", chopStickA, chopStickC).start();
    
     // To resolve this, we pass the same locks
     new PhilosopherAbandonedLock("Ph-A", chopStickA, chopStickB).start();
     new PhilosopherAbandonedLock("Ph-B", chopStickA, chopStickB).start();
     new PhilosopherAbandonedLock("Ph-C", chopStickA, chopStickB).start();
    
  3. When there are too many threads trying to access the tham critical section, this may cause some threads to not possess the lock.

    This is case specific, and each case is handled on its own

Live Lock

This may occur when we try to avoid dead lock scenario, we go into a live lock scenario, where a thread keeps giving its lock up while the other can't use as it will be giving the other lock up

Using the example from the dead lock scenario (Solution 2: Without the random part)

// The run method in the thread shall be as following
public void run() {
    while (sushiCount > 0) {
        firstChopStick.lock();
        // Adding the tryLock method here
        // Pass if the lock is unavailable
        if (! secondChopStick.tryLock()) {

            firstChopStick.unlock();
            continue;
        }

        if (sushiCount > 0) {
            sushiCount--;
            System.out.println(this.getName() + " took a piece, remaining: " + sushiCount);
        }

        secondChopStick.unlock();
        firstChopStick.unlock();

    }
}

If you compare this to the deadlock screenshot, we will see that in this case the CPU usage is at its normal case as the threads are still in action (that's why it's called live lock)

To resolve this issue, we will add some randomness (As done in the deadlock solution) this shall prevent any lock in the system

Conclusion

This is the last article in our series, with a promise with an upcoming series to talk more about parallel programming and threads.

Focus on how and when to implement this way of programming and be very cautios using it. Have a lovely day!

You can check the code here on GitHub in the package (liveness)