Concurrency in LLD
Why Concurrency Matters
Modern applications handle multiple requests simultaneously. Understanding concurrency is essential for designing thread-safe, performant systems.
Thread Safety
The Problem
# ❌ Not thread-safe
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1 # This is NOT atomic!
# Read count → Add 1 → Write count
# Thread A reads 5, Thread B reads 5
# Thread A writes 6, Thread B writes 6
# Expected: 7, Actual: 6 (race condition!)
Solutions
1. Locks (Mutex)
import threading
class ThreadSafeCounter:
def __init__(self):
self.count = 0
self._lock = threading.Lock()
def increment(self):
with self._lock: # Only one thread can enter at a time
self.count += 1
def get_count(self) -> int:
with self._lock:
return self.count
2. Reentrant Locks (RLock)
import threading
class BankAccount:
def __init__(self, balance: float):
self.balance = balance
self._lock = threading.RLock() # Can be acquired multiple times by same thread
def transfer(self, other: 'BankAccount', amount: float):
with self._lock:
self.balance -= amount
other.deposit(amount) # This also acquires lock - RLock allows re-entry
def deposit(self, amount: float):
with self._lock:
self.balance += amount
3. Read-Write Locks
import threading
class ReadWriteLock:
def __init__(self):
self._readers = 0
self._read_lock = threading.Lock()
self._write_lock = threading.Lock()
def acquire_read(self):
with self._read_lock:
self._readers += 1
if self._readers == 1:
self._write_lock.acquire()
def release_read(self):
with self._read_lock:
self._readers -= 1
if self._readers == 0:
self._write_lock.release()
def acquire_write(self):
self._write_lock.acquire()
def release_write(self):
self._write_lock.release()
class ThreadSafeCache:
def __init__(self):
self._cache = {}
self._rw_lock = ReadWriteLock()
def get(self, key: str):
self._rw_lock.acquire_read()
try:
return self._cache.get(key)
finally:
self._rw_lock.release_read()
def set(self, key: str, value):
self._rw_lock.acquire_write()
try:
self._cache[key] = value
finally:
self._rw_lock.release_write()
Immutability
The simplest way to achieve thread safety — make objects immutable.
from dataclasses import dataclass
from typing import Tuple
# Immutable data class
@dataclass(frozen=True)
class Point:
x: float
y: float
def translate(self, dx: float, dy: float) -> 'Point':
# Returns new instance instead of modifying
return Point(self.x + dx, self.y + dy)
# Immutable with frozenset
@dataclass(frozen=True)
class User:
user_id: int
name: str
email: str
roles: frozenset # Immutable set
# Usage
p1 = Point(1, 2)
p2 = p1.translate(3, 4) # New point, p1 unchanged
print(p1) # Point(x=1, y=2)
print(p2) # Point(x=4, y=6)
Java Immutable Class
public final class Point {
private final double x;
private final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
public Point translate(double dx, double dy) {
return new Point(x + dx, y + dy);
}
// No setters - immutable!
}
Concurrency Primitives
Semaphore
import threading
class ConnectionPool:
def __init__(self, max_connections: int):
self._semaphore = threading.Semaphore(max_connections)
self._connections = []
def get_connection(self):
self._semaphore.acquire() # Blocks if pool is empty
return self._connections.pop()
def release_connection(self, conn):
self._connections.append(conn)
self._semaphore.release()
# Usage - limits concurrent connections
pool = ConnectionPool(max_connections=5)
Condition Variables
import threading
class BlockingQueue:
def __init__(self, max_size: int):
self._queue = []
self._max_size = max_size
self._lock = threading.Lock()
self._not_empty = threading.Condition(self._lock)
self._not_full = threading.Condition(self._lock)
def put(self, item):
with self._not_full:
while len(self._queue) >= self._max_size:
self._not_full.wait() # Wait until space available
self._queue.append(item)
self._not_empty.notify() # Signal that queue is not empty
def get(self):
with self._not_empty:
while len(self._queue) == 0:
self._not_empty.wait() # Wait until item available
item = self._queue.pop(0)
self._not_full.notify() # Signal that queue is not full
return item
Atomic Operations
import threading
class AtomicReference:
def __init__(self, initial_value=None):
self._value = initial_value
self._lock = threading.Lock()
def get(self):
with self._lock:
return self._value
def set(self, new_value):
with self._lock:
self._value = new_value
def compare_and_set(self, expected, new_value):
with self._lock:
if self._value == expected:
self._value = new_value
return True
return False
Thread-Safe Data Structures
Thread-Safe Singleton
import threading
class Singleton:
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None: # Double-checked locking
cls._instance = super().__new__(cls)
return cls._instance
Thread-Safe Cache
import threading
from collections import OrderedDict
class ThreadSafeLRUCache:
def __init__(self, capacity: int):
self._cache = OrderedDict()
self._capacity = capacity
self._lock = threading.Lock()
def get(self, key: str):
with self._lock:
if key not in self._cache:
return None
# Move to end (most recently used)
self._cache.move_to_end(key)
return self._cache[key]
def put(self, key: str, value):
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
self._cache[key] = value
if len(self._cache) > self._capacity:
self._cache.popitem(last=False) # Remove oldest
Async Patterns
Producer-Consumer Pattern
import threading
import queue
import time
class Producer(threading.Thread):
def __init__(self, task_queue: queue.Queue, name: str):
super().__init__()
self.task_queue = task_queue
self.name = name
def run(self):
for i in range(5):
task = f"Task-{i}"
self.task_queue.put(task)
print(f"{self.name} produced {task}")
time.sleep(0.1)
class Consumer(threading.Thread):
def __init__(self, task_queue: queue.Queue, name: str):
super().__init__()
self.task_queue = task_queue
self.name = name
self.daemon = True # Thread will exit when main program exits
def run(self):
while True:
task = self.task_queue.get()
if task is None: # Poison pill to stop
break
print(f"{self.name} processing {task}")
time.sleep(0.2)
self.task_queue.task_done()
# Usage
task_queue = queue.Queue(maxsize=10)
producers = [Producer(task_queue, f"Producer-{i}") for i in range(2)]
consumers = [Consumer(task_queue, f"Consumer-{i}") for i in range(3)]
for p in producers:
p.start()
for c in consumers:
c.start()
for p in producers:
p.join()
# Send poison pills to stop consumers
for _ in consumers:
task_queue.put(None)
Thread Pool
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def process_task(task_id: int) -> str:
time.sleep(0.5) # Simulate work
return f"Task {task_id} completed"
# Create thread pool with 4 workers
with ThreadPoolExecutor(max_workers=4) as executor:
# Submit tasks
futures = [executor.submit(process_task, i) for i in range(10)]
# Process results as they complete
for future in as_completed(futures):
result = future.result()
print(result)
Java Concurrency
Synchronized Methods
public class BankAccount {
private double balance;
public synchronized void deposit(double amount) {
balance += amount;
}
public synchronized void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
} else {
throw new IllegalStateException("Insufficient funds");
}
}
public synchronized double getBalance() {
return balance;
}
}
ReentrantLock
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private double balance;
private final ReentrantLock lock = new ReentrantLock();
public void transfer(BankAccount other, double amount) {
lock.lock();
try {
if (balance >= amount) {
balance -= amount;
other.deposit(amount);
}
} finally {
lock.unlock(); // Always unlock in finally
}
}
}
ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ThreadSafeCache<K, V> {
private final ConcurrentHashMap<K, V> cache = new ConcurrentHashMap<>();
public V get(K key) {
return cache.get(key);
}
public void put(K key, V value) {
cache.put(key, value);
}
public V computeIfAbsent(K key, Function<K, V> mappingFunction) {
return cache.computeIfAbsent(key, mappingFunction);
}
}
Common Concurrency Issues
1. Race Condition
# ❌ Race condition
def withdraw(self, amount):
if self.balance >= amount: # Thread A checks: balance = 100, amount = 80 ✓
# Thread B checks: balance = 100, amount = 80 ✓
self.balance -= amount # Thread A: balance = 20
# Thread B: balance = -60 (problem!)
# ✅ Fixed with lock
def withdraw(self, amount):
with self._lock:
if self.balance >= amount:
self.balance -= amount
2. Deadlock
# ❌ Deadlock potential
def transfer(account_a, account_b, amount):
with account_a.lock: # Thread 1: Lock A, Thread 2: Lock B
with account_b.lock: # Thread 1: Wait B, Thread 2: Wait A → Deadlock!
account_a.balance -= amount
account_b.balance += amount
# ✅ Fixed with consistent lock ordering
def transfer(account_a, account_b, amount):
# Always lock in same order (by ID)
first = min(account_a, account_b, key=lambda a: a.id)
second = max(account_a, account_b, key=lambda a: a.id)
with first.lock:
with second.lock:
account_a.balance -= amount
account_b.balance += amount
3. Starvation
# ❌ High-priority thread monopolizes lock
# ✅ Use fair lock (FIFO ordering)
self._lock = threading.Lock() # Not guaranteed fair
# Java: fair lock
ReentrantLock lock = new ReentrantLock(true); // Fair lock
Interview Tips
- Identify shared state — “What data is accessed by multiple threads?”
- Choose appropriate synchronization — Lock, semaphore, atomic, immutable
- Consider performance — Read-write locks for read-heavy workloads
- Mention deadlock prevention — “We’ll use consistent lock ordering”
- Discuss thread pools — “Use a thread pool to limit concurrent connections”
- Consider immutability — “Make this class immutable for thread safety”
- Show awareness of issues — Race conditions, deadlocks, starvation
Common Mistakes
- ❌ Not synchronizing shared state
- ❌ Using coarse-grained locks (performance)
- ❌ Not releasing locks (always use try-finally or context manager)
- ❌ Creating too many threads (use thread pools)
- ❌ Ignoring deadlock potential
- ❌ Not considering immutability as a solution
Cross-References
- SOLID Principles — SRP for concurrent classes
- Design Patterns — Singleton thread safety
- Error Handling — Error handling in async
- LRU Cache — Thread-safe cache implementation
- Notification Service — Async notification delivery
- Concurrency Overview
- Producer-Consumer
- Readers-Writers
- OS Synchronization