Skip to content
StackPractices
intermediate Por Mathias Paulenko

Coordinar Acceso Compartido con Locks, Mutexes y Semáforos

Cómo prevenir condiciones de carrera en programas concurrentes usando mutexes, read-write locks, semáforos y operaciones atómicas en Java, Python y C++.

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Visión general

Cuando múltiples threads acceden a datos compartidos simultáneamente, el resultado depende del timing exacto de su ejecución — una condición de carrera. El thread A lee un balance bancario de $100, el thread B lee el mismo $100, ambos agregan $50, y ambos escriben $150. El resultado correcto es $200, pero el resultado actual es $150. Los $50 perdidos son una data race causada por acceso no coordinado.

Los locks resuelven esto asegurando que solo un thread acceda a datos críticos a la vez. Un mutex (mutual exclusion lock) permite que un solo thread entre a una sección crítica. Un read-write lock permite muchos lectores simultáneamente pero solo un escritor. Un semaphore controla acceso a un pool finito de recursos (ej. 10 conexiones a base de datos). Las operaciones atómicas proveen updates libres de locks para contadores simples. A continuacion se cubre cuándo y cómo usar cada mecanismo.

Cuándo usarlo

Usa esta receta cuando:

  • Múltiples threads leen y escriben el mismo estado mutable
  • Protegiendo caches en memoria, contadores o configuración compartida entre threads
  • Limitando acceso concurrente a recursos externos (APIs, bases de datos, file handles)
  • Implementando estructuras de datos thread-safe (colas, maps, pools)
  • Evitando data races sin rediseñar toda la arquitectura para ser lock-free

Solución

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();
        }
    }
}

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} usando {conn}")
    time.sleep(1)
    pool.release(conn)
    print(f"Worker {worker_id} liberó {conn}")

threads = [Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

Operaciones Atómicas (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;
}

Explicación

  • Mutex: asegura exclusión mutua — solo un thread tiene el lock a la vez. Otros threads bloquean hasta que el lock se libera. Simple y útil, pero puede convertirse en cuello de botella si la sección crítica es grande o frecuentemente accedida.
  • Read-write lock: permite múltiples lectores concurrentes pero solo un escritor. Ideal para cargas de trabajo dominadas por lecturas donde las escrituras son raras. Un lector no bloquea a otros lectores, pero un escritor bloquea a todos. Algunas implementaciones soportan downgrade de write a read.
  • Semaphore: un lock generalizado con un contador. Un mutex es un semaphore con count 1. Un pool semaphore con count 10 permite que 10 threads entren simultáneamente. Útil para pools de recursos, throttling y backpressure.
  • Operaciones atómicas: updates libres de locks usando instrucciones de CPU como CAS (compare-and-swap). Más rápidas que locks para operaciones simples pero limitadas en alcance. Usar para contadores y flags. Updates complejos aún requieren locks.

Variantes

MecanismoLectores concurrentesEscritores concurrentesMejor paraOverhead
MutexNoNoProtección generalMedio
Read-write lockSíNoDatos dominados por lecturaMedio
SemaphoreN (configurable)N (configurable)Pools de recursosMedio
SpinlockNoNoSecciones críticas muy cortasBajo CPU
AtómicoN/A (no lock)N/AContadores, flagsMínimo

Lo que funciona

  • Mantén las secciones críticas pequeñas: entre más pequeña la región bloqueada, menos contención. Bloquea, actualiza una variable, desbloquea. No hagas I/O, cálculos o llamadas externas mientras sostienes un lock. Las secciones críticas largas serializan threads y derrotan el propósito de la concurrencia.
  • Siempre desbloquea en finally: un thread que lanza una excepción mientras sostiene un lock nunca lo liberará, deadlockeando otros threads. Usa try/finally (Java), with (Python) o RAII (C++ std::lock_guard) para asegurar que el unlock ocurre incluso con excepciones.
  • Evita locks anidados: adquirir el lock A y luego el lock B, mientras otro thread adquiere B y luego A, crea un deadlock clásico. Si los locks anidados son inevitables, adquírelos siempre en un orden global consistente. Mejor aún, rediseña para evitar anidamiento.
  • Prefiere read-write locks para datos dominados por lectura: si el 99% de los accesos son lecturas, un mutex serializa el 99% de las operaciones innecesariamente. Un read-write lock permite lecturas paralelas, mejorando dramáticamente el throughput en caches, configuración y tablas de lookup.
  • Usa atómicos para contadores simples: un AtomicInteger o std::atomic<int> para un contador es más rápido que un mutex y elimina el riesgo de deadlock. No uses atómicos para operaciones compuestas — esas requieren un lock. Consulta Thread Pools para gestionar workers concurrentes.

Errores comunes

  • Lockeando en objetos mutables: synchronized(someList) falla si la referencia cambia. Otro thread puede sincronizar en un objeto diferente. Usa un campo privado final como monitor de lock, nunca los datos mismos.
  • Olvidar activar después de retorno temprano: un método con múltiples paths de retorno puede retornar sin activar. Por eso ReentrantLock de Java requiere unlock() explícito — te fuerza a pensar en cada path de salida. Usa try/finally religiosamente.
  • Sobre-lockeo (lockear demasiado): envolver un método completo en synchronized puede proteger datos pero serializa a todos los llamadores, haciendo el código bien single-threaded. Identifica el estado compartido exacto que necesita protección y bloquea solo eso.
  • Testing sin estrés de concurrencia: una condición de carrera puede no manifestarse con 2 threads en una máquina de desarrollo. Usa stress tests con cientos de threads, buclea millones de iteraciones y corre en hardware multi-core. Herramientas como ThreadSanitizer detectan data races en runtime.

Cuando No Usar Este Enfoque

  • Datos compartidos read-only: si los datos se escriben una vez y solo se leen después, no se necesita lock. Usa campos inal en Java, const en C++ o estructuras de datos inmutables. Los locks agregan overhead innecesario
  • Código single-threaded: los locks agregan 10-50ns por acquire/release. En paths single-threaded, esto es desperdicio puro. Remueve locks de code paths que están garantizados a correr en un solo thread
  • Existen alternativas lock-free: para contadores simples, usa AtomicInteger / std::atomic en lugar de incrementos protegidos por mutex. Los atomics son 5-20x más rápidos bajo contención
  • Message passing es más limpio: si el problema es coordinación entre tasks, no protección de datos, los channels o modelos de actor evitan gestión de locks enteramente. Prefiere message passing para coordinación compleja
  • Locking coarse-grained basta: si la contención es baja y la critical section es corta, un solo lock es más simple y rápido que locking fino. No optimices prematuramente la granularidad del lock
  • Sistemas distribuidos: los mutexes locales no funcionan entre procesos o máquinas. Usa locks distribuidos (Redis Redlock, ZooKeeper, etcd) siendo consciente de sus tradeoffs y modos de fallo

Benchmarks de Rendimiento

  • Lock acquire no contendido: synchronized en JVM toma ~10-30ns (biased locking). ReentrantLock toma ~20-50ns. std::mutex en C++ toma ~15-40ns
  • Lock acquire contendido: con 4 threads contendiendo, lock acquire toma 1-10us. Con 16 threads, 10-100us. La contención escala mal — el throughput cae inversamente con el conteo de threads
  • Lock vs atomic: AtomicInteger.incrementAndGet() toma ~5ns no contendido, ~50ns bajo contención de 8 threads. Contador synchronized toma ~50ns no contendido, ~5us bajo contención de 8 threads
  • Read-write lock vs mutex: ReentrantReadWriteLock mejora el throughput de lectura 3-5x cuando las lecturas dominan 90%+. Para 50% lecturas, es más lento que un mutex simple por el overhead
  • Spin lock vs blocking lock: los spin locks gastan CPU pero evitan el costo de context switch. Para hold times <1us, los spin locks son 2-3x más rápidos. Para hold times >10us, los blocking locks son mejores
  • Fair vs unfair locking: los fair locks (ReentrantLock(fair=true)) reducen starvation pero aumentan la contención 30-50%. Usa fair locks solo cuando se observe thread starvation
  • Granularidad de lock: locking fino (un lock por bucket en una hash table) mejora el throughput 5-10x bajo contención alta. El costo es complejidad y potenciales escenarios de deadlock

Estrategia de Testing

  • Stress test con alto conteo de threads: prueba con 2-4x el conteo de threads de producción. Usa CountDownLatch para iniciar todos los threads simultáneamente y maximizar la contención
  • Test de detección de deadlocks: ejecuta tests con detección de deadlocks habilitada (-XX:+UnlockDiagnosticVMOptions -XX:+SyncFlags en JVM). Usa jstack para verificar que no aparezcan patrones de deadlock
  • Test de fairness de locks: si usas fair locks, verifica que los threads adquieran locks en orden FIFO. Usa una cola compartida para registrar el orden de adquisición y verifica el ordenamiento
  • Test de comportamiento de timeout: verifica que ryLock(timeout) retorne false cuando el lock está tomado. Usa un mock que mantenga el lock más tiempo que el timeout
  • Test de reentrancia: verifica que un thread que tiene un ReentrantLock pueda adquirirlo de nuevo sin bloquear. Verifica que el lock count se mantenga correctamente
  • Test de manejo de excepciones: verifica que los locks se liberen cuando ocurran excepciones en la critical section. Usa patrones ry-finally o ry-with-resources
  • Test con ThreadSanitizer: compila con -fsanitize=thread (C/C++) o corre con -race (Go). Estas herramientas detectan data races que los stress tests no encuentran

Estimacion de Costos

  • Costo de servidores: la lock contention reduce el throughput. Un servicio que gasta 30% del tiempo en lock contention necesita 30% más servidores. Reducir la contención de 30% a 5% ahorra ,500-3,000/mes en una flota de 10 servidores
  • Costo de desarrollo: diseñar esquemas de locking fino toma 2-5x más tiempo que locking coarse-grained. Presupuesta design reviews y stress testing
  • Costo de debugging: los bugs de deadlock toman 20-80 horas en diagnosticarse en promedio. Invierte en tooling de detección de deadlocks y capacitación en análisis de thread dumps
  • Profiling de performance: usa async-profiler (JVM), perf (C++) o py-spy (Python) para identificar hotspots de locks. Estas herramientas son gratuitas pero requieren expertise para interpretar
  • Overhead de memoria: cada objeto lock usa 24-48 bytes (JVM) o 40 bytes (pthread mutex). 10,000 locks agregan ~400KB — despreciable, pero los lock pools para locking fino deben dimensionarse cuidadosamente

Monitoring y Observabilidad

  • Tiempo de lock contention: monitorea el tiempo gastado esperando locks. JVM: usa LockSupport.getBlockedTime() o JFR. Alta contención (>10% del tiempo de CPU) indica necesidad de optimización de locks
  • Detección de deadlocks: ejecuta thread dumps periódicos y verifica ciclos de deadlock. JVM: jstack o JMX ThreadMXBean.findDeadlockedThreads(). Alerta ante cualquier deadlock detectado
  • Lock hold time: mide cuánto tiempo se mantienen los locks. Hold times largos (>1ms) indican que la critical section es demasiado grande. Divídela en secciones más pequeñas o usa read-write locks
  • Conteo de threads bloqueados: monitorea el número de threads en estado BLOCKED. Un conteo alto indica lock contention. Alerta cuando >20% de los threads están bloqueados
  • Lock queue depth: trackea el número de threads esperando por cada lock. Colas profundas (>10 waiters) indican locks calientes que necesitan splitting o redisign

Deployment Checklist

  • Verificar que la implementación del lock coincide con el runtime (no uses pthread_mutex en runtimes de green-threads, usa locks nativos del lenguaje)
  • Setear tamaños de thread pool para evitar oversubscription. Más threads que CPU cores aumenta lock contention sin mejorar throughput
  • Configurar detección de deadlocks en producción (JVM: habilitar JFR, Go: usar untime/pprof goroutine profiling)
  • Setear timeouts en todas las adquisiciones de locks en código network-facing. Usa ryLock(timeout) en lugar de lock() para prevenir bloqueo indefinido
  • Habilitar recolección de thread dumps on signal (JVM: -XX:+UnlockDiagnosticVMOptions, C++: instalar signal handler para SIGQUIT)
  • Documentar el orden de locks en comentarios de código. Los deadlocks por orden inconsistente de locks son el bug de concurrencia más común en producción

Consideraciones de Seguridad

  • Denial of service vía retención de lock: un atacante puede mantener un lock indefinidamente enviando un request lento que entra en una critical section. Usa timeouts de lock y deadlines de request para prevenir esto
  • Deadlock como vector de DoS: un atacante puede craftar requests que triggeren violaciones de orden de locks, causando deadlocks que cuelgan todo el sistema. Enforceza orden estricto de locks y usa ryLock con timeouts
  • Side-channel de lock contention: las variaciones de timing por lock contention pueden leakear información sobre las operaciones de otros threads. Un atacante midiendo tiempos de respuesta puede inferir estado interno. Usa operaciones de tiempo constante en paths security-sensitive
  • Priority inversion: un thread de baja prioridad que mantiene un lock puede bloquear threads de alta prioridad. El incidente del Mars Pathfinder fue causado por priority inversion. Usa protocolo de priority inheritance (PTHREAD_PRIO_INHERIT) en sistemas real-time
  • Lock poisoning: si un thread crashea mientras mantiene un lock, el lock queda “poisoned” y adquisiciones subsiguientes pueden colgarse. Usa ryLock con timeouts y watchdog threads para detectar locks envenenados
  • Abuso de reentrant locks: los reentrant locks permiten al mismo thread adquirir un lock múltiples veces. Si un thread adquiere un lock en un loop sin liberar, puede monopolizar el lock. Audita el uso de reentrant locks por adquisición no acotada
  • Publicación insegura de locks: si un objeto lock es accesible a código no confiable, puede ser mantenido indefinidamente o usado para coordinar ataques. Mantén los objetos lock privados y nunca los expongas en APIs públicas
  • Spin lock y agotamiento de CPU: los spin locks gastan CPU mientras esperan. Un atacante puede triggerar alta contención, causando que los spin locks consuman 100% CPU. Usa locks adaptativos que spinnen brevemente y luego bloqueen
  • Bypass de lock vía publicación insegura: si un objeto compartido se publica sin sincronización apropiada (ej. vía un campo non-volatile), otro thread puede ver un objeto parcialmente construido y bypassar la protección del lock. Usa campos inal o publicación olatile
  • Reader-writer lock starvation: un stream continuo de readers puede starvar a los writers en read-write locks non-fair. Un atacante puede explotar esto floodeando requests de lectura, bloqueando todas las escrituras. Usa fair read-write locks
  • Spoofing de condition variables: si las condition variables son accesibles a código no confiable, otify() puede ser llamado espuriamente, despertando threads que deberían permanecer bloqueados. Mantén las condition variables privadas
  • Race de lock file en inicialización: usar locks basados en archivos para inicialización tiene races TOCTOU (time-of-check-to-time-of-use). Un atacante puede reemplazar el lock file entre el check y el uso. Usa O_CREAT|O_EXCL con manejo de errores apropiado

Preguntas frecuentes

P: ¿Debería usar synchronized o ReentrantLock en Java? R: Usa synchronized para casos simples — es menos propenso a errores (unlock es automático). Usa ReentrantLock cuando necesites try-lock (no bloqueante), timed lock (timeout), interrupción de lock o múltiples condition variables.

P: ¿Python tiene GIL, haciendo los locks innecesarios? R: El GIL previene paralelismo real de threads para trabajo CPU, pero los locks aún son necesarios para thread safety. Dos threads aún pueden intercalar operaciones en datos compartidos entre instrucciones de bytecode. Usa threading.Lock para estado mutable compartido.

P: ¿Qué es lock contention y cómo la reduzco? R: Contención ocurre cuando múltiples threads compiten por el mismo lock. Redúcela: (1) achicando secciones críticas, (2) usando read-write locks, (3) sharding datos (cada shard tiene su propio lock), (4) usando estructuras lock-free, o (5) reduciendo el número de threads.

P: ¿Son semáforos y mutexes lo mismo? R: Un mutex es un semáforo binario (count = 1) con semántica de ownership — solo el thread que lo bloqueó puede desbloquearlo. Un semáforo tiene un contador configurable y no tiene ownership. Usa mutex para acceso exclusivo; semáforo para pools de recursos.

¿Esta solución está lista para producción?

Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.

¿Cuáles son las características de rendimiento?

El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.

¿Cómo depuro problemas con este enfoque?

Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.