StackPractices
intermediate By Mathias Paulenko

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++.

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. Complex updates still require locks.

Variants

MechanismConcurrent readersConcurrent writersBest forOverhead
MutexNoNoGeneral protectionMedium
Read-write lockYesNoRead-heavy dataMedium
SemaphoreN (configurable)N (configurable)Resource poolsMedium
SpinlockNoNoVery short critical sectionsLow CPU
AtomicN/A (no lock)N/ACounters, flagsLowest

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.
  • 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 AtomicInteger or std::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.
  • Forgetting to unlock after early return: a method with multiple return paths may return without unlocking. This is why Java’s ReentrantLock requires explicit unlock() — it forces you to think about every exit path.
  • Over-locking (locking too much): wrapping an entire method in synchronized may protect data but serializes all callers, making the code well single-threaded.
  • Testing without concurrency stress: a race condition may not manifest with 2 threads on a development machine. 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.
  • Single-threaded code: locks add 10-50ns per acquire/release. In single-threaded paths, this is pure waste.
  • Lock-free alternatives exist: for simple counters, use AtomicInteger / std::atomic instead of mutex-protected increments.
  • Message passing is cleaner: if the problem is coordination between tasks, not data protection, channels or actor models avoid lock management entirely.
  • 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.
  • Distributed systems: local mutexes do not work across processes or machines.

Performance Benchmarks

  • Uncontended lock acquire: synchronized in JVM takes ~10-30ns (biased locking). ReentrantLock takes ~20-50ns.
  • Contended lock acquire: with 4 threads contending, lock acquire takes 1-10us. With 16 threads, 10-100us.
  • Lock vs atomic: AtomicInteger. incrementAndGet() takes ~5ns uncontended, ~50ns under 8-thread contention.
  • Read-write lock vs mutex: ReentrantReadWriteLock improves read throughput 3-5x when reads dominate 90%+.
  • Spin lock vs blocking lock: spin locks waste CPU but avoid context switch cost. For hold times <1us, spin locks are 2-3x faster.
  • Fair vs unfair locking: fair locks (ReentrantLock(fair=true)) reduce starvation but increase contention by 30-50%.
  • Lock granularity: fine-grained locking (one lock per bucket in a hash table) improves throughput 5-10x under high contention.

Testing Strategy

  • Stress test with high thread counts: test with 2-4x the production thread count.
  • Test deadlock detection: run tests with deadlock detection enabled (-XX:+UnlockDiagnosticVMOptions -XX:+SyncFlags in JVM).
  • Test lock fairness: if using fair locks, verify that threads acquire locks in FIFO order.
  • Test timeout behavior: verify ryLock(timeout) returns false when the lock is held.
  • Test reentrancy: verify that a thread holding a ReentrantLock can acquire it again without blocking.
  • Test exception handling: verify that locks are released when exceptions occur in the critical section.
  • Test with ThreadSanitizer: compile with -fsanitize=thread (C/C++) or run with -race (Go).

Cost Estimation

  • Server cost: lock contention reduces throughput. A service spending 30% of time on lock contention needs 30% more servers.
  • Development cost: designing fine-grained locking schemes takes 2-5x longer than coarse-grained locking.
  • Debugging cost: deadlock bugs take 20-80 hours to diagnose on average.
  • Performance profiling: use async-profiler (JVM), perf (C++), or py-spy (Python) to identify lock hotspots.
  • Memory overhead: each lock object uses 24-48 bytes (JVM) or 40 bytes (pthread mutex).

Monitoring and Observability

  • Lock contention time: monitor time spent waiting for locks. JVM: use LockSupport. getBlockedTime() or JFR.
  • Deadlock detection: run periodic thread dumps and check for deadlock cycles. JVM: jstack or JMX ThreadMXBean. findDeadlockedThreads().
  • Lock hold time: measure how long locks are held. Long hold times (>1ms) indicate the critical section is too large.
  • Thread blocked count: monitor the number of threads in BLOCKED state. A high count indicates lock contention.
  • Lock queue depth: track the number of threads waiting for each lock.

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.
  • Deadlock as a DoS vector: an attacker can craft requests that trigger lock ordering violations, causing deadlocks that hang the entire system.
  • 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.
  • Priority inversion: a low-priority thread holding a lock can block high-priority threads. The Mars Pathfinder incident was caused by priority inversion.
  • Lock poisoning: if a thread crashes while holding a lock, the lock is “poisoned” and subsequent acquisitions may hang.
  • 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.
  • Unsafe lock publication: if a lock object is accessible to untrusted code, it can be held indefinitely or used to coordinate attacks.
  • Spin lock CPU exhaustion: spin locks burn CPU while waiting. An attacker can trigger high contention, causing spin locks to consume 100% CPU.
  • 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.
  • 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.
  • 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.

Troubleshooting

  • Race conditions appear under load: protect shared state with locks, atomics, or message passing. Reproduce with targeted stress tests.
  • Deadlock between workers: establish a consistent lock acquisition order and keep critical sections short.
  • Thread pool saturation: monitor queue length and rejection policy. Increase pool size only if CPU and memory allow.
  • Actor mailbox grows unbounded: apply backpressure, bounded queues, and load shedding.
  • Async task never completes: check for unhandled promise rejections, forgotten awaits, and infinite loops in cooperative scheduling.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply coordinate shared access with locks, mutexes, and semaphores when you need a practical solution for concurrency.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Copying the example without adapting it to real data volumes and failure modes.
  • Skipping load and error-injection tests before the first production deployment.
  • Hard-coding values that should be configurable per environment.
  • Forgetting to add logging and monitoring at each step.
  • Deploying without a rollback plan or a tested backup strategy.
  • Assuming the minimal example will scale without adding caching or batching.
  • Not documenting the version and configuration used in production.
  • Letting the recipe sit unchanged when dependencies or scale evolve.

Frequently Asked Questions

Should I use synchronized or ReentrantLock in Java?

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.

Does Python have a GIL, making locks unnecessary?

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.

What is lock contention and how do I reduce it?

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.

Are semaphores and mutexes the same thing?

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.