Coordinate Shared Access with Locks, Mutexes, and Semaphores
How to prevent race conditions in concurrent programs using mutexes, read-write locks, semaphores, and atomic operations in Java, Python, and C++.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
When multiple threads access shared data simultaneously, the result depends on the exact timing of their execution — a race condition. Thread A reads a bank balance of $100, Thread B reads the same $100, both add $50, and both write back $150. The correct result is $200, but the actual result is $150. The lost $50 is a data race caused by uncoordinated access.
Locks solve this by ensuring that only one thread accesses critical data at a time. A mutex (mutual exclusion lock) allows one thread to enter a critical section. A read-write lock allows many readers simultaneously but only one writer. A semaphore controls access to a finite pool of resources (e.g., 10 database connections). Atomic operations provide lock-free updates for simple counters. Here is how to when and how to use each mechanism.
When to use it
Use this recipe when:
- Multiple threads read and write the same mutable state
- Protecting in-memory caches, counters, or configuration shared across threads
- Limiting concurrent access to external resources (APIs, databases, file handles)
- Implementing thread-safe data structures (queues, maps, pools)
- Avoiding data races without redesigning the entire architecture to be lock-free
Solution
Mutex (Java)
import java.util.concurrent.locks.ReentrantLock;
class BankAccount {
private double balance;
private final ReentrantLock lock = new ReentrantLock();
public void deposit(double amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock();
}
}
public double getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
// Or using synchronized
class SynchronizedAccount {
private double balance;
public synchronized void deposit(double amount) {
balance += amount;
}
public synchronized double getBalance() {
return balance;
}
}
Read-Write Lock (Java)
import java.util.concurrent.locks.ReentrantReadWriteLock;
class CachedData {
private String data;
private boolean cacheValid;
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public String processData() {
rwl.readLock().lock();
if (cacheValid) {
String result = data;
rwl.readLock().unlock();
return result;
}
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
if (!cacheValid) {
data = fetchFromDatabase();
cacheValid = true;
}
return data;
} finally {
rwl.writeLock().unlock();
}
}
}
Semaphore (Python)
from threading import Semaphore, Thread
import time
class ConnectionPool:
def __init__(self, max_connections):
self.semaphore = Semaphore(max_connections)
self.connections = [f"conn-{i}" for i in range(max_connections)]
def acquire(self):
self.semaphore.acquire()
return self.connections.pop()
def release(self, conn):
self.connections.append(conn)
self.semaphore.release()
pool = ConnectionPool(3)
def worker(worker_id):
conn = pool.acquire()
print(f"Worker {worker_id} using {conn}")
time.sleep(1)
pool.release(conn)
print(f"Worker {worker_id} released {conn}")
threads = [Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
Atomic Operations (C++)
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(increment);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Counter: " << counter.load() << std::endl; // 400000
}
Explanation
- Mutex: ensures mutual exclusion — only one thread holds the lock at a time. Other threads block until the lock is released. Simple and useful, but can become a bottleneck if the critical section is large or frequently accessed.
- Read-write lock: allows multiple concurrent readers but only one writer. Ideal for read-heavy workloads where writes are rare. A reader does not block other readers, but a writer blocks everyone. Downgrading from write to read is supported in some implementations.
- Semaphore: a generalized lock with a counter. A mutex is a semaphore with count 1. A pool semaphore with count 10 allows 10 threads to enter simultaneously. Useful for resource pools, throttling, and backpressure.
- Atomic operations: lock-free updates using CPU instructions like
CAS(compare-and-swap). Faster than locks for simple operations but limited in scope. Use for counters and flags. Complex updates still require locks.
Variants
| Mechanism | Concurrent readers | Concurrent writers | Best for | Overhead |
|---|---|---|---|---|
| Mutex | No | No | General protection | Medium |
| Read-write lock | Yes | No | Read-heavy data | Medium |
| Semaphore | N (configurable) | N (configurable) | Resource pools | Medium |
| Spinlock | No | No | Very short critical sections | Low CPU |
| Atomic | N/A (no lock) | N/A | Counters, flags | Lowest |
What works
- Keep critical sections small: the smaller the locked region, the less contention. Lock, update one variable, unlock. Do not perform I/O, calculations, or external calls while holding a lock. Long critical sections serialize threads and defeat the purpose of concurrency.
- Always unlock in finally: a thread that throws an exception while holding a lock will never release it, deadlocking other threads. Use try/finally (Java),
with(Python), or RAII (C++std::lock_guard) to ensure unlock happens even on exceptions. - Avoid nested locks: acquiring lock A then lock B, while another thread acquires B then A, creates a classic deadlock. If nested locks are unavoidable, always acquire them in a consistent global order. Better yet, redesign to avoid nesting.
- Prefer read-write locks for read-heavy data: if 99% of accesses are reads, a mutex serializes 99% of operations unnecessarily. A read-write lock allows parallel reads, dramatically improving throughput on caches, configuration, and lookup tables.
- Use atomics for simple counters: an
AtomicIntegerorstd::atomic<int>for a counter is faster than a mutex and eliminates deadlock risk. Do not use atomics for compound operations — those require a lock. See Thread Pools for managing concurrent workers.
Common mistakes
- Locking on mutable objects:
synchronized(someList)fails if the reference changes. Another thread may synchronize on a different object. Use a final private field as the lock monitor, never the data itself. - Forgetting to unlock after early return: a method with multiple return paths may return without unlocking. This is why Java’s
ReentrantLockrequires explicitunlock()— it forces you to think about every exit path. Use try/finally religiously. - Over-locking (locking too much): wrapping an entire method in
synchronizedmay protect data but serializes all callers, making the code well single-threaded. Identify the exact shared state that needs protection and lock only that. - Testing without concurrency stress: a race condition may not manifest with 2 threads on a development machine. Use stress tests with hundreds of threads, loop millions of iterations, and run on multi-core hardware. Tools like ThreadSanitizer detect data races at runtime.
When Not to Use This Approach
- Read-only shared data: if data is written once and only read afterward, no lock is needed. Use inal fields in Java, const in C++, or immutable data structures. Locks add unnecessary overhead
- Single-threaded code: locks add 10-50ns per acquire/release. In single-threaded paths, this is pure waste. Remove locks from code paths that are guaranteed to run on one thread
- Lock-free alternatives exist: for simple counters, use AtomicInteger / std::atomic instead of mutex-protected increments. Atomics are 5-20x faster under contention
- Message passing is cleaner: if the problem is coordination between tasks, not data protection, channels or actor models avoid lock management entirely. Prefer message passing for complex coordination
- Coarse-grained locking suffices: if contention is low and the critical section is short, a single lock is simpler and faster than fine-grained locking. Do not prematurely optimize lock granularity
- Distributed systems: local mutexes do not work across processes or machines. Use distributed locks (Redis Redlock, ZooKeeper, etcd) with awareness of their tradeoffs and failure modes
Performance Benchmarks
- Uncontended lock acquire: synchronized in JVM takes ~10-30ns (biased locking). ReentrantLock takes ~20-50ns. std::mutex in C++ takes ~15-40ns
- Contended lock acquire: with 4 threads contending, lock acquire takes 1-10us. With 16 threads, 10-100us. Contention scales poorly — throughput drops inversely with thread count
- Lock vs atomic: AtomicInteger.incrementAndGet() takes ~5ns uncontended, ~50ns under 8-thread contention. synchronized counter takes ~50ns uncontended, ~5us under 8-thread contention
- Read-write lock vs mutex: ReentrantReadWriteLock improves read throughput 3-5x when reads dominate 90%+. For 50% reads, it is slower than a plain mutex due to overhead
- Spin lock vs blocking lock: spin locks waste CPU but avoid context switch cost. For hold times <1us, spin locks are 2-3x faster. For hold times >10us, blocking locks are better
- Fair vs unfair locking: fair locks (ReentrantLock(fair=true)) reduce starvation but increase contention by 30-50%. Use fair locks only when thread starvation is observed
- Lock granularity: fine-grained locking (one lock per bucket in a hash table) improves throughput 5-10x under high contention. The cost is complexity and potential deadlock scenarios
Testing Strategy
- Stress test with high thread counts: test with 2-4x the production thread count. Use CountDownLatch to start all threads simultaneously and maximize contention
- Test deadlock detection: run tests with deadlock detection enabled (-XX:+UnlockDiagnosticVMOptions -XX:+SyncFlags in JVM). Use jstack to verify no deadlock patterns appear
- Test lock fairness: if using fair locks, verify that threads acquire locks in FIFO order. Use a shared queue to record acquisition order and assert ordering
- Test timeout behavior: verify ryLock(timeout) returns false when the lock is held. Use a mock that holds the lock longer than the timeout
- Test reentrancy: verify that a thread holding a ReentrantLock can acquire it again without blocking. Assert that lock count is maintained correctly
- Test exception handling: verify that locks are released when exceptions occur in the critical section. Use ry-finally or ry-with-resources patterns
- Test with ThreadSanitizer: compile with -fsanitize=thread (C/C++) or run with -race (Go). These tools detect data races that stress tests miss
Cost Estimation
- Server cost: lock contention reduces throughput. A service spending 30% of time on lock contention needs 30% more servers. Reducing contention from 30% to 5% saves ,500-3,000/month on a 10-server fleet
- Development cost: designing fine-grained locking schemes takes 2-5x longer than coarse-grained locking. Budget for design reviews and stress testing
- Debugging cost: deadlock bugs take 20-80 hours to diagnose on average. Invest in deadlock detection tooling and thread dump analysis training
- Performance profiling: use async-profiler (JVM), perf (C++), or py-spy (Python) to identify lock hotspots. These tools are free but require expertise to interpret
- Memory overhead: each lock object uses 24-48 bytes (JVM) or 40 bytes (pthread mutex). 10,000 locks add ~400KB — negligible, but lock pools for fine-grained locking should be sized carefully
Monitoring and Observability
- Lock contention time: monitor time spent waiting for locks. JVM: use LockSupport.getBlockedTime() or JFR. High contention (>10% of CPU time) indicates a need for lock optimization
- Deadlock detection: run periodic thread dumps and check for deadlock cycles. JVM: jstack
or JMX ThreadMXBean.findDeadlockedThreads(). Alert on any detected deadlock - Lock hold time: measure how long locks are held. Long hold times (>1ms) indicate the critical section is too large. Break it into smaller sections or use read-write locks
- Thread blocked count: monitor the number of threads in BLOCKED state. A high count indicates lock contention. Alert when >20% of threads are blocked
- Lock queue depth: track the number of threads waiting for each lock. Deep queues (>10 waiters) indicate hot locks that need splitting or redesign
Deployment Checklist
- Verify lock implementation matches the runtime (do not use pthread_mutex in green-thread runtimes, use language-native locks)
- Set thread pool sizes to avoid oversubscription. More threads than CPU cores increases lock contention without improving throughput
- Configure deadlock detection in production (JVM: enable JFR, Go: use untime/pprof goroutine profiling)
- Set timeouts on all lock acquisitions in network-facing code. Use ryLock(timeout) instead of lock() to prevent indefinite blocking
- Enable thread dump collection on signal (JVM: -XX:+UnlockDiagnosticVMOptions, C++: install signal handler for SIGQUIT)
- Document lock ordering in code comments. Deadlocks from inconsistent lock ordering are the most common concurrency bug in production
Security Considerations
- Denial of service via lock holding: an attacker can hold a lock indefinitely by sending a slow request that enters a critical section. Use lock timeouts and request deadlines to prevent this
- Deadlock as a DoS vector: an attacker can craft requests that trigger lock ordering violations, causing deadlocks that hang the entire system. Enforce strict lock ordering and use ryLock with timeouts
- Lock contention side-channel: timing variations from lock contention can leak information about other threads’ operations. An attacker measuring response times can infer internal state. Use constant-time operations in security-sensitive paths
- Priority inversion: a low-priority thread holding a lock can block high-priority threads. The Mars Pathfinder incident was caused by priority inversion. Use priority inheritance protocol (PTHREAD_PRIO_INHERIT) in real-time systems
- Lock poisoning: if a thread crashes while holding a lock, the lock is “poisoned” and subsequent acquisitions may hang. Use ryLock with timeouts and watchdog threads to detect poisoned locks
- Reentrant lock abuse: reentrant locks allow the same thread to acquire a lock multiple times. If a thread acquires a lock in a loop without releasing, it can monopolize the lock. Audit reentrant lock usage for unbounded acquisition
- Unsafe lock publication: if a lock object is accessible to untrusted code, it can be held indefinitely or used to coordinate attacks. Keep lock objects private and never expose them in public APIs
- Spin lock CPU exhaustion: spin locks burn CPU while waiting. An attacker can trigger high contention, causing spin locks to consume 100% CPU. Use adaptive locks that spin briefly then block
- Lock bypass via unsafe publication: if a shared object is published without proper synchronization (e.g., via a non-volatile field), another thread may see a partially constructed object and bypass lock protection. Use inal fields or olatile publication
- Reader-writer lock starvation: a continuous stream of readers can starve writers in non-fair read-write locks. An attacker can exploit this by flooding read requests, blocking all writes. Use fair read-write locks
- Condition variable spoofing: if condition variables are accessible to untrusted code, otify() can be called spuriously, waking threads that should remain blocked. Keep condition variables private
- Lock file race in initialization: using file-based locks for initialization has TOCTOU (time-of-check-to-time-of-use) races. An attacker can replace the lock file between check and use. Use O_CREAT|O_EXCL with proper error handling
FAQ
Q: Should I use synchronized or ReentrantLock in Java?
A: Use synchronized for simple cases — it is less error-prone (unlock is automatic). Use ReentrantLock when you need try-lock (non-blocking), timed lock (timeout), lock interruption, or multiple condition variables.
Q: Does Python have a GIL, making locks unnecessary?
A: The GIL prevents true thread parallelism for CPU work, but locks are still necessary for thread safety. Two threads can still interleave operations on shared data between bytecode instructions. Use threading.Lock for shared mutable state.
Q: What is lock contention and how do I reduce it? A: Contention occurs when multiple threads compete for the same lock. Reduce it by: (1) shrinking critical sections, (2) using read-write locks, (3) sharding data (each shard has its own lock), (4) using lock-free structures, or (5) reducing thread count.
Q: Are semaphores and mutexes the same thing? A: A mutex is a binary semaphore (count = 1) with ownership semantics — only the thread that locked it can unlock it. A semaphore has a configurable count and no ownership. Use a mutex for exclusive access; a semaphore for resource pools.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Master Async Patterns with Promises, Futures, and Coroutines
How to write efficient concurrent code using async/await, promises, futures, and coroutines in JavaScript, Python, and Java for non-blocking I/O and parallel processing.
RecipeManage Concurrent Work with Thread Pools and Executors
How to efficiently manage worker threads using thread pools, executors, and rejection policies in Java, Python, and C# for CPU-bound and I/O-bound workloads.
RecipeResilient Microservices with Circuit Breakers, Retries
How to build fault-tolerant distributed systems using microservices patterns including circuit breakers, bulkheads, retries with backoff, and sagas for transaction management.
RecipeDatabase Transactions
How to use ACID transactions to ensure data integrity across Python, JavaScript, and Java with SQL examples.
RecipeUse Concurrent Data Structures for Thread-Safe Collections
How to safely share collections between threads using blocking queues, concurrent maps, copy-on-write lists, and atomic counters in Java, Python, and C++.
RecipeCoordinate Concurrent Tasks with Communicating
How to structure concurrent programs using channels, select statements, and goroutines for safe communication without shared mutable state in Go, Rust, and JavaScript.