Process Synchronization — Mutex, Semaphore & Monitor Explained
In this tutorial, you'll learn about Process Synchronization. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Process synchronization prevents race conditions when multiple threads or processes access shared resources, using tools like mutex locks, semaphores, and monitors to coordinate concurrent execution safely.
What You'll Learn & Why It Matters
In this tutorial, you'll learn how processes and threads synchronize access to shared data using mutexes, semaphores, condition variables, and monitors. You'll understand classic synchronization problems — producer-consumer, reader-writer, dining philosophers — and how to solve them correctly.
Real-world use: A web server handling 10,000 concurrent requests needs synchronized access to shared log files and connection pools. Without proper synchronization, two threads can overwrite each other's data, corrupting logs or crashing the server. Durga Antivirus Pro uses fine-grained locking to scan multiple files simultaneously without data corruption.
graph TD
subgraph "Race Condition"
T1[Thread 1] -->|read counter| C1[counter = 5]
T2[Thread 2] -->|read counter| C2[counter = 5]
C1 -->|increment| C1a[counter = 6]
C2 -->|increment| C2a[counter = 6]
C1a --> WR[Write: 6 - WRONG!]
C2a --> WR
end
subgraph "With Mutex"
M1[Thread 1] -->|lock| L1[Mutex locked]
M2[Thread 2] -->|wait| L2[Mutex locked - waiting]
L1 -->|increment| OK1[counter = 5 -> 6]
OK1 -->|unlock| U1[Mutex unlocked]
U1 -->|lock| U2[Thread 2 locks]
U2 -->|increment| OK2[counter = 6 -> 7]
end
style T1 fill:#ef4444,color:#fff
style T2 fill:#ef4444,color:#fff
style M1 fill:#22c55e,color:#fff
style M2 fill:#22c55e,color:#fff
Race Conditions and Critical Sections
A Race Condition occurs when two or more threads access shared data simultaneously and the final result depends on the timing of their execution. The code segment where shared data is accessed is called the Critical Section.
import threading
counter = 0
iterations = 100000
def increment():
global counter
for _ in range(iterations):
counter += 1
def decrement():
global counter
for _ in range(iterations):
counter -= 1
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=decrement)
t1.start()
t2.start()
t1.join()
t2.join()
print(f'Expected: 0, Got: {counter}')
print(f'Difference: {abs(counter)}')
Expected output (varies, but never 0):
Expected: 0, Got: 5342
Difference: 5342
The counter += 1 operation is actually three CPU instructions: load, add, store. If both threads load 5 before either stores 6, the result is 6 instead of 7.
Mutex Locks
A mutex (mutual exclusion) ensures only one thread enters the Critical Section at a time.
import threading
counter = 0
iterations = 100000
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(iterations):
with lock:
counter += 1
def safe_decrement():
global counter
for _ in range(iterations):
with lock:
counter -= 1
t1 = threading.Thread(target=safe_increment)
t2 = threading.Thread(target=safe_decrement)
t1.start()
t2.start()
t1.join()
t2.join()
print(f'Expected: 0, Got: {counter}')
Expected output:
Expected: 0, Got: 0
Mutex Internals
#include <pthread.h>
#include <stdio.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;
void *worker(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock);
shared_counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter: %d\n", shared_counter);
return 0;
}
Expected output:
Counter: 200000
Semaphores
A Semaphore is an integer variable accessed through two atomic operations: wait (decrement) and signal (increment). If the value reaches zero, wait blocks.
| Type | Initial Value | Purpose |
|---|---|---|
| Binary | 1 | Like a mutex, but without ownership |
| Counting | N | Manages a pool of N identical resources |
import threading
import time
class Semaphore:
def __init__(self, initial=1):
self.value = initial
self.lock = threading.Lock()
self.condition = threading.Condition(self.lock)
def wait(self):
with self.condition:
while self.value == 0:
self.condition.wait()
self.value -= 1
def signal(self):
with self.condition:
self.value += 1
self.condition.notify()
class Database:
def __init__(self, max_connections=3):
self.semaphore = Semaphore(max_connections)
def query(self, db_id, sql):
print(f'[DB-{db_id}] Waiting for connection...')
self.semaphore.wait()
print(f'[DB-{db_id}] Connected. Executing: {sql}')
time.sleep(1)
print(f'[DB-{db_id}] Done.')
self.semaphore.signal()
db = Database(2)
threads = []
for i in range(4):
t = threading.Thread(target=db.query, args=(i, f'SELECT * FROM users_{i}'))
threads.append(t)
t.start()
for t in threads:
t.join()
Expected output:
[DB-0] Waiting for connection...
[DB-0] Connected. Executing: SELECT * FROM users_0
[DB-1] Waiting for connection...
[DB-1] Connected. Executing: SELECT * FROM users_1
[DB-2] Waiting for connection...
[DB-3] Waiting for connection...
[DB-0] Done.
[DB-2] Connected. Executing: SELECT * FROM users_2
[DB-1] Done.
[DB-3] Connected. Executing: SELECT * FROM users_3
[DB-2] Done.
[DB-3] Done.
Producer-Consumer Problem
A classic bounded buffer problem: producers write data, consumers read it. The buffer has a fixed size.
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
#define BUFFER_SIZE 5
#define NUM_ITEMS 10
int buffer[BUFFER_SIZE];
int in = 0, out = 0;
sem_t empty, full;
pthread_mutex_t mutex;
void *producer(void *arg) {
for (int i = 0; i < NUM_ITEMS; i++) {
sem_wait(&empty);
pthread_mutex_lock(&mutex);
buffer[in] = i;
printf("Produced: %d at slot %d\n", i, in);
in = (in + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(&full);
usleep(100000);
}
return NULL;
}
void *consumer(void *arg) {
for (int i = 0; i < NUM_ITEMS; i++) {
sem_wait(&full);
pthread_mutex_lock(&mutex);
int item = buffer[out];
printf("Consumed: %d from slot %d\n", item, out);
out = (out + 1) % BUFFER_SIZE;
pthread_mutex_unlock(&mutex);
sem_post(&empty);
usleep(200000);
}
return NULL;
}
int main() {
pthread_t prod, cons;
sem_init(&empty, 0, BUFFER_SIZE);
sem_init(&full, 0, 0);
pthread_mutex_init(&mutex, NULL);
pthread_create(&prod, NULL, producer, NULL);
pthread_create(&cons, NULL, consumer, NULL);
pthread_join(prod, NULL);
pthread_join(cons, NULL);
sem_destroy(&empty);
sem_destroy(&full);
pthread_mutex_destroy(&mutex);
return 0;
}
Expected output:
Produced: 0 at slot 0
Consumed: 0 from slot 0
Produced: 1 at slot 1
Produced: 2 at slot 2
Consumed: 1 from slot 1
Produced: 3 at slot 3
Consumed: 2 from slot 2
...
Monitors
A monitor is a high-level synchronization construct that bundles mutex + condition variables into one abstraction. Operating systems like Java and Python provide built-in monitor support.
import threading
class Monitor:
def __init__(self):
self.lock = threading.Lock()
self.condition = threading.Condition(self.lock)
def synchronized(self, func):
def wrapper(*args, **kwargs):
with self.lock:
return func(*args, **kwargs)
return wrapper
class BoundedBuffer:
def __init__(self, size=5):
self.size = size
self.buffer = []
self.lock = threading.Lock()
self.not_full = threading.Condition(self.lock)
self.not_empty = threading.Condition(self.lock)
def put(self, item):
with self.not_full:
while len(self.buffer) >= self.size:
self.not_full.wait()
self.buffer.append(item)
print(f'Put: {item}, buffer: {self.buffer}')
self.not_empty.notify()
def get(self):
with self.not_empty:
while len(self.buffer) == 0:
self.not_empty.wait()
item = self.buffer.pop(0)
print(f'Get: {item}, buffer: {self.buffer}')
self.not_full.notify()
return item
import time
def producer_mon(bb, items):
for i in range(items):
bb.put(i)
time.sleep(0.1)
def consumer_mon(bb, items):
for _ in range(items):
bb.get()
time.sleep(0.2)
bb = BoundedBuffer(3)
t1 = threading.Thread(target=producer_mon, args=(bb, 6))
t2 = threading.Thread(target=consumer_mon, args=(bb, 6))
t1.start()
t2.start()
t1.join()
t2.join()
Expected output:
Put: 0, buffer: [0]
Put: 1, buffer: [0, 1]
Put: 2, buffer: [0, 1, 2]
Get: 0, buffer: [1, 2]
Put: 3, buffer: [1, 2, 3]
Get: 1, buffer: [2, 3]
...
Common Mistakes
1. Forgetting to Unlock a Mutex
If a mutex is not unlocked (e.g., early return or exception), all other threads waiting for it will block forever. Use RAII patterns or with statements.
2. Using Busy Waiting Instead of Blocking
while (flag != set); wastes CPU cycles. Use condition variables (pthread_cond_wait) or semaphores to block efficiently.
3. Deadlock from Lock Ordering
Locking mutex A then B in one thread, and B then A in another, causes deadlock. Always acquire locks in a consistent global order.
4. Not Handling Spurious Wakeups
pthread_cond_wait can return without the condition being true. Always use while (condition) not if (condition) after a wait.
5. Using Mutex in Interrupt Context
Mutexes can sleep. In kernel interrupt handlers, use spinlocks instead. Sleeping in interrupt context crashes the system.
Practice Questions
1. What is the fundamental difference between a mutex and a binary Semaphore? A mutex has ownership — the same thread must lock and unlock it. A Semaphore has no ownership; any thread can signal it. Mutexes support priority inheritance; semaphores do not.
2. What problem does the reader-writer lock solve? Multiple readers can access shared data simultaneously, but writers need exclusive access. A reader-writer lock allows concurrent reads while blocking writers during writes, improving performance for read-heavy workloads.
3. What is a spinlock and when should you use it? A spinlock continuously polls a flag until it becomes available. Use it only in interrupt handlers or when the wait time is shorter than a context switch (~1 microsecond). Never use it in user space.
4. Challenge: Implement the dining philosophers problem with 5 philosophers. Use a mutex per fork. Add a deadlock prevention strategy by having philosophers pick up forks in different orders (odd = left first, even = right first).
5. Real-World Task: Run a multi-threaded Python program without synchronization and observe the non-deterministic output. Then add locks and verify the output becomes deterministic. Use time to measure the performance cost of locking.
Mini Project: Thread Pool with Synchronization
import threading
import time
from queue import Queue
class ThreadPool:
def __init__(self, num_workers=4):
self.tasks = Queue()
self.workers = []
self.lock = threading.Lock()
self.completed = 0
for _ in range(num_workers):
t = threading.Thread(target=self._worker)
t.daemon = True
self.workers.append(t)
t.start()
def _worker(self):
while True:
task_id, func, args = self.tasks.get()
if task_id is None:
break
try:
result = func(*args)
with self.lock:
self.completed += 1
print(f'[Pool] Task {task_id} done: {result}')
except Exception as e:
print(f'[Pool] Task {task_id} failed: {e}')
self.tasks.task_done()
def submit(self, task_id, func, *args):
self.tasks.put((task_id, func, args))
def wait_completion(self):
self.tasks.join()
def shutdown(self):
for _ in self.workers:
self.tasks.put((None, None, None))
for w in self.workers:
w.join()
def scan_file(file_id):
time.sleep(0.5)
return f'File {file_id} scanned - clean'
pool = ThreadPool(3)
for i in range(8):
pool.submit(i, scan_file, i)
pool.wait_completion()
print(f'Total completed: {pool.completed}')
pool.shutdown()
Expected output:
[Pool] Task 0 done: File 0 scanned - clean
[Pool] Task 1 done: File 1 scanned - clean
[Pool] Task 2 done: File 2 scanned - clean
[Pool] Task 3 done: File 3 scanned - clean
[Pool] Task 4 done: File 4 scanned - clean
[Pool] Task 5 done: File 5 scanned - clean
[Pool] Task 6 done: File 6 scanned - clean
[Pool] Task 7 done: File 7 scanned - clean
Total completed: 8
FAQ
Related Concepts
What's Next
You now understand process synchronization. Next, learn about deadlock detection and prevention to understand what happens when synchronization goes wrong, or explore IPC mechanisms for passing data between processes.
- Practice daily — Run
top -Hand identify which threads in a multi-threaded process are blocked on locks. - Build a project — Create a thread-safe bounded buffer and benchmark its throughput with different numbers of threads.
- Explore related topics — Study lock-free data structures for high-performance concurrent systems.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro