Thread Safety in OOP: Locks, Sync & Patterns
TL;DR
Thread safety ensures that objects behave correctly when accessed by multiple threads simultaneously. This involves designing classes with immutability, proper synchronization, and careful management of shared state. Understanding thread safety is crucial for designing scalable systems and is frequently tested in system design interviews.
Core Concept
What is Thread Safety?
Thread safety means that a class or object functions correctly when accessed by multiple threads concurrently, regardless of scheduling or interleaving of thread execution. A thread-safe class protects its internal state from race conditions, data corruption, and inconsistent reads.
Why Thread Safety Matters
In modern applications, multiple threads often access shared objects simultaneously. Without thread safety:
- Race conditions occur when threads compete to modify shared data
- Data corruption happens when partial updates are visible to other threads
- Inconsistent state emerges when invariants are temporarily violated during updates
Thread safety is critical for servers handling concurrent requests, caching systems, connection pools, and any shared resource in a multi-threaded environment.
Core Strategies for Thread Safety
1. Immutability
The simplest approach: objects whose state cannot change after construction are inherently thread-safe. No synchronization needed because there’s nothing to corrupt.
2. Synchronization
Use locks (mutexes) to ensure only one thread accesses critical sections at a time. This prevents concurrent modifications but can impact performance.
3. Thread-Local Storage
Give each thread its own copy of data, eliminating sharing entirely. Useful for per-thread caches or context objects.
4. Atomic Operations
Use atomic primitives for simple operations like counters. These provide thread safety without explicit locks for specific operations.
Shared State Pitfalls
The root of most concurrency bugs is shared mutable state. When multiple threads can both read and write the same data, you need explicit coordination. Common pitfalls include:
- Forgetting to synchronize all access paths to shared data
- Holding locks for too long, causing performance bottlenecks
- Creating deadlocks through circular lock dependencies
- Assuming single operations are atomic when they’re not
Visual Guide
Race Condition Example
sequenceDiagram
participant T1 as Thread 1
participant Obj as Shared Object<br/>(balance=100)
participant T2 as Thread 2
T1->>Obj: Read balance (100)
T2->>Obj: Read balance (100)
T1->>T1: Calculate: 100 + 50 = 150
T2->>T2: Calculate: 100 - 30 = 70
T1->>Obj: Write balance (150)
T2->>Obj: Write balance (70)
Note over Obj: Final balance: 70<br/>Lost the +50 update!<br/>Should be 120
Without synchronization, concurrent updates can overwrite each other, leading to lost updates and incorrect state.
Thread Safety Strategies
graph TD
A[Thread Safety Approaches] --> B[Immutability]
A --> C[Synchronization]
A --> D[Thread-Local]
A --> E[Atomic Operations]
B --> B1[No state changes<br/>after construction]
C --> C1[Locks/Mutexes<br/>Synchronized methods]
D --> D1[Each thread has<br/>own copy]
E --> E1[Hardware-level<br/>atomic primitives]
style B fill:#90EE90
style B1 fill:#90EE90
style C fill:#FFD700
style C1 fill:#FFD700
Different strategies for achieving thread safety, with immutability being the safest but not always practical.
Examples
Example 1: Thread-Unsafe Counter (The Problem)
class UnsafeCounter:
def __init__(self):
self.count = 0
def increment(self):
# This looks atomic but it's actually three operations:
# 1. Read self.count
# 2. Add 1
# 3. Write back to self.count
self.count += 1
def get_count(self):
return self.count
# Simulating concurrent access
import threading
counter = UnsafeCounter()
threads = []
for _ in range(10):
t = threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Expected: 10000, Got: {counter.get_count()}")
# Output: Expected: 10000, Got: 9847 (varies each run)
# Lost updates due to race conditions!
What went wrong? Multiple threads read the same value, increment it, and write back, overwriting each other’s updates.
Example 2: Thread-Safe Counter with Lock
import threading
class SafeCounter:
def __init__(self):
self.count = 0
self._lock = threading.Lock() # Mutex for synchronization
def increment(self):
with self._lock: # Only one thread can execute this block at a time
self.count += 1
def get_count(self):
with self._lock: # Even reads need synchronization!
return self.count
# Same test as before
counter = SafeCounter()
threads = []
for _ in range(10):
t = threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Expected: 10000, Got: {counter.get_count()}")
# Output: Expected: 10000, Got: 10000 ✓
Java equivalent:
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Try it yourself: Modify SafeCounter to add a decrement() method. Should it use the same lock?
Example 3: Immutable Thread-Safe Class
from typing import List
class ImmutablePoint:
"""A point in 2D space - thread-safe through immutability"""
def __init__(self, x: float, y: float):
self._x = x # Convention: _ prefix indicates "don't modify"
self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
def translate(self, dx: float, dy: float) -> 'ImmutablePoint':
"""Returns a NEW point - doesn't modify this one"""
return ImmutablePoint(self._x + dx, self._y + dy)
def __repr__(self):
return f"Point({self._x}, {self._y})"
# Usage - completely thread-safe, no locks needed
point = ImmutablePoint(10, 20)
print(point) # Output: Point(10, 20)
new_point = point.translate(5, 5)
print(point) # Output: Point(10, 20) - unchanged!
print(new_point) # Output: Point(15, 25)
# Multiple threads can safely call methods on the same point
# because no method modifies the object's state
Python note: To enforce immutability more strictly, use __slots__ and properties without setters, or use dataclasses with frozen=True:
from dataclasses import dataclass
@dataclass(frozen=True)
class ImmutablePoint:
x: float
y: float
def translate(self, dx: float, dy: float) -> 'ImmutablePoint':
return ImmutablePoint(self.x + dx, self.y + dy)
# Attempting to modify raises an error:
point = ImmutablePoint(10, 20)
# point.x = 30 # FrozenInstanceError!
Try it yourself: Create an immutable Rectangle class with width and height. Add a scale(factor) method that returns a new scaled rectangle.
Example 4: Bank Account with Proper Synchronization
import threading
import time
class BankAccount:
def __init__(self, initial_balance: float):
self._balance = initial_balance
self._lock = threading.Lock()
def deposit(self, amount: float):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
with self._lock:
# Simulate some processing time
current = self._balance
time.sleep(0.001) # Database write delay
self._balance = current + amount
def withdraw(self, amount: float) -> bool:
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
with self._lock:
if self._balance >= amount:
current = self._balance
time.sleep(0.001) # Database write delay
self._balance = current - amount
return True
return False
def get_balance(self) -> float:
with self._lock:
return self._balance
def transfer_to(self, other: 'BankAccount', amount: float) -> bool:
"""Transfer money to another account - needs careful locking!"""
# WRONG: This can deadlock if two threads transfer in opposite directions
# We'll show the correct version below
with self._lock:
if self._balance >= amount:
with other._lock:
self._balance -= amount
other._balance += amount
return True
return False
# Testing
account = BankAccount(1000)
def make_deposits():
for _ in range(100):
account.deposit(10)
def make_withdrawals():
for _ in range(100):
account.withdraw(5)
threads = [
threading.Thread(target=make_deposits),
threading.Thread(target=make_deposits),
threading.Thread(target=make_withdrawals),
threading.Thread(target=make_withdrawals),
]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Final balance: {account.get_balance()}")
# Output: Final balance: 2000.0
# Started with 1000, added 2000 (2×100×10), subtracted 1000 (2×100×5)
Deadlock-free transfer (correct version):
class BankAccount:
# ... (previous methods remain the same)
def transfer_to(self, other: 'BankAccount', amount: float) -> bool:
"""Deadlock-free transfer using lock ordering"""
# Always acquire locks in the same order (by object id)
first_lock = self._lock if id(self) < id(other) else other._lock
second_lock = other._lock if id(self) < id(other) else self._lock
with first_lock:
with second_lock:
if self._balance >= amount:
self._balance -= amount
other._balance += amount
return True
return False
Try it yourself: Add a transfer_between(from_account, to_account, amount) static method that safely transfers between any two accounts.
Common Mistakes
1. Synchronizing Only Writes, Not Reads
Mistake:
class BadCache:
def __init__(self):
self._data = {}
self._lock = threading.Lock()
def put(self, key, value):
with self._lock:
self._data[key] = value
def get(self, key):
# WRONG: No lock on read!
return self._data.get(key)
Why it’s wrong: Even reads need synchronization when writes are happening. Without it, you might read partially updated data or trigger internal dictionary corruption in Python.
Fix: Synchronize all access to shared mutable state, including reads.
2. Assuming Single Operations Are Atomic
Mistake:
class BadCounter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1 # Looks atomic, but it's not!
Why it’s wrong: count += 1 is actually three operations: read, add, write. Another thread can interleave between these steps.
Fix: Use explicit synchronization or atomic operations. In Python, use threading.Lock(). In Java, use AtomicInteger. In C++, use std::atomic<int>.
3. Holding Locks While Calling External Code
Mistake:
class BadService:
def __init__(self):
self._data = []
self._lock = threading.Lock()
self._listeners = []
def add_item(self, item):
with self._lock:
self._data.append(item)
# WRONG: Calling external code while holding lock
for listener in self._listeners:
listener.on_item_added(item) # What if this is slow or blocks?
Why it’s wrong: If a listener callback is slow or tries to acquire another lock, you create performance bottlenecks or deadlocks. You don’t control what external code does.
Fix: Release the lock before calling external code, or copy data needed for callbacks:
def add_item(self, item):
with self._lock:
self._data.append(item)
listeners_copy = list(self._listeners)
# Call listeners outside the lock
for listener in listeners_copy:
listener.on_item_added(item)
4. Inconsistent Lock Ordering (Deadlock)
Mistake:
# Thread 1:
with account_a._lock:
with account_b._lock:
# transfer from A to B
# Thread 2:
with account_b._lock: # Different order!
with account_a._lock:
# transfer from B to A
# DEADLOCK: Each thread holds one lock and waits for the other
Why it’s wrong: When two threads acquire multiple locks in different orders, they can deadlock (each waiting for the other’s lock).
Fix: Always acquire locks in a consistent global order (e.g., by object ID or a designated ordering).
5. Forgetting to Make Fields volatile or Using Proper Memory Barriers
Mistake (Java/C++):
class BadFlag {
private boolean flag = false; // Missing 'volatile'
public void setFlag() {
flag = true; // Other threads might never see this!
}
public boolean isFlag() {
return flag;
}
}
Why it’s wrong: Without volatile (Java) or proper memory ordering (C++), compilers and CPUs can cache values in registers. One thread’s write might never be visible to other threads.
Fix: Use volatile for simple flags, or use proper synchronization/atomic operations.
Python note: Python’s GIL (Global Interpreter Lock) provides some protection here, but you still need locks for compound operations. In truly concurrent Python (e.g., with C extensions or multiprocessing), you face the same issues as Java/C++.
Interview Tips
When to Discuss Thread Safety in LLD Interviews
Mention concurrency when:
- The system explicitly handles multiple concurrent users/requests (web servers, APIs)
- You’re designing shared resources (connection pools, caches, rate limiters)
- The interviewer asks about scalability or performance under load
- You’re designing stateful services that multiple threads will access
Don’t over-engineer: For single-threaded scenarios or when the interviewer hasn’t mentioned concurrency, focus on core design first. You can say: “In a production system, we’d need to make this thread-safe using [specific approach], but I’ll focus on the core logic first.”
How to Articulate Thread Safety Decisions
Use this framework:
- Identify shared state: “The cache will be accessed by multiple request-handling threads simultaneously.”
- Choose a strategy: “I’ll use a read-write lock here because reads are much more frequent than writes.”
- Explain trade-offs: “This adds some overhead, but prevents race conditions. For higher performance, we could use a concurrent hash map with lock striping.”
Specific Interview Scenarios
Scenario 1: Designing a Cache
- Good answer: “I’ll use a
ConcurrentHashMap(Java) orthreading.Lockper cache entry (Python) to allow concurrent reads while protecting writes. For eviction, I’ll use a separate lock to avoid holding the main lock during cleanup.” - Red flag: Not mentioning thread safety at all when designing a shared cache.
Scenario 2: Singleton Pattern
- Good answer: “I’ll use double-checked locking or a class-level initialization to ensure thread-safe lazy initialization.”
- Show depth: Mention that Python’s module-level initialization is inherently thread-safe, while Java requires explicit synchronization.
Scenario 3: Rate Limiter
- Good answer: “Each user’s token bucket needs atomic operations for decrementing tokens. I’ll use
AtomicIntegeror a lock per user bucket, not a global lock, to maximize concurrency.”
Code Review Red Flags to Mention
Show expertise by identifying these issues:
- “This counter should use atomic operations or synchronization.”
- “We need to ensure this read-modify-write sequence is atomic.”
- “This could deadlock if two threads transfer between the same accounts in opposite directions.”
- “Holding this lock while doing I/O will create a bottleneck.”
Language-Specific Points to Know
Python:
- Mention the GIL and its implications (protects some operations but not compound ones)
- Know when to use
threading.Lock,threading.RLock, andqueue.Queue - Be aware that
multiprocessingbypasses the GIL for CPU-bound tasks
Java:
- Know
synchronized,volatile,java.util.concurrentpackage - Understand
AtomicInteger,ConcurrentHashMap,ReentrantLock - Be able to discuss happens-before relationships
C++:
- Know
std::mutex,std::atomic,std::lock_guard - Understand memory ordering (acquire/release semantics)
- Be aware of
std::shared_mutexfor reader-writer scenarios
Practice Phrases
- “To make this thread-safe, I would…”
- “The critical section here is… so I’ll protect it with…”
- “This is inherently thread-safe because it’s immutable.”
- “We need to synchronize here to prevent race conditions on…”
- “For better performance, we could use optimistic locking / lock-free algorithms, but that adds complexity.”
Key Takeaways
-
Thread safety ensures correct behavior when multiple threads access shared objects concurrently — without it, you get race conditions, data corruption, and inconsistent state.
-
Immutability is the simplest path to thread safety — objects that can’t change after construction need no synchronization, making them inherently safe and easy to reason about.
-
Synchronize all access to shared mutable state, not just writes — reads can also see inconsistent data if not properly synchronized, and compound operations like
count++are never atomic. -
Avoid holding locks while calling external code or doing I/O — this creates performance bottlenecks and potential deadlocks; release locks before callbacks or copy data needed outside critical sections.
-
In interviews, mention thread safety for shared resources and concurrent systems — articulate your strategy (immutability, locks, atomics), explain trade-offs, and show awareness of language-specific tools without over-engineering simple scenarios.