Aikido

Cómo prevenir condiciones de carrera: acceso thread-safe al estado compartido

Riesgo de bug

Regla
Asegúrese de que hilo seguro acceso a compartido compartido.
Compartido mutable compartido accesible en a través de múltiples hilos
sin sincronización causa carrera conditions y tiempo de ejecución errores.

Lenguajes compatibles: Python, Java, C#

Introducción

Cuando múltiples hilos acceden y modifican variables compartidas sin sincronización, se producen condiciones de carrera. El valor final depende de la temporización impredecible de la ejecución de los hilos, lo que lleva a la corrupción de datos, cálculos incorrectos o errores en tiempo de ejecución. Un contador incrementado por múltiples hilos sin bloqueo perderá actualizaciones a medida que los hilos lean valores obsoletos, los incrementen y escriban resultados conflictivos.

Por qué es importante

Corrupción de datos y resultados incorrectos: Las condiciones de carrera causan una corrupción silenciosa de los datos donde los valores se vuelven inconsistentes o incorrectos. Los saldos de las cuentas pueden ser erróneos, los recuentos de inventario pueden ser negativos o las estadísticas agregadas pueden estar corruptas. Estos errores son difíciles de reproducir porque dependen de la temporización exacta de los hilos.

Inestabilidad del sistema: El acceso no sincronizado a estados compartidos puede provocar el bloqueo de las aplicaciones. Un hilo podría modificar una estructura de datos mientras otro la lee, causando excepciones como errores de puntero nulo o índices fuera de límites. En producción, estos se manifiestan como bloqueos intermitentes bajo carga.

Complejidad de la depuración: Las condiciones de carrera son notoriamente difíciles de depurar porque no son deterministas. El error podría no aparecer en pruebas de un solo hilo o en entornos de baja carga. La reproducción requiere una intercalación de hilos específica que es difícil de forzar, lo que hace que los problemas aparezcan y desaparezcan aleatoriamente.

Ejemplos de código

❌ No conforme:

class BankAccount:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        current = self.balance
        # Condición de carrera: otro hilo puede modificar el saldo aquí
        time.sleep(0.001)  # Simula tiempo de procesamiento
        self.balance = current + amount

    def withdraw(self, amount):
        if self.balance >= amount:
            current = self.balance
            time.sleep(0.001)
            self.balance = current - amount
            return True
        return False

Por qué está mal: Múltiples hilos llamando a deposit() o withdraw() simultáneamente crean condiciones de carrera. Dos hilos depositando $100 cada uno podrían leer el saldo como $0, luego ambos escribir $100, resultando en un saldo final de $100 en lugar de $200.

✅ Conforme:

import threading

class BankAccount:
    def __init__(self):
        self.__balance = 0
        self.__lock = threading.Lock()

    @property
    def balance(self):
        with self.__lock:
            return self.__balance

    def deposit(self, amount):
        with self.__lock:
            current = self.__balance
            time.sleep(0.001)
            self.__balance = current + amount

    def withdraw(self, amount):
        with self.__lock:
            if self.__balance >= amount:
                current = self.__balance
                time.sleep(0.001)
                self.__balance = current - amount
                return True
            return False

¿Por qué esto importa? El threading.Lock() asegura que solo un hilo acceda al saldo a la vez. Cuando un hilo mantiene el bloqueo, los demás esperan, evitando modificaciones simultáneas. Privado __balance con solo lectura @property evita que el código externo eluda la protección de bloqueo.

Conclusión

Proteja todo el estado mutable compartido con primitivas de sincronización adecuadas como bloqueos, semáforos u operaciones atómicas. Prefiera estructuras de datos inmutables o thread-local storage cuando sea posible. Cuando la sincronización sea necesaria, minimice las secciones críticas para reducir la contención y mejorar el rendimiento.

Preguntas frecuentes

¿Tiene preguntas?

¿Qué primitivas de sincronización debería usar?

Utilice bloqueos (mutex) para acceso exclusivo a estados compartidos. Utilice semáforos para limitar el acceso concurrente a recursos. Utilice variables de condición para la coordinación y señalización de hilos. Para contadores o flags simples, las operaciones atómicas son más rápidas que los bloqueos. Elija según su patrón de concurrencia: bloqueos para exclusión mutua, atómicas para operaciones simples, construcciones de nivel superior como colas para patrones productor-consumidor.

¿Cómo evito interbloqueos al usar múltiples bloqueos?

Adquiera siempre los bloqueos en el mismo orden en todas las rutas de código. Si la función A necesita los bloqueos X e Y, y la función B necesita los bloqueos Y y X, adquiéralos en un orden consistente (siempre X y luego Y). Utilice la adquisición de bloqueos basada en tiempo de espera para detectar posibles interbloqueos. Mejor aún, rediseñe para necesitar solo un bloqueo por sección crítica, o utilice estructuras de datos sin bloqueo.

¿Cuál es el impacto en el rendimiento de la sincronización?

La contención de bloqueos ralentiza el código altamente concurrente porque los hilos esperan a que los poseedores del bloqueo lo liberen. Sin embargo, el código no sincronizado incorrecto es infinitamente más lento porque produce resultados erróneos. Minimice el alcance del bloqueo (secciones críticas) para proteger únicamente la modificación del estado. Utilice bloqueos de lectura-escritura cuando múltiples lectores no entren en conflicto. Realice un perfilado antes de optimizar; la corrección es lo primero.

¿Puedo usar almacenamiento local de hilos en lugar de bloqueos?

Sí, cuando cada hilo necesita su propia copia de datos. El almacenamiento local de hilos (Thread-local storage) elimina la sobrecarga de sincronización al proporcionar a cada hilo un estado privado. Úsalo para cachés, búferes o acumuladores por hilo que se fusionan posteriormente. Sin embargo, aún necesitas sincronización cuando los hilos se comunican o comparten resultados finales.

¿Qué hay del Global Interpreter Lock (GIL) de Python?

El GIL no elimina la necesidad de bloqueos. Aunque evita la ejecución simultánea de bytecode de Python, no hace que las operaciones sean atómicas. Un simple incremento de contador += 1 implica múltiples operaciones de bytecode donde el GIL puede ser liberado entre ellas. Utilice siempre una sincronización adecuada para el estado compartido, incluso en CPython.

¿Cómo pruebo las condiciones de carrera?

Utilice sanitizadores de hilos y herramientas de prueba de concurrencia específicas de su lenguaje. Escriba pruebas de estrés que generen muchos hilos realizando operaciones concurrentes y aseguren que los invariantes se mantienen. Aumente el número de hilos e iteraciones para exponer errores dependientes del tiempo. Sin embargo, las pruebas superadas no demuestran la ausencia de condiciones de carrera, por lo que la revisión del código y un diseño de sincronización cuidadoso siguen siendo críticos.

¿Qué son las estructuras de datos lock-free y wait-free?

Las estructuras de datos sin bloqueo utilizan operaciones atómicas (compare-and-swap) en lugar de bloqueos, garantizando el progreso a nivel de sistema incluso si los hilos se retrasan. Las estructuras wait-free garantizan el progreso por hilo. Son complejas de implementar correctamente, pero ofrecen un mejor rendimiento bajo alta contención. Utilice bibliotecas robustas (java.util.concurrent, la biblioteca atómica de C++) en lugar de implementar las suyas propias.

Asegúrate ahora.

Proteja su código, la nube y el entorno de ejecución en un único sistema central.
Encuentre y corrija vulnerabilidades de forma rápida y automática.

No se requiere tarjeta de crédito | Resultados del escaneo en 32 segundos.