Skip to content

Process Synchronization — Mutex, Semaphore & Monitor Explained

DodaTech Updated 2026-06-23 10 min read

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

What is the difference between a condition variable and a Semaphore?

A condition variable has no counter — it signals a specific condition (data available, buffer empty) to waiting threads. A Semaphore maintains a counter and can be used to manage a pool of resources. Condition variables are always paired with a mutex; semaphores are self-sufficient.

What is priority inversion and how does Linux solve it?

Priority inversion occurs when a high-priority thread waits for a lock held by a low-priority thread, while a medium-priority thread preempts the low-priority one. Linux uses priority inheritance: the low-priority thread temporarily inherits the high priority until it releases the lock.

What is the difference between lock-free and wait-free synchronization?

Lock-free means at least one thread makes progress in any finite time interval. Wait-free means every thread makes progress — no thread can block another. Lock-free uses CAS (compare-and-swap). Wait-free requires more complex designs like RCU (Read-Copy-Update).

Deadlock Handling
IPC
Process Scheduling

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 -H and 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