Noche de estreno

Noche de estreno

Ejercicio de aprendizaje

Introducción

Atomicidad

Una sola instrucción puede parecer una acción indivisible sin serlo en absoluto. Piensa en sumar cinco a un valor en memoria:

add qword [rel counter], 5

Por debajo, el procesador no tiene una forma de sumar directamente a la memoria. Divide esta única instrucción en tres pasos más pequeños, llamados microoperaciones:

  1. leer el valor actual de la memoria;
  2. modificar ese valor en un registro, sumándole cinco;
  3. escribir el resultado de vuelta.
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5                   ; 2. add five
mov qword [rel counter], rax ; 3. store the result back

Este es un patrón común llamado read-modify-write (RMW).

Fíjate que la lectura (o carga) y la escritura (o almacenamiento) son eventos separados, así que hay una ventana de tiempo entre ellos. Normalmente no lo notamos porque, en un solo núcleo, se garantiza que cada instrucción produce su efecto por completo antes de la siguiente. Por eso esa ventana es invisible y add se comporta como una sola unidad.

Sin embargo, las CPU modernas rara vez tienen un solo núcleo, y las aplicaciones suelen ejecutarse en muchos núcleos a la vez, sin ningún orden entre ellos. Con varios núcleos ejecutándose en el mismo instante, otro núcleo puede leer o escribir counter dentro de esa ventana, después de la carga de este núcleo y antes de su almacenamiento. Dos hilos cargan cada uno el mismo valor antiguo, cada uno le suma cinco y cada uno almacena su resultado. Ocurrieron dos sumas, pero el valor solo avanzó cinco. Una actualización se perdió en silencio.

Esto es una carrera de datos, y es un problema común en el código multihilo.

No todos los valores quedan expuestos de esta manera. Cada hilo tiene sus propios registros y su propia pila, así que un valor que está en un registro, o una variable local en la pila de un hilo, es exclusivo de ese hilo y no puede sufrir una carrera. Solo la memoria que comparten los hilos, como counter arriba, necesita protección.

x86-64 ofrece un conjunto de instrucciones para resolver este problema, haciendo que una instrucción sea indivisible. Se comporta como una sola unidad no solo para el núcleo que la procesa, sino también para todos los demás núcleos. Una operación que se mantiene unida, que ningún otro núcleo puede dividir, se llama atómica.

Note

Es común referirse a las aplicaciones que se ejecutan en muchos núcleos como multihilo. Sin embargo, un hilo no es lo mismo que un núcleo. Dos hilos pueden ejecutarse de forma concurrente en el mismo núcleo, intercalándose entre sí, o en paralelo en núcleos distintos.

Los hilos intercalados ya pueden competir en un read-modify-write si este se divide en varias instrucciones, ya que el sistema operativo puede cambiar de hilo entre dos instrucciones cualesquiera. La ventana dentro de una sola instrucción, sin embargo, solo la expone el código verdaderamente paralelo. Como el sistema operativo solo cambia de hilo entre instrucciones, nunca dentro de una, una sola instrucción es intrínsecamente segura en un solo núcleo.

La verdadera atomicidad en varios núcleos es lo que ofrecen las instrucciones de abajo.

Intercambio atómico

La instrucción xchg intercambia dos operandos. El operando de destino pasa a ser igual al valor anterior del operando de origen, mientras que el operando de origen pasa a ser igual al valor anterior del operando de destino. Conceptualmente, se puede pensar en ella como dos instrucciones mov que ocurren a la vez.

Como es habitual, se puede usar con dos operandos de registro o con un operando de memoria y un operando de registro:

mov  eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx         ; edx = 2, ecx = 3

Cuando se usa con un operando de memoria, xchg es siempre atómica.

Caution

xchg es atómica automáticamente cuando uno de los operandos es una ubicación de memoria. Esto también significa que la operación es mucho más lenta en esa situación.

Si no necesitas atomicidad, haz el intercambio mediante un registro libre con instrucciones mov normales.

El prefijo lock

La forma más común de hacer que una instrucción sea atómica en x86-64 es añadir el prefijo lock. Fusiona la lectura, la modificación y la escritura en un solo paso indivisible. Esto significa que el núcleo retiene la memoria en exclusiva durante todo el proceso, así que ningún otro núcleo puede leer o escribir esa ubicación mientras tanto.

lock add qword [rel counter], 5 ; the read, the modify, and the write are one step

lock solo funciona cuando el destino es memoria, y solo en instrucciones que leen, modifican y escriben esa memoria:

  1. operaciones aritméticas, como add, sub, inc, dec, neg;
  2. operaciones a nivel de bits, como and, or, xor, not;
  3. las operaciones de bits bts, btr, btc;
  4. algunas otras instrucciones dedicadas, como xadd y cmpxchg, que se describen más abajo.
Caution

Mantener una ubicación en exclusiva y dejar fuera a todos los demás núcleos no sale gratis. Una operación con el prefijo lock es notablemente más lenta que su forma normal, y más lenta aún cuando varios núcleos compiten por la misma ubicación.

Este prefijo debería reservarse para la memoria que se espera que modifique más de un hilo. Evita usarlo si la memoria no se comparte o si solo se lee.

Intercambio y suma

Un lock add normal actualiza la memoria, pero descarta el valor antiguo. A menudo el valor antiguo es justo lo que se quiere, por ejemplo para dar a cada hilo un número de turno distinto.

La instrucción xadd (la x de intercambio) devuelve el valor anterior mientras suma. Escribe la suma en el destino y deja el valor original del destino en el registro de origen.

mov  rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
                           ; rax = the old value of [rdi]

Con el prefijo lock, esto es un fetch-and-add atómico. Ejecutado por muchos hilos sobre el mismo contador, cada llamada devuelve un valor antiguo distinto.

Al igual que con lock add, el contador termina en el número exacto de llamadas. Sin embargo, a diferencia de lock add, también se devuelve cada valor intermedio, uno a cada quien la llama.

Comparación e intercambio

xadd suma y xchg sobrescribe, pero ninguna de las dos puede hacer que el valor nuevo dependa del actual y aplicarlo solo si nada cambió por debajo. Esa actualización condicional es lo que ofrece cmpxchg, comparación e intercambio, y es la más general de estas primitivas.

cmpxchg dest, src usa rax como acumulador implícito y lo compara con dest:

  • Si dest == rax, entonces dest = src y ZF = 1.
  • Si dest != rax, entonces rax = dest y ZF = 0.

Fíjate que dest solo se actualiza cuando es igual al valor esperado, que se cargó antes en rax. Esta igualdad garantiza que dest todavía contiene el valor a partir del cual se calculó el nuevo, así que nunca se aplica una actualización basada en una lectura obsoleta. Eso hace de cmpxchg el bloque de construcción de una actualización atómica, también conocida como compare-and-swap (CAS):

    mov rax, qword [rdi]          ; rax = the value we expect to find
.retry:
    lea rcx, [rax + 10]           ; rcx = the new value we want to install
    lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
                                  ; otherwise reload rax with the current value, clear ZF
    jnz  .retry                   ; ZF is cleared, so another thread won the race. Recompute and retry

Este bucle de reintentos es el corazón de las actualizaciones sin bloqueos. La ventana entre la lectura y la comparación e intercambio es exactamente cuando otro hilo podría intervenir, y cmpxchg lo detecta al negarse a almacenar un valor calculado a partir de una lectura obsoleta.

Ordenamiento de memoria

Todas las operaciones hasta ahora han tocado una sola ubicación. Cuando los hilos se coordinan a través de más de una ubicación, aparece una nueva pregunta: ¿en qué orden se vuelven visibles las escrituras de un hilo para otro? Las reglas que responden a esta pregunta son el ordenamiento de memoria del procesador.

El concepto de código sin ramas introdujo la idea de que un núcleo moderno no avanza trabajosamente por las instrucciones de una en una. Mantiene muchas en vuelo a la vez y se adelanta cuando puede. Esto significa que una escritura puede volverse visible para los demás núcleos más tarde de lo que sugiere el programa, mientras que las instrucciones posteriores ya se adelantaron.

x86-64 mantiene un ordenamiento de memoria fuerte entre las cargas y los almacenamientos normales, de modo que en cada núcleo:

  1. una carga nunca se reordena después de una carga posterior;
  2. un almacenamiento nunca se reordena después de un almacenamiento posterior;
  3. una carga nunca se reordena después de un almacenamiento posterior.

El único reordenamiento posible es que un almacenamiento parezca completarse después de una carga posterior de una dirección distinta.

Una instrucción con el prefijo lock, o un xchg con un operando de memoria, es una barrera completa: nada parece cruzar de un lado a otro. Por eso bastan para garantizar un ordenamiento completo en la mayoría de las situaciones.

Espera activa y pause

Las instrucciones que ponen una bandera y además devuelven su estado anterior se conocen como test-and-set. Pueden usarse como base de un spinlock, que garantiza que un núcleo tenga acceso exclusivo a alguna parte del código.

Este es el algoritmo general, usando la instrucción xchg con una bandera binaria:

  1. La bandera empieza en 0.
  2. Para adquirir el bloqueo, un núcleo intercambia el valor de la bandera con 1.
  3. Si el valor devuelto es 1, significa que otro núcleo retiene el bloqueo. Entonces el núcleo actual espera y vuelve a intentar adquirir el bloqueo.
  4. Si el valor devuelto es 0, significa que el bloqueo estaba libre. xchg ya lo puso en 1, y los demás núcleos esperarán hasta que este núcleo lo libere.
  5. Cuando el núcleo actual termina su trabajo, actualizar la bandera con 0 libera el bloqueo.
acquire:
    mov  eax, 1
    xchg dword [rdi], eax ; try to take the lock; eax = its old value
    test eax, eax
    jnz  .held            ; old value was 1: someone else holds it
    ret                   ; old value was 0: the lock is ours
.held:
    pause                 ; wait before trying again
    jmp  acquire

La instrucción pause del bucle de espera no cambia lo que calcula el código. Le indica al procesador que se trata de una espera activa. Así, la CPU puede reducir el consumo de energía del hilo que espera y ceder el paso a un hilo hermano que comparte el mismo núcleo. Un bucle de espera activa sin pause sigue siendo correcto, solo que desperdicia recursos.

Cuando el hilo termina su trabajo, puede liberar el bloqueo con un simple almacenamiento de 0 usando mov. No se necesita nada más que un mov, porque en x86-64 las cargas y los almacenamientos nunca se reordenan después de un almacenamiento posterior. Se dice que un almacenamiento que nunca adelanta a los accesos anteriores tiene ordenamiento de liberación, y en x86 todo almacenamiento normal lo tiene.

Note

Cualquier instrucción que pruebe y establezca atómicamente una ubicación de memoria puede usarse para un spinlock. Por ejemplo, puede usarse lock bts en lugar de xchg, para establecer un bit específico mientras se comprueba si ya estaba establecido.

Fíjate que las banderas, como la CF que modifica bts, son parte de rflags, un registro. Esto significa que son exclusivas de cada hilo.

Instrucciones

Es la noche del estreno en el Grand Marquee, y todo el personal trabaja a la vez. La multitud entra por todas las puertas, varias ventanillas de taquilla venden del mismo rollo de boletos, el estreno está a punto de agotarse y, allá arriba, en la cabina de proyección, los rollos no pueden dejar de girar.

Todo lo que el personal comparte vive en una dirección de memoria, y las pruebas llaman a tus funciones desde varios hilos al mismo tiempo. Un valor que se lee, se modifica y se vuelve a escribir en pasos separados perderá actualizaciones bajo esa carga, así que cada función debe hacer su actualización de forma atómica.

Todos los valores compartidos en este ejercicio son enteros con signo de 64 bits.

Tienes cinco tareas.

Note

Las actualizaciones de este ejercicio deben realizarse con instrucciones atómicas. Las pruebas ejecutan tus funciones de forma concurrente y verifican que no se pierda ninguna actualización, así que una lectura-modificación-escritura no atómica tiene muchas probabilidades de fallarlas.

1. Cuenta las admisiones

Las puertas están abiertas y en cada una hay un acomodador rompiendo boletos. Cada grupo que pasa se cuenta en un mismo recuento compartido de admisiones, y aunque todas las puertas cuenten a la vez, el total de la sala debe seguir siendo correcto.

Implementa la función admit_group, que suma de forma atómica el tamaño de un grupo al recuento.

Esta función recibe como argumentos, en este orden:

  • admissions: dirección de memoria del recuento compartido de admisiones, un entero con signo de 64 bits.
  • group_size: el número de personas del grupo, un entero con signo de 64 bits, siempre mayor que 0.
admissions = 120, group_size = 4
// admissions is now 124

Las pruebas admiten grupos por varias puertas a la vez. El recuento final debe ser igual a la suma de todos los grupos que pasaron.

Esta función no devuelve ningún valor. El recuento debe actualizarse en el mismo lugar.

2. Cambia el rollo

El proyector sostiene exactamente un rollo. Colocar el siguiente rollo y quitar el actual debe ser un solo movimiento: con dos proyeccionistas en la máquina, dos pasos separados podrían entregarle el mismo rollo a los dos, o dejar caer uno al suelo.

Implementa la función change_reel, que coloca de forma atómica el siguiente rollo y devuelve el que estaba colocado.

Esta función recibe como argumentos, en este orden:

  • mounted: dirección de memoria del identificador del rollo colocado, un entero con signo de 64 bits.
  • next_reel: el identificador del rollo que se va a colocar, un entero con signo de 64 bits.
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

Esta función devuelve el identificador del rollo que estaba colocado antes, como un entero con signo de 64 bits. Las pruebas hacen que varios hilos intercambien rollos en el mismo soporte a la vez y verifican que cada rollo que entra salga exactamente una vez.

3. Vende un boleto

Cada ventanilla vende a partir de un mismo recuento compartido de boletos vendidos. Aquí no basta con sumar a un recuento, como hacen las puertas: además, cada venta debe conocer el número que le tocó. Dos ventas no pueden compartir un número y, después de la avalancha, el recuento debe ser exactamente igual al número de boletos vendidos.

Implementa la función sell_ticket, que suma de forma atómica uno al recuento y devuelve el número del boleto que se acaba de vender. El número del boleto es el recuento después de esta venta: cuando el recuento era 41, esta venta es el boleto 42.

Esta función recibe como argumento:

  • tickets_sold: dirección de memoria del recuento compartido de boletos vendidos, un entero con signo de 64 bits.
tickets_sold = 0
// => 1, and tickets_sold is now 1

tickets_sold = 41
// => 42, and tickets_sold is now 42

Esta función devuelve el número del boleto como un entero con signo de 64 bits. Las pruebas venden desde varias ventanillas a la vez: cada número devuelto debe ser distinto y el recuento final debe ser igual al número de ventas.

4. Llena el estreno

El estreno tiene un número fijo de asientos y todas las ventanillas están vendiéndolo. Una venta solo puede concretarse mientras quede un asiento, y la proyección nunca debe sobrevenderse, ni siquiera cuando todas las ventanillas se pelean por el último asiento en el mismo instante. Revisar el recuento y luego sumarle uno en dos pasos separados deja un hueco entre medio, así que la comprobación y la actualización deben tener éxito o fallar juntas.

Implementa la función claim_seat, que reclama de forma atómica el siguiente asiento cuando todavía queda uno libre. Cuando se reclama un asiento, la función devuelve el número del asiento, que es el recuento de asientos ocupados después de esta reclamación. Cuando la proyección está agotada, devuelve 0 y deja el recuento sin cambios.

Esta función recibe como argumentos, en este orden:

  • seats_taken: dirección de memoria del recuento de asientos ya ocupados, un entero con signo de 64 bits, nunca mayor que capacity.
  • capacity: el número de asientos de la proyección, un entero con signo de 64 bits, siempre mayor que 0.
seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1

seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83

seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150

seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150

Esta función devuelve el número del asiento, o 0 cuando la proyección está agotada, como un entero con signo de 64 bits.

5. Cuida la cabina

En la cabina de proyección cabe una sola persona. Una palabra en memoria indica si está ocupada: 0 si está libre, 1 si está ocupada. El personal pasa durante toda la noche, cada uno con una tarea que hacer adentro: firmar el libro de registro, enhebrar el proyector, archivar un rollo. Sea cual sea la tarea, la regla de la cabina es la misma, y hacerla cumplir es tu trabajo: una persona adentro a la vez, desde el comienzo de su tarea hasta el final.

Implementa la función visit_booth, que:

  1. espera hasta que la cabina esté libre;
  2. reclama la cabina;
  3. ejecuta la tarea del visitante;
  4. vuelve a abrir la cabina después de la tarea y antes de retornar.

Mientras otra persona tiene la cabina, debe esperar con educación usando pause.

Esta función recibe como argumentos, en este orden:

  • booth: dirección de memoria de la palabra de ocupación, un entero con signo de 64 bits que es 0 o 1.
  • task: la tarea del visitante, una función que no recibe parámetros y no devuelve ningún valor.

Ten en cuenta que las pruebas ejecutan tareas que observan la cabina y un libro de registro compartido. Esto significa que la cabina debe estar ocupada durante toda la tarea, que cada visita debe ejecutar su tarea exactamente una vez y que, con varios visitantes a la vez, el libro de registro debe quedar exacto.

Caution

La tarea del visitante es una función común que sigue la convención de llamada:

  • puede sobrescribir cualquier registro que guarda quien llama, así que debes guardar todo lo que sigas necesitando después de la tarea en un lugar que la llamada no pueda tocar;
  • también espera que la pila esté alineada a 16 bytes en el punto de la llamada.

Incumplir cualquiera de las dos reglas puede manifestarse como un cierre inesperado en lugar de una comprobación fallida.

Editar en GitHub El enlace se abre en una ventana o una pestaña nuevas
x86-64 Assembly Exercism

¿Todo listo para empezar Noche de estreno?

Regístrate en Exercism para aprender y dominar x86-64 Assembly con 22 conceptos130 ejercicios y mentoría humana real, todo gratis.