The Story That Explains Process Synchronization
But suppose the ATMs execute in this order:
• Anita's ATM reads balance = ₹1000
• Rohan's ATM reads balance = ₹1000 (before Anita's update lands)
• Anita's ATM writes new balance = 1000 − 700 = ₹300
• Rohan's ATM writes new balance = 1000 − 500 = ₹500
Both got their cash. Final balance: ₹500. The bank just lost ₹200 — because two operations that should have been atomic got interleaved.
This is a race condition, and preventing it is exactly what Process Synchronization is about.
When multiple processes access shared data concurrently, the final result depends on the order of execution. If nothing enforces ordering, the outcome is unpredictable and often wrong. Synchronization is the discipline of controlling access to shared resources so that concurrent execution still produces correct results.
Background — Why Concurrency Breaks Things
In a modern OS, multiple processes and threads run concurrently. On a uniprocessor, the scheduler rapidly switches between them, giving the illusion of parallelism. On a multicore machine, they may truly execute at the same instant. Both scenarios cause the same problem: the outcome depends on interleaving.
Consider a shared variable counter = 5. Producer wants counter++.
Consumer wants counter--. Each compiles into three machine instructions:
| register1 = counter |
| register1 = register1 + 1 |
| counter = register1 |
| register2 = counter |
| register2 = register2 − 1 |
| counter = register2 |
🎮 Interactive — Watch the Race Unfold, Step by Step
A race condition occurs when the final state of shared data depends on the exact interleaving of concurrent operations. The outcome is non-deterministic: sometimes correct, sometimes not. Race conditions are the number-one source of concurrency bugs in production systems.
The Critical-Section Problem
Galvin formalises the problem as follows. Each of n processes has a segment of code called a critical section, in which it accesses shared variables, modifies a common file, or updates shared data structures. When one process is executing in its critical section, no other process may execute in their critical section.
The Three Requirements
A First Attempt — And Why It Fails
Naive students often reach for a simple boolean lock. Let's see why every naive attempt breaks at least one of the three requirements.
Attempt 1 — Strict Alternation using turn
// Shared variable
int turn = 0; // whose turn is it (0 or 1)
// Process P0 Process P1
while (true) { while (true) {
while (turn != 0); // wait while (turn != 1);
// CRITICAL SECTION // CRITICAL SECTION
turn = 1; turn = 0;
// remainder // remainder
} }
Mutual exclusion holds, but progress fails. If P0 finishes its critical section and enters a long remainder, P1 can enter only once. If P1 wants to enter again, it must wait for P0 — even if P0 doesn't want in. Strict alternation forces useless waiting.
Peterson's Solution — The Two-Process Answer
In 1981, Gary Peterson published an elegant software-only solution for two processes.
It combines a shared turn variable and a shared flag[] array
to signal intent. Peterson's algorithm satisfies all three requirements.
// Shared variables
int turn; // whose turn is it
boolean flag[2]; // flag[i] = true means Pi wants in
// Process Pi (i = 0 or 1, j = 1 − i)
while (true) {
flag[i] = true; // (1) I want to enter
turn = j; // (2) but I let YOU go first
while (flag[j] && turn == j); // (3) wait if other wants AND it's their turn
// CRITICAL SECTION
flag[i] = false; // (4) I'm done, you can go
// remainder section
}
🎮 Interactive — Walk Through Peterson's Algorithm
turn variable resolves the tie.
The trick is setting turn = j after raising your flag. If both processes
race and both set turn, only the last write survives. The process
whose write happened last effectively said "you go first" — and it waits. The other one
proceeds. Elegant, minimal, and mathematically provable.
Hardware Support — Atomic Instructions
Modern hardware provides atomic instructions that make locking easy. Two are especially famous.
boolean TestAndSet(boolean *target) { |
boolean rv = *target; |
*target = true; |
return rv; |
} |
int CompareAndSwap(int *v, |
int expected, int new_val) { |
int temp = *v; |
if (temp == expected) *v = new_val; |
return temp; |
The key property: these instructions execute indivisibly — the CPU guarantees no other core can interleave with them. This is enough to build a spinlock:
// Spinlock using TestAndSet
do {
while (TestAndSet(&lock)) ; // spin until we get the lock
// CRITICAL SECTION
lock = false; // release
// remainder section
} while (true);
A spinlock busy-waits, burning CPU cycles. On a uniprocessor this is pure waste. Spinlocks are only reasonable inside kernels on multiprocessors, where the wait is short. For general use, we need semaphores.
Semaphores — Dijkstra's Elegant Answer
In 1965, Edsger Dijkstra introduced the semaphore — an integer accessed only through two atomic operations: wait() (P) and signal() (V).
// The two atomic operations
wait(S) { signal(S) {
while (S <= 0) ; S = S + 1;
S = S − 1; }
}
🎮 Interactive — Semaphore Wait/Signal Sequence
semaphore mutex = 1; // initialised to 1 → binary semaphore
// Every process does:
while (true) {
wait(mutex); // decrement; block if 0
// CRITICAL SECTION
signal(mutex); // increment; wake one waiter if any
// remainder
}
Bounded Buffer (Producer/Consumer) with Semaphores
A producer generates items and places them in a buffer of size N. A consumer removes them. We need three semaphores: mutex for the buffer, empty counting empty slots, full counting filled slots.
semaphore mutex = 1; // mutual exclusion on buffer
semaphore empty = N; // initially N empty slots
semaphore full = 0; // initially 0 filled slots
// PRODUCER // CONSUMER
while (true) { while (true) {
// produce item wait(full); // wait for item
wait(empty); // wait slot wait(mutex);
wait(mutex); // remove item
// add item to buffer signal(mutex);
signal(mutex); signal(empty); // slot free
signal(full); // item ready // consume item
} }
🎮 Interactive — Bounded Buffer of Size 4
If you do wait(mutex); wait(empty); in the producer, disaster: when the
buffer is full, the producer blocks on empty while holding mutex.
The consumer can never acquire mutex to remove an item. Deadlock.
Rule: always acquire the counting semaphore first, then mutex.
Semaphore Pitfalls
P1: wait(A); wait(B);P2: wait(B); wait(A);Fix: always acquire in the same global order.
Monitors — Language-Level Synchronization
In 1974, Per Brinch Hansen and Tony Hoare proposed the monitor: a high-level construct that automatically enforces mutual exclusion. All shared data and all operations on it are encapsulated in one module; only one process can be active inside a monitor at a time.
🎮 Interactive — Monitor with Condition Variables
A semaphore remembers signals — if you signal() before anyone waits,
the count still increments. A condition variable is stateless: signal()
on an empty queue is lost. This is why cond-var code always uses
while (!condition) x.wait();.
Monitor Example — Bounded Buffer
monitor BoundedBuffer {
int buffer[N];
int count = 0;
condition notFull, notEmpty;
procedure insert(int item) {
while (count == N)
notFull.wait(); // wait until room in buffer
buffer[count] = item;
count++;
notEmpty.signal(); // wake a consumer
}
procedure remove() returns int {
while (count == 0)
notEmpty.wait(); // wait until an item exists
int item = buffer[--count];
notFull.signal(); // wake a producer
return item;
}
initialization { count = 0; }
}
In Java — Every Object Is a Monitor
public class BoundedBuffer<T> {
private final Object[] buf;
private int count = 0;
public BoundedBuffer(int capacity) { buf = new Object[capacity]; }
public synchronized void put(T item) throws InterruptedException {
while (count == buf.length) wait();
buf[count++] = item;
notifyAll();
}
public synchronized T take() throws InterruptedException {
while (count == 0) wait();
T item = (T) buf[--count];
notifyAll();
return item;
}
}
No explicit mutex. No wait/signal on a counting semaphore. No danger of forgetting to release the lock. The monitor guarantees one operation runs at a time; the programmer only writes the logic. This is why every modern language uses monitor-style constructs.
Comparison — Semaphore vs Monitor
| Feature | Semaphore | Monitor |
|---|---|---|
| Level | Low-level OS primitive | High-level language construct |
| Mutex is | Manual — call wait/signal | Automatic — enforced by compiler |
| Waiting semantics | Counting or binary | Condition variables (stateless) |
| Signal semantics | Always increments (remembered) | Lost if no one waiting |
| Programmer effort | High — easy to bug | Low — structured |
| Error prone | Yes (deadlock, forgot signal) | Less so |
| Language support | OS syscall / library | Built-in (Java synchronized, C# lock) |
| Suitable for | Kernel code, resource counting | Application code, structured concurrency |
Real-World Applications
Golden Rules — Process Synchronization
with lock:). The compiler enforces mutual exclusion so you can't forget a release.while loop, not an if. This handles spurious wakeups and Mesa-style signalling.