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

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: 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. 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.
  • 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.
  • 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: 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.
  • 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.
  • 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.
  • Testing sin estrés de concurrencia: una condición de carrera puede no manifestarse con 2 threads en una máquina de desarrollo. 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.
  • Código single-threaded: los locks agregan 10-50ns por acquire/release. En paths single-threaded, esto es desperdicio puro.
  • Existen alternativas lock-free: para contadores simples, usa AtomicInteger / std::atomic en lugar de incrementos protegidos por mutex.
  • 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.
  • 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.
  • Sistemas distribuidos: los mutexes locales no funcionan entre procesos o máquinas.

Benchmarks de Rendimiento

  • Lock acquire no contendido: synchronized en JVM toma ~10-30ns (biased locking). ReentrantLock toma ~20-50ns.
  • Lock acquire contendido: con 4 threads contendiendo, lock acquire toma 1-10us. Con 16 threads, 10-100us.
  • Lock vs atomic: AtomicInteger. incrementAndGet() toma ~5ns no contendido, ~50ns bajo contención de 8 threads.
  • Read-write lock vs mutex: ReentrantReadWriteLock mejora el throughput de lectura 3-5x cuando las lecturas dominan 90%+.
  • 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.
  • Fair vs unfair locking: los fair locks (ReentrantLock(fair=true)) reducen starvation pero aumentan la contención 30-50%.
  • Granularidad de lock: locking fino (un lock por bucket en una hash table) mejora el throughput 5-10x bajo contención alta.

Estrategia de Testing

  • Stress test con alto conteo de threads: prueba con 2-4x el conteo de threads de producción.
  • Test de detección de deadlocks: ejecuta tests con detección de deadlocks habilitada (-XX:+UnlockDiagnosticVMOptions -XX:+SyncFlags en JVM).
  • Test de fairness de locks: si usas fair locks, verifica que los threads adquieran locks en orden FIFO.
  • Test de comportamiento de timeout: verifica que ryLock(timeout) retorne false cuando el lock está tomado.
  • Test de reentrancia: verifica que un thread que tiene un ReentrantLock pueda adquirirlo de nuevo sin bloquear.
  • Test de manejo de excepciones: verifica que los locks se liberen cuando ocurran excepciones en la critical section.
  • Test con ThreadSanitizer: compila con -fsanitize=thread (C/C++) o corre con -race (Go).

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.
  • Costo de desarrollo: diseñar esquemas de locking fino toma 2-5x más tiempo que locking coarse-grained.
  • Costo de debugging: los bugs de deadlock toman 20-80 horas en diagnosticarse en promedio.
  • Profiling de performance: usa async-profiler (JVM), perf (C++) o py-spy (Python) para identificar hotspots de locks.
  • Overhead de memoria: cada objeto lock usa 24-48 bytes (JVM) o 40 bytes (pthread mutex).

Monitoring y Observabilidad

  • Tiempo de lock contention: getBlockedTime() o JFR.
  • Detección de deadlocks: ejecuta thread dumps periódicos y verifica ciclos de deadlock. JVM: jstack o JMX ThreadMXBean. findDeadlockedThreads().
  • Lock hold time: Hold times largos (>1ms) indican que la critical section es demasiado grande.
  • Conteo de threads bloqueados: Un conteo alto indica lock contention.
  • Lock queue depth: trackea el número de threads esperando por cada lock.

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.
  • Deadlock como vector de DoS: un atacante puede craftar requests que triggeren violaciones de orden de locks, causando deadlocks que cuelgan todo el sistema.
  • 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.
  • 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.
  • Lock poisoning: si un thread crashea mientras mantiene un lock, el lock queda “poisoned” y adquisiciones subsiguientes pueden colgarse.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

Lectura Adicional

  • Documentación oficial: consulta la referencia actualizada del framework o herramienta utilizada.
  • Guías relacionadas: explora las guías de concurrency y atomic-operations para profundizar.
  • Patrones complementarios: revisa los patrones de diseño aplicables a tu stack tecnológico.
  • Postmortems públicos: estudia incidentes reales de equipos que enfrentaron problemas similares en producción.

Notas de Producción

  • Despliega gradualmente usando canary o blue-green para detectar regresiones temprano.
  • Configura alertas para errores, latencia p99 y tasa de fallos antes de habilitar en producción.
  • Documenta el rollback en el runbook; prueba el procedimiento en staging al menos una vez por trimestre.
  • Revisa logs estructurados con correlation IDs para trazar requests end-to-end en incidentes.

Puntos Clave

  • Aplica coordinar acceso compartido con locks, mutexes y semã¡foros cuando necesites una solución práctica para concurrency.
  • Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
  • Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
  • Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.

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

Errores Comunes en Producción

  • Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
  • Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
  • Codificar valores fijos que deberían ser configurables por entorno.
  • Olvidar agregar logging y monitoreo en cada paso.
  • Desplegar sin plan de rollback ni estrategia de backup probada.
  • Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
  • No documentar la versión y configuración usadas en producción.
  • Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.

Preguntas frecuentes

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