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.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
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.
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.
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);
};
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.
vector<mutex> mutexes_ = vector<mutex>(5);
: This creates a vector of five mutexes. Each mutex represents a fork.
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.pickLeftFork()
, pickRightFork()
, eat()
, putLeftFork()
, and putRightFork()
calls are placeholders for the actual actions. These would be provided by the LeetCode environment.scoped_lock
automatically releases the mutexes when the function exits, even if exceptions are thrown.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.