Una sola instrucción puede parecer una acción indivisible cuando en realidad no lo es en absoluto. Tomemos como ejemplo sumar cinco a un valor en memoria:
add qword [rel counter], 5
Por debajo, el procesador no dispone de una forma de sumar directamente a la memoria. Divide esta única instrucción en tres pasos más pequeños, llamados microoperaciones:
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 habitual llamado read-modify-write (RMW).
Fíjate en que la lectura (o carga) y la escritura (o almacenamiento) son eventos distintos, así que hay una ventana de tiempo entre ambos.
Normalmente no nos damos cuenta porque, en un solo núcleo, cada instrucción tiene garantizado completar su efecto por entero antes que la siguiente.
Esa ventana es, por tanto, invisible, y add se comporta como una única 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, le suman cinco cada uno y guardan cada uno su resultado.
Se produjeron dos sumas, pero el valor solo avanzó cinco.
Una de las actualizaciones se perdió en silencio.
Esto es una carrera de datos, y es un problema habitual en el código multihilo.
No todos los valores quedan expuestos de esta forma.
Cada hilo tiene sus propios registros y su propia pila, así que un valor guardado 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 única 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.
Es habitual 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, entrelazándose entre sí, o en paralelo en núcleos distintos.
Los hilos entrelazados ya pueden sufrir una carrera 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 única 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 única instrucción es intrínsecamente segura en un solo núcleo.
La verdadera atomicidad entre varios núcleos es lo que proporcionan las instrucciones que siguen.
La instrucción xchg intercambia dos operandos.
El operando destino pasa a ser igual al valor anterior del operando origen, mientras que el operando origen pasa a ser igual al valor anterior del operando destino.
Conceptualmente, puede verse como dos instrucciones mov que ocurren a la vez.
Como es habitual, puede usarse 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.
xchg es automáticamente atómica cuando uno de los operandos es una posició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 a través de un registro libre con instrucciones mov normales.
lock
La forma más habitual de hacer atómica una instrucción en x86-64 es añadir el prefijo lock.
Fusiona la lectura, la modificación y la escritura en un único paso indivisible.
Esto significa que el núcleo retiene la memoria en exclusiva durante todo el proceso, de modo que ningún otro núcleo puede leer ni escribir esa posición entretanto.
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:
add, sub, inc, dec, neg;and, or, xor, not;bts, btr, btc;xadd y cmpxchg, descritas más abajo.Retener una posición de memoria en exclusiva y mantener 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 aún más lenta cuando varios núcleos compiten por la misma posición.
Este prefijo debería reservarse para la memoria que se espera que sea modificada por más de un hilo. Evita usarlo si la memoria no es compartida o si solo se lee.
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 a la vez que suma.
Escribe la suma en el destino y deja el valor original del destino en el registro 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.
Igual que con lock add, el contador acaba en el número exacto de llamadas.
Sin embargo, a diferencia de lock add, también se devuelve cada valor intermedio, uno a cada llamada.
xadd suma y xchg sobrescribe, pero ninguna de las dos puede hacer que el valor nuevo dependa del actual y aplicarlo solo si nada ha cambiado por debajo.
Esa actualización condicional es lo que proporciona cmpxchg, comparar e intercambiar, y es la más general de estas primitivas.
cmpxchg dest, src usa rax como acumulador implícito y lo compara con dest:
dest == rax, dest = src y ZF = 1.dest != rax, rax = dest y ZF = 0.Fíjate en que dest solo se actualiza cuando es igual al valor esperado, previamente cargado en rax.
Esta igualdad garantiza que dest sigue conteniendo el valor a partir del cual se calculó el nuevo, de modo que nunca se aplica una actualización basada en una lectura obsoleta.
Eso convierte a cmpxchg en el bloque básico 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 reintento es el corazón de las actualizaciones sin bloqueo.
La ventana entre la lectura y la operación de comparar e intercambiar es exactamente el momento en que otro hilo podría intervenir, y cmpxchg lo detecta negándose a almacenar un valor calculado a partir de una lectura obsoleta.
Todas las operaciones vistas hasta ahora han tocado una única posición de memoria. Cuando los hilos se coordinan a través de más de una posición, surge una pregunta nueva: en qué orden se hacen visibles las escrituras de un hilo para otro. Las reglas que responden a esa pregunta son la ordenación de memoria del procesador.
El concepto de código sin ramas introdujo la idea de que un núcleo moderno no avanza penosamente por las instrucciones de una en una. Mantiene muchas en vuelo a la vez y se adelanta siempre que puede. Esto significa que una escritura puede hacerse visible para los demás núcleos más tarde de lo que sugiere el programa, mientras que las instrucciones posteriores ya se han adelantado.
x86-64 mantiene una ordenación de memoria fuerte entre las cargas y los almacenamientos ordinarios, de modo que en cada núcleo:
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 moverse a través de ella en ningún sentido.
Por eso bastan para garantizar una ordenación completa en la mayoría de las situaciones.
pause
Las instrucciones que establecen 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:
0.1.1, significa que otro núcleo retiene el bloqueo.
El núcleo actual entonces espera y vuelve a intentar adquirir el bloqueo.0, significa que el bloqueo estaba libre.
xchg lo ha puesto ahora en 1, y los demás núcleos esperarán hasta que este núcleo lo libere.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.
La CPU puede entonces 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 mediante mov.
No hace falta 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 ordenación de liberación, y en x86 todo almacenamiento normal la posee.
Cualquier instrucción que compruebe y establezca atómicamente una posición de memoria puede usarse para un spinlock.
Por ejemplo, lock bts puede usarse en lugar de xchg para establecer un bit concreto a la vez que se comprueba si ya estaba establecido.
Fíjate en que las banderas, como la CF que modifica bts, forman parte de rflags, un registro.
Esto significa que son exclusivas de cada hilo.
Es la noche del estreno en el Grand Marquee y todo el personal trabaja a la vez. El público entra en tropel por todas las puertas, varias taquillas venden del mismo rollo de entradas, el estreno está a punto de agotar las localidades y, arriba, en la cabina de proyección, los rollos no pueden dejar de girar.
Todo lo que comparte el personal vive en una dirección de memoria, y los tests llaman a tus funciones desde varios hilos a la vez. Un valor que se lee, se modifica y se vuelve a escribir en pasos separados perderá actualizaciones con esa carga, así que cada función debe hacer su actualización de forma atómica.
Todos los valores compartidos de este ejercicio son enteros con signo de 64 bits.
Tienes cinco tareas.
Las actualizaciones de este ejercicio deben realizarse con instrucciones atómicas. Los tests ejecutan tus funciones de forma concurrente y comprueban que no se pierda ninguna actualización, así que una lectura-modificación-escritura no atómica tiene muchas probabilidades de fallarlos.
Las puertas están abiertas y en cada una de ellas un acomodador está rasgando entradas. Cada grupo que pasa se cuenta en un recuento de admisiones compartido y, con todas las puertas contando a la vez, el recuento de la sala debe cuadrar igualmente.
Implementa la función admit_group, que suma de forma atómica el tamaño de un grupo al recuento.
Esta función toma como argumentos, en este orden:
admissions: dirección de memoria del recuento de admisiones compartido, 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
Los tests admiten grupos por varias puertas a la vez. El recuento final debe ser igual a la suma de todos los grupos que han pasado.
Esta función no devuelve ningún valor. El recuento debe actualizarse in situ.
El proyector monta exactamente un rollo. Montar el siguiente rollo y sacar el actual debe ser un solo movimiento: con dos proyeccionistas en la máquina, dos pasos separados podrían dar el mismo rollo a los dos, o tirar uno al suelo.
Implementa la función change_reel, que monta de forma atómica el siguiente rollo y devuelve el que estaba montado.
Esta función toma como argumentos, en este orden:
mounted: dirección de memoria del identificador del rollo montado, un entero con signo de 64 bits.next_reel: el identificador del rollo que hay que montar, 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 montado antes, como un entero con signo de 64 bits. Los tests hacen que varios hilos intercambien rollos por el mismo soporte a la vez y comprueban que cada rollo que entra vuelve a salir exactamente una vez.
Cada taquilla vende a partir de un recuento compartido de entradas vendidas. Sumar a un recuento, como hacen las puertas, no basta aquí: cada venta también debe conocer el número que le ha tocado. Ninguna venta puede compartir número con otra y, tras el aluvión, el recuento debe ser exactamente igual al número de entradas vendidas.
Implementa la función sell_ticket, que suma de forma atómica uno al recuento y devuelve el número de la entrada que se acaba de vender.
El número de entrada es el recuento después de esta venta: cuando el recuento era 41, esta venta es la entrada 42.
Esta función toma como argumento:
tickets_sold: dirección de memoria del recuento compartido de entradas vendidas, 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 de entrada como un entero con signo de 64 bits. Los tests venden desde varias taquillas a la vez: cada número devuelto debe ser distinto y el recuento final debe ser igual al número de ventas.
El estreno tiene un número fijo de butacas y todas las taquillas están vendiendo para él. Una venta solo puede realizarse mientras quede una butaca y la sesión nunca debe sobrevenderse, ni siquiera cuando todas las taquillas se lanzan a por la última butaca en el mismo instante. Comprobar el recuento y luego sumarle uno en dos pasos separados deja una ventana de tiempo entre medias, así que la comprobación y la actualización deben tener éxito o fallar juntas.
Implementa la función claim_seat, que reserva de forma atómica la siguiente butaca cuando todavía queda alguna libre.
Cuando se reserva una butaca, la función devuelve el número de la butaca, que es el recuento de butacas ocupadas después de esta reserva.
Cuando la sesión está agotada, devuelve 0 y deja el recuento sin cambios.
Esta función toma como argumentos, en este orden:
seats_taken: dirección de memoria del recuento de butacas ya ocupadas, un entero con signo de 64 bits, nunca superior a capacity.capacity: el número de butacas de la sesió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 de butaca o 0 cuando la sesión está agotada, como un entero con signo de 64 bits.
En la cabina de proyección solo cabe una persona.
Una palabra en memoria indica si está ocupada: 0 si está libre, 1 si está ocupada.
El personal pasa por allí toda la noche, cada uno con una tarea que hacer dentro: firmar el libro de registro, enhebrar el proyector, archivar un rollo.
Sea cual sea la tarea, la regla de la cabina es siempre la misma, y hacerla cumplir es cosa tuya: una sola persona dentro a la vez, desde el principio de su tarea hasta el final.
Implementa la función visit_booth, que:
Mientras otra persona ocupa la cabina, debe esperar amablemente con pause.
Esta función toma 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 toma parámetros y no devuelve ningún valor.Ten en cuenta que los tests ejecutan tareas que miran 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.
La tarea del visitante es una función normal que sigue la convención de llamada:
El incumplimiento de cualquiera de las dos reglas puede manifestarse como un crash en lugar de una comprobación fallida.
Regístrate en Exercism para aprender y dominar x86-64 Assembly con 22 conceptos130 ejercicios y mentoría humana real, todo gratis.