Breaking the Deadlock
In the previous article, we saw how the naive implementation of the Dining Philosophers problem inevitably leads to deadlock. Now we’ll implement the Waiter Solution - a centralized coordinator that prevents deadlock by controlling resource access.
The Waiter Solution Concept
The Idea: Add a waiter who controls access to the forks. No philosopher can pick up forks without the waiter’s permission. The waiter ensures that at most 4 philosophers can attempt to pick up forks simultaneously.
Why it works: With only 4 philosophers competing for 5 forks, at least one philosopher is guaranteed to get both forks. This breaks the circular wait condition.
Real-World Applications
This pattern appears in many production systems:
- Semaphores limiting concurrent access to resources
- Connection pools with max connection limits
- Rate limiters controlling API request rates
- Resource managers in operating systems
- Job schedulers limiting concurrent tasks
Implementation with Semaphore
Go doesn’t have a built-in semaphore type, but we can implement one using channels. A buffered channel naturally provides semaphore semantics!
package main
import (
"fmt"
"sync"
"time"
)
// Fork represents a dining fork
type Fork struct {
id int
sync.Mutex
}
// Waiter controls access to the dining table using a semaphore
type Waiter struct {
semaphore chan struct{}
}
// NewWaiter creates a waiter that allows up to maxSeated philosophers
func NewWaiter(maxSeated int) *Waiter {
return &Waiter{
semaphore: make(chan struct{}, maxSeated),
}
}
// RequestPermission asks the waiter for permission to pick up forks
func (w *Waiter) RequestPermission(philosopherID int) {
fmt.Printf("Philosopher %d is requesting permission from waiter\n", philosopherID)
w.semaphore <- struct{}{} // Acquire semaphore
fmt.Printf("Philosopher %d got permission from waiter\n", philosopherID)
}
// ReleasePermission returns permission to the waiter
func (w *Waiter) ReleasePermission(philosopherID int) {
<-w.semaphore // Release semaphore
fmt.Printf("Philosopher %d returned permission to waiter\n", philosopherID)
}
// Philosopher represents a dining philosopher
type Philosopher struct {
id int
leftFork, rightFork *Fork
waiter *Waiter
eatenCount int
thinkTime time.Duration
eatTime time.Duration
}
// dine simulates a philosopher's behavior with waiter coordination
func (p *Philosopher) dine(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 3; i++ {
// Think
p.think()
// Ask waiter for permission
p.waiter.RequestPermission(p.id)
// Now safe to pick up forks
p.pickUpForks()
// Eat
p.eat()
// Put down forks
p.putDownForks()
// Return permission to waiter
p.waiter.ReleasePermission(p.id)
}
}
func (p *Philosopher) think() {
fmt.Printf("Philosopher %d is thinking...\n", p.id)
time.Sleep(p.thinkTime)
}
func (p *Philosopher) pickUpForks() {
// Pick up left fork
p.leftFork.Lock()
fmt.Printf("Philosopher %d picked up left fork %d\n", p.id, p.leftFork.id)
// Pick up right fork
p.rightFork.Lock()
fmt.Printf("Philosopher %d picked up right fork %d\n", p.id, p.rightFork.id)
}
func (p *Philosopher) eat() {
fmt.Printf("Philosopher %d is eating (meal %d)\n", p.id, p.eatenCount+1)
p.eatenCount++
time.Sleep(p.eatTime)
}
func (p *Philosopher) putDownForks() {
p.rightFork.Unlock()
p.leftFork.Unlock()
fmt.Printf("Philosopher %d put down both forks\n", p.id)
}
func main() {
fmt.Println("=== Dining Philosophers: Waiter Solution ===")
fmt.Println("Using a semaphore to prevent deadlock")
fmt.Println()
// Create 5 forks
forks := make([]*Fork, 5)
for i := 0; i < 5; i++ {
forks[i] = &Fork{id: i}
}
// Create waiter that allows 4 philosophers at a time
// This is key: 4 philosophers competing for 5 forks = no deadlock!
waiter := NewWaiter(4)
// 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],
waiter: waiter,
thinkTime: time.Duration(100+i*20) * time.Millisecond,
eatTime: time.Duration(150+i*10) * time.Millisecond,
}
}
// Start dining
var wg sync.WaitGroup
startTime := time.Now()
fmt.Println("Starting philosophers...")
for i := 0; i < 5; i++ {
wg.Add(1)
go philosophers[i].dine(&wg)
}
// Wait for completion with timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
elapsed := time.Since(startTime)
fmt.Println("\n✓ All philosophers finished dining!")
fmt.Printf("Total time: %v\n\n", elapsed)
fmt.Println("Meal counts:")
totalMeals := 0
for i, p := range philosophers {
fmt.Printf(" Philosopher %d: %d meals\n", i, p.eatenCount)
totalMeals += p.eatenCount
}
fmt.Printf(" Total: %d meals\n", totalMeals)
fmt.Println("\n✓ No deadlock! The waiter solution works!")
case <-time.After(10 * time.Second):
fmt.Println("\n✗ Timeout! Something went wrong.")
}
}
How the Semaphore Works
Buffered Channel as Semaphore
semaphore: make(chan struct{}, maxSeated)
A buffered channel with capacity N acts as a semaphore:
- Sending to the channel acquires a resource
- Receiving from the channel releases a resource
- When full (N sends), further sends block until someone receives
Acquiring Permission
w.semaphore <- struct{}{} // Blocks if 4 philosophers already seated
If 4 philosophers already have permission, the 5th philosopher blocks here until one of them finishes.
Releasing Permission
<-w.semaphore // Unblocks one waiting philosopher
This allows another philosopher to acquire permission.
Why This Prevents Deadlock
Breaking Circular Wait
The waiter solution breaks the circular wait condition:
With 4 philosophers competing for 5 forks:
- At least one philosopher can get both their forks
- That philosopher eats and releases their forks
- This unblocks others
The key insight: N-1 processes competing for N resources guarantees progress.
The Math
With 5 forks and at most 4 philosophers:
- Worst case: 4 philosophers each hold 1 fork
- Forks in use: 4
- Free forks: 1
- At least one philosopher has access to both their forks!
Performance Characteristics
Advantages
✓ No deadlock - Mathematically guaranteed ✓ Simple to understand - Centralized control ✓ Fair - FIFO channel ordering ✓ Prevents starvation - Everyone eventually gets permission
Disadvantages
✗ Limited concurrency - Only 4 of 5 philosophers can compete ✗ Centralized bottleneck - The waiter is a single point of coordination ✗ Not optimal throughput - Could potentially allow more concurrency
Running the Example
go run dining_philosophers_waiter.go
You’ll see output like:
=== Dining Philosophers: Waiter Solution ===
Using a semaphore to prevent deadlock
Starting philosophers...
Philosopher 0 is thinking...
Philosopher 0 is requesting permission from waiter
Philosopher 0 got permission from waiter
Philosopher 0 picked up left fork 0
Philosopher 0 picked up right fork 1
Philosopher 0 is eating (meal 1)
...
✓ All philosophers finished dining!
Total time: 2.5s
Meal counts:
Philosopher 0: 3 meals
Philosopher 1: 3 meals
Philosopher 2: 3 meals
Philosopher 3: 3 meals
Philosopher 4: 3 meals
Total: 15 meals
✓ No deadlock! The waiter solution works!
Key Go Patterns Used
1. Buffered Channel as Semaphore
type Waiter struct {
semaphore chan struct{}
}
This is a common Go idiom for limiting concurrency. The empty struct struct{} uses zero bytes!
2. Struct Embedding for Clean Design
type Fork struct {
id int
sync.Mutex
}
Embedding sync.Mutex makes Fork itself lockable: fork.Lock().
3. Method Receivers for Clean API
func (w *Waiter) RequestPermission(philosopherID int) {
w.semaphore <- struct{}{}
}
Methods on structs provide a clean, object-oriented style API.
4. Defer for Cleanup
func (p *Philosopher) dine(wg *sync.WaitGroup) {
defer wg.Done()
// ... rest of function
}
defer ensures Done() is called even if panic occurs.
Variations to Try
1. Different Semaphore Limits
waiter := NewWaiter(3) // Only 3 can dine at once
waiter := NewWaiter(2) // Only 2 can dine at once
Still no deadlock! As long as limit < numPhilosophers, it works.
2. Weighted Semaphore
Some philosophers might need more forks:
func (w *Waiter) RequestPermission(weight int) {
for i := 0; i < weight; i++ {
w.semaphore <- struct{}{}
}
}
3. Timeout-Based Permission
Add timeouts to permission requests:
func (w *Waiter) RequestPermissionWithTimeout(id int, timeout time.Duration) bool {
select {
case w.semaphore <- struct{}{}:
return true
case <-time.After(timeout):
return false
}
}
When to Use This Pattern
✓ Use when:
- You need guaranteed deadlock prevention
- Simplicity is more important than maximum throughput
- You can afford to limit concurrency
- You need fair resource allocation
✗ Avoid when:
- You need maximum parallelism
- Centralized coordination is a bottleneck
- You can use a lock-free design
- Resources can be ordered (see asymmetric solution)
Next Up: The Asymmetric Solution
The waiter solution sacrifices some concurrency for safety. In the next article, we’ll explore the Asymmetric Solution, which allows all 5 philosophers to dine simultaneously while still preventing deadlock!
Try It Yourself
Experiment with the code:
- Change the semaphore limit - Try 3, 2, or even 1
- Add metrics - Track wait times for permission
- Add priority - Give some philosophers higher priority
- Stress test - Run with 100 philosophers and 100 forks
- Add timeouts - What happens if permission times out?
This is part 2 of the Dining Philosophers series in “Golang Experiments: Classic Concurrency Problems”