1226. The Dining Philosophers
Problem Description
This is the classic Dining Philosophers Problem, a well-known concurrency challenge in computer science.
Setup:
- 5 philosophers sit around a circular table
- Each philosopher has a bowl of spaghetti in front of them
- Between each pair of adjacent philosophers, there is exactly one fork (so 5 forks total)
- Philosophers are numbered 0 to 4 in clockwise order
Rules:
- Each philosopher alternates between thinking and eating
- To eat, a philosopher needs both the left fork AND the right fork
- Each fork can only be held by one philosopher at a time
- After eating, the philosopher must put down both forks
- A philosopher can pick up forks in any order (left first or right first) as they become available
- A philosopher cannot start eating until they have both forks
The Challenge: Design a concurrent algorithm that prevents deadlock and starvation - ensuring every philosopher can continue alternating between eating and thinking forever, without any philosopher being permanently blocked from eating.
Implementation Requirements:
You need to implement the function wantsToEat(philosopher, pickLeftFork, pickRightFork, eat, putLeftFork, putRightFork)
where:
philosopher
: the ID (0-4) of the philosopher who wants to eatpickLeftFork
,pickRightFork
: callback functions to pick up the respective forkseat
: callback function to let the philosopher eat (only call after both forks are picked)putLeftFork
,putRightFork
: callback functions to put down the respective forks
The system will run 5 threads simultaneously (one per philosopher), and the function may be called multiple times for the same philosopher, even before their previous eating session completes.
The Core Problem: The main challenge is avoiding deadlock (where all philosophers pick up their left fork and wait forever for the right fork) while ensuring fairness (no philosopher starves).
Intuition
The key insight to solving this problem is understanding what causes deadlock in the naive approach. If all philosophers simultaneously pick up their left fork, they'll all be waiting for their right fork forever - classic circular wait deadlock.
To prevent deadlock, we need to break the circular dependency. One elegant approach is to ensure that adjacent philosophers cannot attempt to eat at the same time. Think of it this way: if philosopher 0 is eating (holding forks 0 and 1), then philosophers 4 and 1 cannot eat because they need forks 0 and 1 respectively.
This naturally leads us to think about mutexes for each philosopher rather than for each fork. If we assign a mutex to each philosopher and require that when a philosopher wants to eat, they must lock both their own mutex AND their neighbor's mutex, we ensure:
- No adjacent philosophers can eat simultaneously - if philosopher
i
is eating, they hold locks for positionsi
andi+1
, preventing both neighbors from eating - Non-adjacent philosophers can still eat - philosophers 0 and 2 can eat at the same time, as can philosophers 1 and 3, maximizing concurrency
- Deadlock is impossible -
scoped_lock
in C++17 acquires multiple locks in a deadlock-free manner by using a consistent ordering internally
The beauty of scoped_lock(mutex1, mutex2)
is that it:
- Acquires both locks atomically using a deadlock avoidance algorithm
- Automatically releases both locks when the scope ends
- Handles the edge case where philosopher 4's "next" neighbor is philosopher 0 (circular table)
This approach is simpler than managing individual fork locks and naturally prevents the circular wait condition that leads to deadlock, while still allowing maximum possible concurrency (up to 2 philosophers can eat simultaneously).
Solution Approach
The solution uses a mutex-per-philosopher strategy with C++17's scoped_lock
to elegantly handle the concurrency challenges.
Data Structure:
vector<mutex> mutexes_
- An array of 5 mutexes, one for each philosopher position
Core Implementation:
-
Mutex Assignment Strategy:
- Each philosopher
i
is associated with mutex at indexi
- When philosopher
i
wants to eat, they must acquire locks for both positioni
and position(i+1) % 5
- This ensures adjacent philosophers cannot eat simultaneously
- Each philosopher
-
Lock Acquisition:
std::scoped_lock lock(mutexes_[philosopher], mutexes_[philosopher >= 4 ? 0 : philosopher + 1]);
- For philosophers 0-3: lock mutexes
[i]
and[i+1]
- For philosopher 4: lock mutexes
[4]
and[0]
(wrapping around the circular table) scoped_lock
acquires both mutexes atomically using deadlock avoidance algorithms
- For philosophers 0-3: lock mutexes
-
Eating Sequence: Once both locks are acquired, the philosopher performs actions in order:
pickLeftFork(); // Pick up left fork pickRightFork(); // Pick up right fork eat(); // Eat with both forks putLeftFork(); // Put down left fork putRightFork(); // Put down right fork
-
Automatic Lock Release:
- When the function scope ends,
scoped_lock
automatically releases both mutexes in reverse order - This makes the philosopher's position available for adjacent philosophers
- When the function scope ends,
Why This Works:
- Prevents Deadlock:
scoped_lock
internally orders lock acquisition consistently across all threads - Prevents Starvation: Each philosopher gets fair access to attempt eating when their required mutexes are free
- Maximizes Concurrency: Non-adjacent philosophers (e.g., 0 and 2, or 1 and 3) can eat simultaneously since they don't share mutexes
The elegance lies in shifting from "locking forks" to "locking philosopher positions", which naturally prevents the circular dependency that causes deadlock in the traditional fork-based approach.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a scenario with 5 philosophers (0-4) to see how the mutex-per-philosopher approach prevents deadlock and allows concurrent eating.
Initial Setup:
- Philosophers: 0, 1, 2, 3, 4 sitting in a circle
- Mutexes: M0, M1, M2, M3, M4 (one per philosopher)
- Each philosopher needs to lock their own mutex and their right neighbor's mutex
Scenario Timeline:
Time T1: Philosopher 0 wants to eat
- Attempts to acquire:
scoped_lock(M0, M1)
- Success! Locks both M0 and M1
- Executes: pickLeftFork() → pickRightFork() → eat() → putLeftFork() → putRightFork()
- Status: P0 eating, P1 and P4 blocked (need M0 and M1 respectively)
Time T2: Philosopher 2 wants to eat (while P0 is still eating)
- Attempts to acquire:
scoped_lock(M2, M3)
- Success! M2 and M3 are free since P0 only holds M0 and M1
- Both P0 and P2 are now eating simultaneously
- Status: P0 and P2 eating, P1, P3, P4 blocked
Time T3: Philosopher 1 wants to eat
- Attempts to acquire:
scoped_lock(M1, M2)
- Blocked! M1 is held by P0, M2 is held by P2
- P1 waits...
Time T4: Philosopher 0 finishes eating
- Releases M0 and M1
- Now P1 can attempt again, but still needs M2 (held by P2)
- P4 could now eat (needs M4 and M0, both free)
Time T5: Philosopher 4 wants to eat
- Attempts to acquire:
scoped_lock(M4, M0)
(wraps around) - Success! Both M4 and M0 are free
- P4 starts eating
Time T6: Philosopher 2 finishes eating
- Releases M2 and M3
- Now P1 can finally acquire
scoped_lock(M1, M2)
and start eating
Key Observations:
- No Deadlock: Unlike the naive approach where all philosophers grab their left fork simultaneously, here
scoped_lock
prevents circular waiting - Maximum Concurrency: P0 and P2 ate simultaneously (non-adjacent), as did P4 and P1 later
- Fair Access: P1 waited but eventually got to eat once required mutexes became available
- Circular Table Handling: P4's wrap-around case (locking M4 and M0) worked seamlessly
This approach ensures at most 2 philosophers can eat at any given time (any more would require adjacent philosophers to eat, which is prevented by the shared mutex requirement).
Solution Implementation
1import threading
2from typing import Callable
3
4class DiningPhilosophers:
5 def __init__(self):
6 # Five mutexes representing five forks on the table
7 # Each philosopher sits between two forks
8 # Fork i is between philosopher i and philosopher (i+1) % 5
9 self.fork_mutexes = [threading.Lock() for _ in range(5)]
10
11 def wantsToEat(self,
12 philosopher: int,
13 pickLeftFork: Callable[[], None],
14 pickRightFork: Callable[[], None],
15 eat: Callable[[], None],
16 putLeftFork: Callable[[], None],
17 putRightFork: Callable[[], None]) -> None:
18 """
19 Manages a philosopher's eating process to avoid deadlock.
20
21 The dining philosophers problem is solved by acquiring both forks
22 (mutexes) needed by a philosopher before allowing them to eat.
23 This prevents deadlock while allowing non-adjacent philosophers
24 to eat concurrently.
25
26 Args:
27 philosopher: The philosopher's index (0-4)
28 pickLeftFork: Function to pick up the left fork
29 pickRightFork: Function to pick up the right fork
30 eat: Function to eat
31 putLeftFork: Function to put down the left fork
32 putRightFork: Function to put down the right fork
33 """
34
35 # Calculate the indices of the two forks needed by this philosopher
36 # Left fork: same index as philosopher
37 # Right fork: next philosopher's fork (wrapping around for philosopher 4)
38 left_fork_index = philosopher
39 right_fork_index = (philosopher + 1) % 5
40
41 # To prevent deadlock, we need to acquire forks in a consistent order
42 # Always acquire the lower-indexed fork first
43 first_fork = min(left_fork_index, right_fork_index)
44 second_fork = max(left_fork_index, right_fork_index)
45
46 # Acquire both mutexes in order to prevent deadlock
47 # This ensures that adjacent philosophers cannot eat simultaneously
48 # while non-adjacent philosophers can eat concurrently
49 with self.fork_mutexes[first_fork]:
50 with self.fork_mutexes[second_fork]:
51 # Critical section: perform eating actions in sequence
52 # Only execute these actions after both forks are acquired
53 pickLeftFork()
54 pickRightFork()
55 eat()
56 putLeftFork()
57 putRightFork()
58
59 # Mutexes are automatically released when exiting the with blocks
60
1import java.util.concurrent.locks.ReentrantLock;
2
3class DiningPhilosophers {
4 // Five locks representing five forks on the table
5 // Each philosopher sits between two forks
6 private final ReentrantLock[] forkLocks = new ReentrantLock[5];
7
8 public DiningPhilosophers() {
9 // Initialize all fork locks
10 for (int i = 0; i < 5; i++) {
11 forkLocks[i] = new ReentrantLock();
12 }
13 }
14
15 public void wantsToEat(int philosopher,
16 Runnable pickLeftFork,
17 Runnable pickRightFork,
18 Runnable eat,
19 Runnable putLeftFork,
20 Runnable putRightFork) throws InterruptedException {
21
22 // Calculate the indices of the two forks needed by this philosopher
23 // Left fork: same index as philosopher
24 // Right fork: next philosopher's fork (wrapping around for philosopher 4)
25 int leftForkIndex = philosopher;
26 int rightForkIndex = (philosopher + 1) % 5;
27
28 // To prevent deadlock, we need to acquire locks in a consistent order
29 // Always acquire the lower-numbered fork first, then the higher-numbered fork
30 // This prevents circular waiting, which is a necessary condition for deadlock
31 int firstFork = Math.min(leftForkIndex, rightForkIndex);
32 int secondFork = Math.max(leftForkIndex, rightForkIndex);
33
34 // Acquire both locks in order
35 forkLocks[firstFork].lock();
36 try {
37 forkLocks[secondFork].lock();
38 try {
39 // Critical section: perform eating actions in sequence
40 // Both forks are now held, so the philosopher can eat
41 pickLeftFork.run();
42 pickRightFork.run();
43 eat.run();
44 putLeftFork.run();
45 putRightFork.run();
46 } finally {
47 // Release the second fork
48 forkLocks[secondFork].unlock();
49 }
50 } finally {
51 // Release the first fork
52 forkLocks[firstFork].unlock();
53 }
54 }
55}
56
1class DiningPhilosophers {
2public:
3 using Act = function<void()>;
4
5 void wantsToEat(int philosopher, Act pickLeftFork, Act pickRightFork,
6 Act eat, Act putLeftFork, Act putRightFork) {
7 // The dining philosophers problem is solved using C++17's scoped_lock
8 // Each philosopher needs two forks (mutexes) to eat
9
10 // Calculate the indices of the two forks needed by this philosopher
11 // Left fork: same index as philosopher
12 // Right fork: next philosopher's fork (wrapping around for philosopher 4)
13 int leftForkIndex = philosopher;
14 int rightForkIndex = (philosopher + 1) % 5;
15
16 // scoped_lock acquires both mutexes atomically to prevent deadlock
17 // It locks them in a consistent order internally and releases them
18 // automatically when going out of scope (RAII)
19 // This ensures that adjacent philosophers cannot eat simultaneously
20 // while non-adjacent philosophers can eat concurrently
21 std::scoped_lock lock(forkMutexes_[leftForkIndex],
22 forkMutexes_[rightForkIndex]);
23
24 // Critical section: perform eating actions in sequence
25 pickLeftFork();
26 pickRightFork();
27 eat();
28 putLeftFork();
29 putRightFork();
30
31 // Mutexes are automatically released when lock goes out of scope
32 }
33
34private:
35 // Five mutexes representing five forks on the table
36 // Each philosopher sits between two forks
37 vector<mutex> forkMutexes_ = vector<mutex>(5);
38};
39
1// Type alias for action functions that philosophers perform
2type Act = () => void;
3
4// Five mutexes representing five forks on the table
5// Each philosopher sits between two forks
6// Using a simple lock mechanism (1 = available, 0 = taken)
7const forkMutexes: number[] = [1, 1, 1, 1, 1];
8
9// Queue to manage waiting philosophers for each fork
10const forkQueues: Array<Array<() => void>> = [[], [], [], [], []];
11
12/**
13 * Simulates a philosopher wanting to eat
14 * Solves the dining philosophers problem by ensuring mutual exclusion
15 * on fork resources to prevent deadlock and starvation
16 *
17 * @param philosopher - The philosopher's index (0-4)
18 * @param pickLeftFork - Action to pick up the left fork
19 * @param pickRightFork - Action to pick up the right fork
20 * @param eat - Action to eat
21 * @param putLeftFork - Action to put down the left fork
22 * @param putRightFork - Action to put down the right fork
23 */
24async function wantsToEat(
25 philosopher: number,
26 pickLeftFork: Act,
27 pickRightFork: Act,
28 eat: Act,
29 putLeftFork: Act,
30 putRightFork: Act
31): Promise<void> {
32 // Calculate the indices of the two forks needed by this philosopher
33 // Left fork: same index as philosopher
34 // Right fork: next philosopher's fork (wrapping around for philosopher 4)
35 const leftForkIndex = philosopher;
36 const rightForkIndex = (philosopher + 1) % 5;
37
38 // Acquire both forks atomically to prevent deadlock
39 // Always acquire in a consistent order (lower index first) to avoid circular wait
40 const firstFork = Math.min(leftForkIndex, rightForkIndex);
41 const secondFork = Math.max(leftForkIndex, rightForkIndex);
42
43 // Wait until both forks are available
44 await acquireForks(firstFork, secondFork);
45
46 try {
47 // Critical section: perform eating actions in sequence
48 // Only one philosopher can use a fork at a time
49 pickLeftFork();
50 pickRightFork();
51 eat();
52 putLeftFork();
53 putRightFork();
54 } finally {
55 // Release both forks for other philosophers to use
56 releaseForks(firstFork, secondFork);
57 }
58}
59
60/**
61 * Acquires two forks atomically
62 * Waits if forks are not available
63 */
64async function acquireForks(firstFork: number, secondFork: number): Promise<void> {
65 return new Promise((resolve) => {
66 const tryAcquire = () => {
67 // Check if both forks are available
68 if (forkMutexes[firstFork] === 1 && forkMutexes[secondFork] === 1) {
69 // Acquire both forks atomically
70 forkMutexes[firstFork] = 0;
71 forkMutexes[secondFork] = 0;
72 resolve();
73 } else {
74 // Add to queue and wait for forks to become available
75 const callback = () => {
76 // Remove from queues
77 const firstIndex = forkQueues[firstFork].indexOf(callback);
78 if (firstIndex !== -1) {
79 forkQueues[firstFork].splice(firstIndex, 1);
80 }
81 const secondIndex = forkQueues[secondFork].indexOf(callback);
82 if (secondIndex !== -1) {
83 forkQueues[secondFork].splice(secondIndex, 1);
84 }
85 // Try to acquire again
86 tryAcquire();
87 };
88
89 // Add to both fork queues
90 forkQueues[firstFork].push(callback);
91 forkQueues[secondFork].push(callback);
92 }
93 };
94
95 tryAcquire();
96 });
97}
98
99/**
100 * Releases two forks and notifies waiting philosophers
101 */
102function releaseForks(firstFork: number, secondFork: number): void {
103 // Release both forks
104 forkMutexes[firstFork] = 1;
105 forkMutexes[secondFork] = 1;
106
107 // Notify all waiting philosophers on both forks
108 // Use setTimeout to avoid stack overflow and allow async execution
109 const callbacks = [
110 ...forkQueues[firstFork],
111 ...forkQueues[secondFork]
112 ];
113
114 // Execute callbacks asynchronously
115 callbacks.forEach(callback => {
116 setTimeout(callback, 0);
117 });
118}
119
Time and Space Complexity
Time Complexity: O(1)
per philosopher's eating action. Each call to wantsToEat
performs a constant number of operations: acquiring two locks, executing five function calls (pickLeftFork, pickRightFork, eat, putLeftFork, putRightFork), and releasing the locks. The scoped_lock
constructor uses deadlock avoidance algorithms internally but still completes in constant time for a fixed number of mutexes (2 in this case).
Space Complexity: O(1)
overall. The class maintains a fixed-size vector of 5 mutexes regardless of how many times philosophers eat. Each mutex has constant space overhead, and the total space used is O(5) = O(1)
. The scoped_lock
object created in each function call uses O(1)
stack space temporarily, which is released when the function returns.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Circular Wait Deadlock (Without Ordered Locking)
The Pitfall: The most common mistake is having each philosopher always pick up their left fork first, then their right fork:
# WRONG - This causes deadlock!
def wantsToEat(self, philosopher, ...):
left_fork = philosopher
right_fork = (philosopher + 1) % 5
with self.fork_mutexes[left_fork]: # Everyone grabs left fork
with self.fork_mutexes[right_fork]: # Everyone waits for right fork forever!
# Eating logic...
Why it fails: If all 5 philosophers execute simultaneously, they all grab their left fork (philosophers 0-4 grab forks 0-4). Now each philosopher waits for their right fork, but those are held by their neighbors. Classic circular dependency → deadlock!
The Solution: Always acquire locks in a consistent global order:
first_fork = min(left_fork_index, right_fork_index)
second_fork = max(left_fork_index, right_fork_index)
with self.fork_mutexes[first_fork]:
with self.fork_mutexes[second_fork]:
# Safe to eat
2. Incorrect Fork-to-Philosopher Mapping
The Pitfall: Misunderstanding which forks a philosopher needs:
# WRONG - Incorrect fork assignment left_fork = (philosopher - 1) % 5 # Wrong calculation! right_fork = philosopher
Why it fails: This creates gaps where some forks are never used while others are over-contended, breaking the problem's constraints.
The Solution: Remember the circular arrangement:
- Philosopher
i
sits between forki
(left) and fork(i+1) % 5
(right) - Fork 0 is shared by philosophers 0 and 4
3. Lock Acquisition Without Deadlock Prevention
The Pitfall: Using separate lock acquisitions without ordering:
# WRONG - Race condition in acquiring locks if self.fork_mutexes[left_fork].acquire(blocking=False): if self.fork_mutexes[right_fork].acquire(blocking=False): # eat... self.fork_mutexes[right_fork].release() self.fork_mutexes[left_fork].release()
Why it fails: This tries to avoid deadlock through non-blocking acquisition, but it can lead to livelock (philosophers repeatedly picking up and putting down forks) or starvation (some philosophers never getting both forks).
The Solution: Use ordered locking or atomic multi-lock acquisition mechanisms like Python's nested with
statements or C++'s scoped_lock
.
4. Forgetting to Release Resources
The Pitfall: Not properly releasing forks after eating:
# WRONG - Missing proper cleanup
def wantsToEat(self, philosopher, ...):
self.fork_mutexes[first_fork].acquire()
self.fork_mutexes[second_fork].acquire()
pickLeftFork()
pickRightFork()
eat()
# Oops! Forgot to call putLeftFork() and putRightFork()
# Oops! Forgot to release the mutexes!
Why it fails: Forks remain permanently locked, preventing other philosophers from eating.
The Solution: Always use context managers (with
statements) for automatic cleanup, and ensure all callback functions are called in the correct sequence.
How would you design a stack which has a function min
that returns the minimum element in the stack, in addition to push
and pop
? All push
, pop
, min
should have running time O(1)
.
Recommended Readings
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Runtime Overview When learning about algorithms and data structures you'll frequently encounter the term time complexity This concept is fundamental in computer science and offers insights into how long an algorithm takes to complete given a certain input size What is Time Complexity Time complexity represents the amount of time
Want a Structured Path to Master System Design Too? Don’t Miss This!