Sincronización

Cuando dos procesos tocan lo mismo al mismo tiempo

Marco González Luengo

Tabla de contenidos
  1. El problema: condiciones de carrera
  2. La sección crítica
  3. Soluciones por software
  4. Para dos procesos
  5. Para N procesos: el algoritmo de la panadería
  6. Ayuda del hardware
  7. Deshabilitar las interrupciones
  8. Instrucciones atómicas
  9. Semáforos
  10. Para la exclusión mutua
  11. Para ordenar la ejecución
  12. Espera activa o bloqueo
  13. Cómo se puede usar mal
  14. Problemas clásicos
  15. Productor y consumidor (búfer limitado)
  16. Lectores y escritores
  17. Los filósofos comensales
  18. El barbero dormilón
  19. Monitores
  20. Transacciones atómicas
  21. En la práctica
  22. Ejercicios

En Procesos vimos que los hilos comparten memoria, y que eso es a la vez su gran ventaja y su gran riesgo. Este tema es sobre el riesgo: qué pasa cuando varios procesos o hilos modifican los mismos datos, y cómo evitar que se pisen.

El problema: condiciones de carrera

Dos hilos ejecutan contador++ sobre la misma variable, que vale 5. Uno esperaría que termine en 7. Pero contador++ no es una sola instrucción para el procesador; son tres:

  1. Leer el valor de contador a un registro.
  2. Sumar 1 al registro.
  3. Escribir el registro de vuelta en contador.

Y el planificador puede quitarle la CPU a un hilo entre cualquiera de esos pasos. Si los pasos de los dos hilos se intercalan de mala manera, uno pisa el resultado del otro.

Esta simulación ejecuta los pasos de los hilos A y B en el orden que diga $turnos. Ejecútala tal cual, después cambia el orden de los turnos, y después activa el mutex:

<?php
declare(strict_types=1);

// Condición de carrera: dos procesos hacen contador++ una vez cada uno.
// contador++ no es una sola instrucción: son tres pasos (leer, sumar, escribir).
// $turnos dice a quién le toca ejecutar su siguiente paso.

$usarMutex = false;
$turnos = ['A', 'B', 'A', 'B', 'A', 'B'];   // prueba ['A', 'A', 'A', 'B', 'B', 'B']

// ---------------------------------------------------------------------------

$contador = 5;
$pasos = $usarMutex
    ? ['tomar el mutex', 'leer', 'sumar', 'escribir', 'soltar el mutex']
    : ['leer', 'sumar', 'escribir'];

$estado = [
    'A' => ['paso' => 0, 'registro' => null],
    'B' => ['paso' => 0, 'registro' => null],
];
$mutexDe = null;   // quién tiene el mutex

echo "contador parte en {$contador}", PHP_EOL, PHP_EOL;

$i = 0;
// Si se acaban los turnos, se siguen alternando hasta que ambos terminen
while ($estado['A']['paso'] < count($pasos) || $estado['B']['paso'] < count($pasos)) {
    $quien = $turnos[$i] ?? ($i % 2 === 0 ? 'A' : 'B');
    $i++;
    $p = &$estado[$quien];

    if ($p['paso'] === count($pasos)) {
        unset($p);
        continue;   // ya terminó
    }

    $accion = $pasos[$p['paso']];
    switch ($accion) {
        case 'tomar el mutex':
            if ($mutexDe !== null) {
                echo "{$quien}: quiere el mutex, pero lo tiene {$mutexDe}: espera", PHP_EOL;
                unset($p);
                continue 2;
            }
            $mutexDe = $quien;
            echo "{$quien}: toma el mutex", PHP_EOL;
            break;
        case 'leer':
            $p['registro'] = $contador;
            echo "{$quien}: lee contador = {$contador}", PHP_EOL;
            break;
        case 'sumar':
            $p['registro']++;
            echo "{$quien}: suma 1 en su registro, queda {$p['registro']}", PHP_EOL;
            break;
        case 'escribir':
            $contador = $p['registro'];
            echo "{$quien}: escribe contador = {$contador}", PHP_EOL;
            break;
        case 'soltar el mutex':
            $mutexDe = null;
            echo "{$quien}: suelta el mutex", PHP_EOL;
            break;
    }
    $p['paso']++;
    unset($p);
}

echo PHP_EOL, "contador termina en {$contador} (debería ser 7)", PHP_EOL;

Con los turnos alternados, los dos hilos leen 5, los dos calculan 6 y los dos escriben 6: se perdió una actualización. Con ['A', 'A', 'A', 'B', 'B', 'B'] el resultado es correcto, porque cada hilo hizo sus tres pasos sin interrupción.

Esto es una condición de carrera (race condition): el resultado depende del orden exacto en que se intercalan los hilos. Y lo peor es que ese orden cambia en cada ejecución. El programa puede funcionar bien miles de veces y fallar justo en producción. Por eso la programación concurrente tiene fama de difícil.

La sección crítica

La parte del código que accede a los datos compartidos se llama sección crítica. El problema de la sección crítica es diseñar una forma de que, si un proceso está en su sección crítica, ningún otro esté en la suya.

Cada proceso sigue esta estructura:

repetir
    sección de entrada       <- pide permiso para entrar
    sección crítica          <- usa los datos compartidos
    sección de salida        <- avisa que salió
    sección restante         <- todo lo demás

Una solución correcta tiene que cumplir tres requisitos:

  1. Exclusión mutua: si un proceso está en su sección crítica, ningún otro puede estar en la suya.
  2. Progreso: si nadie está en la sección crítica y alguien quiere entrar, la decisión de quién entra no se puede postergar para siempre. Y solo participan de esa decisión los que quieren entrar.
  3. Espera limitada: hay un límite de cuántas veces pueden entrar otros antes de que le toque a un proceso que ya pidió entrar. Nadie espera indefinidamente.

Y no se puede suponer nada sobre la velocidad relativa de los procesos: la solución tiene que funcionar con cualquier intercalación.

Soluciones por software

Para dos procesos

Las diapositivas recorren tres intentos para dos procesos, P0 y P1:

// Variables compartidas
quiere[2] = { falso, falso }
turno     = 0

// Proceso i (el otro es j = 1 - i)
repetir
    quiere[i] = verdadero
    turno = j                              // cede el turno al otro
    mientras quiere[j] y turno == j:
        no hacer nada                      // espera
    sección crítica
    quiere[i] = falso
    sección restante

Si los dos quieren entrar al mismo tiempo, ambos ceden el turno, pero turno solo puede quedar con un valor: entra aquel a quien el otro le cedió el turno al final.

Peterson es elegante, pero tiene dos problemas prácticos: solo sirve para dos procesos, y en procesadores y compiladores modernos, que pueden reordenar lecturas y escrituras de memoria, no funciona de forma confiable sin ayuda del hardware.

Para N procesos: el algoritmo de la panadería

Leslie Lamport lo propuso inspirándose en las panaderías (o las farmacias) donde se saca un número. Antes de entrar a la sección crítica, cada proceso toma un número, mayor que todos los que ve en uso. Entra el que tenga el número más bajo. Si dos procesos sacan el mismo número, entra el que tenga el identificador menor. Los números siempre crecen: 1, 2, 3, 3, 3, 4, 5...

Ayuda del hardware

Las soluciones por software son complicadas y lentas. Con ayuda del hardware se puede hacer mejor.

Deshabilitar las interrupciones

Si no hay interrupciones, no hay cambio de contexto, y nadie puede interrumpir la sección crítica. Pero es peligroso (si el proceso nunca vuelve a habilitarlas, el sistema se congela), solo se puede hacer en modo kernel, y no sirve con varios núcleos: otro núcleo puede estar ejecutando la sección crítica al mismo tiempo.

Instrucciones atómicas

La solución real es que el procesador ofrezca instrucciones que leen y modifican una posición de memoria en un solo paso indivisible (atómico). Si dos núcleos la ejecutan a la vez, el hardware garantiza que ocurren una después de la otra.

La clásica es test-and-set: devuelve el valor anterior de una variable y la deja en verdadero.

función test_and_set(variable):     // todo esto ocurre de una vez
    anterior = variable
    variable = verdadero
    devolver anterior

Con ella, la exclusión mutua es trivial:

cerrojo = falso                     // compartido

repetir
    mientras test_and_set(cerrojo):
        no hacer nada               // alguien está adentro: esperar
    sección crítica
    cerrojo = falso
    sección restante

Los procesadores actuales ofrecen compare-and-swap (CAS): "si la variable vale X, cámbiala a Y, y dime si lo hiciste", también de forma atómica. Con ella se construyen desde cerrojos hasta estructuras de datos concurrentes sin cerrojos.

Estas soluciones hacen espera activa (busy waiting): el proceso que espera gira en un ciclo gastando CPU. Un cerrojo que espera así se llama spinlock. Tiene sentido solo cuando la espera es muy corta (menos que lo que costaría un cambio de contexto) y hay varios núcleos. Linux los usa mucho por dentro del kernel.

Semáforos

Edsger Dijkstra propuso en 1965 una herramienta más cómoda: el semáforo. Es una variable entera S a la que solo se accede con dos operaciones atómicas:

Hay dos tipos:

Para la exclusión mutua

mutex = 1                           // compartido

repetir
    wait(mutex)
    sección crítica
    signal(mutex)
    sección restante

El primer proceso encuentra mutex = 1, lo deja en 0 y entra. Los que llegan después lo encuentran en 0 y esperan. Al salir, signal lo vuelve a 1 y despierta a uno. Es lo que hace la simulación de arriba con $usarMutex = true.

Para ordenar la ejecución

Los semáforos también sirven para coordinar. Si la instrucción S1 del proceso P1 solo debe ejecutarse después de la instrucción S2 de P2:

s = 0                               // compartido

// P1                               // P2
wait(s)                             S2
S1                                  signal(s)

Si P1 llega primero, se queda esperando en wait(s) hasta que P2 ejecute S2 y haga signal(s).

Espera activa o bloqueo

La definición de arriba no dice cómo espera wait. Hay dos formas:

Cómo se puede usar mal

Los semáforos son poderosos, pero fáciles de usar mal:

Problemas clásicos

Son problemas "de juguete" que resumen situaciones reales, y que se usan para probar cualquier mecanismo de sincronización nuevo.

Productor y consumidor (búfer limitado)

Unos procesos producen datos y otros los consumen, a través de un búfer de capacidad fija. El productor debe esperar si el búfer está lleno, y el consumidor si está vacío. Lo simulamos en Procesos. Con semáforos se resuelve con tres: un mutex para el búfer, un semáforo vacíos (parte en la capacidad) y uno llenos (parte en 0).

Lectores y escritores

Un dato compartido (una base de datos, un archivo) lo usan lectores, que solo leen, y escritores, que lo modifican. Muchos lectores pueden leer a la vez sin problema, pero un escritor necesita acceso exclusivo: nadie más puede leer ni escribir mientras tanto.

La dificultad está en las prioridades. Si se prioriza a los lectores, un escritor puede esperar para siempre mientras sigan llegando lectores. Si se prioriza a los escritores, pasa lo mismo al revés. Existen cerrojos específicos para esto: los cerrojos de lectura y escritura (read-write locks).

Los filósofos comensales

Cinco filósofos se sientan alrededor de una mesa redonda. Solo hacen dos cosas: pensar y comer. Entre cada par de filósofos hay un tenedor, y para comer se necesitan los dos tenedores que tiene al lado.

            F1
        T1      T2
     F5            F2
       T5        T3
         F4   T4   F3

La solución ingenua es representar cada tenedor con un semáforo, y que cada filósofo tome primero el tenedor de su izquierda y después el de su derecha. Garantiza que dos filósofos no usen el mismo tenedor... pero si los cinco toman su tenedor izquierdo al mismo tiempo, todos se quedan esperando el derecho, que tiene el vecino. Interbloqueo: nadie come nunca.

Algunas soluciones:

Ojo: evitar el interbloqueo no garantiza evitar la inanición. Un filósofo puede tener tan mala suerte que sus vecinos siempre coman justo cuando él quiere.

El barbero dormilón

Una barbería tiene un barbero, una silla para cortar y n sillas de espera. Si no hay clientes, el barbero se duerme. Cuando llega un cliente: si el barbero duerme, lo despierta; si está ocupado y hay sillas libres, se sienta a esperar; si no hay sillas, se va. Modela, por ejemplo, un servidor con un número limitado de conexiones en espera.

Monitores

Los semáforos se usan mal con facilidad porque cada wait y signal está repartido por el código. Un monitor es una construcción de más alto nivel, propia del lenguaje de programación: un objeto que agrupa los datos compartidos con las funciones que los usan, y garantiza que solo un proceso a la vez ejecuta alguna de sus funciones. La exclusión mutua la pone el lenguaje, no el programador.

Para que un proceso pueda esperar dentro del monitor a que se cumpla una condición (por ejemplo, que el búfer tenga espacio), existen las variables de condición, con dos operaciones:

Hoy los monitores están en casi todos los lenguajes:

(Las diapositivas antiguas mencionan también las regiones críticas, una construcción anterior que protegía una variable compartida dentro de un bloque region v when B do. Su idea sobrevive en los monitores y en los bloques synchronized.)

Transacciones atómicas

Las bases de datos resolvieron hace mucho un problema parecido: un conjunto de operaciones que tienen que ocurrir todas o ninguna. Transferir dinero implica restar de una cuenta y sumar a otra; si el sistema se cae entre ambas cosas, el dinero no puede desaparecer.

Una transacción es un conjunto de operaciones que termina con:

Para poder deshacer o rehacer después de una caída, el sistema anota cada operación en un registro (log) antes de hacerla. Periódicamente marca puntos de control (checkpoints), para no tener que revisar el registro completo desde el principio al recuperarse. Y para aprovechar el tiempo, ejecuta varias transacciones a la vez, cuidando que el resultado sea el mismo que si hubieran ocurrido una tras otra (serializabilidad).

Los sistemas operativos tomaron prestada esta idea: es el journaling de los sistemas de archivos.

En la práctica

Más detalles en los capítulos de concurrencia de OSTEP.

Ejercicios

¿Por qué contador++ puede dar un resultado incorrecto si lo ejecutan dos hilos a la vez?

Porque no es una operación atómica: son tres pasos (leer, sumar, escribir), y el planificador puede cambiar de hilo entre cualquiera de ellos. Si ambos hilos leen el valor antes de que alguno escriba, ambos calculan el mismo resultado y uno de los incrementos se pierde.

¿Cuáles son los tres requisitos de una solución al problema de la sección crítica?
  1. Exclusión mutua: nunca hay dos procesos en su sección crítica al mismo tiempo.
  2. Progreso: si nadie está en la sección crítica y alguien quiere entrar, la decisión no se posterga indefinidamente.
  3. Espera limitada: un proceso que pidió entrar no espera para siempre; hay un límite de cuántos pueden pasar antes que él.
¿Por qué deshabilitar las interrupciones no es una buena solución en un computador con varios núcleos?

Porque deshabilitar las interrupciones en un núcleo solo evita que ese núcleo cambie de proceso. Los demás núcleos siguen ejecutando, y cualquiera de ellos puede entrar a la misma sección crítica al mismo tiempo. Además, es peligroso: un proceso que no las vuelve a habilitar congela el sistema.

¿Qué es la espera activa? ¿Cuándo tiene sentido?

Es esperar girando en un ciclo que pregunta una y otra vez si ya se puede entrar, gastando CPU sin hacer nada útil.

Tiene sentido solo cuando la espera va a ser muy corta (más corta que lo que cuesta bloquear al proceso y hacer un cambio de contexto) y hay varios núcleos, de modo que el que tiene el cerrojo lo está usando en otro núcleo. Así funcionan los spinlocks dentro del kernel. En un solo núcleo, esperar activamente no tiene sentido: el que tiene el cerrojo no puede avanzar mientras el que espera usa la CPU.

Un estacionamiento tiene 3 lugares. ¿Cómo lo modelas con un semáforo?

Con un semáforo contador que parte en 3:

lugares = 3

// Cada auto
wait(lugares)        // si quedan lugares, toma uno; si no, espera
estacionarse
signal(lugares)      // deja el lugar libre y despierta a uno que espera

Los tres primeros autos entran; el cuarto espera en wait hasta que alguno salga.

En los filósofos comensales, ¿por qué la solución ingenua puede producir un interbloqueo?

Porque cada filósofo toma un tenedor y espera el otro. Si los cinco toman su tenedor izquierdo al mismo tiempo, cada uno espera el derecho, que lo tiene su vecino, que a su vez espera el suyo. Se forma una espera circular y nadie puede avanzar. Es exactamente la situación que se estudia en Interbloqueos.

¿Qué diferencia hay entre signal en un semáforo y signal en una variable de condición?
  • En un semáforo, signal incrementa el contador. Si nadie estaba esperando, el valor queda guardado y el próximo wait pasa de largo: la señal no se pierde.
  • En una variable de condición, signal solo despierta a un proceso si hay alguno esperando. Si no hay nadie, la señal se pierde, y un wait posterior se quedará esperando.
¿Qué garantiza una transacción atómica, y cómo se recupera el sistema después de una caída?

Garantiza que todas sus operaciones ocurren, o ninguna: termina con un commit, que las confirma, o con un rollback, que las deshace.

Para recuperarse, el sistema escribe cada operación en un registro antes de hacerla. Después de una caída, revisa el registro desde el último punto de control: rehace las transacciones que alcanzaron a confirmarse y deshace las que quedaron a medias.


Sigue con Interbloqueos.