EthicsComputerCourses
Join training +91 90532 10052

Classic Synchronisation Problems

Operating Systems · Lesson 15 of 32 Intermediate 7 min read Updated

By the end of this lesson you will be able to write semaphore solutions for the three classic synchronisation problems, explain where each one can go wrong, and test them in a small Python simulation.

These three problems are not puzzles for their own sake. Each is a model of a pattern you meet in real software: a queue between two stages, a shared record that many read and few update, and a group of tasks that each need several resources. Learn them once and you will recognise them everywhere.

The tiny kernel we will use

All three simulations use the same small round-robin kernel from the semaphores lesson. Processes are Python generators that yield ("P", sem) or yield ("V", sem). Any other request just uses up a turn, which lets us make a process "slow". If every remaining process is blocked, the kernel reports a deadlock.

Python
from collections import deque

class Sem:
    def __init__(self, v): self.v, self.q = v, deque()

def run(procs):
    ready, blocked = deque(procs.items()), 0
    while ready:
        name, g = ready.popleft()
        try:
            op, arg = next(g)
        except StopIteration:
            continue
        if op == "P":
            arg.v -= 1
            if arg.v < 0:
                arg.q.append((name, g)); blocked += 1; continue
        elif op == "V":
            arg.v += 1
            if arg.v <= 0:
                ready.append(arg.q.popleft()); blocked -= 1
        ready.append((name, g))
    print("DEADLOCK: everyone is blocked" if blocked else "all done")

Each runnable example below includes this code at the top, so it runs on its own.

1. Producer-consumer (bounded buffer)

A producer makes items and puts them in a buffer with room for N items. A consumer takes them out. The producer must wait when the buffer is full, and the consumer must wait when it is empty.

Picture the serving counter between the kitchen and the dining hall in a South Indian tiffin centre. It holds only two plates. The cook places plates; the waiter carries them away. If the counter is full, the cook waits. If it is empty, the waiter waits.

Three semaphores solve it:

  • empty, starting at N: counts free slots.
  • full, starting at 0: counts filled slots.
  • mutex, starting at 1: makes sure only one process changes the buffer at a time.

Step 1: run it. The waiter is slower than the cook, so you will see the buffer fill up.

PythonRun it yourself
from collections import deque

class Sem:
    def __init__(self, v): self.v, self.q = v, deque()

def run(procs):
    ready, blocked = deque(procs.items()), 0
    while ready:
        name, g = ready.popleft()
        try:
            op, arg = next(g)
        except StopIteration:
            continue
        if op == "P":
            arg.v -= 1
            if arg.v < 0:
                arg.q.append((name, g)); blocked += 1; continue
        elif op == "V":
            arg.v += 1
            if arg.v <= 0:
                ready.append(arg.q.popleft()); blocked -= 1
        ready.append((name, g))
    print("DEADLOCK: everyone is blocked" if blocked else "all done")

N = 2                                   # buffer holds 2 plates
buffer = deque()
empty, full, mutex = Sem(N), Sem(0), Sem(1)

def producer():                         # the cook
    for dish in ["idli", "dosa", "vada", "upma", "poha"]:
        if len(buffer) == N:
            print("cook   waits: buffer full")
        yield ("P", empty)              # wait for a free slot
        yield ("P", mutex)
        buffer.append(dish); print(f"cook   put  {dish:5} buffer={list(buffer)}")
        yield ("V", mutex)
        yield ("V", full)               # one more full slot

def consumer():                         # the waiter
    for _ in range(5):
        yield ("P", full)               # wait for a full slot
        yield ("P", mutex)
        dish = buffer.popleft(); print(f"waiter took {dish:5} buffer={list(buffer)}")
        yield ("V", mutex)
        yield ("V", empty)              # one more free slot
        for _ in range(3):
            yield ("serve", None)       # walking to the table takes time

run({"cook": producer(), "waiter": consumer()})
Output
cook   put  idli  buffer=['idli']
waiter took idli  buffer=[]
cook   put  dosa  buffer=['dosa']
cook   put  vada  buffer=['dosa', 'vada']
waiter took dosa  buffer=['vada']
cook   put  upma  buffer=['vada', 'upma']
cook   waits: buffer full
waiter took vada  buffer=['upma']
cook   put  poha  buffer=['upma', 'poha']
waiter took upma  buffer=['poha']
waiter took poha  buffer=[]
all done

Step 2: check the rules. The buffer never holds more than 2 plates, and the waiter never takes from an empty counter.

The classic trap: if the producer does P(mutex) before P(empty), then a producer that finds the buffer full goes to sleep while holding mutex. The consumer can never get mutex to empty the buffer, and both wait for ever. Always wait on the counting semaphore first, then the mutex.

2. Readers-writers

Many processes share a record, such as a train timetable. Readers only look at it, so any number can read together. A writer changes it, so a writer needs the record completely to itself.

The first readers-writers solution uses:

  • rw, starting at 1: held by a writer, or by the group of readers as a whole.
  • read_count: how many readers are inside.
  • mutex, starting at 1: protects read_count.

The first reader to arrive locks rw; the last reader to leave unlocks it. Readers in between just walk in.

PythonRun it yourself
from collections import deque

class Sem:
    def __init__(self, v): self.v, self.q = v, deque()

def run(procs):
    ready, blocked = deque(procs.items()), 0
    while ready:
        name, g = ready.popleft()
        try:
            op, arg = next(g)
        except StopIteration:
            continue
        if op == "P":
            arg.v -= 1
            if arg.v < 0:
                arg.q.append((name, g)); blocked += 1; continue
        elif op == "V":
            arg.v += 1
            if arg.v <= 0:
                ready.append(arg.q.popleft()); blocked -= 1
        ready.append((name, g))
    print("DEADLOCK: everyone is blocked" if blocked else "all done")

rw, mutex = Sem(1), Sem(1)        # rw: the timetable itself; mutex protects read_count
read_count = 0

def reader(name):
    global read_count
    yield ("P", mutex)
    read_count += 1
    if read_count == 1:
        yield ("P", rw)            # first reader locks out writers
    yield ("V", mutex)
    print(f"{name} reading (readers inside: {read_count})")
    yield ("read", None)
    yield ("read", None)
    yield ("P", mutex)
    read_count -= 1
    print(f"{name} done reading")
    if read_count == 0:
        yield ("V", rw)            # last reader lets writers in
    yield ("V", mutex)

def writer(name, arrives_after):
    for _ in range(arrives_after):
        yield ("travel", None)       # the writer shows up a little later
    print(f"{name} wants to write")
    yield ("P", rw)
    print(f"{name} WRITING alone")
    yield ("write", None)
    print(f"{name} done writing")
    yield ("V", rw)

run({"R1": reader("R1"), "R2": reader("R2"), "W1": writer("W1", 3), "R3": reader("R3")})
Output
R1 reading (readers inside: 2)
W1 wants to write
R2 reading (readers inside: 3)
R3 reading (readers inside: 3)
R1 done reading
R2 done reading
R3 done reading
W1 WRITING alone
W1 done writing
all done

Three readers share the timetable, while W1 waits until the last reader leaves. (The count printed is how many readers are inside at that moment.) This version favours readers: if new readers keep arriving, the writer can starve. The second readers-writers problem flips the preference so that a waiting writer blocks new readers. Real libraries offer read-write locks (pthread_rwlock_t on Linux, SRWLOCK on Windows) with fairer rules.

3. Dining philosophers

Five philosophers sit at a round table. Between each pair lies one fork, so there are five forks. A philosopher alternates between thinking and eating, and needs both neighbouring forks to eat.

The obvious solution is: pick up the left fork, then the right fork, eat, put both down. Each fork is a semaphore starting at 1. Step 1: run it, and then run the fixed version.

PythonRun it yourself
from collections import deque

class Sem:
    def __init__(self, v): self.v, self.q = v, deque()

def run(procs):
    ready, blocked = deque(procs.items()), 0
    while ready:
        name, g = ready.popleft()
        try:
            op, arg = next(g)
        except StopIteration:
            continue
        if op == "P":
            arg.v -= 1
            if arg.v < 0:
                arg.q.append((name, g)); blocked += 1; continue
        elif op == "V":
            arg.v += 1
            if arg.v <= 0:
                ready.append(arg.q.popleft()); blocked -= 1
        ready.append((name, g))
    print("DEADLOCK: everyone is blocked" if blocked else "all done")

def philosopher(i, forks, smart):
    left, right = i, (i + 1) % 5
    first, second = (right, left) if smart and i % 2 else (left, right)
    yield ("P", forks[first])
    yield ("P", forks[second])
    print(f"Ph{i} eats with forks {first} and {second}")
    yield ("V", forks[second])
    yield ("V", forks[first])

for smart in (False, True):
    print("Odd philosophers pick right fork first:" if smart else "Everyone picks left fork first:")
    forks = [Sem(1) for _ in range(5)]
    run({f"Ph{i}": philosopher(i, forks, smart) for i in range(5)})
Output
Everyone picks left fork first:
DEADLOCK: everyone is blocked
Odd philosophers pick right fork first:
Ph0 eats with forks 0 and 1
Ph3 eats with forks 4 and 3
Ph1 eats with forks 2 and 1
Ph4 eats with forks 4 and 0
Ph2 eats with forks 2 and 3
all done

Step 2: understand the deadlock. In the first run, every philosopher picks up their left fork in their first turn. Now every fork is taken, and everyone waits for a right fork that their neighbour holds. Nobody can ever eat.

Common fixes:

  • Asymmetry: odd-numbered philosophers pick the right fork first, as in the second run. The circle of waiting is broken.
  • Limit the table: allow at most four philosophers to sit at once (a counting semaphore starting at 4). At least one of them can always get two forks.
  • Both or nothing: pick up both forks only if both are free, checked inside a monitor.

This problem models any set of tasks that each need several locks, such as bank transfers that must lock two accounts. You will study the general theory in Deadlocks & How to Prevent Them.

Common mistakes

  • Swapping P(empty) and P(mutex) in the producer. Sleeping while holding the mutex deadlocks the system.
  • Forgetting that the first readers-writers solution can starve writers. Exams ask which side starves.
  • Thinking the "limit to four" fix also guarantees fairness. It prevents deadlock, but a particular philosopher can still be unlucky.

Key points

  • Producer-consumer uses empty (N), full (0) and mutex (1), and waits on the counting semaphore before the mutex.
  • Readers-writers lets many readers share but gives a writer exclusive access; the first version can starve writers.
  • Naive dining philosophers deadlocks when everyone holds one fork and waits for the next.
  • Asymmetric ordering, limiting diners, or taking both forks at once prevents that deadlock.
  • These patterns model real pipelines, shared records and multi-lock operations.

Exercise

In the producer-consumer simulation, swap the order of yield ("P", empty) and yield ("P", mutex) in the producer (and remove the two "waits" lines above them). Run it and explain the result. Then also remove the waiter's three "serve" steps and run it again. What changed?

Show solution
Python
def producer():
    for dish in ["idli", "dosa", "vada", "upma", "poha"]:
        yield ("P", mutex)              # WRONG ORDER
        yield ("P", empty)
        buffer.append(dish); print(f"cook   put  {dish:5} buffer={list(buffer)}")
        yield ("V", mutex)
        yield ("V", full)
Output
cook   put  idli  buffer=['idli']
waiter took idli  buffer=[]
cook   put  dosa  buffer=['dosa']
cook   put  vada  buffer=['dosa', 'vada']
waiter took dosa  buffer=['vada']
cook   put  upma  buffer=['vada', 'upma']
DEADLOCK: everyone is blocked

The buffer is full, so the cook sleeps on empty while still holding mutex. The waiter then blocks on mutex and can never free a slot. Without the "serve" steps the waiter keeps up, the buffer never fills, and the program finishes with all done: the bug is still there, it just stays hidden. Bugs that appear only with certain timings are the most dangerous kind.

Quick Quiz

1. In the bounded-buffer solution, what is the initial value of the semaphore full?

2. In the first readers-writers solution, who can starve?

3. Why does the naive dining philosophers solution deadlock?

4. Which change prevents deadlock in the dining philosophers problem?

Learn Operating Systems with a mentor

Free lessons get you started. Our Computer Science Fundamentals training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call