Skip to content

Latest commit

 

History

History
677 lines (463 loc) · 45.2 KB

05-spinlocks.adoc

File metadata and controls

677 lines (463 loc) · 45.2 KB

5. Spinlocks avanzados

05 spinlocks

Las soluciones de exclusión mutua anteriores tienen en común una espera activa que continuamente verifica el estado de un registro. Este tipo de algoritmos se denominan spinlocks; el de Dekker, Peterson, la panadería o cualquiera de las soluciones de exclusión mutua del capítulo anterior son también spinlocks.

Tiene sentido usarlos si se cumple una de las siguientes condiciones:

La sección crítica es muy breve y la competencia es relativamente baja

Los spinlocks se emplean en rutinas críticas de los sistemas operativos y para implementar mecanismos de sincronización sin esperas activas. Tienen en común que sus secciones críticas son breves y que no pueden o no tienen acceso a otros tipos de mecanismos.

Los procesos se ejecutan en paralelo

Mientras unos están en sus spinlocks otros pueden avanzar y salir de la sección crítica. Los spinlocks permiten evitar mayores pérdidas de tiempo ocasionadas por llamadas de sistema o cambios de estado de procesos. No obstante, si hay más procesos concurrentes que CPUs se llega a una situación similar a la de un único procesador: los procesos avanzarán muy lentamente. En estos casos quizás tiene más sentido utilizar construcciones sin esperas activas.

El problema de las soluciones algorítmicas, además de la espera activa, es el número de registros necesarios y la necesidad de iterar en cada uno de ellos. Las primitivas de hardware reducen el impacto eliminando esos requisitos. Es una mejora importante, requieren solo una palabra por cada sección crítica (o lock) y simplifican los algoritmos de sincronización.

Pero no todos los procesadores ofrecen las mismas instrucciones. Para obtener el máximo de eficiencia hay que programar para cada arquitectura (como es habitual en el núcleo de los sistemas operativos). Para facilitar la programación y portabilidad, los compiladores incluyen macros (o intrinsics) que son traducidas a las operaciones que implementan o simulan esas primitivas; son los que usé para los programas de ejemplo[1].

Analizaremos el comportamiento de cada uno de los spinlocks previos. Luego veremos las técnicas y algoritmos que se desarrollaron para reducir sus ineficiencias.

Comparación de las instrucciones de hardware

En la siguiente figura se muestran los tiempos de CPU –en segundos– de los algoritmos del capítulo anterior con diferentes procesadores: Intel i5 con cuatro núcleos, Intel Xeon con dos procesadores independientes de ocho núcleos cada uno (16 en total) y Raspberry Pi 2 con ARMv7 de cuatro núcleos.

Cada grupo de barras es una medición diferente. El primero por la izquierda (none) son los tiempos sin ningún mecanismo de exclusión mutua; el segundo los tiempos del algoritmo de la panadería; el tercero (best) es el más eficiente: el incremento de counter se hace directamente con la instrucción atómica get&add. Los siguientes hacia la derecha son los algoritmos con instrucciones de hardware del capítulo anterior swap, get&add, TAS, get&set y CAS respectivamente.

times hardware
Tiempos de CPU para los diferentes macros e instrucciones de hardware

Los algoritmos de exclusión mutua imponen un sobrecoste importante[2] que no es uniforme en todos los procesadores. En Intel i5 y Xeon de 16 núcleos los que mejores tiempo obtienen son get&set y TAS, en Raspberry Pi 2 es casi un empate entre CAS y get&add. Puede observarse también que con CAS el procesador ARM tiene resultados mejores que los más potentes procesadores Intel. Sorprendente, dado que Intel tiene CAS nativo (cmpxhg) pero en ARM se emula con LL/SC.[3]

Spinlocks óptimos

Las implementaciones anteriores son ineficientes por dos razones:

Uso y competencia por el procesador

Los procesos consumen el 100% CPU verificando el valor de una variable. Si hay más hilos concurrentes que procesadores la mayor parte del tiempo se pierde en el bucle de verificación.

La presión sobre la memoria caché

Estos algoritmos no son escalables. En sistemas con varios procesadores todos los procesos verifican el estado de la misma variable (hot spot), por lo que se generan mensajes extras de sincronización de caché.

Aunque en principio son preferibles los spinlocks escalables, antes veremos técnicas básicas para mejorar el rendimiento de los anteriores. Al final del capítulo estudiaremos los dos algoritmos escalables más importantes: MCS y CLH.

Note
Spinlocks escalables

Se denominan spinlocks escalables aquellos en que los fallos[4] de memoria caché se mantienen constantes, independientemente de las iteraciones necesarias en la entrada a la sección crítica ([MCS1], [Boyd-Wickizer]).

En cada iteración (o spin) los procesos en diferentes procesadores verifican las mismas posiciones de memoria. Cada vez que un proceso cambia el valor se debe invalidar la caché local en cada procesador y propagar el valor actualizado. El problema se agrava por el false sharing, los datos que se modifican en la sección crítica suelen estar en áreas cercanas a las variables de los spinlocks. Cada modificación de esas variables supone un fallo de caché en cada uno de los procesos.

El objetivo de los spinlocks escalables es minimizar el sobrecoste inducido por los fallos. La estrategia es que la espera activa de cada proceso se haga sobre posiciones de memoria diferentes.

Veremos tres técnicas de optimización de spinlocks que se pueden aplicar a cualquier espera activa sobre una variable compartida. Comprobaremos que sus mejoras en eficiencia son considerables.

Verificación local

Se trata de reducir la presión sobre el sistema de coherencia de caché y reducir las llamadas a las relativamente costosas primitivas de sincronización. Antes de ejecutarlas se verifica (o cortocircuita) el valor del registro –mutex en los ejemplos–; si vale 1 ya no hace falta continuar con la instrucción atómica. Cuando se usa con TAS esta estrategia es conocida como TTAS o TATAS; con CAS se llama TCAS.

TATAS
        mutex = 0

def lock():
    while mutex or TAS(mutex):
        pass
TCAS
    mutex = 0

def lock():
    local = 0
    while mutex or not CAS(mutex, local, 1):
        local = 0

Las mejoras dependen de la instrucción atómica y las características del sistema de coherencia de caché. En los tiempos de ejecución se observa que la mejora es notable (algo menor en el procesador ARM), tanto en tiempos de CPU como en tiempos de retorno[5].

Código fuente para TAS, swap y CAS.

Ceder el procesador

Si un proceso debe continuar en la espera activa no tiene sentido volver a comparar inmediatamente si la variable cambió de valor. El tiempo es muy breve, la probabilidad de que la sección crítica se haya liberado es muy baja. En un sistema con un único procesador es aún peor, se seguirá consumiendo CPU hasta que finalice el cuanto y no se dejará avanzar a otro proceso.

Lo más lógico es ceder el procesador para dar más tiempo a que el proceso en la sección crítica avance. Una opción es usar la llamada de sistema sched_yield, el núcleo quita de ejecución al proceso y lo mueve a la cola de listos.

        mutex = 0

def lock():
    while mutex or TAS(mutex):
        sched_yield()

Como puede observarse en los gráficos, la cesión del procesador produce reducciones importantes de tiempos en todas las arquitecturas (código fuente para TAS, swap y CAS).

Espera exponencial

La forma de reducir la competencia y evitar el efecto ping-pong de los procesos pasando de listos a ejecución es bloquearlos por un tiempo variable. Este tipo de esperas se denomina exponential backoff, el tiempo depende de las veces que ha fallado la condición durante la espera activa.

Note
Exponential backoff

Exponential backoff es la técnica usada por redes como Ethernet y WiFi para calcular el tiempo de espera para reenviar una trama después de una colisión. El término backoff se refiere a la espera sin interferir; exponential a que el límite del tiempo de espera se duplica en cada fallo. El tiempo efectivo de espera de cada proceso es un número aleatorio entre 1 y el límite[6].

El siguiente es el código en C usado en los ejemplos, provoca esperas de tiempos que se duplican con cada incremento del valor de failures:

#define FAILURES_LIMIT 12
void backoff(int failures) {
    struct timespec deadline = {.tv_sec = 0};
    unsigned limit;

    if (failures > FAILURES_LIMIT) {
        limit = 1 << FAILURES_LIMIT;
    } else {
        limit = 1 << failures;
    }

    deadline.tv_nsec = 1 + rand() % limit;
    clock_nanosleep(CLOCK_REALTIME, 0, &deadline, NULL);
}

En cada iteración fallida del spinlock el proceso incrementa el contador de fallos (failures) y llama a la función backoff. Esta calcula el límite (limit) con desplazamiento de bits. Cada posición desplazada multiplica por dos desplazando el bit 1 hacia la izquierda con un máximo de 12 posiciones, unos 4096 nanosegundos. Luego se calcula el tiempo que esperará con un número aleatorio entre 1 y el límite.

        mutex = 0

def lock():
    failures = 0

    while mutex or TAS(mutex):
        failures += 1
        backoff(failures)

El problema con el backoff es la elección de la unidad de tiempo y el límite de espera, los valores adecuados dependen de las arquitecturas y casos de uso. Si la espera es muy breve podría producir un efecto ping-pong similar a sched_yield, pero con una sobrecarga mayor del núcleo[7]. Por el contrario, si la unidad es muy grande producirá demoras innecesarias y CPUs inactivas porque todos los procesos están bloqueados.

Sin embargo, la mejora del backoff es general para todos los procesadores probados, tanto en tiempos de CPU como de retorno[8] (en los procesadores Intel la diferencia es importante, en ARM es mínima).

Código fuente para TAS, swap y CAS.

Tiempos de ejecución

A continuación tres gráficas que representan los tiempos de CPU de los diferentes algoritmos en procesadores distintos. Cabe recordar que el ejemplo que usamos –hilos que solo incrementan un contador compartido– es muy extremo. Aunque la sección crítica es muy breve, lo único que hacen es entrar y salir continuamente sin ejecutar código fuera de ella; implica que la competencia es extremadamente elevada y muy lejos de ser un caso realista. Pero nos sirve para tener una base de comparación.

También hay que tener en cuenta que los ejemplos están programados con los macros atómicos de GCC. Estos no generan el código más eficiente para cada arquitectura. Por ejemplo, para ARM los macros de barreras de memoria siempre generan una barrera completa, aunque se haya especificado una barrera release. La solución es programar en ensamblador de la arquitectura, como se hace en el núcleo de los sistemas operativos. Pero este nivel de optimización supera los objetivos de este libro.

optimized intel
Figure 1. Intel i5 cuatro núcleos
optimized xeon
Figure 2. Intel Xeon 16 núcleos
optimized arm7
Figure 3. ARMv7 Raspberry Pi 2 cuatro núcleos

Algunos aspectos que vale la pena destacar:

  • El buen comportamiento y uniformidad de ARM para todas las instrucciones, sobre todo porque se emulan con el LL/SC. En ambas versiones del procesador, ARMv6 y ARMv7 (de Raspberry Pi 1 y 2 respectivamente), CAS es la más eficiente.

  • En las plataformas con varios procesadores sched_yield y backoff producen reducciones de tiempos importantes, incluso cuando el número de procesos concurrentes (cuatro) es igual al número de procesadores. La mejora no se debe solo a la reducción de uso de la CPU; también por las reducción de llamadas a instrucciones de sincronización y a la menor presión sobre el sistema de coherencia de caché[9]. La reducción de la presión al sistema de caché fue el objetivo del estudio de los spinlocks escalables que vemos más adelante.

Tiempos de CPU vs tiempos de reloj

En los análisis anteriores usamos tiempos de CPU, no el tiempo de retorno. ¿Cuál es más representativo o útil? Es una duda razonable.

El tiempo de CPU es útil para conocer efectivamente cuánto cálculo real requieren[10], pero no nos da información sobre cuánto tarda la ejecución. Por ejemplo, con más procesadores se consume más CPU, aunque el tiempo de retorno se haya reducido.

La duda es mayor cuando se analiza la conveniencia de usar yield y backoff. Sabemos que lo más probable es que el consumo de ciclos de CPU en la espera activa se reducirá, pero también que aumentará la carga del núcleo por los cambios de contexto. Sin tener los datos de tiempos de retorno no podemos estar seguros que realmente se ejecuten más rápido.

Intento evitar el exceso de gráficos, pero valía la pena mostrar estos tiempos. En los siguientes se puede observar el tiempo de retorno (medido en tiempo de reloj) de los algoritmos anteriores.

optimized intel real
Figure 4. Tiempos de retorno Intel i5 cuatro núcleos
optimized xeon real
Figure 5. Tiempos de retorno en Intel Xeon 16 núcleos
optimized arm7 real
Figure 6. Tiempos de retorno en ARMv7 de Raspberry Pi 2 cuatro núcleos

Aún en arquitecturas tan diferentes, la cesión del procesador representa una reducción importante de tiempo de CPU y de retorno. La mayor diferencia a favor del backoff ocurre en el Xeon de 16 núcleos. Este tiene más núcleos que procesos concurrentes, por lo que un yield solo hace que un proceso abandone el procesador para que el scheduler lo lleve inmediatamente a ejecución en otro núcleo (depende de los algoritmos de afinidad de CPU). También pudo ocurrir que la unidad de tiempo elegida (un nanosegundo) haya sido más adecuada para el Xeon que para el ARM, a pesar de ello se ganan unos pocos milisegundos.

Tip
Cesión del procesador

Las esperas activas ya son suficientemente malas si no son imprescindibles. A menos que se trate de rutinas críticas del núcleo o un sistema de tiempo real medido y calibrado casi al nivel de instrucciones, es conveniente usar yield o backoff exponencial en los spinlocks con mucha competencia. Esta regla es válida aún cuando parezca que sobran procesadores.

Lectores-escritores

En aplicaciones reales, la mayoría de las operaciones sobre la memoria son lecturas. En estos casos lo importante es que estas sean consistentes. En los ejemplos –un único contador entero– no existe el problema de lectura inconsistente: las palabras de 32 bits son registros atómicos en las arquitecturas modernas de 32 o más bits, si un proceso lee la variable siempre obtendrá el último valor escrito. Para estructuras de mayor tamaño –o para acceder a ficheros o dispositivos externos– hay que imponer restricciones para que la memoria no sea modificada cuando otros procesos la están leyendo.

La solución de exclusión mutua no es la más adecuada, la serialización de los accesos de solo lectura provoca esperas innecesarias. Una de las relajaciones más importantes a las condiciones de la exclusión mutua es que se permita más de un lector en la sección crítica. Estos algoritmos son conocidos como lectores-escritores (reader-writer).

Las condiciones que deben cumplir son:

  • Se permite más de un lector en la sección crítica.

  • Mientras haya un lector en la sección crítica no puede entrar ningún escritor.

  • Los lectores no pueden entrar si hay un escritor en la sección crítica.

  • Solo puede haber un escritor en la sección crítica.

Así como la exclusión mutua tiene un protocolo de entrada (lock) y otro de salida (unlock), los de lectores-escritores necesitan distinguir entre ellos con protocolos diferenciados: reader_lock, writer_lock, reader_unlock y writer_unlock.

El siguiente algoritmo es relativamente simple (código en C), está implementado con las instrucciones CAS y get&add. Se usa una variable global entera mutex como en los algoritmos anteriores, pero el bit más significativo se reserva para indicar si un escritor está en la sección crítica. Los bits restantes se usan para contar el número de lectores, para un entero de 32 bits se permiten hasta 231 lectores[11].

Los lectores primero esperan a que no haya ningún escritor, luego incrementan el número de lectores e intentan hacer el CAS. Si fue posible entran a la sección crítica, caso contrario vuelven a intentar desde el inicio del bucle.

Entrada y salida para lectores
            rw_lock = 0             (1)

def reader_lock():
    while True:
        while rw_lock & 0x80000000: (2)
            pass
        old = rw_lock & 0x7fffffff  (3)
        new = old + 1               (4)
        if CAS(rw_lock, old, new):  (5)
            return


def reader_unlock():
    getAndAdd(rw_lock, -1)          (6)
  1. La variable global mutex, en el ejemplo es de 32 bits.

  2. Verifica si el bit más significativo es 1, si es así hay un escritor e itera hasta que sea 0.

  3. No hay escritores, obtiene el número de lectores.

  4. Incrementa el número de lectores.

  5. Si rw_lock no fue modificado, CAS almacenará el nuevo valor. Si rw_lock fue modificado volverá al inicio del while y lo intentará nuevamente.

  6. Decrementa atómicamente el número de lectores.

Los escritores primero esperan a que no haya otro escritor en la sección crítica, luego ponen el bit más significativo en 1 e intentan el intercambio con CAS. Si no fue posible vuelven a intentarlo desde el principio. Si fue satisfactorio esperan a que no queden lectores para entrar a la sección crítica.

Entrada y salida para escritores
def writer_lock():
    while True:
        while rw_lock & 0x80000000:     (1)
            pass
        old = rw_lock & 0x7fffffff      (2)
        new = old | 0x80000000          (3)
        if CAS(rw_lock, old, new):      (4)
            while rw_lock & 0x7fffffff: (5)
                pass
            return


def writer_unlock():
    rw_lock = 0    (6)
  1. Verifica el bit más significativo e itera hasta que no haya ningún escritor.

  2. Obtiene el número de lectores actuales.

  3. Calcula el nuevo valor, será el número de lectores con el bit más significativo en 1 indicando que hay un escritor.

  4. Si el valor tomado de rw_lock no cambió se almacena el nuevo, caso contrario vuelve al principio del while para reintentar.

  5. Espera que salgan todos los lectores, los siguientes ya no podrán entrar porque el bit más significativo está en 1.

  6. Para salir solo debe poner rw_lock en cero ya que no quedan lectores ni escritores en la sección crítica.

Una característica importante de los algoritmos de lectores-escritores es la prioridad de unos y otros. Si lo que interesa es rendimiento (throughput) y lecturas muy rápidas, es mejor dar prioridad a los lectores. Si interesa que las actualizaciones sean rápidas y acceder a los últimos valores lo antes posible, es mejor usar algoritmos que den prioridad a los escritores. El problema es el riesgo de inanición de los de menor prioridad, aunque hay algoritmos que aseguran equidad los más comunes dan prioridad a uno de ellos ([MCS2]).

Queda a ejercicio del lector encontrar si este algoritmo da prioridad a los lectores o escritores[12].

Spinlocks equitativos

Los algoritmos con instrucciones de hardware anteriores no cumplen uno de los requisitos deseables de la exclusión mutua, asegurar espera limitada. Aunque estadísticamente no se pueden producir esperas infinitas[13], presenta problemas de equidad: unos procesos pueden retrasarse más que otros. Por ejemplo, en 2008 se detectó este efecto en el núcleo de Linux ([Corbet1], [Corbet2]):

En un Opteron con 8 núcleos (2 procesadores), la injusticia de los spinlocks es extremadamente notable, con un test en espacio de usuario se obtienen diferencias de tiempo de CPU de hasta 2 veces por hilo, y mientras algunos hilos sufren inanición a otros se les garantiza el lock hasta 1 000 000 (!) de veces.
— Nick Piggin

Para evitarlo hay que usar algoritmos que aseguran que los procesos entran a la sección crítica en el orden que llegaron (FIFO).

Ticket-lock

Una solución sencilla la hemos descubierto al introducir la instrucción get&add. La idea es la misma que el algoritmo de la panadería solo que la obtención del número se hace con esta operación atómica. Así se evita que los procesos puedan seleccionar el mismo número.

Se usan dos variables: la secuencia creciente de números y el turno. Un proceso obtiene su número y luego espera por su turno. Cuando sale de la sección crítica incrementa el turno para que entre el siguiente proceso.

El código en C de este algoritmo es idéntico al anterior de get&add, para hacerlo más eficiente se unificaron ambas variables en una única estructura de 32 bits: 16 bits para turn y number respectivamente. Con ejecuciones extensas número y turno llegarán hasta 216 y rotarán.

struct tickets {
    uint16_t turn;
    uint16_t number;
};

Lectores-escritores equitativo

Con la base el algoritmo ticket-lock se puede implementar un algoritmo de lectores-escritores equitativo. Se necesitan dos registros diferentes para los turnos, uno para lectores y otro para escritores. El esquema de la estructura es la siguiente:

ticket rw

En C se define de la siguiente forma:

struct ticket_rw {
    uint16_t number;
    union {
        uint32_t combined;
        struct {
            uint16_t writer_turn;
            uint16_t reader_turn;
        };
    };
};

El campo number es similar al algoritmo ticket-lock: writer_turn y reader_turn indicarán los turnos para escritores y lectores respectivamente. Ambas variables serán incrementadas para permitir que entren lectores o escritores de forma equitativa. El orden en que se haga la suma dejará entrar a unos o a otros:

  1. Un lector dará paso a otros lectores en cuanto haya entrado a la sección crítica, permitirá la entrada de escritores cuando haya salido.

  2. Un escritor solo dará el turno a otros lectores o escritores cuando salga de la sección crítica.

Se define el campo combined que incluye a ambos turnos, así se puede asignar a ambos simultáneamente en una única operación atómica. Para el desarrollo del algoritmo suponemos una variable global rw_local del tipo o clase ticket_rw.

Entrada y salida para escritores
def writer_lock():
    number = getAndAdd(rw_lock.number, 1) (1)
    while number != rw_lock.writer_turn:  (2)
        pass
  1. El escritor obtiene su número.

  2. Espera a que sea su turno.

def writer_unlock():
    tmp.writer_turn = rw_lock.writer_turn + 1 (1)
    tmp.reader_turn = rw_lock.reader_turn + 1 (1)
    rw_lock.combined = tmp.combined           (2)
  1. Incrementa el turno para lectores y escritores en una variable temporal.

  2. Asigna atómicamente ambos turnos. Cuando el escritor sale de la sección crítica debe poder entrar el siguiente lector o escritor, por lo tanto, incrementa ambas variables.

Entrada y salida para lectores
def reader_lock:
    number = getAndAdd(rw_lock.number, 1)  (1)

    while number != rw_lock.reader_turn:   (2)
        pass
    rw_lock.reader_turn++                  (3)
  1. El lector obtiene su número.

  2. Espera su turno.

  3. Cuando entró incrementa el turno de lectores para que pueda entrar el siguiente lector. Este hará lo mismo, así puede haber varios lectores en la sección crítica[14].

def reader_unlock:
    getAndAdd(rw_lock.writer_turn) (1)
  1. El lector al salir incrementa el turno de escritor por si el siguiente es uno de ellos. No hace falta incrementar el turno de lectores, ya lo hizo al entrar a la sección crítica.

El algoritmo es equitativo, los procesos entran en el orden en que obtuvieron su número independientemente de que sean lectores o escritores. Los lectores incrementan el turno de lectores inmediatamente, si el siguiente proceso es un escritor ningún lector podrá entrar. Estos esperarán hasta que entre el escritor que tiene el turno y a su salida incremente el turno dando oportunidad de entrada a un lector o escritor.

Spinlocks escalables

Es deseable que los spinlocks sean escalables: el número de invalidaciones de caché (generan fallos de caché, también llamados cache bouncing o cache thrashing) debe ser constante, independientemente del número de procesos o procesadores involucrados. La forma de lograrlo es que cada proceso itere sobre posiciones de memoria diferentes.

Array-lock

La solución es que cada proceso tenga su propia posición en un array de locks inicializados a cero; salvo la primera posición que se inicializará con 1 para que el primer proceso pueda entrar. La posición del último proceso en espera está indicada por la variable tail (también inicializada a cero). Cada proceso obtiene su posición con la operación get&add sobre tail.

La variable que indica si un proceso puede entrar es booleana, usa un único byte. Para evitar el false sharing hay que separar las posiciones por varios bytes. Para ello se define una estructura de mayor tamaño, con un campo de un byte para la verificación. La alternativa equivalente es definir un array con posiciones que no se usarán, solo servirán de relleno (padding).

La siguiente figura es un esquema general del funcionamiento. Las zonas grises del array son las variables booleanas de verificación en el spinlock de cada proceso. Las zonas blancas son el relleno o padding. El proceso en verde está en la sección crítica, los amarillos en espera activa en su posición del array.

array lock
Figure 7. Estructura de array-lock

Thread 0 ya entró en la sección crítica, Thread 1 y Thread 2 esperan verificando el estado de sus respectivas posiciones en el array, tail apunta a la siguiente posición. Cuando Thread 0 salga de la sección crítica cambiará el estado de flag[1] y podrá entrar Thread 1.

La inicialización (en C) es la siguiente:

#define PADDING 32
char flag[NUM_THREADS * PADDING];
int tail;
...
    flag[0] = 1;

Si hay cuatro hilos máximo la dimensión del array será 4 * 32 (128 bytes en total). El cálculo de la posición real (my_index) requiere de una multiplicación y módulo. El algoritmo simplificado (código completo en C) es el siguiente:

def lock(my_index):
    slot = getAndAdd(tail, 1)
    my_index = (slot % NUM_THREADS) * PADDING
    while not flag[my_index]:
        pass
    flag[my_index] = 1


def unlock(my_index):
    next = (my_index + PADDING) % SIZE
    flag[next] = 1;

Este algoritmo también es equitativo, los procesos entran en orden FIFO. Solo requiere la instrucción atómica get&add. Según la bibliografía especializada ([Herlihy12]), se evita el false sharing y por lo tanto es más eficiente que ticket-lock. Analizaremos cuánto hay de verdad más adelante.

MCS Spinlock (1991)

Una estrategia para disminuir la presión sobre la caché es hacer que las esperas activas se hagan sobre una variable local de cada proceso. Así se asegura que no se comparten líneas de caché. Tampoco habrá penalización si la variable del spinlock está próxima a otras variables locales, pueden compartir las misma líneas de cache pero no están compartidas con los otros procesos.

El algoritmo de cola MCS[15] fue descubierto[16] en 1991 por John M. Mellor-Crummey y Michael L. Scott ([MCS1]). Se considera uno de los algoritmos más importantes e influyentes de exclusión mutua, sus autores recibieron el premio Edsger W. Dijkstra Prize in Distributed Computing de 2006.

Algoritmos derivados, conocidos como colas sin exclusión mutua (lock-free queues), son muy usados en librerías runtime y maquinas virtuales, como en los monitores de la máquina virtual de Java y en las librerías java.util.concurrent ([Lea]).

Cada proceso hace la espera activa en su propia posición de memoria. En lugar de un array se usa una lista ordenada FIFO. Cada nodo pertenece a un proceso que espera para entrar a la sección crítica. Para implementar MCS se requieren las operaciones atómicas swap y CAS. Es rápido, equitativo (FIFO) y no necesita asignación previa de memoria (como en array-lock). Los hilos deben pasar como argumento la dirección de un nodo, preferiblemente local para evitar el false sharing.

Cada nodo tiene la siguiente estructura:

struct mcs_spinlock {
    struct mcs_spinlock *next;
    unsigned char locked;
};

El campo next es un puntero al nodo del siguiente proceso en la cola de espera. El campo locked es una variable booleana, será 1 si el proceso debe esperar, o 0 cuando puede entrar a la sección crítica. Cada proceso verifica su propia variable, el que sale de la sección crítica actualiza el campo del siguiente en la cola.

mcs
Figure 8. Cola MCS

En la figura anterior se representa al hilo Thread 0 que ya salió de su sección crítica; Thread 1 está en ella; el siguiente en la cola es Thread 2; el último es Thread 3. Cada uno de los procesos en espera activa verifica el campo locked de su nodo local. La variable tail apunta al último proceso en la cola, si no hay ningún proceso será NULL.

El siguiente es el código en C simplificado del algoritmo[17]:

Note

Disculpas por las largas explicaciones en las leyendas, no tenía sentido hacerlo de otra manera. Este algoritmo, y sobre todo el siguiente, son breves pero complejos y abstractos. No hay otra manera de entenderlos, hay que leer y estudiar el código con paciencia y concentración.

void lock(mcs_spinlock *node) {
    mcs_spinlock *predecessor;

    node->next = NULL;
    node->locked = 1;                (1)
    predecessor = node;              (2)
    predecessor = SWAP(&tail, node); (2)
    if (predecessor != NULL) {       (3)
        predecessor->next = node;    (3)
        while (node->locked);        (4)
    }
    node->locked = 0;
}
  1. Inicialización del nodo, locked se pone en verdadero.

  2. Preparación para el swap, predeccesor apunta inicialmente al nodo actual. Cuando se haga el intercambio, si había un proceso esperando o en la sección crítica predecessor apuntará al nodo de ese proceso, caso contrario será NULL.

  3. Si hay otro proceso hará que su campo next apunte al nodo actual.

  4. Espera activa hasta que el predecesor cambie el estado de locked a falso.

void unlock(mcs_spinlock *node) {
    mcs_spinlock *last;

    if (! node->next) {
        last = node;                     (1)
        if ( CAS(&tail, &last, NULL) ) { (1)
            return;                      (2)
        } else {
            while (! node->next);        (3)
        }
    }
    node->next->locked = 0;              (4)
}
  1. Si next del proceso actual es NULL entonces podría ser el último de la cola; prepara last para hacer el CAS.

  2. Se pudo hacer el intercambio, significa que no hay competencia, retorna sin hacer nada más; el puntero tail valdrá NULL.

  3. Si no se pudo hacer el intercambio, hay un proceso que está ejecutando el lock pero todavía no ejecutó la instrucción predecessor->next = node. Se espera hasta que lo haga.

  4. Se ejecuta solo si había un proceso esperando, en este caso asigna 0 al campo locked de su nodo para que pueda continuar.

Note
Barreras de memoria

En el código C de algunos de los algoritmos se usa thread_fence o store_n para introducir barreras de memoria explícitas. La necesidad de barreras no se menciona en la bibliografía o los artículos científicos citados, pero son necesarias por lo explicado en [barriers]: aunque el sistema de caché sea coherente aún se pueden producir ejecuciones de instrucciones fuera de orden.

Si algunos caminos del protocolo de salida (unlock) no ejecutan ninguna instrucción atómica no habrá barreras de memoria. Puede ocurrir que instrucciones de la sección crítica se ejecuten después de haber superado la salida.

Durante las pruebas y validación del código comprobé que en algunos procesadores se manifestaba esta condición de carrera, en particular con el ARMv7 de Raspberry Pi 2. Preferí mostrar la versión simplificada en estas páginas, pero la versión completa y correcta para todas las arquitecturas en el listado del código fuente.

CLH Spinlock (1993)

Una par de años después de la publicación del algoritmo de MCS, dos grupos descubrieron el CLH de forma independiente: Travis Craig de la Universidad de Washington ([Craig]) y Anders Landin y Eric Hagersten del Instituto Sueco de Ciencias de la Computación ([CLH]).

Como el MCS, este algoritmo también está basado en una cola y es equitativo, pero los punteros son en sentido inverso. Apuntan al proceso con el turno anterior, no al siguiente.

clh
Figure 9. Cola CLH

El algoritmo es breve pero más complejo. Tiene más niveles de indirección[18] y, a diferencia de MCS, los procesos verifican el estado de una variable en el nodo predecesor. Sus ventajas son:

  • Como MCS, la espera activa se hace sobre variables independientes, aunque no necesariamente locales a cada proceso.

  • Solo requiere la instrucción atómica get&set.

  • La memoria de los nodos puede ser gestionada independientemente. Los procesos pueden proveer un nodo a una dirección estática, o puede gestionarlo el propio módulo de spinlocks. [19].

  • Puede ser adaptado a sistemas sin coherencia de caché.

La estructura de cada nodo es similar a MCS:

struct clh_node {
    unsigned char locked;
    struct clh_node *prev;
};

A diferencia de MCS, se debe comenzar con un nodo inicializado sin propietario y la variable tail apuntando a dicho nodo.

Por ejemplo:

struct clh_node lock_node;          (1)
struct clh_node *tail = &lock_node; (2)
  1. El nodo sin propietario.

  2. tail apunta inicialmente a ese nodo.

La versión simplificada del algoritmo en C es la siguiente:

void lock(clh_node *node) {
    clh_node *predecessor;

    node->locked = 1;                    (1)
    node->prev = getAndSet(&tail, node); (2)
    predecessor = node->prev;            (2)
    while (predecessor->locked);         (3)
}
  1. Se almacena al nodo actual como locked, este campo será verificado por el siguiente proceso que pretenda entrar a la sección crítica.

  2. Se obtiene la dirección de tail, indica cuál es el predecesor del proceso actual y se almacena en tail la dirección del nodo actual. El valor que tenía tail se almacena en el campo prev (es el puntero al nodo del proceso anterior) y se hace una copia en predecessor.

  3. Se hace la espera activa sobre el campo locked del nodo anterior, cuando sea falso el proceso actual podrá continuar.

void unlock(clh_node **node) {
    clh_node *pred;
    clh_node *tmp;

    pred = (*node)->prev; (1)
    tmp = *node;          (2)
    *node = pred;         (3)
    tmp->locked = 0;      (4)
}
  1. Se hace una copia del puntero al nodo del proceso anterior (sobre el que este proceso iteró en el lock).

  2. Se hace una copia temporal para no perder la dirección del nodo actual.

  3. El puntero que apuntaba al nodo del proceso actual ahora apuntará al del predecesor. Se podría liberar esa memoria pero en estos ejemplos la reciclamos para no hacer malloc/free en cada lock y unlock.

  4. Se almacena falso en el campo locked del nodo actual, el proceso que está a continuación en la cola podrá entrar a la sección crítica.

Análisis de tiempos de ejecución

Ticket-lock es un algoritmo equitativo muy utilizado pero no es escalable, los procesos verifican la misma posición de memoria. La respuesta es usar un array, además con posiciones de relleno (padding) para evitar el false sharing. Algunos autores proponen que el relleno complete el tamaño de una palabra (cuatro u ocho bytes), otros que sean de mayor longitud para que no compartan líneas de caché.

¿Cuál es la separación apropiada?, depende de la arquitectura, es difícil saber a priori cuál es la mejor para cada una. Depende de muchos factores, el tipo de instrucción, los canales de comunicación para sincronización, o el mecanismo de monitorización de los registros de LL/SC (en las arquitecturas que lo implementan).

Para tomar una decisión informada hice pruebas con diferentes procesadores y tamaños de relleno. La siguiente figura muestra los tiempos de CPU de cada procesador para diferentes tamaños. El eje horizontal muestra la separación entre las diferentes posiciones del array (desde 2 a 256 bytes) y el vertical el tiempo de CPU en segundos.

array paddings
Figure 10. Diferentes tamaños de relleno

En Intel Xeon e i5 los tiempos son constantes, en Raspberry Pi 2 se produce un descenso importante a los 16 y 32 bytes. Para hacer una comparación razonable elegí un relleno de 32 bytes.

En las dos imágenes a continuación se muestran los tiempos comparados de CPU y tiempo de reloj para los algoritmos ticket-lock, array-lock, MCS y CLH.

ticket mcs clh
Figure 11. Ticket-lock vs array-lock vs MCS vs CLH
ticket mcs clh real
Figure 12. Tiempos de retorno

En las arquitecturas modernas no hay demasiada diferencia entre ticket-lock y array-lock, de hecho en Intel Xeon esta última es peor. Además, array-lock necesita más espacio –una palabra más el relleno por cada proceso concurrente– que hay que reservar desde el principio (como en el algoritmo de la panadería), mientras que ticket-lock solo requiere una palabra.

En general MCS y CLH son los más eficientes en tiempos de CPU, pero la diferencia no es considerable. Como array-lock, también requieren más espacio: un nodo por cada proceso activo, aunque la asignación puede ser dinámica y solo cuando se necesita. Esta es una de las razones por la que ticket-lock sigue siendo el spinlock preferido en el núcleo de Linux.

Muchos artículos afirman que CLH es mejor que MCS, aunque en los procesadores probados la diferencia es despreciable y en algunos casos es a peor[20]. La ventaja de CLH es la mayor flexibilidad para gestionar la memoria, puede hacerse en las propias funciones lock y unlock de forma transparente a los procesos.

Recapitulación

Comenzamos con las optimizaciones básicas a spinlocks construidos con las instrucciones de hardware de capítulo anterior. La primera fue agregar un control local a la variable compartida para evitar consumir ciclos con instrucciones más complejas. Esta solución no requiere nada especial ni cambia el estado del proceso.

A continuación vimos dos optimizaciones que sí cambian el estado del proceso, son adecuadas cuando se puede permitir que el proceso en el spinlock abandone el procesador[21]. Ambas soluciones mejoran mucho la eficiencia, tanto en tiempos de CPU como de retorno.

Luego vimos la implementación de lectores-escritores con spinlocks. Este mecanismo es muy común, lo volveremos a ver implementado con otras técnicas en capítulos posteriores. Su utilidad se basa en que las actualizaciones son menos frecuentes que las lecturas, interesa relajar las restricciones de exclusión mutua para permitir mayor concurrencia.

A continuación se introdujo el tema de los spinlocks equitativos (fair). Estos aseguran que los procesos entran a la sección crítica en el orden que llegan (FIFO), se puede demostrar formalmente que no se produce inanición (starvation).

El primer algoritmo fue ticket-lock, basado en las mismas ideas del algoritmo de la panadería. Cada proceso obtiene un número único y creciente que sirve para sincronizar la entrada a la sección crítica mediante un turno que también crece monótonamente. A continuación extendimos este algoritmo para lectores-escritores, que además tiene la propiedad de ser equitativo.

Finalmente, vimos dos algoritmos fundamentales de concurrencia que implementan colas sin exclusión mutua (lock-free queues), MCS y CLH. Ambos son equitativos y escalables, no incrementan la presión sobre el sistema de caché cuando se incrementa el número de procesos. Estos algoritmos funcionan sobre sistemas de caché coherentes, pero hay modificaciones que permiten que sean usados en sistemas no coherentes y en arquitecturas NUMA.

A partir del siguiente capítulo veremos construcciones y abstracciones de más alto nivel. Sus objetivos son evitar las esperas activas y facilitar la programación de mecanismos de sincronización más sofisticados que la exclusión mutua.


1. Salvo el código en ensamblador con ldrex/strex para ARM.
2. Como era de esperar, el algoritmo de la panadería es el menos eficiente.
3. También muestra las buenas propiedades de LL/SC y la complejidad de CAS.
4. No implica que haya producido un error en el sistema, sino que el procesador no tiene una copia actualizada en su memoria caché, por lo que se deben producir intercambios de mensajes para actualizarla al último valor.
5. Se denomina tiempo de retorno al tiempo total que tarda un proceso desde que se creó hasta que acabó. El tiempo de respuesta es el tiempo que transcurre desde que ocurrió un evento que debe ser tratado por el proceso hasta que este empezó a ejecutarse.
6. Se usa un número aleatorio para evitar que todos los procesos reintenten simultáneamente.
7. El proceso pasa de ejecución a bloqueado, luego a listo y nuevamente a ejecución en un tiempo muy breve.
8. Me sorprendió, no esperaba que mejore al yield, y menos por el sobrecoste de lo cálculos de backoff más la transición breve por el estado bloqueado.
9. Puedes hacer la prueba, en la versión de backoff reemplaza el clock_nanosleep por un bucle como for (i = 0; i < limit; i+);+ y verás que se produce también una reducción importante.
10. Es una medida importante, por ejemplo para reducir el consumo de batería en móviles.
11. Es un número muy elevado y puede reducirse a enteros más pequeños pero en las mediciones de tiempo no encontré diferencias favorables.
12. ¡Seguro que no lo has pensado! este algoritmo da prioridad a los escritores. Cuando un escritor desea entrar a la sección crítica pone en 1 el bit más significativo, independientemente del estado y número de lectores. Esto hace que los siguientes lectores deban esperar hasta que el escritor haya entrado y salido.
13. En miles o centenares de miles de iteraciones es extremadamente improbable que nunca le toque a un proceso.
14. No hace falta que la suma se haga con operaciones atómicas ya que solo un lector puede ejecutarla, el siguiente no entra hasta que haya sido incrementada.
15. El nombre MCS son las iniciales de los apellidos los autores.
16. Siempre tengo la duda –no soy el único– de si a los algoritmos son inventados o descubiertos, uso indistintamente ambas dependiendo e influido por el tipo de algoritmo o lo que leí de otros autores.
17. Dada la importancia de manipular punteros en este algoritmo y el siguiente consideré más apropiado mostrar en pseudocódigo C.
18. Se opera sobre las direcciones de memoria de punteros de memoria.
19. Por ejemplo, haciendo malloc en el lock y free del nodo que ya no se usa en el unlock.
20. También hay que aclarar que las diferencias sí pueden ser importantes en sistemas con más procesadores.
21. No suele ser el caso en rutinas del núcleo del sistema operativo o gestores de interrupciones.