{x}
blog image

The Dining Philosophers

Five silent philosophers sit at a round table with bowls of spaghetti. Forks are placed between each pair of adjacent philosophers.

Each philosopher must alternately think and eat. However, a philosopher can only eat spaghetti when they have both left and right forks. Each fork can be held by only one philosopher and so a philosopher can use the fork only if it is not being used by another philosopher. After an individual philosopher finishes eating, they need to put down both forks so that the forks become available to others. A philosopher can take the fork on their right or the one on their left as they become available, but cannot start eating before getting both forks.

Eating is not limited by the remaining amounts of spaghetti or stomach space; an infinite supply and an infinite demand are assumed.

Design a discipline of behaviour (a concurrent algorithm) such that no philosopher will starve; i.e., each can forever continue to alternate between eating and thinking, assuming that no philosopher can know when others may want to eat or think.

The problem statement and the image above are taken from wikipedia.org

 

The philosophers' ids are numbered from 0 to 4 in a clockwise order. Implement the function void wantsToEat(philosopher, pickLeftFork, pickRightFork, eat, putLeftFork, putRightFork) where:

  • philosopher is the id of the philosopher who wants to eat.
  • pickLeftFork and pickRightFork are functions you can call to pick the corresponding forks of that philosopher.
  • eat is a function you can call to let the philosopher eat once he has picked both forks.
  • putLeftFork and putRightFork are functions you can call to put down the corresponding forks of that philosopher.
  • The philosophers are assumed to be thinking as long as they are not asking to eat (the function is not being called with their number).

Five threads, each representing a philosopher, will simultaneously use one object of your class to simulate the process. The function may be called for the same philosopher more than once, even before the last call ends.

 

Example 1:

Input: n = 1
Output: [[4,2,1],[4,1,1],[0,1,1],[2,2,1],[2,1,1],[2,0,3],[2,1,2],[2,2,2],[4,0,3],[4,1,2],[0,2,1],[4,2,2],[3,2,1],[3,1,1],[0,0,3],[0,1,2],[0,2,2],[1,2,1],[1,1,1],[3,0,3],[3,1,2],[3,2,2],[1,0,3],[1,1,2],[1,2,2]]
Explanation:
n is the number of times each philosopher will call the function.
The output array describes the calls you made to the functions controlling the forks and the eat function, its format is:
output[i] = [a, b, c] (three integers)
- a is the id of a philosopher.
- b specifies the fork: {1 : left, 2 : right}.
- c specifies the operation: {1 : pick, 2 : put, 3 : eat}.

 

Constraints:

  • 1 <= n <= 60

Solution Explanation for the Dining Philosophers Problem

The Dining Philosophers problem is a classic concurrency problem that illustrates the challenges of deadlock and starvation in concurrent systems. The solution presented uses C++ mutexes and scoped locks to prevent these issues.

Approach

The core idea is to prevent deadlock by ensuring that philosophers acquire forks in a specific order. Deadlock occurs when two or more philosophers simultaneously grab one fork each, preventing any of them from getting the second fork and proceeding to eat. Starvation occurs when a philosopher is perpetually unable to acquire the necessary resources to eat.

The provided C++ solution uses a vector of mutex objects, mutexes_, one for each fork. A philosopher, before attempting to eat, tries to acquire the mutexes for both its left and right forks. Crucially, the code employs std::scoped_lock to acquire these locks in a specific order. std::scoped_lock ensures that the locks are acquired atomically (all or none), preventing partial acquisition and thus avoiding deadlock.

The order of acquiring the locks is important. A philosopher will always try to acquire the mutex associated with the fork that's lower in index order. If philosopher 1 has forks 1 and 2, then he'll grab mutex 1 before mutex 2. This prevents circular dependencies.

After eating, the philosopher releases both forks by letting go of the mutexes. This allows other philosophers to acquire the forks.

C++ Code Explained

class DiningPhilosophers {
public:
    using Act = function<void()>;
 
    void wantsToEat(int philosopher, Act pickLeftFork, Act pickRightFork, Act eat, Act putLeftFork, Act putRightFork) {
        std::scoped_lock lock(mutexes_[philosopher], mutexes_[philosopher >= 4 ? 0 : philosopher + 1]);
        pickLeftFork();
        pickRightFork();
        eat();
        putLeftFork();
        putRightFork();
    }
 
private:
    vector<mutex> mutexes_ = vector<mutex>(5);
};
  1. using Act = function<void()>;: This line defines a type alias Act for a function that takes no arguments and returns void. This is used to represent the actions of picking up/putting down forks and eating.

  2. vector<mutex> mutexes_ = vector<mutex>(5);: This creates a vector of five mutexes. Each mutex represents a fork.

  3. wantsToEat(...): This function is the core logic.

    • std::scoped_lock lock(mutexes_[philosopher], mutexes_[philosopher >= 4 ? 0 : philosopher + 1]);: This line is crucial. It uses std::scoped_lock to acquire two mutexes atomically:
      • mutexes_[philosopher]: The mutex representing the philosopher's left fork.
      • mutexes_[philosopher >= 4 ? 0 : philosopher + 1]: The mutex representing the philosopher's right fork. The ternary operator handles the case of philosopher 4 (the last philosopher), whose right fork is fork 0.
    • The pickLeftFork(), pickRightFork(), eat(), putLeftFork(), and putRightFork() calls are placeholders for the actual actions. These would be provided by the LeetCode environment.
    • The scoped_lock automatically releases the mutexes when the function exits, even if exceptions are thrown.

Time and Space Complexity

  • Time Complexity: The time complexity is highly dependent on the concurrency and the specific implementation details of the mutexes and the eat function. In the best case (no contention), it's O(1) for each philosopher. However, in the worst case (high contention), the time complexity could be unbounded due to the potential for waiting on mutexes.

  • Space Complexity: The space complexity is O(n), where n is the number of philosophers (5 in this case). This is due to the storage of the mutexes_ vector.

This solution effectively avoids deadlock and starvation by carefully managing the acquisition and release of resources (forks) using mutexes and a specific locking order. The use of scoped_lock ensures atomicity and simplifies the code, making it easier to reason about the correctness.