← Índice del curso

Sistemas Embebidos · Tema 22

Problemas de concurrencia

Las colas, los semáforos y los mutex del tema anterior resuelven problemas, pero usados sin cuidado crean otros: carreras que quedaron escondidas en una comprobación, tareas importantes que esperan a tareas irrelevantes, equipos que se detienen porque dos tareas se esperan mutuamente. Son defectos que no fallan siempre, que desaparecen al depurar y que a veces recién aparecen en el campo. Este tema los cataloga, explica por qué ocurren y cómo se diseña para que no ocurran.

01 · Punto de partida

Por qué la concurrencia es difícil

Un programa secuencial tiene una sola historia: la misma entrada produce la misma ejecución, y el depurador la repite tantas veces como haga falta. Un sistema con varias tareas e interrupciones tiene muchas historias posibles, una por cada manera de entrelazar las instrucciones de sus partes, y cada ejecución sigue una distinta según el instante exacto de un tick o de una interrupción. Un defecto que solo se manifiesta en una de esas historias puede pasar millones de ejecuciones sin aparecer, y luego aparecer una vez por mes en un equipo del cliente.

Lo que lo hace tan traicionero es la combinación de tres propiedades que ya vimos en el tema 16, ahora multiplicadas:

  • Es raro: la probabilidad de caer en la ventana crítica por ejecución es mínima, y por eso las pruebas lo pasan.
  • Es silencioso: casi nunca hay un error ni una excepción; hay un valor equivocado, una tarea que no responde o un equipo que se reinicia, lejos del origen.
  • Es sensible a la observación: agregar una impresión o detener en un punto de interrupción cambia los tiempos y el defecto se esconde (en la jerga, un heisenbug).

Los problemas de concurrencia que aparecen en un sistema embebido se agrupan en unas pocas familias, y el tema recorre cada una:

Las familias de problemas de concurrencia.
ProblemaQué ocurreSíntoma típico
Condición de carreraEl resultado depende del orden en que se entrelazan los accesosDatos corruptos de vez en cuando
Inversión de prioridadUna tarea de alta prioridad espera por una de baja, que a su vez queda detenida por una intermediaPlazos perdidos sin causa aparente; reinicios por watchdog
InterbloqueoDos o más tareas se esperan mutuamente para siempreParte del sistema se congela y el resto sigue
Inanición y livelockUna tarea nunca llega a correr, o corre sin avanzarUna función que «nunca» se ejecuta; carga alta sin progreso
Falta de reentranciaUna función con estado interno es usada por dos tareas a la vezResultados mezclados entre tareas

Seguimos con FreeRTOS para los ejemplos, pero estos problemas existen en cualquier sistema con tareas, hilos o interrupciones, y las soluciones son las mismas.

02 · El orden importa

Condiciones de carrera

Hay una distinción útil. Una carrera de datos (data race) es el caso del tema 16: dos accesos a la misma variable sin sincronización, uno de ellos una escritura. Una condición de carrera (race condition) es más general: el comportamiento del programa depende del orden relativo de sucesos que no controlás. Se puede tener lo segundo sin lo primero, y es el caso que más sorprende: cada acceso está protegido, y el programa sigue siendo incorrecto.

La forma más común se llama comprobar y luego actuar (check-then-act): se mira un estado y, según lo que se ve, se hace algo, pero el estado puede cambiar entre ambas operaciones.

static volatile bool ocupado;                    /* ¿está en uso la salida de la pantalla? */

/* MAL: cada instrucción es atómica, el conjunto no lo es */
void pantalla_usar(void) {
    if (!ocupado) {                              /* 1) comprobar */
        /* ← otra tarea puede entrar aquí: también ve "libre" */
        ocupado = true;                          /* 2) tomar */
        dibujar();                               /* 3) usar: ¡puede haber DOS tareas dentro! */
        ocupado = false;
    }
}

/* BIEN: comprobar y tomar en una única operación indivisible */
void pantalla_usar(void) {
    if (xSemaphoreTake(mutex_pantalla, 0) == pdTRUE) {      /* el Take es "comprobar y tomar" atómico */
        dibujar();
        xSemaphoreGive(mutex_pantalla);
    }
}

El programa de Python de este tema enumera todas las formas de entrelazar las dos tareas de la versión incorrecta: de 52 secuencias posibles, en 24 hay dos tareas dibujando a la vez. Con una operación indivisible de «comprobar y tomar», ninguna de las 6 secuencias posibles tiene a dos adentro. La diferencia es exactamente la ventana entre las dos líneas.

La misma estructura se repite con otras caras:

  • Un contador de pendientes: if (pendientes > 0) { pendientes--; procesar(); } ejecutado por dos tareas con pendientes = 1: ambas ven 1, ambas decrementan, y una procesa un trabajo que ya no existe (con un entero sin signo, el contador queda en 4 294 967 295).
  • Una invariante de varios campos: una estructura con un puntero y su longitud, una hora y una fecha, un par de coordenadas. Cada campo se escribe atómicamente, pero entre una escritura y la otra, otra tarea ve una combinación que nunca existió.
  • Inicialización perezosa: if (!iniciado) { iniciar(); iniciado = true; } llamado por dos tareas a la vez inicia dos veces. La solución es no tener inicialización perezosa en un sistema concurrente: se crean todos los objetos (colas, mutex, semáforos) antes de arrancar el planificador.
  • El orden de arranque: una tarea de mayor prioridad que arranca y usa una cola que otra tarea aún no creó. Mismo origen: el resultado depende de quién llega primero.

Las soluciones se ordenan de más a menos recomendables: no compartir (cada dato tiene una sola tarea dueña); un solo escritor por variable, con datos atómicos (tema 16); pasar copias por mensajes en lugar de compartir memoria (tema 21); y como último recurso, un mutex o una sección crítica que cubra toda la secuencia de comprobar y actuar, no cada paso por separado. La regla que resume el apartado: proteger los accesos no basta; hay que proteger la operación completa cuyo resultado depende de lo que se vio.

03 · Cuando la prioridad miente

Inversión de prioridad

Es el problema más específico de los sistemas de tiempo real y el que más daño hizo. Con prioridades y mutex ocurre lo siguiente. Tres tareas: L (baja), M (media) y H (alta). L toma un mutex y empieza a trabajar dentro de la región crítica. H se libera, expropia a L y a poco de empezar pide el mismo mutex: se bloquea esperando a L, lo cual es razonable, porque L debe terminar su sección crítica para soltarlo. Pero ahora se libera M. M tiene más prioridad que L y no necesita el mutex, de modo que expropia a L y corre todo lo que quiera. L no puede avanzar y no libera el mutex; H, la más urgente de las tres, está esperando, en la práctica, a M. La prioridad se invirtió: una tarea intermedia retrasa a la más importante, y por un tiempo que no depende de H ni de L sino de M, que puede ser cualquiera.

Inversión de prioridad sin herencia Línea de tiempo de 0 a 20 milisegundos. L corre y toma el mutex a los 1 ms. H llega a los 2 ms, corre 1 ms y se bloquea pidiendo el mutex a los 3 ms. M llega a los 3 ms y corre 10 ms hasta los 13 ms mientras L espera sin procesador y H sigue bloqueada. L termina su sección crítica entre los 13 y los 16 ms y recién entonces H puede correr, de 16 a 18 ms. H (alta)M (media)L (baja) H bloqueada esperando el mutex: 13 ms M corre 10 ms (no usa el mutex) L tiene el mutex (de 1 a 16 ms) 05101520 ms L espera sin procesador (M la desplaza)

El caso real más famoso es el de la sonda Mars Pathfinder, que aterrizó en Marte en julio de 1997 y empezó a reiniciarse sola a los pocos días. La causa, reconstruida por los ingenieros del proyecto, fue exactamente este esquema: una tarea de baja prioridad que recolectaba datos meteorológicos compartía un bus interno, protegido por un mutex, con una tarea de gestión del bus de alta prioridad; una tarea de comunicaciones de prioridad intermedia a veces desplazaba a la de baja mientras tenía el mutex. La de alta quedaba esperando tanto que un temporizador de vigilancia (watchdog) detectaba que no había corrido y reiniciaba la computadora. La solución, enviada desde la Tierra, fue activar un parámetro del sistema operativo que ya existía: la herencia de prioridad en ese mutex.

Hay dos soluciones estándar, que los sistemas operativos de tiempo real implementan en el propio mutex:

  • Herencia de prioridad (priority inheritance): cuando una tarea se bloquea esperando un mutex, la que lo tiene hereda temporalmente la prioridad de la que espera. En el ejemplo, L pasa a correr con la prioridad de H apenas H se bloquea; M ya no puede expropiarla, L termina su región crítica, suelta el mutex y vuelve a su prioridad original. El bloqueo de H queda acotado: no puede durar más que lo que le falta a L dentro de la sección crítica. FreeRTOS la implementa en sus mutex (no en los semáforos binarios, razón por la cual no se debe usar un semáforo binario para exclusión mutua).
  • Techo de prioridad (priority ceiling): cada mutex tiene asignada la prioridad de la tarea más prioritaria que lo usa, y quien lo toma pasa a correr con esa prioridad desde el primer instante. Así ninguna tarea que use el mutex puede interrumpir a quien lo tiene, y se elimina además el interbloqueo entre mutex. Es lo que usan los sistemas del automóvil (OSEK/AUTOSAR) y la opción PTHREAD_PRIO_PROTECT de POSIX.

Ninguna de las dos hace magia. La herencia acota la espera pero no la acorta: H todavía espera a que L termine su sección crítica, y si esa sección dura 8 ms, H pierde su plazo de 8 ms aunque no haya inversión (el laboratorio lo muestra). Por eso la regla de diseño que acompaña a las dos es la misma de siempre: las regiones críticas, cortísimas. Y la mejor solución es evitar el problema de raíz: si H y L no comparten un mutex —porque el recurso lo posee una tarea guardián (tema 21)— no hay inversión posible.

04 · Esperas que no terminan

Interbloqueo

El interbloqueo (deadlock) es la situación en que un grupo de tareas queda detenido para siempre porque cada una espera algo que tiene otra del grupo. El ejemplo mínimo usa dos mutex:

/* Tarea 1 */                                         /* Tarea 2 */
xSemaphoreTake(mutex_i2c, portMAX_DELAY);             xSemaphoreTake(mutex_pantalla, portMAX_DELAY);
    /* ← si acá hay un cambio de tarea... */              /* ← ...y acá otro... */
xSemaphoreTake(mutex_pantalla, portMAX_DELAY);        xSemaphoreTake(mutex_i2c, portMAX_DELAY);
/* trabajo con ambos */                               /* trabajo con ambos */
xSemaphoreGive(mutex_pantalla);                       xSemaphoreGive(mutex_i2c);
xSemaphoreGive(mutex_i2c);                            xSemaphoreGive(mutex_pantalla);

Si la Tarea 1 toma el bus I²C y la Tarea 2 toma la pantalla antes de que cualquiera pida el segundo, la Tarea 1 espera la pantalla (que tiene la 2) y la 2 espera el bus (que tiene la 1): ninguna puede avanzar, ninguna soltará lo que tiene, y el resto del sistema sigue funcionando como si nada. Es el defecto más difícil de reproducir, porque requiere que el cambio de tarea ocurra exactamente en la ventana entre los dos Take. El programa de Python explora todos los caminos posibles: con las dos tareas tomando en el mismo orden hay 2 secuencias y ningún interbloqueo; con órdenes opuestos hay 6 secuencias y 2 terminan en interbloqueo.

En 1971 Coffman describió las cuatro condiciones que deben cumplirse a la vez para que haya interbloqueo. Es la guía de prevención más útil que existe, porque basta con romper una:

Las cuatro condiciones del interbloqueo y cómo romper cada una.
CondiciónSignificaCómo se rompe
1. Exclusión mutuaEl recurso solo lo puede usar una tarea a la vezNo compartir; usar datos inmutables o copias; tarea guardián que serializa sin candado
2. Retención y esperaUna tarea mantiene un recurso mientras espera otroTomar todo lo necesario de una vez, o tiempo máximo y soltar todo si no se consigue el siguiente
3. Sin expropiación del recursoNadie puede quitarle un mutex a quien lo tieneTiempos máximos que fuerzan a la tarea a abandonar y reintentar
4. Espera circularHay un ciclo: A espera a B, B espera a C, C espera a AOrden global de adquisición: todas las tareas toman los recursos siempre en el mismo orden

La regla más barata y efectiva de todas es la cuarta: numerar los mutex (o ponerles un nivel) y tomarlos siempre en orden creciente. Si en el ejemplo se decide que «el bus I²C va antes que la pantalla», la Tarea 2 tiene que escribirse tomando primero el bus; con eso no puede haber un ciclo. Se anota en un solo lugar del proyecto (un comentario junto a la declaración de los mutex) y se revisa en cada cambio.

El interbloqueo no es exclusivo de los mutex. Hay otras tres formas frecuentes:

  • Autointerbloqueo: una tarea toma un mutex y luego llama a una función que intenta tomar el mismo mutex. Con un mutex común se bloquea contra sí misma para siempre. Soluciones: un mutex recursivo (xSemaphoreCreateRecursiveMutex), o mejor, estructurar el código con funciones internas «sin bloqueo» que asumen que el mutex ya está tomado.
  • Colas que se esperan: la tarea A envía a la cola de B con portMAX_DELAY y B envía a la cola de A con portMAX_DELAY; si las dos colas están llenas, ambas esperan lugar para siempre. Se evita con tiempos máximos, con envíos sin espera en uno de los sentidos, o con un diseño en que los mensajes fluyan en un solo sentido.
  • Esperar un evento que solo produce quien está esperando: una tarea espera un semáforo que dará otra que, a su vez, está bloqueada esperando a la primera.

05 · Otros problemas

Inanición, livelock y reentrancia

Inanición

La inanición (starvation) es la situación en que una tarea lista nunca llega a ejecutarse porque siempre hay otra más prioritaria lista. Ya vimos la forma más directa (el bucle de alta prioridad que nunca se bloquea, tema 20). La forma sutil es una carga que sube: si las tareas más prioritarias consumen el 95 % del procesador, las de abajo reciben el resto, tardan muchísimo y pierden sus plazos, sin que ninguna esté «colgada». La defensa es el análisis de utilización y de tiempo de respuesta del tema 20, y una regla simple: ninguna tarea de alta prioridad corre sin esperar algo.

Livelock

En el livelock las tareas no están bloqueadas sino que reaccionan una a la otra sin avanzar. El ejemplo clásico: dos tareas toman cada una un mutex, intentan el segundo, no lo consiguen, sueltan el primero «por cortesía» (para evitar el interbloqueo) y reintentan, las dos exactamente a la vez, y repiten para siempre. El procesador está ocupado, pero no se hace ningún progreso. Se resuelve rompiendo la simetría: una espera tiempos distintos o aleatorios antes de reintentar (backoff), o se impone un orden fijo.

Reentrancia

Una función es reentrante si puede ser llamada por dos tareas (o por una tarea y una interrupción) en forma concurrente sin que se corrompan sus datos. Lo es cuando solo usa sus parámetros y sus variables locales, que viven en la pila de cada tarea. No lo es cuando guarda estado en variables estáticas o globales:

  • strtok guarda en una variable estática dónde quedó: si dos tareas parten cadenas a la vez, una continúa desde donde la otra dejó. La versión reentrante es strtok_r.
  • rand y los generadores con semilla global; localtime, que devuelve un puntero a una estructura estática; printf y otras funciones de la biblioteca estándar, con buffers internos; y la variable errno, que es una sola para todo el programa.
  • malloc y free manipulan una estructura global del reservador de memoria; sin protección, dos tareas que reserven a la vez la corrompen.

Los sistemas operativos ofrecen soluciones parciales (una copia de errno por tarea, una biblioteca estándar «reentrante» que se configura al compilar, malloc protegido con un mutex), pero conviene no depender de ellas sin leer la configuración. La regla práctica: en tareas concurrentes, usar solo funciones reentrantes o llamarlas desde una única tarea (un guardián), y escribir las propias sin estado interno.

06 · Prevenir

Diseñar para evitarlos

Los defectos de concurrencia son más baratos de evitar en el diseño que de encontrar en la depuración. Los principios que más sirven, ordenados por cuánto problema evitan:

  1. Compartir lo menos posible. Cada dato tiene una única tarea dueña que lo modifica; las demás reciben copias por mensajes. Un dato que nadie comparte no puede tener una carrera.
  2. Preferir mensajes a memoria compartida. Una cola con copias traslada la sincronización a un mecanismo probado y deja a cada tarea con sus propios datos.
  3. Un recurso, un dueño. Para un periférico o un bus que se usa desde varias tareas, una tarea guardián que recibe pedidos (tema 21) elimina la exclusión mutua y todos sus riesgos asociados.
  4. Si hay mutex, que sean pocos, cortos y ordenados. Pocos: cada uno es un punto de espera. Cortos: nada que espere, llame a algo lento o a una función de la biblioteca dentro de la región crítica. Ordenados: un orden global de adquisición, anotado y respetado.
  5. Nunca más de un mutex a la vez, si se puede. El interbloqueo necesita al menos dos.
  6. Activar la herencia o el techo de prioridad en todo mutex que pueda ser compartido entre tareas de prioridades distintas. En FreeRTOS, usar mutex y no semáforos binarios.
  7. Poner un tiempo máximo en cada espera, y definir qué se hace cuando vence (registrar, liberar lo tomado, reintentar o pasar a un estado seguro). Un portMAX_DELAY en un recurso compartido es una apuesta.
  8. Crear todos los objetos antes de arrancar el planificador, y arrancar las tareas en un orden que no importe.
  9. Documentar el modelo de concurrencia: qué tareas hay, qué datos toca cada una, qué mutex protege qué, en qué orden se toman. Una página que se actualiza con el código.

07 · Depuración

Cómo se encuentran

Cuando el defecto ya existe, la búsqueda parte de los síntomas. La tabla ordena las pistas más habituales:

Síntomas de concurrencia y la causa más probable.
SíntomaCausa más probableQué mirar
Un valor equivocado de vez en cuando, sin patrónCarrera de datos o de comprobar y actuarToda variable tocada por más de un contexto; las secuencias de «si... entonces...» sobre estado compartido
Una función de alta prioridad pierde plazos cuando otra tarea intermedia trabajaInversión de prioridadMutex compartidos entre prioridades distintas; si es semáforo binario o mutex con herencia
Una parte del sistema deja de responder y el resto sigueInterbloqueoEn qué objeto está bloqueada cada tarea (el depurador con soporte de RTOS lo muestra); orden de los mutex
Reinicios por watchdog sin falla visibleUna tarea que no corre: inanición, inversión o interbloqueoCuál tarea dejó de alimentar al watchdog (tema 38) y en qué estado está
Se corrompe la memoria cerca de la pila de otra tareaDesborde de pilaLa verificación de desborde del RTOS y la marca de agua de la pila
Resultados mezclados entre tareasFunción no reentranteVariables estáticas dentro de funciones usadas por varias tareas
Desaparece al agregar un printf o un punto de interrupciónCualquier carrera (cambian los tiempos)Buscar por revisión del diseño, no por depuración interactiva

Las técnicas que mejor funcionan:

  • Trazas de ejecución. Registrar los cambios de contexto y los eventos del RTOS con marca de tiempo (con el contador de ciclos del tema 18) y mirarlos como una línea de tiempo. Muestran una inversión de prioridad como una imagen: H bloqueada, M corriendo, L esperando. Las herramientas de traza de los RTOS (como la que acompaña a FreeRTOS) generan esa imagen sin mucho trabajo.
  • Tiempos máximos con registro. Cada espera con tiempo máximo que vence debe registrar quién esperaba qué: convierte un interbloqueo silencioso en una línea de diagnóstico.
  • Inyección de retrasos. En una prueba, agregar a propósito demoras aleatorias entre las instrucciones críticas (o forzar un cambio de tarea) amplía las ventanas y hace que los defectos raros aparezcan en minutos y no en meses.
  • Pruebas de esfuerzo. Todas las fuentes de eventos al máximo ritmo, colas llenas, memoria al límite, durante horas.
  • Revisión sistemática. Hacer la tabla de datos compartidos (apartado 6) y recorrerla acceso por acceso: es más efectivo que cualquier prueba, porque razona sobre todos los casos y no sobre el que tocó.
  • Análisis estático y herramientas en la PC. Las partes de lógica pura (el controlador, la máquina de estados) pueden compilarse en la computadora y ejecutarse con detectores de carreras (como ThreadSanitizer), que sí encuentran las que en el equipo real tardarían meses.

08 · Analogía con programación

Explorar todos los casos en Python

Los defectos de concurrencia se resisten a las pruebas porque dependen de qué historia, entre muchas, toca ejecutar. Una simulación permite algo que un equipo real no: enumerar todas las historias. El programa tiene tres partes. La primera simula, con un paso de 1 ms, tres tareas (H, M y L) que comparten un mutex con y sin herencia de prioridad. La segunda explora exhaustivamente todas las secuencias posibles de dos tareas que toman dos mutex, con el mismo orden y con órdenes opuestos, y cuenta cuántas terminan en interbloqueo. La tercera hace lo mismo con el patrón «comprobar y luego actuar».

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

# --- Parte 1: inversión de prioridad ------------------------------------------------------------
def simular(c_medio, c_critica, herencia, t_fin=40):
    """Tres tareas con un mutex. Planificación expulsiva por prioridad, de a 1 ms.
    L (prio 1): corre 1 ms, toma el mutex, trabaja 'c_critica' ms adentro, lo libera y corre 1 ms más. Llega en t=0.
    H (prio 3): llega en t=2: corre 1 ms, toma el mutex, trabaja 1 ms adentro, lo libera y corre 1 ms. Plazo: 8 ms.
    M (prio 2): llega en t=3 y corre 'c_medio' ms sin usar el mutex."""
    tareas = {
        "H": dict(prio=3, llega=2, pasos=[("run", 1), ("lock",), ("run", 1), ("unlock",), ("run", 1)]),
        "M": dict(prio=2, llega=3, pasos=[("run", c_medio)]),
        "L": dict(prio=1, llega=0, pasos=[("run", 1), ("lock",), ("run", c_critica), ("unlock",), ("run", 1)]),
    }
    for t in tareas.values():
        t.update(i=0, resto=None, bloq=False, fin=None)
    dueno, esperan, linea = None, [], {k: "" for k in tareas}
    for ahora in range(t_fin):
        def prio_efectiva(nombre):
            p = tareas[nombre]["prio"]
            if herencia and dueno == nombre:
                p = max([p] + [tareas[w]["prio"] for w in esperan])
            return p
        corriendo = None
        while True:
            listas = [n for n, t in tareas.items() if t["llega"] <= ahora and t["fin"] is None and not t["bloq"]]
            if not listas: break
            n = max(listas, key=prio_efectiva)
            t = tareas[n]
            paso = t["pasos"][t["i"]]
            if paso[0] == "lock":
                if dueno is None: dueno = n; t["i"] += 1
                else: t["bloq"] = True; esperan.append(n)
            elif paso[0] == "unlock":
                dueno = None; t["i"] += 1
                if esperan:
                    w = max(esperan, key=lambda x: tareas[x]["prio"]); esperan.remove(w)
                    dueno = w; tareas[w]["bloq"] = False; tareas[w]["i"] += 1     # el despertado ya tiene el mutex
            else:
                if t["resto"] is None: t["resto"] = paso[1]
                corriendo = n; break
        for n, t in tareas.items():
            if n == corriendo: c = "█"
            elif t["fin"] is not None or t["llega"] > ahora: c = " "
            elif t["bloq"]: c = "░"
            else: c = "·"
            linea[n] += c
        if corriendo:
            t = tareas[corriendo]; t["resto"] -= 1
            if t["resto"] == 0:
                t["resto"] = None; t["i"] += 1
                if t["i"] >= len(t["pasos"]): t["fin"] = ahora + 1
    return {n: (t["fin"] - t["llega"]) for n, t in tareas.items()}, linea

print("Parte 1: H (prio 3, plazo 8 ms), M (prio 2) y L (prio 1) comparten un mutex")
for herencia in (False, True):
    resp, linea = simular(10, 4, herencia)
    print(f"  {'CON' if herencia else 'SIN'} herencia de prioridad: respuesta de H = {resp['H']} ms "
          f"({'cumple' if resp['H'] <= 8 else 'PIERDE el plazo'}), M = {resp['M']} ms, L = {resp['L']} ms")
    for n in "HML":
        print(f"     {n} |{linea[n][:22]}|")
print("     (█ corre   ░ bloqueada esperando el mutex   · lista pero sin procesador)")

# --- Parte 2: interbloqueo, explorando TODOS los órdenes posibles ---------------------------
def explorar(plan1, plan2):
    """Cada plan es una lista de pasos ('lock', X) o ('unlock', X). Se prueban todas las secuencias de ejecución."""
    planes = (plan1, plan2)
    resultados = {"completan": 0, "interbloqueo": 0}
    def paso(i1, i2, duenos):
        pos = [i1, i2]
        opciones = []
        for k in (0, 1):
            if pos[k] < len(planes[k]):
                op, x = planes[k][pos[k]]
                if op == "unlock" or duenos.get(x) is None:
                    opciones.append(k)
        if not opciones:
            if pos[0] == len(plan1) and pos[1] == len(plan2): resultados["completan"] += 1
            else: resultados["interbloqueo"] += 1
            return
        for k in opciones:
            op, x = planes[k][pos[k]]
            nuevos = dict(duenos)
            nuevos[x] = k if op == "lock" else None
            npos = list(pos); npos[k] += 1
            paso(npos[0], npos[1], nuevos)
    paso(0, 0, {})
    return resultados

mismo = [("lock", "A"), ("lock", "B"), ("unlock", "B"), ("unlock", "A")]
opuesto = [("lock", "B"), ("lock", "A"), ("unlock", "A"), ("unlock", "B")]
print()
print("Parte 2: dos tareas, dos mutex, todas las secuencias de ejecución posibles")
for titulo, p2 in (("las dos toman A y luego B     ", mismo), ("una toma A,B y la otra B,A     ", opuesto)):
    r = explorar(mismo, p2)
    total = r["completan"] + r["interbloqueo"]
    print(f"  {titulo}: {total:3d} secuencias, {r['interbloqueo']:3d} terminan en interbloqueo ({100 * r['interbloqueo'] / total:.0f} %)")

# --- Parte 3: "si está libre, tomarlo": comprobar y actuar no son atómicos ----------------
def secuencias(atomico):
    """Cada tarea: leer 'ocupado'; si estaba libre, ponerlo en True; entrar; salir. ¿Alguna vez hay dos adentro?"""
    pasos = ["tomar"] if atomico else ["leer", "escribir"]
    cuenta = {"total": 0, "dos_adentro": 0}
    def avanzar(estado, ocupado, adentro, violo):
        opciones = [k for k in (0, 1) if estado[k] != "fin"]
        if not opciones:
            cuenta["total"] += 1; cuenta["dos_adentro"] += violo; return
        for k in opciones:
            e = list(estado); oc, ad, v = ocupado, adentro, violo
            s = e[k]
            if s == "leer":      e[k] = ("escribir", oc is False)       # recordar lo que vio
            elif isinstance(s, tuple) and s[0] == "escribir":
                if s[1]: oc = True; e[k] = "entrar"
                else:    e[k] = "fin"                                  # lo vio ocupado: no entra
            elif s == "tomar":
                if oc is False: oc = True; e[k] = "entrar"
                else:           e[k] = "fin"
            elif s == "entrar":  ad += 1; v = v or ad > 1; e[k] = "salir"
            elif s == "salir":   ad -= 1; oc = False; e[k] = "fin"
            avanzar(tuple(e), oc, ad, v)
    avanzar((pasos[0], pasos[0]), False, 0, False)
    return cuenta

print()
print("Parte 3: dos tareas hacen 'si está libre, ocuparlo y entrar'")
for titulo, at in (("comprobar y escribir por separado", False), ("una sola operación atómica       ", True)):
    c = secuencias(at)
    print(f"  {titulo}: {c['total']:3d} secuencias, {c['dos_adentro']:3d} con DOS tareas adentro a la vez")

Resultado, parte 1: sin herencia, H tarda 16 ms en terminar y pierde su plazo de 8: está bloqueada 13 ms (de los 3 a los 16 ms), aunque a L solo le faltaban 3 ms dentro de la región crítica; M, que no usa el mutex, corre 10 ms en el medio. Con herencia, H termina en 6 ms y cumple: L pasa a correr con su prioridad, termina la región crítica en 3 ms y suelta el mutex. El precio lo paga M, que sube de 10 a 15 ms de respuesta, y L termina a los 19 ms en ambos casos. Parte 2: con el mismo orden de adquisición hay 2 secuencias posibles y ninguna termina en interbloqueo; con órdenes opuestos hay 6 y 2 terminan en interbloqueo (un tercio de las secuencias; en un equipo real, la fracción de tiempo es mucho menor, y por eso el defecto aparece rara vez). Parte 3: con la comprobación y la toma por separado hay 52 secuencias y en 24 hay dos tareas adentro a la vez; con una operación atómica, 6 secuencias y ninguna viola la exclusión.

Probá cambiar el tiempo de la tarea M o el de la región crítica en la parte 1, y agregar un tercer mutex al esquema de la parte 2. Es la forma de pensar que conviene entrenar: no «¿qué hace mi programa?» sino «¿qué podría hacer en el peor entrelazado?».

09 · Exploración interactiva

La inversión de prioridad

El laboratorio ejecuta la parte 1 del programa anterior. Tres tareas comparten un mutex: L (prioridad 1) lo toma a los 1 ms, H (prioridad 3, plazo de 8 ms) se libera a los 2 ms y lo pide, y M (prioridad 2) se libera a los 3 ms y corre sin usarlo. Elegí si el mutex tiene herencia de prioridad, cuánto trabaja M y cuánto dura la región crítica de L. Verás la línea de tiempo milisegundo a milisegundo y si H cumple su plazo.

Tres tareas y un mutex

H · tiempo de respuesta—
H · espera por el mutex—
M · tiempo de respuesta—
L · tiempo de respuesta—
Resultado—

Línea de tiempo (40 ms)

—

Cambiá las opciones para ver cuándo se produce la inversión.

Recorrido guiado

  1. Con los valores iniciales (sin herencia, M de 10 ms, región crítica de 4 ms): H tarda 16 ms y pierde su plazo de 8. Estuvo 13 ms bloqueada, aunque L solo necesitaba 3 ms más para soltar el mutex. En la línea de tiempo, M corre sus 10 ms mientras L (con puntos, lista sin procesador) espera.
  2. Activá la herencia: H termina en 6 ms y cumple. Su espera es de solo 3 ms, lo que le faltaba a L. Mirá la fila de L: corre inmediatamente después de que H se bloquea, y M queda con puntos hasta que H termina. M paga el costo y responde en 15 ms en vez de 10.
  3. Sin herencia, subí el trabajo de M a 20 ms: H tarda 26 ms. Con 5 ms, 11 ms. La espera de H depende de M, una tarea que ni siquiera usa el recurso. Con herencia, en cambio, H sigue en 6 ms sea cual sea el trabajo de M.
  4. Con herencia, probá la región crítica de L en 2 ms: H responde en 4 ms. Con 8 ms: 10 ms, y H pierde su plazo aun con herencia. La herencia acota la espera pero no la acorta; la defensa es la región crítica corta o no compartir el recurso.
  5. Volvé a una región de 4 ms con M de 10 ms y alterná la herencia varias veces mirando L: termina en el mismo instante (19 ms) en ambos casos. La herencia no agrega trabajo; solo cambia el orden en que se hace el que ya hay, a favor de la tarea más urgente.
Desafío: ¿qué cambiaría con un techo de prioridad en lugar de herencia?

Con el techo, el mutex tiene asignada la prioridad de H (la mayor de las que lo usan). Apenas L lo toma, a los 1 ms, pasa a correr con prioridad 3: H, cuando se libera a los 2 ms, ya no puede expropiarla (misma prioridad no expropia), y M tampoco. L completa su región crítica y suelta el mutex, y recién entonces H corre sin bloquearse nunca en el mutex. H espera el mismo tiempo de CPU de L, pero lo hace sin haber llegado a bloquearse, y se elimina la posibilidad de interbloqueo entre mutex. El precio es que L se vuelve «urgente» durante toda su región crítica, haya o no competencia, y que hay que conocer de antemano qué tareas usan cada mutex. Es una buena opción en sistemas donde el conjunto de tareas es fijo y se analiza completo, como los del automóvil.

10 · Método de trabajo

Método de revisión

Una revisión de concurrencia es una actividad sistemática con papel y lápiz, antes que cualquier prueba. Se hace para cada versión del diseño y cada vez que se agrega una tarea o un recurso:

  1. Inventario de contextos: listá todas las tareas y todas las interrupciones, con su prioridad. Cada una es un «hilo» que puede interrumpir a los de menor prioridad en cualquier instrucción.
  2. Inventario de datos compartidos: para cada variable, cola, buffer o periférico, anotá qué contextos lo leen y cuáles lo escriben. Todo lo que tenga más de un contexto es un punto de revisión.
  3. Para cada dato compartido, anotá el mecanismo: un solo escritor atómico, una cola, un mutex o una sección crítica. «Ninguno» es una respuesta que hay que justificar, no un descuido.
  4. Buscá los «si... entonces...» sobre estado compartido y verificá que la comprobación y la acción estén dentro de la misma región protegida.
  5. Para cada mutex: ¿quiénes lo usan?, ¿las prioridades son distintas?, ¿tiene herencia o techo?, ¿cuánto dura la región crítica en el peor caso?, ¿se libera por todos los caminos?
  6. Buscá tareas que tomen dos o más recursos a la vez y verificá el orden global. Dibujá el grafo «quién espera a quién»: si tiene un ciclo, hay riesgo de interbloqueo.
  7. Para cada espera, revisá el tiempo máximo y la acción cuando vence. Marcá cada portMAX_DELAY y justificá que el aviso no puede dejar de llegar.
  8. Para cada función llamada desde más de un contexto, verificá que sea reentrante o esté protegida.
  9. Probá con esfuerzo e inyección de retrasos, sabiendo que sirve para confirmar el análisis, no para reemplazarlo.

11 · Comprensión

Confusiones frecuentes

«Si protejo cada acceso con un mutex, no hay carreras»

Protege cada acceso, pero no las secuencias. Un «si está libre, tomarlo» hecho con dos accesos protegidos por separado sigue teniendo una ventana entre ellos. Lo que hay que proteger es la operación completa cuyo resultado depende de lo que se vio.

«La inversión de prioridad es un problema de los sistemas viejos»

Ocurrió en una sonda de 1997, y sigue ocurriendo cada vez que se usan semáforos binarios para exclusión mutua, o mutex sin herencia, compartidos entre tareas de prioridades distintas. Es un defecto de diseño, no de época.

«La herencia de prioridad resuelve la inversión del todo»

Acota la espera de la tarea de alta prioridad a la duración de la región crítica de la baja. No la elimina: si esa región es larga, el plazo se pierde igual. Y solo ayuda con ese mutex; cadenas de varios mutex pueden complicarla.

«Si dos tareas toman los mutex en cualquier orden, algún día se resolverá solo»

Un interbloqueo no se resuelve solo: ninguna de las tareas involucradas puede avanzar, y nada del sistema la sacará de ahí salvo un tiempo máximo o un reinicio. Lo que se resuelve solo es la ventana de riesgo… hasta la próxima vez que se cumpla.

«Si no hay interrupciones ni tareas expropiadas, estoy a salvo»

En un esquema cooperativo las carreras entre tareas no existen, pero siguen existiendo con las interrupciones, y la inanición, los bucles sin espera y la reentrancia con las ISR siguen vigentes. La seguridad depende de los contextos que puedan interrumpirse, no de si se usa un RTOS.

«Un programa multitarea que funcionó mil veces es correcto»

Funcionó en las mil historias que tocaron. Un defecto de concurrencia puede requerir una historia que ocurre una vez cada millón de ejecuciones. La corrección se razona sobre todas las historias posibles, no se observa en algunas.

«Las funciones de la biblioteca estándar son seguras para usar desde cualquier tarea»

Muchas no lo son (strtok, rand, localtime, malloc, printf) porque guardan estado interno. Hay que leer la documentación del RTOS y de la biblioteca, o usar las variantes reentrantes.

12 · Práctica guiada

Predecí antes de comprobar

1. La espera de H

L tiene un mutex y le faltan 3 ms de región crítica cuando llega H (la más prioritaria) y lo pide. Un instante después llega M (prioridad intermedia) y necesita 12 ms de procesador sin usar el mutex. ¿Cuánto espera H sin herencia de prioridad, y cuánto con herencia?

Ver solución

Sin herencia: M expropia a L y corre sus 12 ms; recién después L puede terminar sus 3 ms y soltar el mutex. H espera 12 + 3 = 15 ms. Con herencia: L corre con la prioridad de H, M no puede expropiarla y H espera solo los 3 ms que le faltan a L. La diferencia, 12 ms, es exactamente la duración de M: la espera de H dependía de una tarea que no usaba el recurso.

2. Tres mutex en ciclo

Tres tareas usan tres mutex: T1 toma A y luego B, T2 toma B y luego C, T3 toma C y luego A. ¿Puede haber interbloqueo? ¿Cómo se evita sin tocar los recursos?

Ver solución

Sí: si cada tarea toma su primer mutex antes de que alguna pida el segundo, T1 espera a T2 (que tiene B), T2 espera a T3 (que tiene C) y T3 espera a T1 (que tiene A): un ciclo. Se evita con un orden global, por ejemplo A < B < C: T1 y T2 ya cumplen el orden; T3 debe tomar A antes que C (aunque trabaje primero con C). Con ese orden no puede formarse ningún ciclo.

3. Comprobar y actuar

Dos tareas ejecutan if (pendientes > 0) { pendientes--; procesar(); } sobre un entero sin signo que vale 1. ¿Qué puede pasar? Dos soluciones.

Ver solución

Si ambas leen 1 antes de que cualquiera decremente, las dos entran: procesar() se ejecuta dos veces para un único trabajo y pendientes queda en 4 294 967 295 (desborde hacia abajo del entero sin signo). Soluciones: (a) encerrar la comprobación y el decremento en una sección crítica o un mutex; (b) reemplazar el contador por un semáforo de conteo, cuyo Take es comprobar y decrementar en una operación indivisible, y que además bloquea a la tarea cuando no hay trabajo en lugar de sondear.

4. Herencia insuficiente

H tiene un plazo de 8 ms, y L mantiene el mutex durante 8 ms. Hay herencia de prioridad. ¿Cumple H? Proponé dos soluciones de diseño.

Ver solución

No: aun sin tareas intermedias, H espera casi toda la región crítica de L; el laboratorio da 10 ms de respuesta con esos valores. La herencia acota la espera, no la acorta. Soluciones: (1) acortar la región crítica: L copia lo que necesita del recurso dentro del mutex (un instante) y hace el procesamiento largo fuera, con la copia; (2) eliminar el mutex convirtiendo el recurso en propiedad de una tarea guardián que recibe pedidos por cola, de modo que H solo deposita un mensaje y no espera a L.

5. Dos tareas parten cadenas

Dos tareas llaman a strtok a la vez, cada una sobre su propia cadena. ¿Qué ocurre y qué se hace?

Ver solución

strtok recuerda en una variable estática dónde quedó. Si una tarea es expropiada entre dos llamadas y la otra llama a strtok con su cadena, la primera, al retomar, continúa desde la posición de la segunda: los fragmentos salen mezclados o fuera de rango. Se usa strtok_r, que recibe ese estado como parámetro (un puntero propio de cada tarea), o se limita el uso a una sola tarea.

6. El techo de prioridad

En el escenario del ejercicio 1 con un mutex de techo de prioridad (la de H), ¿qué hace L apenas toma el mutex? ¿Se bloquea H alguna vez?

Ver solución

L pasa a correr con la prioridad de H desde el instante en que toma el mutex. Cuando H llega, tiene la misma prioridad que L y no la expropia: espera a que L termine su región crítica y suelte el mutex (L vuelve entonces a su prioridad y H corre). H nunca llega a bloquearse sobre el mutex: lo encuentra libre. M tampoco puede interrumpir a L durante la región. La espera de H es, otra vez, lo que falta de la región crítica de L, sin posibilidad de interbloqueo entre mutex.

13 · Síntesis

Ideas para recordar

  • Los defectos de concurrencia son raros, silenciosos y se esconden al depurar: dependen de una historia entre muchas posibles. Se evitan razonando sobre todas, no probando algunas.
  • Una condición de carrera puede existir aunque cada acceso esté protegido: el patrón «comprobar y luego actuar» necesita que la comprobación y la acción sean una sola operación indivisible. Se protege la operación completa, no los pasos.
  • En la inversión de prioridad, una tarea intermedia retrasa a la más urgente porque la de baja prioridad que tiene el mutex no puede avanzar. La herencia (o el techo) de prioridad la acota, pero no acorta la región crítica: sigue haciendo falta que sea corta. Se usan mutex, no semáforos binarios, para exclusión.
  • El interbloqueo necesita cuatro condiciones a la vez (exclusión mutua, retención y espera, falta de expropiación del recurso y espera circular); romper una alcanza. La regla más barata es un orden global de adquisición de los mutex.
  • También hay interbloqueo con colas que se esperan y con una tarea que se espera a sí misma; la inanición deja tareas sin correr, el livelock las hace girar sin progreso, y la falta de reentrancia mezcla datos de tareas distintas.
  • Se diseña para evitarlos: compartir lo menos posible, preferir mensajes, un dueño por recurso, pocos mutex cortos y ordenados, herencia activada, tiempos máximos en todas las esperas, objetos creados antes de arrancar el planificador.
  • Se encuentran con trazas de ejecución, esperas con registro, inyección de retrasos, pruebas de esfuerzo y, sobre todo, con una revisión sistemática del inventario de contextos y datos compartidos.
  • La mejor solución a un problema de concurrencia es el diseño que lo vuelve imposible: sin recurso compartido no hay carrera, sin dos mutex no hay interbloqueo.

¿Listo para el tema 23?

Antes de avanzar, explicá en voz alta, con tres tareas, cómo se produce una inversión de prioridad y qué cambia con la herencia; enunciá las cuatro condiciones del interbloqueo y proponé cómo romper una de ellas en un caso con dos mutex; y mostrá un «comprobar y luego actuar» y su corrección. Si podés justificar las tres, estás listo para el taller.

En el próximo tema, un taller, ponemos todo en práctica: un firmware con varias tareas que lee un sensor, procesa los datos y se comunica, repartidos en tareas conectadas por colas, aplicando el análisis de tiempos y las reglas de concurrencia de los cuatro temas anteriores.