Facebook Pixel

1226. The Dining Philosophers

MediumConcurrency
Leetcode Link

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 eat
  • pickLeftFork, pickRightFork: callback functions to pick up the respective forks
  • eat: 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).

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. No adjacent philosophers can eat simultaneously - if philosopher i is eating, they hold locks for positions i and i+1, preventing both neighbors from eating
  2. Non-adjacent philosophers can still eat - philosophers 0 and 2 can eat at the same time, as can philosophers 1 and 3, maximizing concurrency
  3. 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:

  1. Mutex Assignment Strategy:

    • Each philosopher i is associated with mutex at index i
    • When philosopher i wants to eat, they must acquire locks for both position i and position (i+1) % 5
    • This ensures adjacent philosophers cannot eat simultaneously
  2. 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
  3. 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
  4. 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

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 Evaluator

Example 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:

  1. No Deadlock: Unlike the naive approach where all philosophers grab their left fork simultaneously, here scoped_lock prevents circular waiting
  2. Maximum Concurrency: P0 and P2 ate simultaneously (non-adjacent), as did P4 and P1 later
  3. Fair Access: P1 waited but eventually got to eat once required mutexes became available
  4. 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 fork i (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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

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

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More