Articulo de referencia

Linealizabilidad

En gris, una subhistoria lineal, los procesos que comienzan en b no tienen una historia linealizable porque b0 o b1 pueden completarse en cualquier orden antes de que ocurra b2 ...

En gris, una subhistoria lineal, los procesos que comienzan en b no tienen una historia linealizable porque b0 o b1 pueden completarse en cualquier orden antes de que ocurra b2 .

En la programación concurrente , una operación (o conjunto de operaciones) es linealizable si consiste en una lista ordenada de eventos de invocación y respuesta , que puede extenderse agregando eventos de respuesta de tal manera que:

  1. La lista extendida se puede expresar nuevamente como un historial secuencial (es serializable ).
  2. Esa historia secuencial es un subconjunto de la lista original sin extender.

De manera informal, esto significa que la lista de eventos sin modificar es linealizable si y solo si sus invocaciones fueron serializables, pero algunas de las respuestas del cronograma serial aún no han regresado. [ 1 ]

En un sistema concurrente, los procesos pueden acceder a un objeto compartido simultáneamente. Dado que varios procesos acceden a un mismo objeto, puede darse la situación de que, mientras un proceso accede al objeto, otro modifica su contenido. Una solución a este problema es hacer que un sistema sea linealizable. En un sistema linealizable, aunque las operaciones se superponen en un objeto compartido, cada operación parece ejecutarse instantáneamente. La linealizabilidad es una condición de corrección estricta que restringe las posibles salidas cuando varios procesos acceden a un objeto concurrentemente. Es una propiedad de seguridad que garantiza que las operaciones no finalicen de forma inesperada o impredecible. Si un sistema es linealizable, permite al programador razonar sobre él. [ 2 ]

Historia

La linealizabilidad fue introducida por primera vez como un modelo de consistencia por Herlihy y Wing en 1987. Incluía definiciones más restrictivas de atómico, como "una operación atómica es aquella que no puede ser (o no es) interrumpida por operaciones concurrentes", que suelen ser vagas en cuanto a cuándo se considera que una operación comienza y termina.

Un objeto atómico puede comprenderse de forma inmediata y completa a partir de su definición secuencial, como un conjunto de operaciones que se ejecutan en paralelo y que siempre parecen ocurrir una tras otra; no pueden surgir inconsistencias. En concreto, la linealizabilidad garantiza que los invariantes de un sistema se observen y se conserven en todas las operaciones: si todas las operaciones conservan individualmente un invariante, el sistema en su conjunto también lo hará.

Definición

Un sistema concurrente consiste en un conjunto de procesos que se comunican mediante estructuras de datos u objetos compartidos. La linealización es importante en estos sistemas, donde varios procesos pueden acceder a los objetos simultáneamente y el programador necesita prever los resultados esperados. La ejecución de un sistema concurrente genera un historial , una secuencia ordenada de operaciones completadas.

Un historial es una secuencia de invocaciones y respuestas que un conjunto de hilos o procesos realizan sobre un objeto. Una invocación puede considerarse como el inicio de una operación, y la respuesta como la señal de finalización de dicha operación. Cada invocación de una función tendrá una respuesta posterior. Esto puede utilizarse para modelar cualquier uso de un objeto. Supongamos, por ejemplo, que dos hilos, A y B, intentan obtener un bloqueo, desistiendo si ya está ocupado. Esto se modelaría como la invocación de la operación de bloqueo por ambos hilos, seguida de la recepción de una respuesta por cada hilo, uno exitoso y otro no.

Un historial secuencial es aquel en el que todas las invocaciones tienen respuestas inmediatas; es decir, se considera que la invocación y la respuesta ocurren instantáneamente. Un historial secuencial debería ser fácil de comprender, ya que no presenta concurrencia real; el ejemplo anterior no era secuencial y, por lo tanto, resulta difícil de analizar. Aquí es donde entra en juego la linealización.

Una historia es linealizable si existe un orden lineal.σ{\displaystyle \sigma }de las operaciones completadas de tal manera que:

  1. Por cada operación completada enσ{\displaystyle \sigma }La operación devuelve el mismo resultado en la ejecución que si cada operación se completara una por una en orden.σ{\displaystyle \sigma }.
  2. Si una operación op 1 se completa (recibe una respuesta) antes de que op 2 comience (se invoque), entonces op 1 precede a op 2 enσ{\displaystyle \sigma }. [ 1 ]

En otras palabras:

  • Sus invocaciones y respuestas pueden reordenarse para generar una historia secuencial;
  • que la historia secuencial es correcta según la definición secuencial del objeto;
  • Si una respuesta precedió a una invocación en el historial original, aún debe precederla en la reordenación secuencial.

Nótese que los dos primeros puntos coinciden con la serializabilidad : las operaciones parecen ocurrir en algún orden. Es el último punto el que es exclusivo de la linealizabilidad y, por lo tanto, constituye la principal contribución de Herlihy y Wing. [ 1 ]

Consideremos dos maneras de reordenar el ejemplo de bloqueo anterior.

Reordenar la invocación de B tras la respuesta de A da como resultado un historial secuencial. Esto es fácil de comprender, ya que todas las operaciones se suceden en un orden obvio. Sin embargo, no coincide con la definición secuencial del objeto (no coincide con la semántica del programa): A debería haber obtenido el bloqueo correctamente y B debería haber abortado posteriormente.

Esta es otra historia secuencial correcta. También es una linealización, ya que coincide con la definición secuencial. Cabe señalar que la definición de linealizabilidad solo impide que las respuestas que preceden a las invocaciones se reordenen; dado que la historia original no tenía respuestas antes de las invocaciones, estas pueden reordenarse. Por lo tanto, la historia original es, en efecto, linealizable.

Un objeto (a diferencia de un historial) es linealizable si todos los historiales válidos de su uso pueden linealizarse. Esta afirmación es mucho más difícil de demostrar.

Linealizabilidad versus serializabilidad

Consideremos la siguiente historia, de nuevo de dos objetos que interactúan con una cerradura:

Este historial no es válido porque existe un punto en el que tanto A como B poseen el bloqueo; además, no se puede reordenar a un historial secuencial válido sin violar la regla de ordenación. Por lo tanto, no es linealizable. Sin embargo, bajo serializabilidad, la operación de desbloqueo de B puede moverse antes del bloqueo original de A, lo que constituye un historial válido (suponiendo que el objeto comienza el historial en un estado bloqueado):

Esta reordenación es sensata siempre que no exista otro medio de comunicación entre A y B. La linealización es mejor al considerar los objetos individualmente, ya que las restricciones de reordenación garantizan que, en conjunto, varios objetos linealizables sigan siéndolo.

Puntos de linealización

Esta definición de linealizabilidad es equivalente a la siguiente:

  • Todas las llamadas a funciones tienen un punto de linealización en algún instante entre su invocación y su respuesta.
  • Todas las funciones parecen producirse instantáneamente en su punto de linealización, comportándose según lo especificado por la definición secuencial.

Esta alternativa suele ser mucho más fácil de demostrar. También es mucho más fácil de comprender para el usuario, en gran medida debido a su intuición. Esta propiedad de ocurrir instantáneamente, o indivisiblemente, lleva al uso del término atómico como alternativa al término más largo "linealizable". [ 1 ]

En los ejemplos siguientes, el punto de linealización del contador construido mediante la operación de comparación e intercambio coincide con el punto de linealización de la primera (y única) actualización exitosa mediante dicha operación. El contador construido mediante bloqueo puede considerarse linealizado en cualquier momento mientras se mantienen los bloqueos, ya que cualquier operación potencialmente conflictiva queda excluida de la ejecución durante ese período.

Instrucciones atómicas primitivas

Los procesadores cuentan con instrucciones que permiten implementar algoritmos de bloqueo , sin bloqueo y sin espera . La capacidad de inhibir temporalmente las interrupciones , asegurando que el proceso en ejecución no pueda cambiar de contexto , también es suficiente en un monoprocesador . Estas instrucciones son utilizadas directamente por los desarrolladores de compiladores y sistemas operativos , pero también se abstraen y se exponen como bytecodes y funciones de biblioteca en lenguajes de alto nivel.

La mayoría de los procesadores incluyen operaciones de escritura que no son atómicas con respecto a la memoria. Estas incluyen escrituras de varias palabras y operaciones con cadenas de caracteres. Si se produce una interrupción de alta prioridad cuando se completa una parte de la escritura, la operación debe finalizar cuando se devuelve el nivel de interrupción. La rutina que procesa la interrupción no debe modificar la memoria que se está modificando. Es importante tener esto en cuenta al escribir rutinas de interrupción.

Cuando hay varias instrucciones que deben completarse sin interrupción, se utiliza una instrucción de la CPU que desactiva temporalmente las interrupciones. Esto debe limitarse a unas pocas instrucciones y las interrupciones deben reactivarse para evitar tiempos de respuesta inaceptables o incluso la pérdida de interrupciones. Este mecanismo no es suficiente en un entorno multiprocesador, ya que cada CPU puede interferir con el proceso independientemente de si se producen interrupciones o no. Además, en presencia de una segmentación de instrucciones , las operaciones ininterrumpibles presentan un riesgo de seguridad, ya que potencialmente pueden encadenarse en un bucle infinito para crear un ataque de denegación de servicio , como en el caso del error de coma de Cyrix .

El estándar C y SUSv3 permiten sig_atomic_tlecturas y escrituras atómicas simples; no se garantiza que el incremento o decremento sea atómico. [ 3 ] En C11 se encuentran disponibles operaciones atómicas más complejas stdatomic.h. Los compiladores utilizan las características del hardware o métodos más complejos para implementar las operaciones; un ejemplo es libatomic de GCC.

El conjunto de instrucciones ARM proporciona LDREXinstrucciones STREXque se pueden usar para implementar el acceso atómico a la memoria mediante el uso de monitores exclusivos implementados en el procesador para rastrear los accesos a la memoria para una dirección específica. [ 4 ] Sin embargo, si se produce un cambio de contexto entre las llamadas a LDREXy STREX, la documentación indica que STREXfallará, lo que indica que la operación debe reintentarse. En el caso de la arquitectura ARMv8-A de 64 bits, proporciona LDXRinstrucciones STXRpara tamaños de byte, media palabra, palabra y doble palabra. [ 5 ]

Operaciones atómicas de alto nivel

La forma más sencilla de lograr la linealización es ejecutar grupos de operaciones primitivas en una sección crítica . Estrictamente hablando, se puede permitir cuidadosamente que las operaciones independientes se solapen en sus secciones críticas, siempre que esto no viole la linealización. Este enfoque debe equilibrar el costo de un gran número de bloqueos con los beneficios de un mayor paralelismo.

Otro enfoque, preferido por los investigadores (aunque todavía no muy extendido en la industria del software), consiste en diseñar un objeto linealizable utilizando las primitivas atómicas nativas que proporciona el hardware. Esto tiene el potencial de maximizar el paralelismo disponible y minimizar los costes de sincronización, pero requiere demostraciones matemáticas que muestren que los objetos se comportan correctamente.

Una prometedora combinación de estas dos técnicas consiste en proporcionar una abstracción de memoria transaccional . Al igual que con las secciones críticas, el usuario marca el código secuencial que debe ejecutarse de forma aislada de otros hilos. La implementación garantiza entonces que el código se ejecute de forma atómica. Este estilo de abstracción es común al interactuar con bases de datos; por ejemplo, al usar el Spring Framework , anotar un método con @Transactional garantiza que todas las interacciones con la base de datos se produzcan dentro de una única transacción . La memoria transaccional va un paso más allá, asegurando que todas las interacciones con la memoria se produzcan de forma atómica. Al igual que con las transacciones de base de datos, surgen problemas con respecto a la composición de transacciones, especialmente entre las transacciones de base de datos y las transacciones en memoria.

Un tema común al diseñar objetos linealizables es proporcionar una interfaz de todo o nada: o una operación se completa con éxito o falla y no hace nada. ( Las bases de datos ACID se refieren a este principio como atomicidad ). Si la operación falla (generalmente debido a operaciones concurrentes), el usuario debe volver a intentarlo, normalmente realizando una operación diferente. Por ejemplo:

  • La operación de comparación e intercambio escribe un nuevo valor en una ubicación solo si el contenido de esta coincide con un valor anterior proporcionado. Esto se usa comúnmente en una secuencia de lectura-modificación-CAS: el usuario lee la ubicación, calcula un nuevo valor para escribir y lo escribe con una operación CAS (comparación e intercambio); si el valor cambia simultáneamente, la operación CAS fallará y el usuario lo intentará de nuevo.
  • El patrón Load-link/Store-conditional se codifica de forma más directa: el usuario lee la ubicación con load-link, calcula un nuevo valor para escribir y lo escribe con store-conditional; si el valor ha cambiado simultáneamente, el SC (store-conditional) fallará y el usuario lo intentará de nuevo.
  • En una transacción de base de datos , si la transacción no se puede completar debido a una operación concurrente (por ejemplo, en un interbloqueo ), la transacción se abortará y el usuario deberá intentarlo de nuevo.

Ejemplos

Contadores

Para demostrar el poder y la necesidad de la linealización, consideraremos un contador simple que diferentes procesos pueden incrementar.

Nos gustaría implementar un objeto contador al que puedan acceder múltiples procesos. Muchos sistemas comunes utilizan contadores para llevar un registro de la cantidad de veces que ha ocurrido un evento.

El objeto contador puede ser accedido por múltiples procesos y tiene dos operaciones disponibles.

  1. Incremento: suma 1 al valor almacenado en el contador y devuelve una confirmación.
  2. Leer: devuelve el valor actual almacenado en el contador sin modificarlo.

Intentaremos implementar este objeto contador utilizando registros compartidos .

Nuestro primer intento, que veremos que no es linealizable, tiene la siguiente implementación utilizando un registro compartido entre los procesos.

No atómico

La implementación ingenua y no atómica:

Incremento:

  1. Lee el valor en el registro R.
  2. Suma uno al valor
  3. Escribe el nuevo valor de nuevo en el registro R.

Leer:

Leer el registro R

Esta sencilla implementación no es linealizable, como demuestra el siguiente ejemplo.

Imaginemos que dos procesos se están ejecutando y acceden al mismo objeto contador inicializado con el valor 0:

  1. El primer proceso lee el valor del registro como 0.
  2. El primer proceso suma uno al valor, el valor del contador debería ser 1, pero antes de que termine de escribir el nuevo valor en el registro puede quedar suspendido, mientras tanto el segundo proceso se está ejecutando:
  3. El segundo proceso lee el valor del registro, que sigue siendo igual a 0;
  4. El segundo proceso añade uno al valor;
  5. El segundo proceso escribe el nuevo valor en el registro; ahora el registro tiene el valor 1.

El segundo proceso ha finalizado su ejecución y el primer proceso continúa ejecutándose desde donde se interrumpió:

  1. El primer proceso escribe 1 en el registro, sin saber que el otro proceso ya ha actualizado el valor del registro a 1.

En el ejemplo anterior, dos procesos invocaron un comando de incremento; sin embargo, el valor del objeto solo aumentó de 0 a 1, en lugar de a 2 como debería. Una de las operaciones de incremento se perdió debido a que el sistema no era linealizable.

El ejemplo anterior muestra la necesidad de reflexionar detenidamente sobre las implementaciones de estructuras de datos y cómo la linealización puede afectar la corrección del sistema.

Atómico

Para implementar un objeto contador linealizable o atómico, modificaremos nuestra implementación anterior de modo que cada proceso P i utilice su propio registro R i . Esto es similar a cómo funcionan los CRDT de contador de solo crecimiento .

Cada proceso se incrementa y lee según el siguiente algoritmo:

Incremento:

  1. Leer el valor en el registro R i .
  2. Suma uno al valor.
  3. Escriba el nuevo valor de vuelta en R i

Leer:

  1. Lea los registros R 1, R 2, ... R n .
  2. Devuelve la suma de todos los registros.

Esta implementación resuelve el problema de nuestra implementación original. En este sistema, las operaciones de incremento se linealizan en la etapa de escritura. El punto de linealización de una operación de incremento se produce cuando dicha operación escribe el nuevo valor en su registro R i. Las operaciones de lectura se linealizan hasta un punto del sistema en el que el valor devuelto por la lectura es igual a la suma de todos los valores almacenados en cada registro R i.

Este es un ejemplo trivial. En un sistema real, las operaciones pueden ser más complejas y los errores introducidos, extremadamente sutiles. Por ejemplo, leer un valor de 64 bits de la memoria puede implementarse como dos lecturas secuenciales de dos ubicaciones de memoria de 32 bits . Si un proceso solo ha leído los primeros 32  bits y, antes de leer los siguientes 32  , el valor en memoria cambia, no tendrá ni el valor original ni el nuevo, sino un valor mezclado.

Además, el orden específico en que se ejecutan los procesos puede cambiar los resultados, lo que dificulta la detección, reproducción y depuración de dicho error .

Comparar e intercambiar

La mayoría de los sistemas proporcionan una instrucción atómica de comparación e intercambio que lee de una ubicación de memoria, compara el valor con uno "esperado" proporcionado por el usuario y escribe un valor "nuevo" si coinciden, indicando si la actualización se realizó correctamente. Podemos usar esto para corregir el algoritmo de contador no atómico de la siguiente manera:

  1. Lee el valor en la ubicación de memoria;
  2. sumar uno al valor;
  3. Utilice la función de comparación e intercambio para volver a escribir el valor incrementado;
  4. Reintentar si el valor leído por la función de comparación e intercambio no coincide con el valor que leímos originalmente.

Dado que la operación de comparación e intercambio se produce (o parece producirse) instantáneamente, si otro proceso actualiza la ubicación mientras estamos en curso, la operación de comparación e intercambio tiene garantizado el fracaso.

Obtener e incrementar

Muchos sistemas proporcionan una instrucción atómica de búsqueda e incremento que lee de una ubicación de memoria, escribe incondicionalmente un nuevo valor (el valor anterior más uno) y devuelve el valor anterior. Podemos usar esto para corregir el algoritmo de contador no atómico de la siguiente manera:

  1. Utilice la función de obtención e incremento para leer el valor anterior y escribir de nuevo el valor incrementado.

El uso de fetch-and-increment siempre es mejor (requiere menos referencias a memoria) para algunos algoritmos —como el que se muestra aquí— que compare-and-swap, [ 6 ] aunque Herlihy demostró anteriormente que compare-and-swap es mejor para otros algoritmos que no se pueden implementar en absoluto utilizando solo fetch-and-increment. Por lo tanto, los diseños de CPU con fetch-and-increment y compare-and-swap (o instrucciones equivalentes) pueden ser una mejor opción que aquellos con solo uno de ellos. [ 6 ]

Cierre

Otro enfoque consiste en convertir el algoritmo ingenuo en una sección crítica , impidiendo que otros hilos lo interrumpan mediante un bloqueo . Una vez más, se corrige el algoritmo de contador no atómico:

  1. Adquiera un bloqueo que impida que otros subprocesos ejecuten la sección crítica (pasos 2 a 4) simultáneamente;
  2. leer el valor en la ubicación de memoria;
  3. sumar uno al valor;
  4. escribir el valor incrementado de vuelta a la ubicación de memoria;
  5. Suelta el candado.

Esta estrategia funciona como se espera; el bloqueo impide que otros hilos actualicen el valor hasta que se libere. Sin embargo, en comparación con el uso directo de operaciones atómicas, puede sufrir una sobrecarga significativa debido a la contención de bloqueos. Para mejorar el rendimiento del programa, podría ser conveniente reemplazar secciones críticas simples con operaciones atómicas para la sincronización sin bloqueo (como acabamos de hacer con el contador mediante comparación e intercambio, y obtención e incremento), en lugar de al revés. Lamentablemente, no se garantiza una mejora significativa y los algoritmos sin bloqueo pueden volverse demasiado complejos como para justificar el esfuerzo.

Véase también

Referencias

  1. 1 2 3 4 Herlihy, Maurice P.; Wing, Jeannette M. (1990). "Linearizabilidad: una condición de corrección para objetos concurrentes". ACM Transactions on Programming Languages ​​and Systems . 12 (3): 463– 492. CiteSeerX 10.1.1.142.5315 . doi : 10.1145/78969.78972 . S2CID 228785 .  
  2. Shavit, Nir; Taubenfel, Gadi (2016). "La computabilidad de estructuras de datos relajadas: colas y pilas como ejemplos" (PDF) . Computación distribuida . 29 (5): 396– 407. doi : 10.1007/s00446-016-0272-0 . S2CID 16192696 . 
  3. Kerrisk, Michael (7 de septiembre de 2018). La interfaz de programación de Linux . No Starch Press. ISBN 9781593272203 vía Google Libros.
  4. "Artículo sobre el desarrollo de primitivas de sincronización ARM" .
  5. "Primitivas de sincronización ARMv8-A" . pág. 6. Consultado el 14 de diciembre de 2023 . 
  6. 1 2 Fich, Faith; Hendler, Danny; Shavit, Nir (2004). «Sobre la debilidad inherente de las primitivas de sincronización condicional». Actas del vigésimo tercer simposio anual de la ACM sobre Principios de computación distribuida – PODC '04 . Nueva York, NY: ACM. págs. 80–87 . doi : 10.1145/1011767.1011780 . ISBN  978-1-58113-802-3. S2CID 9313205 . 

Lecturas adicionales

  • Herlihy, Maurice P.; Wing, Jeannette M. (1987). «Axiomas para objetos concurrentes». Actas del 14.º simposio ACM SIGACT-SIGPLAN sobre principios de lenguajes de programación - POPL '87 . págs. 13-26 . doi : 10.1145/41625.41627 . ISBN  978-0-89791-215-0. S2CID 16017451 . 
  • Herlihy, Maurice P. (1990). Una metodología para implementar estructuras de datos altamente concurrentes . Vol.  25. pp. 197–206 . CiteSeerX 10.1.1.186.6400 . doi : 10.1145/99164.99185 . ISBN   978-0-89791-350-8.{{cite book}}: |journal=ignorado ( ayuda )
  • Herlihy, Maurice P.; Wing, Jeannette M. (1990). "Linearizabilidad: una condición de corrección para objetos concurrentes". ACM Transactions on Programming Languages ​​and Systems . 12 (3): 463– 492. CiteSeerX 10.1.1.142.5315 . doi : 10.1145/78969.78972 . S2CID 228785 .  
  • Aphyr. "Modelos de consistencia fuerte" . aphyr.com . Aphyr . Consultado el 13 de abril de 2018 .