The Classic Dining Philosophers Problem

The Dining Philosophers problem is one of the most famous concurrency problems, introduced by Edsger Dijkstra in 1965. It beautifully illustrates the challenges of resource sharing and deadlock in concurrent systems.

The Setup: Five philosophers sit around a circular table. Between each pair of philosophers is a single fork (5 forks total). Each philosopher needs two forks to eat - one from their left and one from their right.

The Challenge: How do we coordinate the philosophers so they can all eat without:

  • Deadlock: Everyone waiting forever
  • Starvation: Someone never getting to eat
  • Race conditions: Two philosophers grabbing the same fork

In this first implementation, we’ll explore the naive solution that leads to deadlock.

Why This Problem Matters

This isn’t just a theoretical exercise. The same pattern appears in real-world systems:

  • Database transactions needing multiple locks
  • Resource pools where tasks need multiple resources
  • Distributed systems coordinating shared resources
  • Network protocols managing connections

Understanding this problem teaches you to recognize and avoid deadlock in production code.

The Deadlock Implementation

Let’s start with the naive implementation that will deadlock. This helps us understand what goes wrong before we fix it.

package main

import (
	"fmt"
	"sync"
	"time"
)

// Fork represents a dining fork as a mutex
type Fork struct {
	id int
	sync.Mutex
}

// Philosopher represents a dining philosopher
type Philosopher struct {
	id                  int
	leftFork, rightFork *Fork
	eatenCount          int
}

// dine simulates a philosopher's behavior
// This implementation WILL DEADLOCK!
func (p *Philosopher) dine(wg *sync.WaitGroup) {
	defer wg.Done()

	for i := 0; i < 3; i++ {
		// Think for a bit
		p.think()

		// Pick up left fork first
		p.leftFork.Lock()
		fmt.Printf("Philosopher %d picked up left fork %d\n", p.id, p.leftFork.id)

		// Small delay to increase chance of deadlock
		time.Sleep(50 * time.Millisecond)

		// Pick up right fork
		p.rightFork.Lock()
		fmt.Printf("Philosopher %d picked up right fork %d\n", p.id, p.rightFork.id)

		// Eat
		p.eat()

		// Put down forks
		p.rightFork.Unlock()
		p.leftFork.Unlock()
		fmt.Printf("Philosopher %d put down both forks\n", p.id)
	}
}

func (p *Philosopher) think() {
	fmt.Printf("Philosopher %d is thinking...\n", p.id)
	time.Sleep(time.Duration(100+p.id*10) * time.Millisecond)
}

func (p *Philosopher) eat() {
	fmt.Printf("Philosopher %d is eating (meal %d)\n", p.id, p.eatenCount+1)
	p.eatenCount++
	time.Sleep(200 * time.Millisecond)
}

func main() {
	fmt.Println("=== Dining Philosophers: Deadlock Example ===")
	fmt.Println("This implementation will likely deadlock!")
	fmt.Println()

	// Create 5 forks
	forks := make([]*Fork, 5)
	for i := 0; i < 5; i++ {
		forks[i] = &Fork{id: i}
	}

	// Create 5 philosophers
	philosophers := make([]*Philosopher, 5)
	for i := 0; i < 5; i++ {
		philosophers[i] = &Philosopher{
			id:        i,
			leftFork:  forks[i],
			rightFork: forks[(i+1)%5], // Circular arrangement
		}
	}

	// Start dining with a timeout to detect deadlock
	var wg sync.WaitGroup
	wg.Add(5)

	// Start all philosophers
	for i := 0; i < 5; i++ {
		go philosophers[i].dine(&wg)
	}

	// Wait with timeout
	done := make(chan struct{})
	go func() {
		wg.Wait()
		close(done)
	}()

	select {
	case <-done:
		fmt.Println("\n✓ All philosophers finished dining!")
		for i, p := range philosophers {
			fmt.Printf("  Philosopher %d ate %d times\n", i, p.eatenCount)
		}
	case <-time.After(5 * time.Second):
		fmt.Println("\n✗ DEADLOCK DETECTED!")
		fmt.Println("All philosophers are waiting for forks.")
		fmt.Println("This is the classic deadlock scenario.")
	}
}

Understanding the Deadlock

The Circular Wait Condition

The deadlock occurs due to circular wait:

  1. Philosopher 0 holds Fork 0, waits for Fork 1
  2. Philosopher 1 holds Fork 1, waits for Fork 2
  3. Philosopher 2 holds Fork 2, waits for Fork 3
  4. Philosopher 3 holds Fork 3, waits for Fork 4
  5. Philosopher 4 holds Fork 4, waits for Fork 0

Each philosopher is waiting for a resource held by the next philosopher in the circle. No one can proceed!

The Four Conditions for Deadlock

This implementation demonstrates all four Coffman conditions that must hold for deadlock:

  1. Mutual Exclusion: Forks can only be used by one philosopher at a time (mutex)
  2. Hold and Wait: Philosophers hold one fork while waiting for another
  3. No Preemption: Forks can’t be forcibly taken away
  4. Circular Wait: The circular dependency shown above

Break ANY of these conditions and you prevent deadlock.

Visualizing the Problem

Here’s what happens when deadlock occurs:

Time 0: All philosophers thinking
Time 1: All pick up left fork simultaneously
        P0 has F0, wants F1
        P1 has F1, wants F2
        P2 has F2, wants F3
        P3 has F3, wants F4
        P4 has F4, wants F0
Time 2: All wait forever... DEADLOCK!

Running the Example

Save this code and run it:

go run dining_philosophers_deadlock.go

You’ll likely see output like:

=== Dining Philosophers: Deadlock Example ===
This implementation will likely deadlock!

Philosopher 0 is thinking...
Philosopher 1 is thinking...
Philosopher 2 is thinking...
Philosopher 3 is thinking...
Philosopher 4 is thinking...
Philosopher 0 picked up left fork 0
Philosopher 1 picked up left fork 1
Philosopher 2 picked up left fork 2
Philosopher 3 picked up left fork 3
Philosopher 4 picked up left fork 4

✗ DEADLOCK DETECTED!
All philosophers are waiting for forks.
This is the classic deadlock scenario.

Key Go Concurrency Concepts

1. sync.Mutex for Resource Protection

type Fork struct {
    id int
    sync.Mutex
}

The sync.Mutex provides mutual exclusion - only one goroutine can hold the lock at a time.

2. Goroutines for Concurrent Execution

for i := 0; i < 5; i++ {
    go philosophers[i].dine(&wg)
}

Each philosopher runs in their own goroutine, simulating concurrent execution.

3. WaitGroup for Synchronization

var wg sync.WaitGroup
wg.Add(5)
// ... start goroutines ...
wg.Wait()

The WaitGroup lets us wait for all philosophers to finish (or detect when they don’t!).

4. Timeout Pattern with Select

select {
case <-done:
    fmt.Println("Success!")
case <-time.After(5 * time.Second):
    fmt.Println("Deadlock!")
}

This is a critical pattern for detecting deadlocks in production systems. Always use timeouts!

What We Learned

  1. Deadlock is easy to create - The naive implementation naturally deadlocks
  2. Circular wait is dangerous - When everyone grabs resources in the same order
  3. You need deadlock detection - Use timeouts to detect stuck systems
  4. Go makes it visible - Goroutines and mutexes make the problem explicit

Next Steps

In the next articles, we’ll explore two solutions:

  1. The Waiter Solution - Using a centralized coordinator
  2. The Asymmetric Solution - Breaking the circular wait

Both prevent deadlock while allowing philosophers to eat. Stay tuned!

Try It Yourself

Experiment with the code:

  1. Reduce the delay in dine() - Does deadlock happen less often?
  2. Add more philosophers - Does it deadlock faster?
  3. Add logging - Track which fork each philosopher holds
  4. Measure timing - How long until deadlock occurs?

Understanding deadlock through experimentation is the best way to recognize it in production code!


This is part 1 of the Dining Philosophers series in “Golang Experiments: Classic Concurrency Problems”