Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

Synchronization Primitives

The building blocks of thread-safe programming.

When multiple threads access shared data concurrently, we need synchronization primitives to ensure correctness and prevent race conditions. These tools are the foundation of thread-safe programming.

Visual: The Problem Without Synchronization

Section titled “Visual: The Problem Without Synchronization”
Diagram

A critical section is a code segment that accesses shared resources and must be executed atomically (as a single, indivisible operation) to prevent race conditions.

Diagram
race_condition.py
import threading
# Shared counter
counter = 0
def increment():
global counter
# Critical section - NOT protected!
for _ in range(100000):
counter += 1 # Not atomic: read-modify-write
# Create multiple threads
threads = []
for _ in range(5):
thread = threading.Thread(target=increment)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print(f"Expected: 500000, Got: {counter}")
# Output: Expected: 500000, Got: 342156 (WRONG!)

Locks (also called mutexes) provide mutual exclusion—ensuring only one thread can execute a critical section at a time.

Diagram
lock_example.py
import threading
counter = 0
lock = threading.Lock() # Create a lock
def increment():
global counter
for _ in range(100000):
lock.acquire() # Acquire lock
try:
counter += 1 # Critical section - protected!
finally:
lock.release() # Always release lock
# Or use context manager (recommended)
def increment_safe():
global counter
for _ in range(100000):
with lock: # Automatically acquire/release
counter += 1 # Critical section
threads = []
for _ in range(5):
thread = threading.Thread(target=increment_safe)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print(f"Expected: 500000, Got: {counter}")
# Output: Expected: 500000, Got: 500000 (CORRECT!)

A reentrant lock allows the same thread to acquire the lock multiple times without deadlocking. This is useful for recursive methods or when calling other methods that need the same lock.

Diagram
reentrant_lock.py
import threading
lock = threading.RLock() # Reentrant Lock
def outer_function():
with lock: # First acquisition
print("Outer: Lock acquired")
inner_function() # Calls inner function
print("Outer: Lock released")
def inner_function():
with lock: # Second acquisition (same thread!)
print("Inner: Lock acquired (reentrant)")
# Do work
print("Inner: Lock released")
# This works without deadlock!
outer_function()

Java provides two ways to achieve mutual exclusion:

FeaturesynchronizedReentrantLock
SyntaxKeyword (implicit)Class (explicit)
FairnessNoYes (optional)
Try LockNoYes (tryLock())
InterruptibleNoYes (lockInterruptibly())
Condition SupportLimitedFull (newCondition())
PerformanceSlightly fasterSlightly slower
SynchronizedExample.java
public class SynchronizedExample {
private int counter = 0;
// Implicit locking with synchronized
public synchronized void increment() {
counter++;
}
// Synchronized block
public void incrementBlock() {
synchronized (this) {
counter++;
}
}
}

A semaphore controls access to a resource with a counter. Unlike a lock (which allows only one thread), a semaphore can allow N threads to access a resource simultaneously.

Diagram
  • Binary Semaphore: Count = 1 (similar to a lock, but can be released by different thread)
  • Counting Semaphore: Count = N (allows N concurrent accesses)
rate_limiter.py
import threading
import time
class RateLimiter:
def __init__(self, max_requests, time_window):
self.semaphore = threading.Semaphore(max_requests)
self.time_window = time_window
self.last_reset = time.time()
self.lock = threading.Lock()
def acquire(self):
"""Acquire a permit, blocking if necessary"""
self.semaphore.acquire()
with self.lock:
current_time = time.time()
if current_time - self.last_reset >= self.time_window:
# Reset permits
for _ in range(self.semaphore._value):
self.semaphore.release()
self.last_reset = current_time
def release(self):
"""Release a permit"""
self.semaphore.release()
# Usage: Allow max 5 concurrent requests
limiter = RateLimiter(max_requests=5, time_window=1.0)
def make_request(request_id):
limiter.acquire()
try:
print(f"Request {request_id} processing...")
time.sleep(0.5) # Simulate work
finally:
limiter.release()
print(f"Request {request_id} completed")
# Create 10 threads
threads = []
for i in range(10):
thread = threading.Thread(target=make_request, args=(i,))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()

Condition variables allow threads to wait for a specific condition to become true, enabling efficient thread coordination.

Diagram

Example: Producer-Consumer with Condition Variables

Section titled “Example: Producer-Consumer with Condition Variables”
condition_example.py
import threading
import time
class BoundedBuffer:
def __init__(self, capacity):
self.capacity = capacity
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.lock:
# Wait while buffer is full
while len(self.buffer) >= self.capacity:
self.not_full.wait() # Releases lock, waits
self.buffer.append(item)
print(f"Produced: {item}")
self.not_empty.notify() # Wake up consumer
def get(self):
with self.lock:
# Wait while buffer is empty
while len(self.buffer) == 0:
self.not_empty.wait() # Releases lock, waits
item = self.buffer.pop(0)
print(f"Consumed: {item}")
self.not_full.notify() # Wake up producer
return item
# Usage
buffer = BoundedBuffer(capacity=5)
def producer():
for i in range(10):
buffer.put(i)
time.sleep(0.1)
def consumer():
for _ in range(10):
buffer.get()
time.sleep(0.2)
threading.Thread(target=producer).start()
threading.Thread(target=consumer).start()
  • notify(): Wakes up one waiting thread (unpredictable which one)
  • notifyAll(): Wakes up all waiting threads (they compete for the lock)

A barrier makes threads wait until all threads reach a synchronization point.

Diagram
barrier_example.py
import threading
barrier = threading.Barrier(3) # 3 threads must wait
def worker(worker_id):
print(f"Worker {worker_id}: Phase 1")
barrier.wait() # Wait for all threads
print(f"Worker {worker_id}: Phase 2 (all arrived!)")
threads = []
for i in range(3):
thread = threading.Thread(target=worker, args=(i,))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()

A CountDownLatch is a one-time synchronization point. Threads wait until a count reaches zero.

CountDownLatchExample.java
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3); // Count starts at 3
// Worker threads
for (int i = 0; i < 3; i++) {
final int workerId = i;
new Thread(() -> {
try {
System.out.println("Worker " + workerId + " working...");
Thread.sleep(1000);
latch.countDown(); // Decrement count
System.out.println("Worker " + workerId + " finished");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
System.out.println("Main thread waiting...");
latch.await(); // Wait until count reaches 0
System.out.println("All workers finished! Main thread proceeds.");
}
}

Read-Write Locks optimize for read-heavy workloads by allowing multiple readers or a single writer.

Diagram

Example: Thread-Safe Cache with Read-Write Lock

Section titled “Example: Thread-Safe Cache with Read-Write Lock”
ReadWriteLockExample.java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockExample {
private final Map<String, String> cache = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public String get(String key) {
lock.readLock().lock(); // Multiple readers allowed
try {
return cache.get(key);
} finally {
lock.readLock().unlock();
}
}
public void put(String key, String value) {
lock.writeLock().lock(); // Exclusive access
try {
cache.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
public static void main(String[] args) {
ReadWriteLockExample cache = new ReadWriteLockExample();
// Multiple readers can read simultaneously
for (int i = 0; i < 10; i++) {
final int readerId = i;
new Thread(() -> {
String value = cache.get("key");
System.out.println("Reader " + readerId + " read: " + value);
}).start();
}
// Writer has exclusive access
new Thread(() -> {
cache.put("key", "value");
System.out.println("Writer updated cache");
}).start();
}
}

Thread-Local Storage provides each thread with its own independent copy of a variable, avoiding the need to pass parameters through method calls.

Diagram
thread_local_example.py
import threading
# Thread-local storage
request_context = threading.local()
def set_user(user_id):
request_context.user_id = user_id
request_context.request_id = threading.current_thread().name
def get_user():
return getattr(request_context, 'user_id', None)
def process_request(user_id):
set_user(user_id)
print(f"Thread {threading.current_thread().name}: "
f"Processing request for user {get_user()}")
# Each thread has its own context
threads = []
for i in range(3):
thread = threading.Thread(
target=process_request,
args=(f"user-{i}",),
name=f"Thread-{i}"
)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()

The volatile keyword in Java ensures visibility of changes across threads and prevents certain compiler optimizations.

Diagram
VolatileExample.java
public class VolatileExample {
private volatile boolean flag = false; // Volatile ensures visibility
public void setFlag() {
flag = true; // Write is immediately visible to all threads
}
public boolean getFlag() {
return flag; // Read sees the latest value
}
public static void main(String[] args) throws InterruptedException {
VolatileExample example = new VolatileExample();
// Reader thread
Thread reader = new Thread(() -> {
while (!example.getFlag()) {
// Busy wait - will see flag change immediately
}
System.out.println("Flag is now true!");
});
reader.start();
Thread.sleep(1000);
// Writer thread
example.setFlag(); // This change is immediately visible
reader.join();
}
}

PrimitiveUse CaseJavaPythonWhen to Use
Lock/MutexMutual exclusionReentrantLockthreading.LockProtect critical sections
Reentrant LockRecursive lockingReentrantLockthreading.RLockSame thread needs lock multiple times
Read-Write LockRead-heavy workloadsReentrantReadWriteLockLimited supportMany readers, few writers
SemaphoreLimit concurrent accessSemaphorethreading.SemaphoreRate limiting, resource pools
ConditionThread coordinationConditionthreading.ConditionWait for conditions (Producer-Consumer)
BarrierSynchronization pointCyclicBarrierthreading.BarrierAll threads wait, then proceed together
LatchOne-time syncCountDownLatchN/AWait for N events to complete
Thread-LocalPer-thread variablesThreadLocalthreading.local()Avoid parameter passing
VolatileMemory visibilityvolatileN/ASimple flags, single variable updates

Design a thread-safe counter that supports increment(), decrement(), and get() operations.

Solution
import threading
class ThreadSafeCounter:
def __init__(self):
self._value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self._value += 1
def decrement(self):
with self._lock:
self._value -= 1
def get(self):
with self._lock:
return self._value

Implement a rate limiter that allows N requests per second using semaphores.

Solution

See the Rate Limiter example in the Semaphores section above.


Q1: “What’s the difference between synchronized and ReentrantLock?”

Section titled “Q1: “What’s the difference between synchronized and ReentrantLock?””

Answer:

  • synchronized: Implicit locking keyword, simpler syntax, slightly faster, but less flexible
  • ReentrantLock: Explicit lock class, more features (fairness, tryLock, interruptible), more control, slightly slower
  • When to use: Use synchronized for simple cases, ReentrantLock when you need advanced features

Q2: “When would you use a semaphore vs a lock?”

Section titled “Q2: “When would you use a semaphore vs a lock?””

Answer:

  • Lock (Mutex): Allows only ONE thread at a time (mutual exclusion)
  • Semaphore: Allows N threads at a time (resource limiting)
  • Use semaphore: When you need to limit concurrent access to a resource (e.g., database connections, API rate limiting)
  • Use lock: When you need exclusive access to shared data

Q3: “Explain the difference between notify() and notifyAll().”

Section titled “Q3: “Explain the difference between notify() and notifyAll().””

Answer:

  • notify(): Wakes up ONE waiting thread (unpredictable which one)
  • notifyAll(): Wakes up ALL waiting threads (they compete for the lock)
  • Use notify(): When only one thread can proceed (e.g., single consumer)
  • Use notifyAll(): When multiple threads might proceed (e.g., multiple consumers, complex conditions)

Q4: “What is ThreadLocal and when would you use it?”

Section titled “Q4: “What is ThreadLocal and when would you use it?””

Answer:

  • ThreadLocal: Provides each thread with its own independent copy of a variable
  • Use cases: Request context in web servers, avoiding parameter passing, per-thread configuration
  • Benefits: No synchronization needed, thread-safe by design
  • Caution: Can cause memory leaks in thread pools if not cleaned up


Continue learning concurrency patterns:

Mastering synchronization primitives is essential for building thread-safe systems! 🔒