Libro - Programacion Concurrente (Traducido) (Andrews)
Libro - Programacion Concurrente (Traducido) (Andrews)
-----------------------------------------
Conceptos básicos
Un programa concurrente especifica dos o más procesos que cooperan para realizar una
tarea. Cada proceso es un programa secuencial que ejecuta una secuencia de sentencias. Los procesos
cooperan por comunicación: se comunican usando variables compartidas o pasaje de mensajes. Cuando
se usan variables compartidas, un proceso escribe en una variable que es leída por otra. Cuando se usa
pasaje de mensajes, un proceso envía un mensaje que es recibido por el otro.
Los programas concurrentes son inherentemente más complejos que los secuenciales. En
muchos aspectos, son a los programas secuenciales como el ajedrez a las damas: ambos son
interesantes, pero el primero es intelectualmente más intrigante. El libro explora el “juego” de la
programación concurrente, observando sus reglas, piezas de juego y estrategias. Las reglas son
herramientas formales que nos ayudan a entender y desarrollar programas correctos; las piezas son los
mecanismos de los lenguajes para describir computaciones concurrentes; y las estrategias son conjuntos
de paradigmas de programación útiles.
Hardware y aplicaciones
La historia de la programación concurrente siguió las mismas etapas que otras áreas
experimentales de la ciencia de la computación. Así como el hardware fue cambiando en respuesta a los
cambios tecnológicos, las aproximaciones ad-hoc iniciales evolucionaron hacia técnicas de programación
generales.
Hay muchos ejemplos de programas concurrentes además de los SO. Se dan cuando la
implementación de una aplicación involucra paralelismo real o aparente. Por ej, se usan programas
concurrentes para implementar:
Página N° 1
Programación Concurrente - Cap. 1. -----------------------------------------
La última clase de programa concurrente suele llamarse programa paralelo dado que
típicamente es ejecutado en un multiprocesador. Un programa distribuido es un programa concurrente (o
paralelo) en el cual los procesos se comunican por pasaje de mensajes.
S0 → S1 → .... → Si → ..... a1 a2
ai ai+1
Cada ejecución de un programa concurrente produce una historia. Aún para los programas
más triviales el posible número de historias es enorme. Cada proceso tomará distintas acciones en
respuesta a diferentes estados iniciales.
Propiedades de programa
Página N° 2
Programación Concurrente - Cap. 1. -----------------------------------------
Dado un programa y una propiedad deseada, cómo podemos demostrar que el programa
satisface la propiedad? Una aproximación es testear o debuggear, lo cual puede ser caracterizado como
“tome el programa y vea que sucede”. Esto corresponde a enumerar algunas de las posibles historias de
un programa y verificar que son aceptables. El defecto del testeo es que cada test considera solo una
historia de ejecución específica; un test no puede demostrar la ausencia de historias malas.
Programación Secuencial
Los programas concurrentes extienden los programas secuenciales con mecanismos para
especificar concurrencia, comunicación y sincronización. El objetivo es entender cómo construir programas
concurrentes correctos. Aquí se mostrará como construir programas secuenciales correctos.
Página N° 3
Programación Concurrente - Cap. 1. -----------------------------------------
Declaraciones Los tipos básicos son bool, int, real, char, string y enumeración.
El identificador idi es el nombre de una variable con tipo de datos tipoi y un valor inicial
opcional valori . Una constante es una clase especial de variable; se le asigna valor una sola vez al
declararla. La forma de una declaración constante es la misma que para las variables, excepto que var es
reemplazado por const.
Sentencias
La sentencia de swap es una clase especial de asignación que intercambia los valores de
dos variables. Si v1 y v2 son variables del mismo tipo, entonces
v1 :=: v2
Las sentencias de alternativa (if) e iterativa (do) contienen una o más sentencias
guardadas, cada una de la forma:
B→S
if B1 → S1
...
Página N° 4
Programación Concurrente - Cap. 1. -----------------------------------------
Bn → Sn fi
if x ≥ y → m := x y ≥
x → m := y fi
Si ambas guardas son verdaderas (en el ejemplo, x=y), la elección de cuál se ejecuta es no
determinística. Si ninguna guarda es verdadera, la ejecución del if no tiene efecto. Por ejemplo:
if x < 0 → x := -x fi
setea x a su valor absoluto. Si x es no negativo no se ejecuta ninguna sentencia; en otro
caso, el signo de x se invierte. El programador puede hacer esto explícito usando skip y escribiendo:
if x < 0 → x := -x x ≥ 0 → skip fi
La sentencia iterativa es similar a la alternativa, excepto que las sentencias guardadas son
evaluadas y ejecutadas repetidamente hasta que todas las guardas sean falsas. La forma del do es:
do B1 → S1 ...
Bn → Sn od
Como en el if, las guardas se evaluadas en algún orden arbitrario. Si al menos una guarda
es verdadera, se ejecuta la correspondiente sentencia, y se repite el proceso de evaluación. Como el if, el
do es no determinístico si más de una guarda es verdadera. La ejecución termina cuando no hay más
guardas verdaderas.
La sentencia for-all es una forma especial y compacta de la sentencia iterativa que es útil
para iterar a través de los elementos de un arreglo. Su estructura es:
fa cuantificadores → sentencias af
Si hay más de un cuantificador, se separan con comas. En este caso, el cuerpo del fa se
ejecuta para cada combinación de valores de las variables de iteración, con la variable más a la derecha
variando más rápidamente. Asumiendo que cada iteración del cuerpo del fa termina, los valores finales de
las variables de iteración son uno más que los valores finales especificados en el cuantificador. Como
Se usa una sentencia de swap dentro del cuerpo para intercambiar elementos. El valor
inicial de la segunda variable de iteración depende de la primera, para evitar intercambios redundantes.
Como segundo ejemplo, la siguiente sentencia ordena un arreglo de enteros a[1:n] en orden ascendente:
Página N° 5
Programación Concurrente - Cap. 1. -----------------------------------------
Procedures Un procedure define un patrón parametrizado para una operación. Su forma general es:
declaraciones
sentencias end
El identificador p es el nombre del procedure. Los fi son los nombres de los parámetros
formales; los ti son los tipos correspondientes. La parte de returns es una especificación opcional del
nombre y el tipo de la variable de retorno. El cuerpo de un procedure contiene declaraciones de variables
locales y sentencias que implementan las acciones del procedure.
Un procedure que no tiene una parte de retorno es invocado explícitamente por medio de la
sentencia call:
x : = p( e1, ...., en )
Cuando se invoca un procedure, los parámetros reales son evaluados y luego pasados al
procedure por valor (copy in), por valor/resultado (copy in, copy out) o por resultado (copy out). Un
parámetro por valor se indica por la palabra clave val en la especificación del parámetro formal (es el
default). Un parámetro por valor/resultado se indica por var, y los parámetros resultado se indican por res.
Consideremos el siguiente problema llamado búsqueda lineal. Dado un arreglo a[1:n], con
n>0; un valor x que se sabe que está en a, quizás más de una vez; el problema es computar el índice i de
la primera ocurrencia de x en a, es decir, el valor más chico de i tal que a[i]=x. Podemos resolver este
problema por el siguiente programa obvio:
var i := 1 do a[i] ≠ x → i := i
+ 1 od
Cómo puedo probar que este programa resuelve correctamente el problema? El punto en
cualquier prueba es proveer evidencia convincente de la correctitud de alguna sentencia. Para este
problema específico, la descripción en lenguaje corriente y el programa son probablemente evidencia
convincente para alguien que entiende el lenguaje corriente y programación. Pero con frecuencia las
sentencias en lenguaje corriente son ambiguas. También, la mayoría de los programas (especialmente los
concurrentes) son largos y complejos. Entonces es necesario tener un marco más riguroso.
• un conjunto de símbolos
• un conjunto de fórmulas construidas a partir de estos símbolos
Página N° 6
Programación Concurrente - Cap. 1. -----------------------------------------
Las fórmulas son secuencias bien formadas de símbolos. Los axiomas son fórmulas
especiales que a priori se asumen verdaderas. Las reglas de inferencia especifican cómo derivar fórmulas
verdaderas adicionales a partir de axiomas y otras fórmulas verdaderas.
Una prueba en un sistema lógico formal es una secuencia de líneas, cada una de las cuales
es un axioma o puede ser derivada de líneas previas por la aplicación de una regla de inferencia. Un
teorema es cualquier línea en una prueba. Así, los teoremas son axiomas o se obtienen aplicando una
regla de inferencia a otros teoremas.
Un sistema lógico formal es una abstracción matemática (colección de símbolos y
relaciones entre ellos). Se vuelve interesante cuando las fórmulas representan sentencias sobre algún
dominio de discurso y las fórmulas que son teoremas son sentencias verdaderas. Esto requiere dar una
interpretación a las fórmulas. Una interpretación de una lógica mapea cada fórmula a verdadero o falso.
Una lógica es sound (fuerte, robusta) con respecto a una interpretación si todos sus axiomas y reglas de
inferencia son sound. Un axioma es sound si se mapea a verdadero; una regla de inferencia es sound si
su conclusión se mapea a verdadera, asumiendo que todas las hipótesis se mapean a verdadero. Así, si
una lógica es sound, todos los teoremas en la lógica son sentencias verdaderas en ese dominio de
discurso. En este caso, la interpretación se llama modelo para la lógica.
Proposiciones La lógica proposicional es una instancia de un sistema lógico formal que formaliza lo que
llamamos razonamiento de “sentido común”. Las fórmulas de la lógica son llamadas proposiciones; son
sentencias que son verdaderas o falsas. Los axiomas son proposiciones especiales que se asumen
verdaderas; por ejemplo, “Es un día soleado implica que es de día” y “Es de día implica que las estrellas
no son visibles”. Las reglas de inferencia permiten que se formen nuevas proposiciones verdaderas a partir
de las existentes. Por ejemplo, si tuviéramos una regla de transitividad, de las dos sentencias anteriores
podríamos concluir que “Es un día soleado implica que las estrellas no son visibles”.
Para ser más precisos, en una lógica proposicional los símbolos proposicionales son:
Página N° 7
Programación Concurrente - Cap. 1. -----------------------------------------
Una fórmula P se satisface en un estado si es verdadera en ese estado; P es satisfacible si hay algún
estado en el cual es satisfecha. La fórmula P es válida si es satisfacible en cualquier estado. Por ejemplo,
p ∨ ¬p es válida. Una proposición válida se llama tautología.
Hay muchas tautologías dentro de la lógica proposicional; las que más usaremos están
listadas a continuación. Son llamadas leyes de equivalencia proposicional ya que permiten que una
proposición sea reemplazada por una equivalente.
Ley de Negación: P = ¬ (¬ P )
Ley de Implicación: P ⇒ Q = ¬P ∨ Q
Ley de Igualdad: ( P = Q ) = ( P ⇒ Q ) ∧ ( Q ⇒ P )
Leyes de Or-Simplificación: P ∨ P = P
P ∨ true = true P ∨
false = P P ∨ ( P ∧
Q)=P
Leyes de And-Simplificación: P ∧ P = P
P ∧ true = P P ∧
false = false P ∧ ( P
∨Q)=P
Leyes Conmutativas: ( P ∧ Q ) = ( Q ∧ P )
(P∨Q)=(Q∨P)(
P=Q)=(Q=P)
Leyes Asociativas: P ∧ ( Q ∧ R ) = ( P ∧ Q ) ∧ R )
P∨(Q∨R)=(P∨Q)∨R)
Leyes Distributivas: P ∨ ( Q ∧ R ) = ( P ∨ Q ) ∧ ( P ∨ R
)
P∧(Q∨R)=(P∧Q)∨(P∧R
)
Leyes de De Morgan: ¬ ( P ∧ Q ) = ¬P ∨ ¬Q
¬ ( P ∨ Q ) = ¬P ∧ ¬Q
Supongamos que sabemos que si A está en su SC, entonces B no está en su SC; es decir
que InA ⇒ ¬ InB. Entonces podemos reescribir esta fórmula usando dos de las leyes anteriores:
Página N° 8
Programación Concurrente - Cap. 1. -----------------------------------------
And-Eliminación: ( P ∧ Q ) ⇒ P
Or-Introducción: P ⇒ ( P ∨ Q )
No son reglas de equivalencia sino de implicación, que nos permiten concluir que el lado
derecho es verdadero si el izquierdo lo es.
Ambas reglas son ejemplos de debilitamiento de una proposición. Por ejemplo, si un estado
satisface P ∧ Q, entonces satisface P. Así, P es una proposición más débil que P ∧ Q dado que en
general más estados satisfacen sólo P que P y Q. Similarmente, P ∨ Q es una proposición más débil que
P (o que Q). Dado que false implica cualquier cosa, false es la proposición más fuerte (ningún estado la
satisface). Similarmente, true es la proposición más débil (cualquier estado la satisface).
Predicados
La lógica proposicional provee las bases para el razonamiento asercional. Sin embargo, por
sí misma es demasiado restrictiva ya que las únicas constantes proposicionales son los valores booleanos
true y false. Los lenguajes de programación tienen tipos de datos y valores adicionales tales como enteros
y reales. Así, necesitamos una manera de manipular cualquier clase de expresión boolean-valued. Una
lógica de predicados extiende una lógica proposicional para soportar esta generalización. Las dos
extensiones son las siguientes:
• Cualquier expresión tal como x<y que se mapea a true o false puede usarse en
lugar de una variable proposicional
Los símbolos de la lógica de predicados son los de la lógica proposicional más otras
variables, operadores relacionales y cuantificadores. Las fórmulas (llamadas predicados) son fórmulas
proposicionales en las cuales pueden usarse expresiones relacionales y cuantificadas en lugar de
variables proposicionales. Para interpretar un predicado en un estado, primero interpretamos cada
expresión relacional y cuantificada (obteniendo true o false para cada una) y luego interpretamos la
fórmula proposicional resultante.
Una expresión relacional contiene dos términos separados por un operador relacional tal
como =, ≠ o >. Estos operadores están definidos para caracteres, enteros, reales y a veces strings,
registros, etc. Introduciremos operadores relacionales adicionales tales como miembro de (∈) y
subconjunto (⊂) según sea necesario.
Cuando trabajamos con conjuntos de valores, con frecuencia queremos asegurar que
algunos o todos los valores satisfacen una propiedad (por ej, que todos los elementos de un arreglo son
0). Las expresiones cuantificadas proveen un método preciso y compacto.
Página N° 9
Programación Concurrente - Cap. 1. -----------------------------------------
Los bi se llaman variables ligadas; R es una fórmula que especifica el conjunto de valores
(rango) de las variables ligadas; P es un predicado. La interpretación de (1.1) es true si P es true para
alguna combinación de los valores de las variables ligadas. Por ejemplo:
(∃i : 1 ≤ i ≤ n : a[i] = 0 )
(∀i : 1 ≤ i ≤ n : a[i] = 0 )
Una ocurrencia de una variable en un predicado se dice libre si (1) no está dentro de un
cuantificador o (2) está dentro de un cuantificador y es distinta del nombre de cualquier variable ligada
cuyo alcance incluye ese cuantificador. Todas las ocurrencias de variables ligadas son bound dentro de su
alcance. En los ejemplos anteriores, todas las ocurrencias de a son libres y todas las ocurrencias de i son
bound. Sin embargo, en el siguiente predicado, la primera ocurrencia de i es libre pero las otras son
bound:
Los cuantificadores existencial y universal son duales uno del otro: sólo uno es necesario,
aunque ambos son convenientes. En particular, consideremos la siguiente expresión:
(∃B: R: P )
¬ ( ∀B: R: ¬P )
Una dualidad similar existe en la otra dirección. Las siguientes son algunas leyes (axiomas)
que emplearemos:
Para ilustrar el uso de la primera ley de rango vacío, consideremos el siguiente predicado,
que asegura que todos los elementos del 1al k de un arreglo a están ordenados ascendentemente:
Página N° 10
Programación Concurrente - Cap. 1. -----------------------------------------
Si k es 1 (por ejemplo, al comienzo de un programa de ordenación) la expresión es true ya que el rango es el
conjunto vacío. En otras palabras, ninguno de los elementos de a es necesariamente ordenados.
El concepto final que emplearemos de la lógica de predicados es la sustitución textual.
(1.3) Sustitución Textual: Si ninguna variable en la expresión e tiene el mismo nombre que otra variable ligada en el
Px
predicado P, entonces se define como el resultado de sustituir e para cada ocurrencia libre de x en P. e
Pex se lee “P con x reemplazada por e”. Los nombres de las variables en e no deben conflictuar con variables
ligadas.
La definición (1.3) trata con la sustitución de una expresión para una variable en un predicado. Podemos generalizar
la definición para permitir sustitución simultánea de varias expresiones para varias variables libres:
(1.4) Sustitución Simultánea: Si ninguna variable en las expresiones e1, ..., en tiene el mismo nombre que otra
1
variable ligada en el predicado P, y si x1, ..., xn son identificadores distintos, entonces Pe x 1,......, ,....,
n
exn
se define como el resultado de sustituir simultáneamente las e’s para cada ocurrencia de las x’s en P.
Se requiere que las x’s sean distintas en (1.4) pues sino más de una expresión podría ser sustituida por la misma
variable. Sin embargo, si dos identificadores x1 y x2 son el mismo, la sustitución simultánea está bien definida si e1 y
e2 son sintácticamente la misma expresión. Usaremos esta propiedad en el axioma para la sentencia de swap.
UNA LOGICA DE PROGRAMACION
Una lógica de programación es un sistema lógico formal que facilita hacer precisiones sobre la ejecución de un
programa. En esta sección se describe una lógica de programación (PL) para las sentencias de programación
secuencial descriptas. Posteriormente se extiende esta lógica para incluir procedures y varias construcciones de
programación concurrente.
Como cualquier sistema lógico formal, PL contiene símbolos, fórmulas, axiomas y reglas de inferencia. Los símbolos
son predicados sentencias del lenguaje de programación. Las fórmulas de PL son triplas de las forma:
{P}S{Q}
P y Q son predicados, y S es una sentencia simple o compuesta.
En P y Q, las variables libres son variables de programa y variables lógicas. Las variables de programa son
introducidas en las declaraciones. Las variables lógicas son variables especiales que sirven para mantener valores
arbitrarios; aparecen sólo en predicados, no en sentencias de programa.
Dado que el propósito de PL es facilitar la prueba de propiedades de la ejecución de programas, la interpretación de
una tripla caracteriza la relación entre los predicados P y Q y el efecto de ejecutar la sentencia S.
(1.5) Interpretación de una tripla. Sea que cada variable lógica tiene algún valor del tipo correcto. Luego, la
interpretación de una tripla { P } S { Q } es true si, para cualquier ejecución de S que comienza en un estado que
satisface P y la ejecución de S termina, el estado resultante satisface Q.
La interpretación es llamada corrección parcial, la cual es una propiedad de seguridad. Dice que, si el estado inicial
del programa satisface P, entonces el estado final en cualquier historia finita resultante de ejecutar S, va a satisfacer
Q. La propiedad de vida relacionada es la corrección total, que es corrección parcial más terminación; es decir que
En una tripla, P y Q se llaman aserciones, ya que afirman que el estado de programa debe
satisfacer el predicado para que la interpretación de la tripla sea true. Así, una aserción caracteriza un
estado de programa aceptable. El predicado P es llamado precondición de S ( se anota pre(S); caracteriza
la condición que el estado debe satisfacer antes de que comience la ejecución de S. El predicado Q se
llama postcondición de S ( post(S) ); caracteriza el estado que resulta de ejecutar S, si S termina. Dos
aserciones especiales son true, que caracteriza todos los estados de programa, y false, que caracteriza a
ningún estado.
Para que una interpretación sea un modelo para PL, los axiomas y reglas de inferencia de
PL deben ser sound con respecto a (1.5). Esto asegurará que todos los teoremas probables en PL son
sound. Por ejemplo, la siguiente tripla debería ser un teorema:
{ x=0 } x := x + 1 { x = 1 }
{ x=0 } x := x + 1 { y = 1 }
Además de ser sound, la lógica debería ser (relativamente) completa para que todas las
triplas que son true sean de hecho probables como teoremas.
Axiomas
{ true } x := 5 { x = 5 }
(x = 5 )
Esto indica que comenzar en cualquier estado y asignar un valor a una variable le da ese
valor a la variable. Como segundo ejemplo, consideremos la tripla:
{ y = 1 } x := 5 { y = 1 ∧ x = 5 }
Esto ilustra que las relaciones para las variables que no son asignadas no son afectadas
por una asignación.
Página N° 12
Programación Concurrente - Cap. 1. -----------------------------------------
Desafortunadamente, (1.7) no es sound para asignaciones a elementos de un arreglo o campos de registro.
Consideremos la siguiente tripla:
{P 8
a
3[ ] }
a[3] := 8 { P: i=3 ∧ a[i] = 6 }
Claramente, la postcondición P no debería ser satisfecha y la interpretación de la tripla sería falsa. Pero cuál es la
precondición que resulta del axioma de asignación? Debería ser:
i=3∧8=6
Esto es falso, pero para alcanzar esta conclusión tenemos que darnos cuenta que a[i] y a[3] son el mismo elemento.
No podemos hacerlo sólo con sustitución textual.
Podemos ver un arreglo como una colección de variables independientes a[1], a[2], etc. Alternativamente, podemos
ver un arreglo como una función (parcial) de los valores suscriptos a los elementos del arreglo. Con esta segunda
visión, un arreglo se convierte en una variable simple que contiene una función.
Sea a un arreglo unidimensional, y sean i y e expresiones cuyos tipos matchean los del rango y el tipo base de a,
respectivamente. Denotemos con (a; i : e ) el arreglo (función) cuyo valor es el mismo que a para todos los índices
excepto i, donde su valor es e:
(1.8) (a; i : e) [j] = a[j] si i≠j
e si i=j
Con esto, a[i] = e es simplemente una abreviación para a := (a; i : e); es decir, reemplazamos a por una nueva
función que es la misma que la vieja, excepto quizás en el lugar i. Dado que a es ahora una variable simple, se
aplica (1.7). Podemos manejar las asignaciones a arreglos multidimensionales y registros de manera similar.
Para ilustrar la notación funcional para arreglos, consideremos nuevamente la tripla:
{P 8
a
3[ ] }
a[3] := 8 { P: i=3 ∧ a[i] = 6 }
Reescribiendo la asignación como a := (a; 3 : 8) y sustituyendo en P queda:
P ( aa; )38 : = ( i=3 ∧ (a; 3 : 8)[i] = 6 )
De la definición (1.8) junto al hecho de que i=3, la parte derecha se simplifica a:
( i=3 ∧ 8=6 ) = false
Esta es una interpretación sound ya que a[3] no puede ser igual a 6 luego de que se le asigna 8. Lo que ha ocurrido
es que, reescribiendo la asignación al arreglo, toda la información relevante acerca de a fue transportada en la
sustitución textual.
La sentencia de swap intercambia los valores de dos variables v1 y v2. El efecto es asignar simultáneamente v1 a v2
y v2 a v1. Luego, el axioma para el swap generaliza (1.7) empleando sustitución simultánea (1.4).
,
(1.9) Axioma de Swap: { Pv v 2 1 ,v v
1
2
} v1 :=: v2 { P }
Como ejemplo, el siguiente teorema se deriva directamente del axioma de swap:
{ x=X ∧ y=Y } x :=: y { x=Y ∧ y=X }
X e Y son variables lógicas. Manejamos swapping de elementos de arreglos o campos de registros viéndolos como
funciones parciales.
Página N° 13
Programación Concurrente - Cap. 1. -----------------------------------------
Reglas de inferencia
Una sentencia compuesta ejecuta la sentencia S1, luego S2. La Regla de Composición nos
permite combinar triplas válidas concernientes a S1 y S2.
x := 1; y := 2
IF: if B1 → S1 ..... Bn → Sn fi
decir que Bi no contiene llamadas a funciones que cambian parámetros resultado o variables globales. Por
lo tanto, ninguna variable de programa puede cambiar el estado como resultado de evaluar una guarda.
Con esta suposición, si ninguna guarda es true, la ejecución de IF es equivalente a skip. Por otra parte, si
al menos una guarda es true, entonces una de las sentencias guardadas será ejecutada, con la elección
no determinística si más de una es true.
es true y todas las Bi son falsas cuando el IF es ejecutado, Q es true ya que en este caso If es equivalente
a skip. Sin embargo, si alguna Bi es true y Si es elegida para ejecutarse, entonces el siguiente es un
teorema:
{ P∧Bi } Si { Q }
Página N° 14
Programación Concurrente - Cap. 1. -----------------------------------------
Necesitamos teoremas para cada sentencia guardada ya que no podemos saber cuál será
ejecutada. Poniendo todo esto junto, tenemos la siguiente regla:
{ P ∧ Bi } Si { Q } , 1 ≤ i ≤ n
------------- { P } IF { Q }
if x ≥ y → m := x y ≥ x → m := y fi
(P∧x≥y)=(P∧X≥Y)
Dado que esto implica que R es true, podemos usar la Regla de Consecuencia para tener el
teorema:
{ P ∧ x ≥ y } m := x { P ∧ MAX }
Un teorema similar se tiene para la segunda sentencia guardada. Así, podemos usar la
regla de alternativa para inferir que (1.15) es un teorema de PL.
Con frecuencia usamos la regla de consecuencia para construir triplas que satisfacen la
Alternativamente, podemos usarla con las postcondiciones de las Si. Supongamos que las siguientes son
triplas válidas: { P ∧ Bi } Si { Qi }, 1 ≤ i ≤ n
Esto es, cada branch de una sentencia alternativa produce una condición posiblemente
distinta. Dado que podemos debilitar cada una de tales postcondiciones a la disyunción de todas las Qi,
{ P ∧ Bi } Si { Q1 ∨ ...... ∨ Qn }, 1 ≤ i ≤ n
Por lo tanto, podemos usar la disyunción de las Qi como postcondición del if, asumiendo
que también satisfacemos la primera hipótesis de la regla de alternativa.
Ahora consideremos la sentencia iterativa. Recordemos que la ejecución del do difiere del if
en lo siguiente: la selección y ejecución de una sentencia guardada se repite hasta que todas las guardas
son falsas. Así, una sentencia do puede iterar un número arbitrario de veces, aún cero. Por esto, la regla
de inferencia se basa en un loop invariant: un predicado (aserción) I que se mantiene antes y después de
cada iteración del loop. Sea DO la sentencia do:
DO: do B1 → S1 ..... Bn → Sn od
Página N° 15
Programación Concurrente - Cap. 1. -----------------------------------------
iteración. Entonces, si Si es seleccionada para ejecución, pre(Si) satisface I y Bi, y post(Si) debe satisfacer
I. La ejecución de DO termina cuando todas las guardas son falsas; luego, post(DO) satisface esto e I.
Estas observaciones dan la siguiente regla de inferencia:
--------------- { I } DO { I ∧ ¬ ( B1 ∨
....∨ Bn ) }
var fact := 1; i := 1` do i ≠ n → i := i + 1;
fact := fact * i od
Antes y después de cada iteración, fact contiene el factorial de i. Por lo tanto, la siguiente
aserción es un loop invariant:
fact = i! ∧ 1 ≤ i ≤ n
Cuando el loop termina, tanto el invariante como la negación de la guarda del loop son true:
fact = i! ∧ i=n
Por lo tanto, el programa computa correctamente el factorial de n. La sentencia for-all es la
que nos queda. Recordemos que fa es una abreviación para el uso especial del do. Luego, podemos
trasladar un programa que contiene fa en uno equivalente que contiene do, luego usar la regla iterativa
para desarrollar una prueba de corrección parcial del programa trasladado.
PRUEBAS EN PL
Dado que la lógica de programación PL es un sistema lógico formal, una prueba consiste en
una secuencia de líneas. Cada línea es una instancia de un axioma o se deduce de líneas previas por
aplicación de una regla de inferencia. Los axiomas y reglas de inferencia de PL son los que vimos en la
sección anterior más algunos de las lógicas proposicional y de predicados.
La primera parte de LS dice que i es un índice tal que a[i] = x; la segunda parte dice que
ningún índice más chico satisface esta propiedad. Está implícito en el enunciado del problema que n, a y x
no deberían cambiar. Podríamos especificar esto formalmente incluyendo el siguiente predicado en P y LS;
en el predicado, N, A, y X son variables lógicas:
n = N ∧ ( ∀ i: 1 ≤ i ≤ n: a[i] = A[i] ) ∧ x = X
A continuación se dará una prueba completa de que la siguiente tripla es válida y por lo
tanto que el programa es correcto parcialmente:
{ P } i := 1 do a[i] ≠ x → i := i
+ 1 od { LS }
Página N° 16
Programación Concurrente - Cap. 1. -----------------------------------------
Proof Outlines Como muestra el ejemplo anterior, es tedioso construir una prueba formal en PL (o
cualquier sistema lógico formal). La prueba tiene la virtud de que cada línea puede ser chequeada
mecánicamente. Sin embargo, la forma de la prueba la hace difícil de leer.
Un proof outline (a veces llamado programa comentado) provee una manera compacta en
la cual presentar el esbozo de una prueba. Consiste de las sentencias de un programa con aserciones
intercaladas. Un complete proof outline contiene al menos una aserción antes y después de cada
sentencia. Por ejemplo, la siguiente es un proof outline completo para el programa de búsqueda lineal:
{ P: n > 0 ∧ ( ∃ j : 1 ≤ j ≤ n : a[j] = x ) } i
:= 1 { P ∧ i = 1 } { I: P ∧ ( ∀ j : 1 ≤ j < i:
a[j] ≠ x ) } do a[i] ≠ x → { I ∧ a[i] ≠ x }
i := i + 1
Página N° 17
Programación Concurrente - Cap. 1. -----------------------------------------
Un proof outline completo incluye aplicaciones de los axiomas y reglas de inferencia para cada sentencia
en el proof outline. En particular, representa los pasos en una prueba formal de la siguiente manera: •
Cada sentencia de skip, asignación o swap junto con sus pre y postcondiciones forma una tripla que
representa una aplicación del axioma correspondiente.
• Una sentencia alternativa junto con sus pre y postcondiciones representa una
aplicación de la regla de alternativa, con las hipótesis representadas por las
aserciones y sentencias en las sentencias guardadas.
• Una sentencia iterativa junto con sus pre y postcondiciones representa una
aplicación de la regla iterativa; nuevamente las hipótesis de la regla son
representadas por las aserciones y sentencias en las sentencias guardadas.
Las aserciones en proof outlines pueden ser vistas como comentarios precisos en un
lenguaje de programación. Caracterizan exactamente qué es true del estado en varios puntos del
programa. Así como no siempre es necesario poner comentarios en cada sentencia del programa, no
siempre es necesario poner una aserción antes de cada sentencia en un proof outline. Así, generalmente
pondremos aserciones sólo en puntos críticos donde ayuden a proveer “evidencia convincente” de que un
proof outline de hecho representa una prueba. Como mínimo, los puntos críticos incluyen el comienzo y
final de un programa y el comienzo de cada loop.
Equivalencia y simulación
En PL, dos programas se dice que son parcialmente equivalentes si cada vez que
comienzan en el mismo estado inicial, terminan en el mismo estado final, asumiendo que ambos terminan.
(1.17) Equivalencia Parcial. Las listas de sentencias S1 y S2 son parcialmente equivalentes si, para
todos los predicados P y Q, { P } S1 { Q } es un teorema si y solo si { P } S2 { Q } es un teorema.
Esto se llama equivalencia parcial dado que PL es una lógica para probar solo propiedades
de correctitud parcial. Si uno de S1 y S2 termina pero el otro no, deberían ser equivalentes de acuerdo a la
definición anterior cuando en realidad no lo son. En un programa secuencial, si S1 y S2 son parcialmente
equivalentes y ambos terminan, son intercambiables. Lo mismo no es necesariamente true en un
programa concurrente debido a la potencial interferencia entre procesos.
Como ejemplo, los siguientes dos programas son parcialmente equivalentes: Página N° 18
Programación Concurrente - Cap. 1. -----------------------------------------
En PL, un programa se dice que simula a otro si, cada vez que ambos empiezan en el
mismo estado inicial y terminan, el estado final del primer programa satisface todas las aserciones que se
aplican al estado final del segundo. Esencialmente, la simulación es equivalencia parcial en una dirección:
(1.17) Simulación. La lista de sentencias S1 simula a S2 si, para todos los predicados P y Q, { P } S1 { Q }
es un cada vez que { P } S2 { Q } es un teorema.
t := x ; x := y ; y := t
Estas sentencias no son equivalentes a x :=: y, ya que la simulación contiene una variable
adicional t que podría ser usada en el programa circundante. Aunque la implementación de una sentencia
swap en la mayoría de las máquinas requeriría usar una variable temporaria, esa variable no sería visible
al programador y por lo tanto no podría ser usada en otro lugar del programa.
DERIVACION DE PROGRAMAS
Los ejemplos de la sección previa mostraban cómo usar PL para construir una prueba de
corrección parcial a posteriori. Esto es importante, pero con frecuencia un programador tiene un objetivo
(postcondición) y una suposición inicial (precondición) y se pregunta cómo construir un programa que
llegue al objetivo bajo las suposiciones establecidas. Además, típicamente se espera que el programa
termina. PL no provee una guía de cómo demostrar esto.
Precondiciones Weakest
Las precondiciones weakest están muy relacionadas a las triplas en PL. De la definición de
wp, { wp(S,Q) } S { Q } es un teorema de PL. Esto significa que:
Página N° 19
Programación Concurrente - Cap. 1. -----------------------------------------
Supongamos que S comienza en un estado que satisface tanto wp(S,Q) y wp(S,R). Luego
por (1.20), S terminará en un estado que satisface Q ∧ R. Además, nuevamente por (1.20), un estado que
satisface wp(S,Q∧R) satisface tanto wp(S,Q) y wp(S,R). Así tenemos:
Sin embargo, la implicación en (1.23) no puede ser reemplazada por igualdad ya que if y do
son sentencias no determinísticas. Para ver por qué, consideremos el siguiente programa, que simula
echar una moneda al aire:
Dado que ambas guardas en flip son true, cualquier sentencia guardada puede ser elegida.
Así, no hay un estado de comienzo que garantiza un valor final particular para outcome. En particular,
Por otra parte, una de las sentencias guardadas será ejecutada, con lo cual outcome será o
HEADS o TAILS cuando flip termina, cualquiera sea el estado inicial. Por lo tanto:
Aunque este ejemplo demuestra que la disyunción es en general distributiva en solo una
dirección, para sentencias determinísticas la implicación en (1.23) puede ser transformada en igualdad:
Esta ley vale para lenguajes de programación secuenciales, tal como Pascal, que no
contienen sentencias no determinísticas.
Esta sección presenta reglas para computar wp para las sentencias secuenciales ya
introducidas. Hay una regla para cada clase de sentencia. Dado que wp está altamente relacionado con
PL, la mayoría de las reglas son bastante similares a los axiomas o reglas de inferencia correspondientes
de PL.
La sentencia skip siempre termina y no cambia ninguna variable lógica o de programa. Así:
(1.25) wp(skip,Q) = Q
Página N° 20
Programación Concurrente - Cap. 1. -----------------------------------------
Una sentencia if termina si todas las expresiones están bien definidas y la sentencia elegida
Si termina. Sea IF la sentencia:
IF: if B1 → S1 ..... Bn → Sn fi
Si ninguna guarda es true, entonces ejecutar IF es lo mismo que ejecutar skip ya que se
asume que la evaluación de expresiones no tiene efectos laterales. Si Si se ejecuta, entonces la guarda Bi
debe haber sido true; para asegurar terminación en un estado que satisface Q, debe ser el caso en que Bi
Aplicando la definición (1.28) a esta sentencia y predicado (usando (1.26) para computar wp
de la asignación) se tiene:
Dado que al menos una de las guardas es true, la primera línea se simplifica a true y por lo
tanto puede ser ignorada. Reescribiendo las implicaciones usando la Ley de Implicación, la expresión
anterior se simplifica a:
x=X ∧ y=Y
Y sea BB el predicado:
Página N° 21
Programación Concurrente - Cap. 1. -----------------------------------------
Esto es, BB es true si alguna guarda es true, y BB es false en otro caso. Podemos reescribir
DO en términos de IF como sigue:
Ahora sea Hk(Q) un predicado que caracteriza todos los estados desde los cuales la
ejecución de DO lleva en k o menos iteraciones a terminación en un estado que satisface Q. En particular,
wp(IF, Hk-1(Q))
La ejecución del DO termina si realiza sólo un número finito de iteraciones. Así, la
precondición weakest de DO es:
(1.32) Relación entre wp(DO,Q) y la Regla Iterativa. Sea DO una sentencia do como se definió en (1.29),
y represente BB la disyunción de las guardas como se definió en (1.30). Además, supongamos que I es un
predicado y bound es una expresión entera cuyo valor es no negativo. Entonces I ⇒ wp(DO, I ∧ ¬BB ) si
las siguientes condiciones son true:
La relación (1.32) da una idea de cómo entender y desarrollar un loop. Primera, identificar
un predicado invariante que es true antes y después de cada iteración. El invariante captura las relaciones
unchanging entre variables. Segundo, identificar una bounding expression que es no negativa y decrece
en cada iteración. Esta expresión captura las relaciones changing entre variables, con los cambios
resultantes de progresar hacia terminación.
I: ( ∀ j : 1 ≤ j < i: a[j] ≠ x )
La negación del conjuntor borrado luego se usa como guarda del loop. La variable i se
inicializa de manera que I es true antes de la primera iteración. Esto lleva al siguiente esqueleto de
programa:
Página N° 22
Programación Concurrente - Cap. 1. -----------------------------------------
{ I ∧ a[i]=x }
El programa se completa diseñando un cuerpo de loop apropiado. Dentro del cuerpo, los
objetivos son reestablecer el invariante y progresar hacia terminación. Aquí, esto se hace incrementando i.
Esto reestablece el invariante ya que la guarda del loop asegura que x aún no se encontró. Incrementar i
también progresa hacia terminación. Dado que hay a lo sumo n elementos para examinar, n-i es el número
máximo de elementos que quedan examinar. Esta es una bounding expression dado que su rango es no
negativo y su valor decrece en cada iteración. Así tenemos el programa final y su proof outline:
Ordenación Esta sección desarrolla un algoritmo que ordena un arreglo entero a[1:n} en forma
ascendente. Ilustra el desarrollo de loops anidados y también presenta dos técnicas adicionales para
encontrar un loop invariante: reemplazar una constante por una variable y combinar pre y post
P: ( ∀ k: 1 ≤ k ≤ n: a[k] = A[k] )
donde A es un arreglo de variables lógicas con los valores iniciales de a. El objetivo del
algoritmo es establecer:
Obviamente tenemos que usar un loop para ordenar un arreglo, entonces debemos
encontrar un invariante. No se puede usar la técnica anterior. Borrar un conjuntor de SORT no dará un
invariante adecuado pues ninguno puede usarse como guarda y ninguno da una guía de cómo el loop
establecerá el otro conjuntor.
Dado que un invariante debe ser true inicialmente y luego de cada iteración, con frecuencia
es útil examinar las pre y postcondiciones de un loop cuando se desarrolla un invariante. Esto es
especialmente verdadero cuando los valores de entrada son modificados. La pregunta es: pueden ponerse
las dos aserciones en la misma forma? Aquí, P es un caso especial del segundo conjuntor de SORT dado
que el valor inicial de a es exactamente A; así a es trivialmente una permutación de A. Además, el estado
inicial ( en el cual el arreglo no está ordenado) es un caso degenerado del primer conjuntor de SORT si a
no tiene elementos. Así, si cualquiera de las constantes en SORT (1 o n) es reemplazada por una variable
cuyo valor inicial convierte en vacío el rango de k, tanto P como la versión modificada de SORT tendrán la
misma forma. Reemplazando n por la variable i nos da el predicado:
a[1] ... ordenado ... a[i-1] a[i] ... no ordenado ... a[n]
Página N° 23
Programación Concurrente - Cap. 1. -----------------------------------------
Al comienzo de cada iteración, a[1:i-1] está ordenado; al final, queremos a[1:i] ordenado.
Hay dos estrategias para hacer esto. Una es examinar todos los elementos en a[1:n], seleccionar el más
chico, e intercambiarlo con a[i]. Esta aproximación se llama selection sort. Una segunda estrategia es
mover el valor en a[i] al lugar apropiado en a[1:i], corriendo los otros valores como sea necesario para
hacer lugar. Esto es llamado insertion sort.
Desarrollaremos un algoritmo para insertion sort ya que es una estrategia un poco mejor,
especialmente si a está inicialmente cerca del ordenado. Al comienzo de un paso de inserción, sabemos
del invariante I que a[1:i-1] está ordenado. Necesitamos insertar a[i] en el lugar adecuado para terminar
con a[1:i] ordenado. Una manera simple de hacer esto es primero comparar a[i] con a[i- 1]. Si están en
orden correcto, ya está. Si no, los intercambiamos, y repetimos el proceso comparando a[i-1] (el antiguo
a[i]) y a[i-2]. Seguimos hasta que el valor que inicialmente estaba en a[i] ha llegado a la posición correcta.
Nuevamente necesitamos un invariante. Sea j el índice del nuevo valor que estamos
insertando; inicialmente j es igual a i. Al final del loop de inserción, queremos que el siguiente predicado
El primer conjuntor dice que a[1:j-1] está ordenado; el segundo dice que a[j:i] está
ordenado; el tercero que a[j] está en el lugar correcto (el cual podría ser a[1]). Nuevamente podemos usar
la técnica de borrar un conjuntor para tener un invariante apropiado. Borramos el tercer conjuntor ya que
es el único que podría ser falso al comienzo del loop de inserción; en particular, es lo que el loop debe
hacer verdadero. Esto da el invariante:
Usamos la negación del tercer conjuntor en la guarda del loop. Para mantener invariante II,
el cuerpo del loop interno intercambia a[j] y a[j-1] y luego decrementa j. La bounding expression es i- j. El
programa completo sería:
{ P: ( ∀ k: 1 ≤ k ≤ n: a[k] = A[k] ) }
var i := 1 : int
Página N° 24
Sincronización
Recordemos que un programa concurrente especifica dos o más procesos cooperantes. Cada
proceso ejecuta un programa secuencial y es escrito usando la notación introducida en el capítulo
anterior. Los procesos interactúan comunicándose, lo cual lleva a la necesidad de sincronización.
El problema clave que puede darse en los programas concurrentes es la interferencia, la cual
resulta cuando un proceso toma una acción que invalida las suposiciones hechas por otro proceso.
frecuencia llamada sentencia cobegin). Sea S1 un programa secuencial: una secuencia de sentencias
secuenciales y declaraciones opcionales de variables locales. La siguiente sentencia ejecuta las Si
concurrentemente:
(2.1) co S1 // ..... // Sn oc
El efecto es equivalente a algún interleaving de las acciones atómicas de las Si. La ejecución del
(2.2) x := 0; y := 0
co x := x + 1 // y := y + 1 oc z
:= x + y
Con frecuencia un programa concurrente contiene un número de procesos que realizan la misma
computación sobre diferentes elementos de un arreglo. Especificaremos esto usando cuantificadores en
las sentencias co, con los cuantificadores idénticos en forma a los de la sentencia fa. Por ejemplo, la
siguiente sentencia especifica n procesos que en paralelo inicializan todos los elementos de a[1:n] en 0:
co i := 1 to n → a[i] := 0 oc
Cada proceso tiene una copia local de i, la cual es una constante entera declarada
implícitamente; el valor de i está entre 1 y n y es distinta en cada proceso. Así, cada proceso tiene una
identidad única.
Página N° 1
Programación Concurrente - Cap. 2. ---------------------------------------------
Como segundo ejemplo, el siguiente programa multiplica las matrices a y b, ambas nxn, en
paralelo, poniendo el resultado en la matriz c:
(2.3) co i := 1 to n, j := 1 to n →
var sum : real := 0 fa k := 1 to n → sum := sum + a[i,
k] * b[k, j] af c[i, j] := sum oc
Cada una de los n2 procesos tiene constantes locales i y j y variables locales sum y k. El proceso
(i, j) computa el producto interno de la fila i de a y la columna j de b y almacena el resultado en c[i, j].
Cuando hay muchos procesos (o los procesos son largos) puede ser inconveniente especificar la
ejecución concurrente usando solo la sentencia co. Esto es porque el lector puede perder la pista del
contexto. En consecuencia, emplearemos una notación alternativa como se muestra en el siguiente
programa, que busca el valor del elemento más grande de a y en paralelo asigna a cada b[i] la suma de
los elementos de a[1:i]:
Dado que la notación del ejemplo es solo una abreviación de la sentencia co, la semántica de la
ejecución concurrente depende solo de la semántica de co.
Atomicidad Fine-Grained
Recordemos que una acción atómica hace una transformación de estado indivisible. Esto
significa que cualquier estado intermedio que podría existir en la implementación de la acción no debe
ser visible para los otros procesos. Una acción atómica fine-grained es implementada directamente por el
hardware sobre el que ejecuta el programa concurrente.
Página N° 2
Programación Concurrente - Cap. 2. ---------------------------------------------
Para que el axioma de asignación sea sound, cada sentencia de asignación debe ser ejecutada
como una acción atómica. En un programa secuencial, las asignaciones aparecen como atómicas ya que
ningún estado intermedio es visible al programa. Sin embargo, esto en general no ocurre en los
programas concurrentes, ya que una asignación con frecuencia es implementada por una secuencia de
instrucciones de máquina fine-grained. Por ejemplo, consideremos el siguiente programa, y asumamos
que las acciones atómicas fine-grained están leyendo y escribiendo las variables:
y := 0; x := 0 co x := y + z // y := 1;
z := 2 oc
Asumiremos que las máquinas que ejecutan los programas tienen las siguientes características:
* Los valores de los tipos básicos y enumerativos (por ej, int) son almacenados en elementos de
memoria que son leídos y escritos como acciones atómicas. (Algunas máquinas tienen otras
instrucciones indivisibles, tales como incrementar una posición de memoria o mover los contenidos de
una posición a otra).
* Los valores son manipulados cargándolos en registros, operando sobre ellos, y luego
almacenando los resultados en memoria.
* Cada proceso tiene su propio conjunto de registros. Esto se realiza teniendo distintos conjuntos
de registros o salvando y recuperando los valores de los registros cada vez que se ejecuta un proceso
diferente. ( Esto se llama context switch).
Con este modelo de máquina, si una expresión e en un proceso no referencia una variable
alterada por otro proceso, la evaluación de expresión será atómica, aún si requiere ejecutar muchas
acciones atómicas fine-grained. Esto es porque ninguno de los valores de los que depende e podría
cambiar mientras e está siendo evaluada y porque cada proceso tiene su propio conjunto de registros y
su propia área de almacenamiento temporario. De manera similar, si una asignación x:=e en un proceso
no referencia ninguna variable alterada por otro proceso, la ejecución de la asignación será atómica. El
programa en (2.2) cumple este requerimiento, y por lo tanto las asignaciones concurrentes son atómicas.
(2.4) Propiedad de “a lo sumo una vez”. Una expresión e satisface la propiedad de “a lo sumo una vez”
si se refiere a lo sumo a una variable simple y que podría ser cambiada por otro proceso mientras e está
siendo evaluada, y se refiere a y a lo sumo una vez. Una sentencia de asignación x:=e satisface esta
propiedad si e satisface la propiedad y x no es leída por otro proceso, o si x es una variable simple y e no
se refiere a ninguna variable que podría ser cambiada por otro proceso.
Esta propiedad asegura atomicidad ya que la variable compartida, si la hay, será leída o escrita
solo una vez como una acción atómica fine-grained.
Página N° 3
Programación Concurrente - Cap. 2. ---------------------------------------------
Para que una sentencia de asignación satisfaga (2.4), e se puede referir a una variable alterada
por otro proceso si x no es leída por otro proceso (es decir, x es una variable local). Alternativamente, x
puede ser leída por otro proceso si e no referencia ninguna variable alterada por otro proceso. Sin
embargo, ninguna asignación en lo siguiente satisface (2.4):
co x := y + 1 // y := x + 1 oc
En realidad, si x e y son inicialmente 0, sus valores finales podrían ser ambos 1 (Esto resulta si el
proceso lee x e y antes de cualquier asignación a ellas). Sin embargo, dado que cada asignación se
refiere sólo una vez a solo una variable alterada por otro proceso, los valores finales serán algunos de los
que realmente existieron en algún estado. Esto contrasta con el ejemplo anterior en el cual y+z se refería
a dos variables alteradas por otro proceso.
Especificación de la Sincronización
Si una expresión o sentencia de asignación no satisface la propiedad de a lo sumo una vez, con
frecuencia necesitamos tener que ejecutarla atómicamente. Más generalmente, necesitamos ejecutar
secuencias de sentencias como una única acción atómica. En ambos casos, necesitamos usar un
mecanismo de sincronización para construir una acción atómica coarse grained, la cual es una secuencia
de acciones atómicas fine grained que aparecen como indivisibles.
Como ejemplo concreto, supongamos una BD que contiene dos valores x e y, y que en todo
momento x e y deben ser lo mismo en el sentido de que ningún proceso que examina la BD puede ver un
estado en el cual x e y difieren. Luego, si un proceso altera x, debe también alterar y como parte de la
misma acción atómica.
Como segundo ejemplo, supongamos que un proceso inserta elementos en una cola
representada por una lista enlazada. Otro proceso remueve elementos de la lista, asumiendo que hay
elementos. Dos variables apuntan a la cabeza y la cola de la lista. Insertar y remover elementos requiere
manipular dos valores; por ejemplo, para insertar un elemento, tenemos que cambiar el enlace del que
era anteriormente último elemento para que apunte al nuevo elemento, y tenemos que cambiar la
variable que apunta al último para que apunte al nuevo elemento. Si la lista contiene solo un elemento, la
inserción y remoción simultánea puede conflictuar, dejando la lista en un estado inestable. Así, insertar y
remover deben ser acciones atómicas. Además, si la lista está vacía, necesitamos demorar la ejecución
de remover hasta que se inserte un elemento.
Especificaremos acciones atómicas por medio de corchetes angulares 〈 y 〉. Por ejemplo, 〈e〉
indica que la expresión e debe ser evaluada atómicamente.
〈 await B → S 〉
La sentencia await es muy poderosa ya que puede ser usada para especificar acciones
atómicas arbitrarias coarse grained. Esto la hace conveniente para expresar sincronización (más
adelante la usaremos para desarrollar soluciones iniciales a problemas de sincronización). Este poder
expresivo también hace a await muy costosa de implementar en su forma más general. Sin embargo,
hay casos en que puede ser implementada eficientemente. Por ejemplo, (2.5) es un ejemplo de la
operación P sobre el semáforo s.
Página N° 4
Programación Concurrente - Cap. 2. ---------------------------------------------
La forma general de la sentencia await especifica tanto exclusión mutua como sincronización
por condición. Para especificar solo exclusión mutua, abreviaremos una sentencia await como sigue:
〈S〉
〈 x := x + 1 ; y := y + 1 〉
El estado interno en el cual x fue incrementada pero y no es invisible a los otros procesos que
referencian x o y. Si S es una única sentencia de asignación y cumple los requerimientos de la propiedad
(2.4) (o si S es implementada por una única instrucción de máquina) entonces S será ejecutada
atómicamente; así, 〈S〉 tiene el mismo efecto que S.
Para especificar solo sincronización por condición, abreviaremos una sentencia await como:
〈 await B 〉
Por ejemplo, lo siguiente demora el proceso ejecutante hasta que count sea mayor que 0:
Si B cumple los requerimientos de la propiedad (2.4), como en este ejemplo, entonces 〈await B〉
puede ser implementado como:
do not B → skip od
Una acción atómica incondicional es una que no contiene una condición de demora B. Tal acción
puede ejecutarse inmediatamente, sujeta por supuesto al requerimiento de que se ejecute atómicamente.
Las acciones fine grained (implementadas por hardware), las expresiones en corchetes angulares, y las
sentencias await en la cual la guarda es la constante true o se omite son todas acciones atómicas
incondicionales.
Una acción atómica condicional es una sentencia await con una guarda B. Tal acción no puede
ejecutarse hasta que B sea true. Si B es false, solo puede volverse true como resultado de acciones
tomadas por otros procesos. Así, un proceso que espera ejecutar una acción atómica condicional podría
esperar un tiempo arbitrariamente largo.
SEMANTICA DE LA EJECUCION CONCURRENTE
La sentencia co en (2.2) incrementa tanto x como y. Así, una lógica de programación para
concurrencia debería permitir probar lo siguiente:
(2.6) { x = 0 ∧ y = 0 }
co x := x + 1 // y := y + 1 oc { x
=1∧y=1}
Esto requiere una regla de inferencia para la sentencia co. Dado que la ejecución del co resulta
en la ejecución de cada proceso, el efecto del co es la conjunción de los efectos de los procesos
constituyentes. Así, la regla de inferencia para el co se basa en combinar triplas que capturan el efecto
de ejecutar cada proceso.
Página N° 5
Programación Concurrente - Cap. 2. --------------------------------------------- (2.7) Regla de Sincronización: { P
∧ B } S’ { Q }
------------ { P } 〈 await B → S 〉
{Q}
Las dos formas especiales de la sentencia await son casos especiales de esta regla. Para 〈 S 〉,
B es true, y por lo tanto la hipótesis se simplifica a { P } S { Q }. Para 〈 await B 〉, S es skip, entonces P
∧ B debe implicar la verdad de Q.
Como ejemplo del uso de (2.7), por el axioma de asignación el siguiente es un teorema:
{ s > 0 } s := s - 1 { s ≥ 0 }
{ s ≥ 0 } 〈 await s > 0 → s := s - 1 〉 { s ≥ 0 }
es un teorema con P y Q ambos siendo s ≥ 0. El hecho de que las sentencias await se ejecuten
atómicamente afecta la interacción entre procesos, como se discute abajo.
inicia en un estado que satisface Pi y Si termina, entonces el estado va a satisfacer Q. Para que esta
interpretación se mantenga cuando los procesos son ejecutados concurrentemente, los procesos deben
ser iniciados en un estado que satisfaga la conjunción de las Pi. Si todos los procesos terminan, el estado
final va a satisfacer la conjunción de las Qi. Así, uno esperaría poder concluir que lo siguiente es un
teorema:
Para el programa (2.6), tal conclusión sería sound. En particular, a partir de las triplas válidas
{ x = 0 } x := x + 1 { x = 1 } { y
= 0 } y := y + 1 { y = 1 }
{ x = 0 ∧ y = 0 } co x := x + 1 // y := y + 1 oc { x = 1 ∧ y = 1 }
co 〈 x := x + 1 〉 // 〈 x := x + 1 〉 oc
Las sentencias de asignación son atómicas, luego si x es inicialmente 0, su valor final es 2. Pero
cómo podemos probar esto? Aunque lo siguiente es un teorema aisladamente
{ x = 0 } 〈 x := x + 1 〉 { x = 1 }
Página N° 6
Programación Concurrente - Cap. 2. ---------------------------------------------
Asumimos que cada expresión o sentencia de asignación se ejecuta atómicamente, o porque
cumple los requerimientos de la propiedad de a lo sumo una vez o porque está entre corchetes
angulares. Una acción atómica en un proceso es elegible si es la próxima acción atómica que el proceso
ejecutará. Cuando se ejecuta una acción elegible T, su precondición pre(T) debe ser true. Por ejemplo, si
la próxima acción elegible es evaluar las guardas de una sentencia if, entonces la precondición de la
sentencia if debe ser true. Dado que las acciones elegibles en distintos procesos se ejecutan en
cualquier orden, pre(T) no debe volverse falsa si alguna otra acción elegible se ejecuta antes que T.
Llamamos al predicado pre(T) aserción crítica dado que es imperativo que sea true cuando se ejecuta T.
Más precisamente, lo siguiente define el conjunto de aserciones críticas en una prueba.
(2.8) Aserciones Críticas. Dada una prueba de que { P } S { Q } es un teorema, las aserciones críticas
en la prueba son: (a) Q, y (b) para cada sentencia T dentro de S que no está dentro de una sentencia
await, el predicado weakest pre(T) tal que { pre(T) } T { post(T) } es un teorema dentro de la prueba.
En el caso (b), solo las sentencias que no están dentro de los await necesitan ser consideradas
dado que los estados intermedios dentro del await no son visibles a los otros procesos. Además, si hay
más de una tripla válida acerca de una sentencia dada T (debido al uso de la regla de consecuencia) sólo
el predicado weakest pre(T) que es precondición de T es una aserción crítica. Esto es porque no se
necesitan suposiciones más fuertes para construir la prueba entera.
Recordemos que una proof outline completa contiene una aserción antes y después de cada
sentencia. Codifica una prueba formal, presentándola de una manera que la hace más fácil de entender.
También codifica las aserciones críticas, asumiendo que son las weakest requeridas. En particular, las
aserciones críticas de un proceso son la postcondición y las precondiciones de cada sentencia que no
está dentro de un await. Por ejemplo, en
{ Pex } 〈 x := e 〉 { P }
Pex es una aserción crítica ya que debe ser true cuando la sentencia de asignación se ejecuta; P
también es una aserción crítica si la asignación es la última sentencia en un proceso. Como segundo
ejemplo, en
{ P } 〈 S1; { Q } S2 〉 { R }
Para que la prueba de un proceso se mantenga válida de acuerdo a la Interpretación para Triplas
(1.5), las aserciones criticas no deben ser interferidas por acciones atómicas de otros procesos
ejecutando concurrentemente. Esto se llama libertad de interferencia, lo cual definiremos más
formalmente.
Una acción de asignación es una acción atómica que contiene una o más sentencias de
asignación. Sea C una aserción crítica en la prueba de un proceso. Entonces la única manera en la cual
C podría ser interferida es si otro proceso ejecuta una acción de asignación a y a cambia el estado de
manera que C sea falsa. La interferencia no ocurrirá si se mantienen las siguientes condiciones:
(2.9) No Interferencia. Si es necesario, renombrar las variables locales en C para que sus nombres sean
distintos de los de las variables locales en a y pre(a). Entonces la acción de asignación a no interfiere con
la aserción crítica C si lo siguiente es un teorema en la Lógica de Programación:
NI(a, C) : { C ∧ pre(a) } a { C }
Página N° 7
Programación Concurrente - Cap. 2. ---------------------------------------------
Un conjunto de procesos está libre de interferencia si ninguna acción de asignación en un
proceso interfiere con ninguna aserción crítica en otro. Si esto es verdad, entonces las pruebas de los
procesos individuales se mantienen verdaderas en presencia de la ejecución concurrente.
Si las pruebas de los n procesos son libres de interferencia, entonces las pruebas pueden ser
combinadas cuando los procesos se ejecutan concurrentemente. En particular, si los procesos
comienzan la ejecución en un estado que satisface todas sus precondiciones, y si todos los procesos
terminan, entonces cuando los procesos terminan el estado va a satisfacer la conjunción de sus
postcondiciones. Esto lleva a la siguiente regla de inferencia para co.
Para aplicar la regla de concurrencia (2.11), primero necesitamos construir pruebas de los
procesos individuales. Dado que los procesos son programas secuenciales, las pruebas se desarrollan
usando las técnicas ya descriptas. Luego tenemos que mostrar que las pruebas están libres de
interferencia. Este es el nuevo requerimiento introducido por la ejecución concurrente.
Recordemos que el número de historias distintas de un programa concurrente es exponencial
con respecto al número de acciones atómicas que se ejecutan. Por contraste, el número de maneras en
que los procesos pueden interferir depende solo del número de acciones atómicas distintas; este número
no depende de cuantas acciones son ejecutadas realmente. Por ejemplo, si hay n procesos y cada uno
contiene a acciones de asignación y c aserciones críticas, entonces en el peor caso tenemos que probar
n * (n-1) * a * c teoremas de no interferencia. Aunque este número es mucho menor que el número de
historias, igual es bastante grande. Sin embargo, muchos de estos teoremas serán los mismos, ya que
con frecuencia los procesos son idénticos sintácticamente o al menos simétricos. Además, hay maneras
de evitar completamente la interferencia.
Variables Disjuntas
El write set de un proceso es el conjunto de variables que asigna. El reference set de un proceso
es el conjunto de variables referenciadas en las aserciones en una prueba de ese proceso. (Con
frecuencia es el mismo que el conjunto de variables referenciadas en sentencias del proceso, pero podría
no serlo; con respecto a interferencia, las variables críticas son las de las aserciones).
Si el write set de un proceso es disjunto del reference set de otro, y viceversa, entonces los
procesos no pueden interferir. Esto es porque el axioma de asignación (1.7) emplea sustitución textual, la
cual no tiene efecto sobre un predicado que no contiene una referencia al destino de la asignación (Las
variables locales con igual nombre en distintos procesos pueden ser renombradas para aplicar el axioma
de asignación).
Página N° 8
Programación Concurrente - Cap. 2. ---------------------------------------------
co x := x + 1 // y := y + 1 oc
Si x e y son inicialmente 0, entonces a partir del axioma de asignación, los siguientes son
teoremas:
{ x = 0 } x := x + 1 { x = 1 } { y
= 0 } y := y + 1 { y = 1 }
Cada proceso contiene una sentencia de asignación y dos aserciones; por lo tanto hay que
probar 4 teoremas de no interferencia. Por ejemplo, para mostrar que x = 0 no es interferida con la
asignación a y, NI(y:=y+1, x=0): { x = 0 ∧ y = 0 } y := y + 1 { x = 0 }
y ( x=0 ∧ y=0 ) ⇒ x=0. Las otras tres pruebas de no interferencia son similares dado que los
conjuntos write y reference son disjuntos. Así, podemos aplicar la Regla de Concurrencia (2.11) para
concluir que el siguiente es un teorema:
Técnicamente, si una aserción en un proceso referencia una variable arreglo, el arreglo entero
es una parte del reference set del proceso. Esto es porque el axioma de asignación trata los arreglos
como funciones. Sin embargo, asignar a un elemento del arreglo no afecta el valor de ningún otro. Por lo
tanto si los elementos del arreglo reales en el write set de un proceso difieren del los elementos del
arreglo reales en el reference set del otro (y los conjuntos son en otro caso disjuntos) el primer proceso
no interfiere con el segundo. Por ejemplo, en
co i := 1 to n → a[i] := i oc
Dado que el valor de i es distinto en cada proceso, las pruebas son libres de interferencia.
Análogamente, los procesos producto interno en (2.3) y los procesos Sum no interferirían uno con otro si
sus pruebas no referencian elementos de arreglo asignados por otros procesos. En casos simples como
estos, es fácil ver que los índices de los arreglos son diferentes; en general, esto puede ser difícil de
verificar, si no imposible.
Los write/reference sets disjuntos proveen la base para muchos algoritmos paralelos,
especialmente los del tipo de (2.3) que manipulan matrices. Como otro ejemplo, las diferentes ramas del
árbol de posibles movimientos en un programa de juegos pueden ser buscadas en paralelo. O múltiples
transacciones pueden examinar una BD en paralelo, o pueden actualizar distintas relaciones.
Aserciones Weakened
Aún cuando los write y reference sets de los procesos se overlapen, a veces podemos evitar
interferencia debilitando aserciones para tomar en cuenta los efectos de la ejecución concurrente. Por
ejemplo, consideremos lo siguiente:
{ x = 0 } x := x + 1 { x = 1 }
Página N° 9
Programación Concurrente - Cap. 2. ---------------------------------------------
{ x = 0 } x := x + 2 { x = 2 }
Sin embargo, cada asignación interfiere con ambas aserciones de la otra tripla. Además, la
conjunción de las postcondiciones no da el resultado correcto de x=3.
Si el proceso P1 se ejecuta antes que P2, entonces el estado va a satisfacer x=1 cuando P2
comienza la ejecución. Si debilitamos la precondición de P2 para tomar en cuenta esta posibilidad,
resulta la siguiente tripla:
(2.13) { x = 0 ∨ x = 1 } x := x + 2 { x = 2 ∨ x = 3 }
Análogamente, si P2 se ejecuta antes que P1, el estado va a satisfacer x=2 cuando P1 comienza
su ejecución. Así, también necesitamos debilitar la precondición de P1:
(2.14) { x = 0 ∨ x = 2 } x := x + 1 { x = 1 ∨ x = 3 }
Las pruebas no interfieren. Por ejemplo, la precondición y asignación en (2.14) no interfieren con
la precondición en (2.13). Aplicando la definición de no interferencia (2.9):
(2.15) { (x = 0 ∨ x = 1) ∧ ( x = 0 ∨ x = 2) } x := x + 1 { x = 0 ∨ x = 1 }
Los otros tres chequeos de no interferencia son similares. Por lo tanto, podemos aplicar la regla
de concurrencia (2.11) a (2.13) y (2.14), obteniendo el teorema:
{ (x = 0 ∨ x = 1) ∧ ( x = 0 ∨ x = 2) } co P1:
〈 x := x + 1 〉 // P2: 〈 x := x + 2 〉 oc { (x = 2
∨ x = 3) ∧ ( x = 1 ∨ x = 3) }
Aunque el programa anterior es simple, ilustra un principio importante: al desarrollar una prueba de un
proceso que referencia variables alteradas por otro proceso, tomar en cuenta los efectos de los otros
procesos. El ejemplo también ilustra una manera de hacerlo: hacer una aserción más débil acerca de las
variables compartidas que la que podría hacerse si un proceso se ejecutara aisladamente. Siempre
podemos debilitar lo suficiente las aserciones para evitar interferencia (por ej, cualquier estado satisface
true) pero entonces el resultado deseado probablemente no pueda ser probado.
Invariantes Globales
Otra técnica para evitar interferencia es emplear un invariante global para capturar la relación
entre variables compartidas. Supongamos que I es un predicado que referencia variables globales.
Entonces I es un invariante global con respecto a un conjunto de procesos si (1) I es true cuando los
procesos comienzan la ejecución, y (2) I es invariante con respecto a la ejecución de cualquier acción de
asignación (sentencias de asignación o await que contienen una asignación). La condición 1 se satisface
si I es true en el estado inicial de cada proceso. La condición 2 se satisface si para cada acción de
asignación a con precondición pre(a),
{ I ∧ pre(a) } a { I }
es un teorema; es decir, a no interfiere con I. Recordemos que pre(a) se incluye dado que a solo
puede ser ejecutada en un estado en el cual pre(a) es true.
Supongamos que I es un invariante global. También supongamos que toda aserción crítica C en
la prueba de un proceso Pj puede ponerse en la forma:
Página N° 10
Programación Concurrente - Cap. 2. --------------------------------------------- (2.16) C: I ∧ L
Si todas las aserciones pueden ponerse en la forma (2.16), entonces las pruebas de los
procesos están libres de interferencia. Esto es porque I es invariante con respecto a cualquier acción de
asignación a y porque ninguna acción de asignación en un proceso puede interferir con un predicado
local L en otro proceso pues los destinos en a son distintos de todas la variables de L. Así, el
requerimiento de no interferencia (2.19) se cumple para cada par de acciones de asignación y aserciones
críticas. Además, solo tenemos que chequear las triplas en cada proceso para verificar que cada
aserción critica tiene la forma anterior y que I es un invariante global; no tenemos que considerar las
aserciones o sentencias en otros procesos. De hecho, para un arreglo de procesos idénticos, solo
tenemos que chequear uno de ellos.
Aún cuando no podemos poner toda aserción crítica en la forma (2.16), a veces podemos usar
una combinación de invariantes globales y aserciones debilitadas para evitar interferencia. Ilustramos
esto usando el programa de productor/consumidor:
Este programa copia los contenidos del arreglo Productor a en el arreglo Consumidor b usando
un buffer simple compartido buf. El Productor y el Consumidor alternan el acceso a buf. Las variables p y
c cuentan el número de items que han sido depositados y buscados, respectivamente. Las sentencias
await se usan para sincronizar el acceso a buf. Cuando p = c el buffer está vacío, cuando p > c el buffer
está lleno.
Supongamos que los contenidos iniciales de a[1:n] son A[1:n], donde A es un arreglo de
variables lógicas. El objetivo es probar que, al terminar el programa anterior, los contenidos de b[1:n] son
A[1:n]. Esto se puede hacer usando un invariante global como sigue. Dado que los procesos se alternan
el acceso a buf, en todo momento p es igual a c o a uno más que c. También, cuando el buffer está lleno
(es decir, p=c+1), contiene A[p]. Finalmente, a no es alterada, por lo tanto siempre es igual a A. Así, un
candidato a invariante global es:
Este predicado es true inicialmente ya que p = c = 0. Esto es mantenido por cada sentencia de
asignación, como se ve en la proof outline completa:
Página N° 11
Programación Concurrente - Cap. 2. ---------------------------------------------
{ PC ∧ p = n } Consumidor :: var b[1:n]
: int
{ IC: PC ∧ c ≤ n ∧ b[1:c] = A[1:c] }
do c < n → { IC ∧ c < n }
〈 await p > c 〉 { IC ∧ c < n ∧ p > c} b[c+1] := buf {
IC ∧ c < n ∧ p > c ∧ b[c+1] = A[c+1] } c := c + 1 { IC
} od { IC ∧ c = n + 1 }
Esta prueba contiene todas las aserciones críticas. IP es el invariante para el loop productor e IC
para el loop consumidor.
Aisladamente, la proof outline para cada proceso del ejemplo anterior se deduce directamente de
las sentencias de asignación del proceso. Asumiendo que cada proceso continuamente tiene chance de
ejecutar, las sentencias await terminan dado que primero es true una guarda, luego la otra, etc. Dado
que las sentencias await terminan, cada proceso termina luego de n iteraciones. Así, como las pruebas
son libres de interferencia, podemos usar la regla de concurrencia (2.11) para combinar las
postcondiciones de los procesos y concluir que el programa copia los contenidos de a en b.
La mayoría de las aserciones críticas en el ejemplo anterior tienen la forma (2.16): contienen la
conjunción del invariante global PC y predicados que referencian variables que no son alteradas por los
otros procesos. Por lo tanto las aserciones no pueden ser interferidas. Las únicas aserciones que no
siguen esta forma son las dos en Productor que afirman que p = c y las dos en Consumidor que afirman
que p > c. Tenemos que chequear si se interfieren.
Consideremos la siguiente aserción en Productor:
A1: { PC ∧ p < n ∧ p = c}
NI(c:=c+1,A1): { A1 ∧ A2 } c := c + 1 { A1 }
Dado que p no puede ser a la vez igual y mayor que c, ( A1 ∧ A2 ) es falsa. Por lo tanto,
podemos usar la regla de consecuencia para obtener cualquier predicado, en este caso A1cc+1. Por el
axioma de asignación y la regla de consecuencia, lo anterior es un teorema. Las otras tres pruebas de no
interferencia son casi idénticas. Por lo tanto el programa está libre de interferencia.
Lo que ocurre es que las sentencias await aseguran que los procesos alternan el acceso al
buffer; es decir, p y c alternativamente son iguales y luego difieren en uno. Esto resguarda a las
sentencias que incrementan p y c de interferir con aserciones críticas en el otro proceso.
Dado que cada sentencia await en el ejemplo cumple los requerimientos de la propiedad de a lo
sumo una vez, cada una puede implementarse con un loop do. Por ejemplo, la sentencia 〈await p = c 〉
puede ser implementada por:
do p ≠ c → skip od
Cuando la sincronización se implementa de esta manera, un proceso se dice que está en busy
waiting o spinning, ya que está ocupado haciendo nada más que un chequeo de la guarda.
Página N° 12
Programación Concurrente - Cap. 2. --------------------------------------------- Sincronización
Como ya describimos, podemos ignorar una sentencia de asignación que está dentro de
corchetes angulares cuando consideramos obligaciones de no interferencia. Dado que una acción
atómica aparece hacia los otros procesos como una unidad indivisible, alcanza con establecer que la
acción entera no causa interferencia. Por ejemplo, dado
〈 x := x + 1; y := y + 1 〉
ninguna asignación por si misma puede causar interferencia; solo podría el par de asignaciones.
Además, los estados internos de los segmentos de programa dentro de los corchetes no son visibles. Por
lo tanto, ninguna aserción respecto un estado interno puede ser interferida por otro proceso. Por ejemplo,
la siguiente aserción del medio no es una aserción crítica:
Estos dos atributos de las acciones atómicas nos llevan a dos técnicas adicionales para evitar
interferencia: exclusión mutua y sincronización por condición. Consideremos las proofs outlines:
P2 :: ... S1 { C } S2 ...
S2 〉
Otra manera de eliminar interferencia en (2.17) es usar sincronización por condición para
fortalecer la precondición de a. El requerimiento de no interferencia (2.9) será satisfecho si:
* C es falso cuando a se ejecuta, y por lo tanto el proceso P2 no podría estar listo para ejecutar
S2; o * la ejecución de a hace true a C, es decir, si post(a) ⇒ C.
〈 await not C or B → a 〉
Aquí, B es un predicado que caracteriza un conjunto de estados tales que ejecutar a hará true a
C. De la definición de la precondición weakest, B = wp(a, C) caracteriza el mayor conjunto de tales
estados. Para ilustrar estas técnicas, consideremos el siguiente ejemplo de una sistema bancario
simplificado. Supongamos que un banco tiene un conjunto de cuentas, les permite a los clientes transferir
dinero de una cuenta a otra, y tiene un auditor para chequear malversaciones. Representamos las
cuentas con account[1:n]. Una transacción que transfiere $100 de la cuenta x a la y podría ser
implementada por un proceso Transfer, donde asumimos que x≠y, que hay fondos suficientes, y que
ambas cuentas son válidas. En las aserciones de Transfer, X e Y son variables lógicas.
Página N° 13
Programación Concurrente - Cap. 2. ---------------------------------------------
account[y] := account[y] + 100 〉 { account[x] = X - 100 ∧ account[y] = Y + 100 } Auditor ::
var total := 0, i :=1, embezzle := false { total = account[1] + ....
account[i-1] } do i ≤ n → { C1: total = account[1] + .... account[i-1] ∧ i ≤ n
}
total := total + account[i]; i := i + 1 { C2: total =
account[1] + .... account[i-1] } od { total = account[1] +
.... account[n] ∧ i = n } if total ≠ CASH → embezzle :=
true fi
Sea CASH el monto total en todas las cuentas. El proceso Auditor chequea malversaciones
iterando a través de las cuentas, sumando los montos en cada una, y luego comparando el total con
CASH.
Como está programado en el ejemplo, Transfer se ejecuta como una sola acción atómica, de
modo que Auditor no verá un estado en el cual account[x] fue debitada y luego termine de auditar sin ver
el crédito (pendiente) en account[y]. Pero esto no es suficiente para prevenir interferencia. El problema es
que, si Transfer ejecuta mientras el índice i en Auditor está entre x e y, el monto viejo en una de las
cuentas ya fue sumado a total, y más tarde la nueva cantidad en la otra cuenta va a ser sumada, dejando
que el Auditor crea incorrectamente que hubo malversación. Más precisamente, las asignaciones en
Transfer interfieren con las aserciones C1 y C2 de Auditor.
Se necesita sincronización adicional para evitar interferencia entre los procesos del ejemplo. Una
aproximación es usar exclusión mutua para ocultar las aserciones críticas C1 y C2. Esto se hace
poniendo entre corchetes el loop do entero en Auditor convirtiéndolo en atómico. Desafortunadamente,
esto tiene el efecto de hacer que casi todo el Auditor ejecute sin interrupción. Una alternativa es usar
sincronización por condición para evitar que Transfer ejecute si al hacerlo interfiere con C1 y C2. En
particular, podemos reemplazar la acción atómica incondicional en Transfer por la siguiente acción
atómica condicional:
Esta aproximación tiene el efecto de hacer que Transfer se demore solo cuando Auditor está en
un punto crítico entre las cuentas x e y.
Como ilustra el ejemplo, la exclusión mutua y la sincronización por condición pueden ser usadas
siempre para evitar interferencia. Más aún, con frecuencia son requeridas dado que las otras técnicas por
si mismas son insuficientes. En consecuencia, usaremos mucho la sincronización en combinación con
otras técnicas, especialmente invariantes globales. Sin embargo, la sincronización tiene que ser usada
con cuidado dado que implica overhead y puede llevar a deadlock si una condición de demora nunca se
convierte en true. Afortunadamente, es posible resolver problemas de sincronización en una manera
eficiente y sistemática.
VARIABLES AUXILIARES
La lógica de programación PL extendida con la regla de concurrencia (2.11) no es aún una lógica
(relativamente) completa. Algunas triplas son válidas pero no se puede probar que lo sean. El problema
es que con frecuencia necesitamos hacer aserciones explícitas acerca de los valores de los contadores
de programa de los distintos procesos. Sin embargo los program counters son parte del estado oculto:
valore que no están almacenados en variables de programa. Otros ejemplos de estado oculto son las
colas de procesos bloqueados y las colas de mensajes que fueron enviados pero no recibidos.
Página N° 14
Programación Concurrente - Cap. 2. ---------------------------------------------
Supongamos que x es inicialmente 0. Entonces x será 2 cuando el programa termina. Pero cómo
podemos probarlo? En el programa muy similar (2.12), en el cual un proceso incrementaba x en 1 y el
otro en 2, pudimos debilitar aserciones para contar con el hecho de que los procesos ejecutaran en
cualquier orden. Sin embargo, esa técnica no funciona aquí. Aisladamente, cada una de las siguientes es
una tripla válida:
Sin embargo, P1 interfiere con ambas aserciones de la segunda tripla, y P2 interfiere con las de
la primera. Para que P2 ejecutara antes que P1, podemos debilitar las aserciones en P1:
Pero P1 aún interfiere con las aserciones de P2. Podemos tratar de debilitar las aserciones en
P2:
En (2.12) el hecho de que los procesos incrementaran x en distintos valores proveía suficiente
información para distinguir cuál proceso ejecutaba primero. Sin embargo, en (2.18) ambos procesos
incrementan x en 1, y entonces el estado x=1 no codifica cuál ejecuta primero. En consecuencia, el orden
de ejecución debe ser codificado explícitamente. Esto requiere introducir variables adicionales. Sean t1 y
t2 variables agregadas a (2.18) de la siguiente forma:
(2.19) var x := 0, t1 := 0, t2 := 0
co P1: 〈 x := x + 1 〉; t1 := 1 // P2: 〈 x := x + 1 〉; t2 := 1 oc
La proof outline para cada proceso es válida aisladamente. Dado que cada aserción es la
conjunción del invariante global y un predicado sobre una variable no referenciada por los otros procesos,
los procesos están libres de interferencia. Así, (2.20) es una proof outline válida.
El programa (2.20) no es el mismo que (2.18) ya que contiene dos variables extra. Sin embargo,
t1 y t2 son variables auxiliares. Fueron agregadas al programa sólo para registrar suficiente información
de estado para poder construir una prueba. En particular, t1 y t2 cumplen la siguiente restricción:
(2.21) Restricción de Variable Auxiliar. Las variables auxiliares aparecen solo en sentencias de
asignación x := e donde x es una variable auxiliar.
Página N° 15
Programación Concurrente - Cap. 2. ---------------------------------------------
Una variable auxiliar no puede aparecer en asignaciones a variables de programa o en guardas
en sentencias if, do, y await. Por lo tanto no pueden afectar la ejecución del programa al que se
agregan. Esto significa que el programa sin variables auxiliares tiene las mismas propiedades de
correctitud parcial (y total) que el programa con las variables auxiliares. La siguiente regla establece esto.
gramas concurrentes son exclusión mutua y ausencia de deadlock. Para exclusión mutua, lo
malo es tener más de un proceso ejecutando secciones críticas de sentencias al mismo tiempo. Para
ausencia de deadlock, lo malo es tener algunos procesos esperando condiciones que no ocurrirán nunca.
Ejemplos de propiedades de vida de programas concurrentes son que una pedido de servicio
eventualmente será atendido, que un mensaje eventualmente alcanzará su destino, y que un proceso
eventualmente entrará a su sección crítica. Las propiedades de vida están afectadas por las políticas de
scheduling, las cuales determinan cuales acciones atómicas elegibles son las próximas en ejecutarse.
Todas las acciones que toma un programa deben estar basadas en su estado. Aún las acciones
realizadas en respuesta a leer una entrada están basadas en el estado del programa dado que los
valores de entrada disponibles par un programa pueden pensarse como parte del estado. Así, si un
programa falla al satisfacer esta propiedad de seguridad, debe haber alguna secuencia de estados
(historia) que fallan al satisfacer esta propiedad.
Para las propiedades de seguridad que nos interesan (por ejemplo, corrección parcial, exclusión
mutua, y ausencia de deadlock) debe haber algún estado de programa individual que falla al satisfacer
esta propiedad. Por ejemplo, si la propiedad de exclusión mutua falla, debe haber algún estado en el cual
dos (o más) procesos están simultáneamente en sus secciones críticas. Página N° 16
Programación Concurrente - Cap. 2. ---------------------------------------------
Para las propiedades de seguridad que pueden ser especificadas por la ausencia de un mal
estado de programa, hay un método simple para probar que un programa satisface la propiedad. Sea
BAD un predicado que caracteriza un estado de programa malo. Entonces un programa satisface la
propiedad de seguridad asociada si BAD no es true en ningún estado del programa. Dado el programa S,
para mostrar que BAD no es true en ningún estado se requiere mostrar que no es true en el estado
inicial, en el segundo estado, y así siguiendo, donde el estado cambia como resultado de ejecutar
acciones atómicas elegibles. Las aserciones críticas en una prueba { P } S { Q } caracterizan los estados
inicial, intermedio y final. Esto provee la base para el siguiente método de prueba de que un programa
satisface una propiedad de seguridad.
(2.23) Prueba de una Propiedad de Seguridad. Sea BAD un predicado que caracteriza un estado de
programa malo. Asumimos que { P } S { Q } es una prueba en PL y que la precondición P caracteriza el
estado inicial del programa. Entonces S satisface la propiedad de seguridad especificada por ¬BAD si,
para toda aserción crítica C en la prueba, C ⇒ ¬BAD.
P debe caracterizar el estado inicial del programa para descartar la prueba trivial en la cual toda
aserción crítica es la constante false.
Recordemos que un invariante global I es un predicado que es true en cualquier estado visible
de un programa concurrente. Esto sugiere un método alternativo para probar una propiedad de
seguridad.
(2.24) Prueba de una Propiedad de Seguridad usando un Invariante. Sea BAD un predicado que
caracteriza un estado de programa malo. Asumimos que { P } S { Q } es una prueba en PL, que P
caracteriza el estado inicial del programa, y que I es un invariante global en la prueba. Entonces S
satisface la propiedad de seguridad especificada por ¬BAD si I ⇒ ¬BAD.
Supongamos que una propiedad de seguridad puede ser caracterizada por BAD = (P∧Q) y que
P y Q no son simultáneamente true en un programa dado; es decir, (P∧Q)=false. Entonces el programa
satisface la propiedad de seguridad. Esto es porque ¬BAD = ¬(P∧Q)=true, y cualquier aserción crítica en
una prueba implicará esto trivialmente. Esto lleva al siguiente método muy útil para probar una propiedad
de seguridad.
(2.25) Exclusión de Configuraciones. Dada una prueba de un programa, un proceso no puede están en
un estado que satisface P mientras otro proceso está en un estado que satisface Q si (P∧Q)=false.
Como ejemplo del uso de (2.25), consideremos la proof outline del programa que copia el arreglo. La
sentencia await en cada proceso puede causar demora. El proceso podría quedar en deadlock si ambos
estuvieran demorados y ninguno pudiera proceder. El proceso Productor es demorado si está en su
sentencia await y la condición de demora es falsa; en ese estado, el siguiente predicado sería true:
PC ∧ p < n ∧ p ≠ c
IC ∧ c < n ∧ p ≤ c
Dado que la conjunción de estos dos predicados es falsa, los procesos no pueden estar
simultáneamente en estos estados; por lo tanto no puede ocurrir deadlock.
Página N° 17
Programación Concurrente - Cap. 2. --------------------------------------------- Políticas de Scheduling y
Fairness
La mayoría de las propiedades de vida dependen de fairness, la cual trata de garantizar que los
procesos tengan chance de avanzar, sin importar lo que hagan los otros procesos. Recordemos que una
acción atómica en un proceso es elegible si es la próxima acción atómica en el procesos que será
ejecutado. Cuando hay varios procesos, hay varias acciones atómicas elegibles. Una política de
scheduling determina cuál será la próxima en ejecutarse.
Dado que las acciones atómicas pueden ser ejecutadas en paralelo solo si no interfieren, la
ejecución paralela puede ser modelizada por ejecución serial, interleaved. Por lo tanto para definir los
atributos formales de las políticas de scheduling, enfatizamos en esta sección el scheduling en un solo
procesador.
Una política de scheduling de bajo nivel, tal como la política de alocación de procesador en un
sistema operativo, concierne a la performance y utilización del hardware. Esto es importante, pero
igualmente importante son los atributos globales de las políticas de scheduling y sus efecto sobre la
terminación y otras propiedades de vida de los programas concurrentes. Consideremos el siguiente
programa con dos procesos, Loop y Stop:
Supongamos una política de scheduling que asigna un procesador a un proceso hasta que el
proceso termina o se demora. Si hay un solo procesador, el programa anterior no terminará si Loop se
ejecuta primero. Sin embargo, el programa terminará si eventualmente Stop tiene chance de ejecutar.
(2.26) Fairness Incondicional. Una política de scheduling es incondicionalmente fair si toda acción
atómica incondicional que es elegible eventualmente es ejecutada.
Para el programa anterior, round-robin sería una política incondicionalmente fair en un único
procesador, y la ejecución paralela sería incondicionalmente fair en un multiprocesador.
(2.27) Fairness Débil. Una política de scheduling es débilmente fair si es incondicionalmente fair y toda
acción atómica condicional que se vuelve elegible eventualmente es ejecutada si su guarda se convierte
en true y de allí en adelante permanece true.
Sin embargo, esto no es suficiente para asegurar que cualquier sentencia await elegible
eventualmente se ejecuta. Esto es porque la guarda podría cambiar el valor (de false a true y
nuevamente a false) mientras un proceso está demorado. En este caso, necesitamos una política de
scheduling más fuerte.
(2.28) Fairness Fuerte. Una política de scheduling es fuertemente fair si es incondicionalmente fair y
toda acción atómica condicional que se vuelve elegible eventualmente es ejecutada si su guarda es true
con infinita frecuencia.
Una guarda es true con infinita frecuencia si es true un número infinito de veces en cada historia
de ejecución de un programa (non-terminating). Para ser fuertemente fair, una política no puede
considerar seleccionar solo una acción cuando la guarda es false; debe seleccionar alguna vez la acción
cuando la guarda es true.
Página N° 18
Programación Concurrente - Cap. 2. ---------------------------------------------
Para ver la diferencia entre las políticas débiles y fuertes, consideremos el siguiente programa:
Con una política fuertemente fair, este programa eventualmente terminará ya que try es true con
infinita frecuencia. Sin embargo, con una política débilmente fair podría no terminar ya que try es también
false con infinita frecuencia.
Sin embargo, hay instancias especiales de políticas fuertemente fair y prácticas. Por ejemplo,
supongamos dos procesos que ejecutan repetidamente
〈 await s > 0 → s := s - 1 〉
y que otros procesos repetidamente incrementan s. También supongamos que los dos procesos
son scheduled en orden FCFS (es decir, cada vez que ambos están tratando de ejecutar await, el que
estuvo esperando más tiempo es scheduled primero). Entonces cada proceso progresará continuamente.
FCFS es una instancia especial de round-robin.
Ambas sentencias await en ese programa tienen la forma 〈 await B 〉, y B se refiere a solo una
variable alterada por el otro proceso. En consecuencia, ambas sentencias await pueden ser
implementadas por loops busy waiting. Por ejemplo, 〈 await p=c 〉 en el Productor puede ser
implementada por:
do p ≠ c → skip od
Página N° 19
Compartidas
Los loops son las sentencias más complejas en los programas secuenciales. Como ya
describimos, la clave para desarrollar y entender un loop es encontrar un invariante. Esto involucra
enfocar qué es lo que no cambia cada vez que el loop se ejecuta. El mismo punto de vista puede usarse
para resolver problemas de sincronización, que son los problemas claves en los programas concurrentes.
Los primeros tres pasos son esencialmente independientes de las primitivas de sincronización
disponibles. El punto de partida es dar con un invariante apropiado. Dado que todas las propiedades de
seguridad pueden ser expresadas como un predicado sobre los estados de programa, esto sugiere dos
maneras de encontrar un invariante: especificar un predicado BAD que caracterice un estado malo y
luego usar ¬BAD como invariante global; o directamente especificar un predicado GOOD que caracteriza
estados buenos y usarlo como invariante.
Dado un invariante, el segundo y tercer paso son mecánicos. El paso creativo es el primero, que
involucra usar algún mecanismo de sincronización específico para implementar las acciones atómicas.
Página N° 1
Fine-Grained
Recordemos que busy waiting es una forma de sincronización en la cual un proceso chequea
repetidamente una condición hasta que es verdadera. La ventaja es que podemos implementarlo usando
solo las instrucciones de máquina disponibles en cualquier procesador. Aunque es ineficiente cuando los
procesos se ejecutan por multiprogramación, puede ser aceptable y eficiente cuando cada proceso se
ejecuta en su propio procesador. Esto es posible en multiprocesadores. El hardware en sí mismo emplea
sincronización por busy waiting; por ejemplo, se usa para sincronizar transferencias de datos sobre buses
de memoria y redes locales.
Este capitulo examina problemas prácticos y muestra cómo resolverlos usando busy waiting. El
primer problema es el de la sección crítica. Se desarrollan 4 soluciones con distintas técnicas y distintas
propiedades. Las soluciones a este problema son importantes pues pueden usarse para implementar
sentencias await y por lo tanto acciones atómicas arbitrarias.
En este problema, n procesos repetidamente ejecutan una sección crítica de código, luego una
sección no crítica. La sección crítica está precedida por un protocolo de entrada y seguido de un
protocolo de salida.
Cada sección crítica es una secuencia de sentencias que acceden algún objeto compartido.
Cada sección no crítica es otra secuencia de sentencias. Asumimos que un proceso que entra en su
sección crítica eventualmente sale; así, un proceso solo puede finalizar fuera de su sección crítica.
Nuestra tarea es diseñar protocolos de entrada y salida que satisfagan las siguientes propiedades:
(3.2) Exclusión mutua. A lo sumo un proceso a la vez está ejecutando su sección crítica
(3.3) Ausencia de Deadlock. Si dos o más procesos tratan de entrar a sus secciones críticas, al menos
uno tendrá éxito.
(3.4) Ausencia de Demora Innecesaria. Si un proceso está tratando de entrar a su SC y los otros están
ejecutando sus SNC o terminaron, el primero no está impedido de entrar a su SC.
Página N° 2
Programación Concurrente - Cap. 3. --------------------------------------------- (3.5) Eventual Entrada. Un proceso
que está intentando entrar a su SC eventualmente lo hará.
La primera propiedad es de seguridad, siendo el estado malo uno en el cual dos procesos están
en su SC. En una solución busy-waiting, (3.3) es una propiedad de vida llamada ausencia de livelock.
Esto es porque los procesos nunca se bloquean (están vivos) pero pueden estar siempre en un loop
tratando de progresar. (Si los procesos se bloquean esperando para entrar a su SC, (3.3) es una
propiedad de seguridad). La tercera propiedad es de seguridad, siendo el estado malo uno en el cual el
proceso uno no puede continuar. La última propiedad es de vida ya que depende de la política de
scheduling.
En esta sección se desarrolla una solución que satisface las primeras tres propiedades. Esto es
suficiente para la mayoría de las aplicaciones ya que es poco probable que un proceso no pueda
eventualmente entrar a su SC.
Sean in1 e in2 variables booleanas. Cuando el proceso P1 está en su SC, in1 es true; en otro
caso, in1 es falsa. El proceso P2 e in2 están relacionadas de la misma manera. Podemos especificar la
propiedad de exclusión mutua por:
Este predicado tiene la forma de una propiedad de seguridad, donde lo malo es que in1 e in2
sean true a la vez. Agregando estas nuevas variables a (3.1) tenemos el siguiente outline de solución:
Este programa no resuelve el problema. Para asegurar que MUTEX es invariante, necesitamos
que sea true antes y después de cada asignación a in1 o in2. A partir de los valores iniciales asignados a
las variables, MUTEX es inicialmente true. Consideremos ahora el entry protocol en P1. Si MUTEX es
true después de esta asignación, antes de la asignación el estado debe satisfacer:
Página N° 3
Programación Concurrente - Cap. 3. ---------------------------------------------
Luego, necesitamos fortalecer el entry protocol en P1 reemplazando la asignación incondicional
por la acción atómica condicional:
Los procesos son simétricos, entonces usamos la misma clase de acción atómica condicional
para el entry protocol de P2.
Qué sucede con los protocolos de salida? En general, nunca es necesario demorar cuando se
deja la SC. Más formalmente, si MUTEX debe ser true luego de que el protocolo de salida de P1, la
precondición debe cumplir:
wp(in1 := false, MUTEX) = ¬(false ∧ in2) = true
Esto, por supuesto, es satisfecho por cualquier estado. La misma situación existe para el
protocolo de P2. Entonces, no necesitamos guardar los protocolos de salida.
Página N° 4
Programación Concurrente - Cap. 3. ---------------------------------------------
Finalmente, consideremos la propiedad de vida de que un proceso que intenta entrar a su SC
eventualmente es capaz de hacerlo. Si P1 está tratando de entrar pero no puede, P2 está en su SC e in2
es true. Como suponemos que un proceso que está en su SC eventualmente sale, in2 se volverá falsa y
la guarda de entrada de P1 será verdadera. Si P1 aún no puede entrar, es porque el scheduler es unfair o
porque P2 volvió a ganar la entrada. En la última situación, la historia se repite, e in2 en algún momento
se vuelve false. Así, in2 es true con infinita frecuencia (o P2 se detiene, en cuyo caso in2 se convierte y
permanece true. Una política de scheduling strongly fair es suficiente para asegurar que P1 va a entrar.
La argumentación para P2 es simétrica.
Para este problema, sea lock una variable booleana que indica cuando un proceso está en su
SC. O sea que lock es true cuando in1 o in2 lo son:
El significado de este cambio de variables es que casi todas las máquinas (especialmente
multiprocesadores) tienen alguna instrucción especial que puede usarse para implementar las acciones
atómicas condicionales de este programa (test-and-set, fetch-and-add, compare-and-swap). Por ahora
definimos y usamos Test-and-Set (TS):
Página N° 5
Programación Concurrente - Cap. 3. ---------------------------------------------
non-critical section od
Reemplazamos las acciones atómicas condicionales en la solución coarse-grained por loops que
no terminan hasta que lock es false, y por lo tanto TS setea cc a falso. Si ambos procesos están tratando
de entrar a su SC, solo uno puede tener éxito en ser el primero en setear lock en true; por lo tanto, solo
uno terminará su entry protocol. Cuando se usa una variable de lockeo de este modo, se lo llama spin
lock pues los procesos “dan vueltas” (spin) mientras esperan que se libere el lock.
La última solución tiene un atributo adicional que no tiene la primera solución planteada (con in1
e in2): resuelve el problema para cualquier número de procesos, no solo para dos. Esto es porque hay
solo dos estados de interés, independiente del número de procesos.
Además, la instrucción TS escribe en lock cada vez que es ejecutada, aún cuando el valor de
lock no cambie. Dado que la mayoría de los multiprocesadores emplean caches para reducir el tráfico
hacia la memoria primaria, esto hace a TS mucho más cara que una instrucción que solo lee una variable
compartida (al escribir un valor en un procesador, deben invalidarse o modificarse los caches de los otros
procesadores). El overhead por invalidación de cache puede reducirse modificando el entry protocol para
usar un protocolo test-and-test-and-set como sigue:
Aquí, un proceso solamente examina lock hasta que hay posibilidad de que TS pueda tener
éxito, Como lock solo es examinada en los dos loops adicionales, su valor puede ser leido desde un
cache local sin afectar a los otros procesadores. Sin embargo, memory contention sigue siendo un
problema. Cuando lock es limpiada, al menos uno y posiblemente todos los procesos demorados
ejecutarán TS, aunque solo uno puede proseguir. La próxima sección presenta una manera de atacar
este problema.
Página N° 6
Programación Concurrente - Cap. 3. --------------------------------------------- Implementación de Sentencias
Await
Cualquier solución al problema de la SC puede usarse para implementar una acción atómica
incondicional 〈 S 〉 ocultando puntos de control internos a los otros procesos. Sea CSenter un entry
protocol a una SC, y CSexit el correspondiente exit protocol. Entonces 〈 S 〉 puede ser implementada por:
CSenter S
CSexit
Esto asume que las SC en todos los procesos que examinan variables alteradas en S también
están protegidas por entry y exit protocols similares. En esencia, 〈 es reemplazada por CSenter, y 〉 por
CSexit.
El esqueleto de código anterior puede usarse como building block para implementar cualquier
acción atómica condicional 〈 await B → S 〉. Recordemos que una acción atómica condicional demora al
proceso hasta que B es true, luego ejecuta S. También, B debe ser true cuando comienza la ejecución de
S. Para asegurar que la acción entera es atómica, podemos usar un protocolo de SC para ocultar los
estados intermedios en S. Luego podemos usar un loop para testear B repetidamente hasta que sea true.
Luego, el esqueleto de código para implementar 〈 await B → S 〉 es:
CSenter do not B →
? od S CSexit
Asumimos que las SC en todos los procesos que alteran variables referenciadas en B o S o que
referencian variables alteradas en S están protegidas por protocolos similares.
Lo que resta es ver cómo implementar el cuerpo del loop. Si el cuerpo se ejecuta, B era falsa.
Luego, la única manera en que B se volverá true es si algún otro proceso altera una variable referenciada
en B. Como asumimos que cualquier sentencia en otro proceso que altera una variable referenciada en B
debe están en una SC, tenemos que salir de la SC mientras esperamos que B se vuelva true. Pero para
asegurar la atomicidad de la evaluación de B y la ejecución de S, debemos reentrar a la SC antes de
reevaluar B. Un refinamiento del anterior código es:
(3.8) CSenter
do not B → CSexit; CSenter od S
CSexit
Aunque (3.8) es correcta, es ineficiente. Esto es porque un proceso está “spinning” en un “hard
loop” (continuamente saliendo y entrando a la SC) aunque posiblemente no podrá proceder hasta que al
menos algún otro proceso altere una variable referenciada en B. Esto lleva a memory contention ya que
cada proceso demorado accede continuamente las variables usadas en los protocolos de SC y las
variables de B.
Para reducir el problema, es preferible para un proceso demorarse algún período antes de
reentrar a la SC. Sea Delay algún código que “enlentece” a un proceso. Podemos reemplazar (3.8) por el
siguiente protocolo para implementar una acción atómica condicional:
(3.9) CSenter
Página N° 7
Programación Concurrente - Cap. 3. ---------------------------------------------
do not B → CSexit; Delay; CSenter od S
CSexit
El código de Delay podría, por ejemplo, ser un loop vacío que itera un número aleatorio de
veces. Este clase de protocolo back-off también es útil dentro de los protocolos CSenter en si mismos;
por ej, puede ser agregado al loop de demora en el entry protocolo test-and-set.
do not B → skip od
Esta implementación también es suficiente si B permanece true una vez que se convirtió en true.
Como mencionamos al comienzo, la sincronización busy waiting con frecuencia es usada dentro
del hardware. De hecho, un protocolo similar a (3.9) se usa en los controladores Ethernet para
sincronizar el acceso a una LAN. En particular, para transmitir un mensaje, un controlador Ethernet
primero lo envía, luego escucha para ver si colisionó con otro mensaje de otro controlador. Si no hay
colisión, se asume que la transmisión fue exitosa. Si se detecta una colisión, el controlador se demora, y
luego intenta reenviar el mensaje. Para evitar una race condition en la cual dos controladores colisionan
repetidamente, la demora es elegida aleatoriamente en un intervalo que es doblado cada vez que ocurre
una colisión. Por lo tanto, esto es llamado protocolo “binary exponential back-off”. Este tipo de protocolos
es útil en (3.9) y en entry protocols de SC.
Solución Coarse-Grained
Como punto de partida, consideremos implementar cada sentencia await primero quedándonos
en un loop hasta que la guarda sea true, y luego ejecutando la asignación. El entry protocol para P1
sería:
Página N° 8
Programación Concurrente - Cap. 3. ---------------------------------------------
in1 := true
Análogamente, el entry protocol para P2 sería:
El problema con esta “solución” es que las dos acciones en los entry protocols no se ejecutan
atómicamente, por lo que no podemos asegurar exclusión mutua. Por ejemplo, la postcondición deseada
para el loop de demora en P1 es que in2 es falso. Desafortunadamente, esto es interferido por la
asignación in2:=true. Operacionalmente es posible para ambos procesos evaluar sus condiciones de
demora casi al mismo tiempo y encontrar que son true.
Dado que cada proceso quiere estar seguro que el otro no está en su SC cuando el do termina,
consideremos cambiar el orden de las sentencias en los entry protocols:
Esto ayuda pero aún no resuelve el problema. Se asegura exclusión mutua pero puede haber
deadlock: Si in1 e in2 son ambas true, ningún loop va a terminar. Sin embargo, hay una manera simple
de evitar deadlock: Usar una variable adicional para romper el empate si ambos procesos están
demorados.
Sea last una variable entera que indica cuál de P1 y P2 fue el último en comenzar a ejecutar su
entry protocol. Luego, si P1 y P2 están tratando de entrar a sus SC (in1 e in2 son true) el último proceso
en comenzar su entry protocol es demorado. Esto lleva a la siguiente solución coarse- grained:
Solución Fine-Grained
El algoritmo anterior está muy cercano a una solución fine-grained que no requiere sentencias
await. En particular, si cada await satisficiera los requerimientos de la propiedad de a-lo- sumo-una-vez
(2.4), podría ser implementada por loops busy-waiting. Desafortunadamente, cada await referencia dos
variables alteradas por otro proceso. Sin embargo, en este caso no es necesario que las condiciones de
demora sean evaluadas atómicamente. Informalmente esto es verdad por las siguientes razones.
Página N° 9
Programación Concurrente - Cap. 3. ---------------------------------------------
Consideremos las sentencias await en P1. Si la condición de demora es true, o in2 es false o
last es 2. La única manera que P2 podría hacer false la condición de demora es si ejecuta la primera
sentencia de su entry protocol, la cual setea in2 a true. Pero entonces la próxima acción de P2 es hacer
true nuevamente la condición seteando last a 2. Así, cuando P1 está en su SC, puede asegurarse que
P2 no estará en su SC. El argumento para P2 es simétrico.
Dado que las condiciones de demora no necesitan ser evaluadas atómicamente, cada await
puede ser reemplazada por un loop do que itera mientras la negación de la condición de demora es
false. Esto lleva al siguiente algoritmo tie-breaker fine-grained:
Para probar formalmente que el algoritmo es correcto, podemos introducir dos variables
auxiliares para registrar cuando cada proceso está entre las dos primeras asignaciones en su entry
protocol. Por ejemplo, podemos reemplazar las dos primeras asignaciones en P1 por:
Haciendo un cambio similar en P2, la siguiente aserción es verdadera cuando P1 está en su SC:
Solución N-Proceso
El anterior algoritmo tie-breaker resuelve el problema de la SC para dos procesos. Podemos usar
la idea básica para resolver el problema para cualquier número de procesos. En particular, si hay n
procesos, el entry protocol en cada proceso consiste de un loop que itera a través de n-1 etapas. En cada
etapa, usamos instancias del algoritmo tie-breaker para dos procesos para determinar cuales procesos
avanzan a la siguiente etapa. Si aseguramos que a lo sumo a un proceso a la vez se le permite ir a
través de las n-1 etapas, entonces a lo sumo uno a la vez puede estar en su SC.
Sean in[1:n] y last[1:n] arreglos enteros, donde n>1. El valor de in[i] indica cuál etapa está
ejecutando p[i]; el valor de last[j] indica cuál proceso fue el último en comenzar la etapa j. Estas variables
son usadas de la siguiente manera:
Página N° 10
Programación Concurrente - Cap. 3. ---------------------------------------------
# espera si el proceso k está en una etapa mayor y # el proceso
i fue el último en entrar a esta etapa do in[k] ≥ in[i] and last[j] = i
→ skip od af af critical section in[i] := 0 # exit protocol
non-critical section od
El for-all externo se ejecuta n-1 vez. El for-all interno en el proceso P[i] chequea los otros
procesos. En particular, P[i] espera si hay algún otro proceso en una etapa numerada igual o mayor y P[i]
fue el último proceso en entrar a la etapa j. Una vez que otro proceso entra a la etapa j o todos los
procesos “adelante” de P[i] dejaron su SC, P[i] puede pasar a la siguiente etapa. Así, a lo sumo n-1
procesos pueden haber pasado la primera etapa, n-2 la segunda, etc. Esto asegura que a lo sumo un
proceso a la vez puede completar las n-1 etapas y por lo tanto estar ejecutando su SC.
La solución n-proceso está libre de livelock, evita demora innecesaria, y asegura eventual
entrada. Estas propiedades se desprenden del hecho de que un proceso se demora solo si algún otro
proceso está adelante de él en el entry protocol, y de la suposición de que todo proceso sale de su SC.
En esta solución, el entry protocol ejecuta O(n2) instancias del algoritmo tie-breaker para dos
procesos. Esto es porque el for-all interno es ejecutado n-1 veces en cada una de las n-1 iteraciones del
loop externo. Esto sucede aún si solo un proceso está tratando de entrar a su SC. Hay una variación del
algoritmo que requiere ejecutar solo O(n*m) instancias del algoritmo tie-breaker si solo m procesos están
compitiendo para entrar a la SC. Sin embargo, el otro algoritmo tiene una varianza mucho mayor en
tiempo de demora potencial cuando hay contención.
Algunos negocios emplean el siguiente método para asegurar que los clientes son servidos en
orden de llegada. Luego de entrar al negocio, un cliente toma un número que es mayor que el tomado
por cualquier otro. El cliente luego espera hasta que todos los clientes con un número más chico sean
atendidos. Este algoritmo es implementado por un repartidor de números y por un display que indica qué
cliente está siendo servido. Si el negocio tiene un empleado, los clientes son servidos uno a la vez en
orden de llegada. Podemos usar esta idea para implementar un protocolo de SC fair.
Sean number y next enteros inicialmente 1, y sea turn[1:n] un arreglo de enteros, cada uno de
los cuales es inicialmente 0. Para entrar a su SC, el proceso P[i] primero setea turn[i] al valor corriente de
number y luego incrementa number. Son acciones atómicas simples para asegurar que los clientes
obtienen números únicos. El proceso P[i] luego espera hasta que el valor de next es igual a su número.
En particular, queremos que el siguiente predicado sea invariante:
El segundo conjuntor dice que los valores de turn que no son cero son únicos; entonces a lo
sumo un turn[i] es igual a next. Luego de completar su SC, P[i] incrementa next, nuevamente como una
acción atómica. El protocolo resulta en el siguiente algoritmo:
Página N° 11
Programación Concurrente - Cap. 3. ---------------------------------------------
var number := 1, next := 1, turn[1:n] : int := ( [n] 0 ) {
TICKET: ( P[i] está en su SC) ⇒ ( turn[i] = next) ∧
( ∀i,j : 1 ≤ i, j ≤ n, i ≠ j: turn[i] = 0 ∨ turn[i] ≠ turn[j] ) } P[i: 1..n] :: do true
→
〈 turn[i] := number; number := number + 1 〉 〈
await turn[i] := next 〉 critical section 〈 next :=
next + 1 〉 non-critical section od
El predicado TICKET es un invariante global ya que number es leído e incrementado como una
acción atómica y next es incrementada como una acción atómica. Por lo tanto a lo sumo un proceso
puede estar en su SC. La ausencia de deadlock y demora innecesaria se desprenden del hecho de que
los valores distintos de cero en turn son únicos. Finalmente, si el scheduling es débilmente fair, el
algoritmo asegura entrada eventual ya que una vez que una condición de demora se vuelve verdadera,
permanece verdadera.
A diferencia del algoritmo tie-breaker, el algoritmo ticket tiene un problema potencial que es
común en algoritmos que emplean incrementos en contadores: los valores de number y next son
ilimitados. Si el algoritmo corre un tiempo largo, se puede alcanzar un overflow. Para este algoritmo
podemos resolver el problema reseteando los contadores a un valor chico (digamos 1) cada vez que
sean demasiado grandes. Si el valor más grande es al menos tan grande como n, entonces los valores
de turn[i] se garantiza que son únicos.
Solución Fine-Grained
Algunas máquinas tienen instrucciones que retornan el viejo valor de una variable y la
incrementan o decrementan como una operación indivisible simple. Esta clase de instrucción hace
exactamente lo que requiere el algoritmo ticket. Como ejemplo específico, Fetch-and-Add es una
instrucción con el siguiente efecto:
En máquinas que no tienen una instrucción FA o similar, tenemos que usar otra aproximación. El
requerimiento clave en el algoritmo ticket es que cada proceso obtenga un número único. Si una máquina
tiene una instrucción de incremento atómica, podríamos considerar implementar el primer paso en el
entry protocol por:
Página N° 12
Programación Concurrente - Cap. 3. ---------------------------------------------
turn[i] := number; 〈 number := number + 1 〉
Esto asegura que number es incrementada correctamente, pero no asegura que los procesos
obtengan números únicos. En particular, cada proceso podría ejecutar la primera asignación casi al
mismo tiempo y obtener el mismo número. Así, es esencial que ambas asignaciones sean ejecutadas
como una acción atómica simple.
Ya vimos otras dos maneras de resolver el problema de la SC: spin locks y el algoritmo
tie-breaker. Cualquiera de estas podría usarse dentro del algoritmo ticket para hacer atómica la obtención
de número. En particular, sea CSenter un entry protocol de SC, y CSexit el correspondiente exit protocol.
Entonces podríamos reemplazar la sentencia FA por:
Aunque esta podría parecer una aproximación curiosa, en la práctica funcionaría bastante bien,
especialmente si se dispone de una instrucción como Test-and-Set para implementar CSenter y CSexit.
Con Test-and-Set, los procesos podrían obtener los números no en exactamente el orden que intentan (y
teóricamente un proceso podría quedarse dando vueltas para siempre) pero con probabilidad muy alta
cada proceso obtendría un número, y la mayoría en orden. Esto es porque la SC dentro de (3.10) es muy
corta, y por lo tanto un proceso no se demoraría en CSenter. La mayor fuente de demora en el algoritmo
ticket es esperar a que turn[i] sea igual a next.
El algoritmo ticket puede ser implementado directamente en máquinas que tienen una
instrucción como Fetch-and-Add. Si solo tenemos disponibles instrucciones menos poderosas, podemos
simular la parte de obtención del número del algoritmo ticket usando (3.10). Pero eso requiere usar otro
protocolo de SC, y la solución podría no ser fair. Presentaremos un algoritmo del tipo de ticket (llamado
bakery algorithm) que es fair y no requiere instrucciones de máquina especiales. El algoritmo es más
complejo que el ticket, pero ilustra una manera de romper empates cuando dos procesos obtienen el
mismo número.
Solución Coarse-Grained
En el algoritmo ticket, cada cliente obtiene un número único y luego espera a que su número sea
igual a next. El algoritmo bakery no requiere un “despachante” de números atómico y no usa un contador
next. En particular, cuando un cliente entra al negocio, primero mira alrededor a todos los otros clientes y
setea su número a uno mayor a cualquiera que ve. Luego espera a que su número sea menor que el de
cualquier otro cliente. Como en el algoritmo ticket, el cliente con el número más chico es el próximo en
ser servido. La diferencia es que los clientes se chequean uno con otro en lugar de con un contador
central next para decidir el orden de servicio.
Como en el algoritmo ticket, sea turn[1:n] un arreglo de enteros, cada uno de los cuales es
inicialmente 0. Para entrar a su SC, el proceso P[i] primero setea turn[i] a uno más que el máximo de los
otros valores de turn. Luego P[i] espera hasta que turn[i] sea el más chico de los valores no nulos de turn.
Así, el algoritmo bakery mantiene invariante el siguiente predicado:
Página N° 13
Programación Concurrente - Cap. 3. ---------------------------------------------
〈 turn[i] := max(turn[1:n]) + 1 〉 fa
j := 1 to n st j ≠ i →
〈 await turn[j] = 0 or turn[i] < turn[j] 〉 af critical section 〈 turn[i] := 0 〉
non-critical section od
La primera acción atómica garantiza que los valores no nulos de turn son únicos. La sentencia
for-all asegura que el consecuente en el predicado BAKERY es true cuando P[i] está ejecutando su SC.
El algoritmos satisface la propiedad de exclusión mutua pues turn[i] ≠ 0, turn[j] ≠ 0, y BAKERY no pueden
ser todos verdaderos a la vez. No puede haber deadlock pues los valores no nulos de turn son únicos, y
como es usual asumimos que cada proceso eventualmente sale de su SC. Los procesos no son
demorados innecesariamente pues turn[i] es 0 cuando P[i] está fuera de su SC. Finalmente, el algoritmo
asegura entrada eventual si el scheduling es débilmente fair pues una vez que una condición de demora
se convierte en true, permanece true.
Los valores de turn en el algoritmo bakery pueden volverse arbitrariamente grandes. A diferencia
del algoritmo ticket, este problema no puede ser resuelto “ciclando” sobre un conjunto finito de enteros.
Sin embargo, turn[i] sigue agrandándose solo si siempre hay al menos un proceso tratando de entrar a
su SC. Este no es un problema práctico pues significa que los procesos están gastando demasiado
tiempo para entrar a sus SC. En este caso, es inapropiado usar busy waiting.
Solución Fine-Grained
Cuando n procesos necesitan sincronizar, con frecuencia es útil desarrollar una solución para n =
2 y luego generalizar esa solución. Este fue el caso para el algoritmo tie-breaker y nuevamente es útil
aquí ya que ayuda a ilustrar los problemas que hay que resolver. Consideremos la siguiente versión de
dos procesos del algoritmo bakery coarse-grained:
Aquí, cada proceso setea su valor de turn con una versión optimizada de (3.10), y las sentencias
await son implementadas tentativamente por un loop busy-waiting.
El problema con esta “solución” es que ni las sentencias de asignación en los entry protocols ni
las guardas del do loop satisfacen la propiedad de A-lo-sumo-una-vez (2.4), por lo que no serán
evaluadas atómicamente. En consecuencia, los procesos podrían comenzar sus entry protocols casi al
mismo tiempo, y ambos podrían setear turn1 y turn2 a 1. Si ocurre esto, ambos procesos podrían estar
en su SC al mismo tiempo.
Página N° 14
Programación Concurrente - Cap. 3. ---------------------------------------------
El algoritmo tie-breaker para dos procesos sugiere una solución parcial al problema de (3.11): si
turn1 y turn2 son ambos 1, se deja a uno de los procesos seguir y se demora al otro. Por ejemplo,
dejemos seguir al proceso de menor número fortaleciendo el segundo conjuntor en el loop de demora en
P2 a turn2 ≥ turn1.
Desafortunadamente, aún es posible para ambos procesos entrar a su SC. Por ejemplo,
supongamos que P1 lee turn2 y obtiene 0. Luego supongamos que P2 comienza su entry protocol, ve
que turn1 aún es 0, setea turn2 a 1, y luego entra a su SC. En este punto, P1 puede continuar su entry
protocol, setea turn1 a 1, y luego entra a su SC pues turn1 y turn2 son 1 y P1 tiene precedencia en este
caso. Esta clase de situación es llamada race condition pues P2 “raced by” P1 y por lo tanto P1 se perdió
ver que P2 estaba cambiando turn2.
Para evitar esta race condition, podemos hacer que cada proceso setee su valor de turn a 1 (o
cualquier otro valor no nulo) al comienzo de su entry protocol. Luego examina los otros valores de turn y
resetea el suyo. La solución es la siguiente:
Un proceso no puede salir de su loop do hasta que el otro terminó de setear su valor de turn si
está en el medio de hacer esto. La solución da a P1 precedencia sobre P2 en caso de que ambos tengan
el mismo valor (no nulo) para turn. Cuando P1 está en su SC, el siguiente predicado es true:
Los procesos en la solución anterior no son simétricos ya que las condiciones de demora en el
segundo loop son apenas diferentes. Sin embargo, podemos reescribirlas en una forma simétrica como
sigue. Sean (a,b) y (c,d) pares de enteros, y definamos la relación mayor que entre tales pares como
sigue:
Luego podemos reescribir turn1 > turn2 en P1 como (turn1,1) > (turn2,2) y podemos reescribir
turn2 ≥ turn1 en P2 como (turn2,2) > (turn1,1).
La virtud de una especificación simétrica es que ahora es fácil generalizar el algoritmo bakery de
dos procesos para n procesos:
Página N° 15
Programación Concurrente - Cap. 3. --------------------------------------------- ( ∀,j : 1 ≤ j ≤ n, j ≠ i: turn[j] = 0 ∨
turn[i] < turn[j]
∨ (turn[i] = turn[j] ∧ i < j) ) ) } P[i: 1..n] :: var j : int
do true →
turn[i] := 1; turn[i] := max(turn[1:n]) + 1 fa
j := 1 to n st j ≠ i →
do turn[j] ≠ 0 and (turn[i],i) > (turn[j],j) → skip od af critical section turn[i] := 0
non-critical section od
La solución emplea un loop for-all como en la solución coarse-grained de modo que un proceso
se demora hasta que tiene precedencia sobre todos los otros. El predicado BAKERY es un invariante
global que es casi idéntico al invariante global de la solución coarse-grained. La diferencia está en la
tercera línea, la cual refleja el hecho de que, en la solución fine-grained, dos procesos podrían tener el
mismo valor para su elemento de turn. Aquí se da precedencia al proceso de índice menor.
SINCRONIZACION BARRIER
Muchos problemas pueden ser resueltos usando algoritmos iterativos que sucesivamente
computan mejores aproximaciones a una respuesta, terminando o cuando la respuesta final fue
computada o (en el caso de muchos algoritmos numéricos) cuando la respuesta final ha convergido.
Típicamente tales algoritmos manipulan un arreglo de valores, y cada iteración realiza la misma
computación sobre todos los elementos del arreglo. Por lo tanto, podemos usar múltiples procesos para
computar partes disjuntas de la solución en paralelo.
Un atributo clave de la mayoría de los algoritmos iterativos paralelos es que cada iteración
típicamente depende de los resultados de la iteración previa. Una manera de estructurar tal algoritmo es
implementar el cuerpo de cada iteración usando una o mas sentencias co. Ignorando terminación, y
asumiendo que hay n tareas paralelas en cada iteración, esta aproximación tiene la forma general:
do true →
co i := 1 to n → código para implementar tarea i oc od
Este tipo de sincronización es llamada barrier synchronization dado que el punto de demora al
final de cada iteración representa una barrera a la que todos los procesos deben arribar antes de que se
les permita pasar.
Página N° 16
Programación Concurrente - Cap. 3. --------------------------------------------- Contador Compartido
La manera más simple de especificar los requerimientos para una barrera es emplear un entero
compartido, count, el cual es inicialmente 0. Asumimos que hay n procesos worker que necesitan
encontrarse en una barrera. Cuando un proceso llega a la barrera, incrementa count. Por lo tanto, cuando
count es n, todos los procesos pueden continuar. Para especificar esto precisamente, sea passed[i] una
variable booleana que inicialmente es false; Worker[i] setea passed[i] a true cuando ha pasado la barrera.
Entonces la propiedad de que ningún worker pase la barrera hasta que todos hayan arribado es
asegurada si el siguiente predicado es un invariante global:
COUNT :: ( ∀ i : 1 ≤ i ≤ n : passed[i] ⇒ count = n )
Si los procesos usan count y passed como definimos, y guardando las asignaciones a passed
para asegurar que COUNT es invariante, tenemos la siguiente solución parcial:
Aquí, passed es una variable auxiliar que es usada solo para especificar la propiedad de barrera.
Después de borrarla del programa, podemos implementar la sentencia await por un loop busy-waiting.
También, muchas máquinas tienen una instrucción de incremento indivisible. Por ejemplo, usando la
instrucción Fetch-and-Add podemos implementar la barrera anterior por:
FA(count,1) do count ≠ n →
skip od
Pero el programa anterior no resuelve totalmente el problema. La dificultad es que count debe
ser 0 al comienzo de cada iteración. Por lo tanto, count necesita ser reseteada a 0 cada vez que todos
los procesos han pasado la barrera. Más aún, tiene que ser reseteada antes de que cualquier proceso
trate nuevamente de incrementar count.
Es posible resolver este problema de “reset” empleando dos contadores, uno que cuenta hasta n
y otro que cuenta hacia abajo hasta 0, con los roles de los contadores switcheados después de cada
etapa. Sin embargo, hay problemas adicionales, pragmáticos, con el uso de contadores compartidos.
Primero, tienen que ser incrementados y/o decrementados como acciones atómicas. Segundo, cuando
un proceso es demorado en (3.12), está examinando continuamente count. En el peor caso, n - 1
procesos podrían estar demorados esperando que el n-ésimo proceso llegue a la barrera. Esto podría
llevar a memory contention, excepto en multiprocesadores con caches coherentes. Pero aún así, el valor
de count está cambiando continuamente, por lo que cada cache necesita ser actualizado. Así, es
apropiado implementar una barrera usando contadores solo si la máquina destino tiene instrucciones de
incremento atómicas, caches coherentes, y actualización de cache eficiente. Más aún, n debería ser
relativamente chico (a lo sumo 30).
Flags y Coordinadores
Página N° 17
Programación Concurrente - Cap. 3. ---------------------------------------------
Con el cambio anterior, los problemas que restan son implementar la sentencia await en (3.12) y
resetear los elementos de arrive al final de cada iteración. Usando la relación (3.13) e ignorando la
variable auxiliar passed, la sentencia await puede ser implementada como:
Sin embargo, esto reintroduce memory contention. Además es ineficiente ya que la suma de los
arrive[i] está siendo computada continuamente por cada Worker que está esperando.
Podemos resolver los problemas de contención y reset usando un conjunto adicional de valores
compartidos y empleando un proceso adicional, Coordinator. En lugar de que cada Worker tenga que
sumar y testear los valores de arrive, hacemos que cada Worker espere que un único valor se convierta
en true. En particular, sea continue[1:n] otro arreglo de enteros, inicializado en 0. Después de setear
arrive[i] en 1, Worker[i] se demora esperando que continue[i] sea seteada en 1:
(3.14) arrive[i] := 1
〈 await continue[i] = 1 〉
El proceso Coordinator espera a que todos los elementos de arrive se vuelvan 1, luego setea
todos los elementos de continue en 1:
Las sentencias await en (3.14) y (3.15) pueden ser implementadas por do loops dado que cada
una referencia una única variable compartida. También, el Coordinator puede usar una sentencia for-all
para esperar que cada elemento de arrive sea seteado; dado que todos deben ser seteados antes de que
a cualquier Worker se le permita continuar, el Coordinator puede testear el arreglo arrive en cualquier
orden. Finalmente, la contención de memoria no es un problema pues los procesos esperan que distintas
variables sean seteadas y estas variables podrían estar almacenadas en distintas unidades de memoria.
Las variables arrive y continue son llamadas flag variables. Esto es porque cada variable es
alcanzada por un proceso para señalar que una condición de sincronización es true. El problema
remanente es aumentar (3.14) y (3.15) con código para limpiar los flags reseteándolos a 0 en preparación
para la próxima iteración. Aquí se aplican dos principios generales.
(3.16) Flag Synchronization Principles. El proceso que espera a que un flag de sincronización sea
seteado es el que debería limpiar el flag. Un flag no debería ser seteado hasta que se sabe que está
limpio. La primera parte del principio asegura que un flag no es limpiado antes de que se vio que fue
seteado. Así, en (3.14) Worker[i] debería limpiar continue[i], y en (3.15) Coordinator debería limpiar todos
los elementos de arrive. La segunda parte del principio asegura que otro proceso no puede setear
nuevamente el mismo flag antes de que el flag sea limpiado, lo cual podría llevar a deadlock si el primer
proceso espera a que el flag sea seteado nuevamente. En (3.15) esto significa que Coordinator debería
limpiar arrive[i] antes de setear continue[i]. El Coordinator puede hacer esto ejecutando otra sentencia
for-all después de la primera en (3.15). Alternativamente, Coordinator puede limpiar arrive[i]
inmediatamente después de que esperó a que sea seteada. Agregando el código para limpiar los flags,
tenemos la siguiente solución:
Página N° 18
Programación Concurrente - Cap. 3. ---------------------------------------------
continue[i] := 0 od Coordinator :: var i :
int do true →
fa i := 1 to n → 〈 await arrive[i] = 1 〉; arrive[i] := 0 af fa i :=
1 to n → continue[i] := 1 af od
Aunque esto implementa barrier synchronization en una forma que evita la contención de
memoria, la solución tiene dos atributos indeseables. Primero, requiere un proceso extra. Dado que la
sincronización busy-waiting es ineficiente a menos que cada proceso ejecute en su propio procesador, el
Coordinator debería ejecutar en su propio procesador, el cual no está disponible para la ejecución de otro
proceso que podría estar haciendo trabajo “útil”.
Podemos solucionar estos problemas combinando las acciones del coordinador y los workers de
modo que cada worker sea también un coordinador. En particular, podemos organizar los workers en un
árbol. Entonces podemos hacer que los workers envíen señales de arribo hacia arriba en el árbol y
señales de continue hacia abajo. En particular, un nodo worker primero espera a que sus hijos arriben,
luego le dice a su nodo padre que él también arribó. Cuando el nodo raíz sabe que sus hijos han
arribado, sabe que todos los otros workers también lo han hecho. Por lo tanto la raíz puede decirle a sus
hijos que continúen; estos a su vez pueden decirle a sus hijos que continúen, etc. Las acciones
específicas de cada clase de proceso worker se ven en el siguiente programa. (Las sentencias await
pueden ser implementadas por spin loops):
Esta implementación es llamada combining tree barrier. Esto es porque cada proceso combina
los resultados de sus hijos, y luego se los pasa a su padre. Dado que la altura del árbol es (log2n), la
aproximación es buena. En particular, usa el mismo número de variables que el coordinador centralizado,
pero es mucho más eficiente para un n grande.
En multiprocesadores que usan broadcast para actualizar caches, podemos hacer más eficiente
esta solución si el nodo raíz hace broadcast de un único mensaje que le dice a todos los otros nodos que
continúen. En particular, la raíz setea un flag continue, y todos los otros nodos esperan que sea seteado.
Este flag continue puede luego ser limpiado de dos maneras: Una es usar doble buffering (usar dos flags
continue y alternar entre ellos); la otra es alternar el sentido del flag continue (en rondas impares esperar
a que sea seteada en 1, y en rondas pares esperar a que sea 0).
Página N° 19
Programación Concurrente - Cap. 3. --------------------------------------------- Barreras Simétricas
En combining tree barrier, los procesos juegan distintos roles. En particular, los nodos en el
interior ejecutan más acciones que las hojas o la raíz. Más aún, el nodo raíz necesita esperar el arribo de
señales de arribo para propagarlas hacia arriba en el árbol. Si cada proceso está ejecutando en un
procesador diferente y está ejecutando el mismo algoritmo (que es el caso de los algoritmos iterativos
paralelos) entonces todos los procesos deberían arribar a la barrera casi al mismo tiempo. Así, si cada
proceso toma la misma secuencia de acciones cuando alcanza una barrera, entonces todos podrían ser
capaces de seguir en paralelo. Esta sección presenta dos barreras simétricas. Son especialmente
adecuadas para multiprocesadores de memoria compartida con tiempo de acceso a memoria no
uniforme.
Una barrera simétrica para n procesos se construye a partir de pares de barreras simples para
dos procesos. Para construir una barrera de dos procesos, podríamos usar la técnica coordinador/worker.
Sin embargo, las acciones de los dos procesos serían diferentes. En lugar de esto, podemos construir
una barrera completamente simétrica como sigue. Sea que cada proceso tiene un flag que setea cuando
arriba a la barrera. Luego espera a que el otro proceso setee su flag y finalmente limpia la bandera del
otro. Si P[i] es un proceso y P[j] es el otro, la barrera simétrica de dos procesos es implementada como
sigue:
La segunda, tercera y cuarta líneas en cada proceso sigue los principios de flags de
sincronización (3.16). La primera línea se necesita para guardar contra un proceso racing back la barrera
y seteando su flag antes de que otro proceso limpió su flag.
La pregunta ahora es cómo combinar las barreras para dos procesos para construir una barrera
n-proceso. Sean Worker[1:n] los n procesos. Si n es potencia de 2, podríamos combinarlos como
muestra la siguiente figura:
Workers 1 2 3 4 5 6 7 8
└───────┘
└───────────────┘ Etapa 3
└───────────────┘ └───────────────┘
└───────────────┘
Esta clase de barrera se llama butterfly barrier debido a la forma del patrón de interconexión, el
cual es similar al patrón de interconexión para la transformada de Fourier.
Una butterfly barrier tiene log2n etapas. Cada Worker sincroniza con un Worker distinto en cada
etapa. En particular, en la etapa s un Worker sincroniza con un Worker a distancia 2s-1. Cada una de las
barreras de dos procesos es implementada como muestra la Fig. 3.17 (ver texto), y se usan distintas
variables flag para cada barrera de dos procesos. Cuando cada Worker pasó a través de log2n etapas,
todos los Workers deben haber arribado a la barrera y por lo tanto todos pueden seguir. Esto es porque
cada Worker ha sincronizado directa o indirectamente con cada uno de los otros.
Página N° 20
Programación Concurrente - Cap. 3. ---------------------------------------------
Cuando n no es potencia de 2, puede construirse una butterfly barrier usando la siguiente
potencia de 2 mayor que n y teniendo procesos Worker que sustituyan los perdidos en cada etapa. Esto
no es muy eficiente. En general, es mejor usar lo que se llama dissemination barrier. Nuevamente hay
etapas, y en la etapa s un Worker sincroniza con uno a distancia 2 s-1. Sin embargo, cada barrera de dos
procesos es implementada ligeramente diferente a la butterfly barrier. En particular, cada Worker setea su
flag de arribo para un Worker a su derecha (módulo n) y espera el flag de arribo de un Worker a su
izquierda (módulo n) y luego lo limpia. esta clase de barrera es llamada dissemination barrier pues está
basada en una técnica para diseminar información a n procesos en log2n rondas. En este caso, cada
Esta sección desarrolla soluciones paralelas a tres problemas: sumas parciales de un arreglo,
encontrar el final de una lista enlazada, y etiquetado de regiones. Estos ilustran las técnicas básicas que
se encuentran en los algoritmos paralelos de datos y el uso de sincronización barrier. Al final de la
sección se describen los multiprocesadores SIMD y cómo remueven muchas fuentes de interferencia y
por lo tanto remueven la necesidad de programar barreras.
Con frecuencia es útil aplicar una operación a todos los elementos de un arreglo. Por ejemplo,
para computar el promedio de un arreglo de valores a[1:n], primero necesitamos sumar todos los
elementos, y luego dividir por n. O podríamos querer saber los promedios de todos los prefijos a[1:i] del
arreglo, lo cual requiere computar las sumas de todos los prefijos. A causa de la importancia de esta
clase de computación, el lenguaje APL provee operadores especiales llamados reducción y scan.
En esta sección se muestra cómo computar en paralelo las sumas de todos los prefijos de un
arreglo. Esto es llamado una computación parallel prefix. El algoritmo básico puede usarse para
cualquier operador binario asociativo (suma, multiplicación, operadores lógicos, o máximo). En
consecuencia, estas computaciones son útiles en muchas aplicaciones, incluyendo procesamiento de
imágenes, computaciones de matrices, y parsing en un lenguaje regular.
Supongamos que tenemos un arreglo a[1:n] y queremos obtener sum[1:n], donde sum[i] es la
suma de los primeros i elementos de a. La manera obvia de resolver este problema secuencialmente es
iterar a través de los dos arreglos:
En particular, cada iteración suma a[i] a la suma ya computada de los i-1 elementos anteriores.
Consideremos cómo podemos paralelizar esta aproximación. Si nuestra tarea fuera solo buscar
la suma de todos los elementos, podríamos proceder como sigue. Primero, sumar pares de elementos en
paralelo; por ejemplo, sumar a[1] y a[2] en paralelo con la suma de otros pares. Segundo, combinar los
resultados del primer paso, nuevamente en pares; por ejemplo, sumar la suma de a[1] y a[2] con la suma
de a[3] y a[4] en paralelo con la computación de otras sumas parciales. Si seguimos este proceso, en
cada paso doblaríamos el número de elementos que han sido sumados. Así, en (log2n) pasos podríamos
haber computado la suma de todos los elementos. Esto es lo mejor que podemos hacer si tenemos que
combinar todos los elementos de a dos a la vez.
Página N° 21
Programación Concurrente - Cap. 3. ---------------------------------------------
Para computar la suma de todos los prefijos en paralelo, podemos adaptar esta técnica de doblar
el número de elementos que han sido sumados. Primero, seteamos todos los sum[i] a a[i]. Luego, en
paralelo sumamos sum[i-1] a sum[i], para todo i > 1. En particular, sumamos los elementos que están a
distancia 1. Ahora doblamos la distancia, sumando sum[i-2] a sum[i], en este caso para todo i > 2. Si
seguimos doblando la distancia, entonces después de (log2n) rondas habremos computado todas las
sumas parciales. Como ejemplo, la siguiente tabla ilustra los pasos del algoritmo para un arreglo de 6
elementos:
Cada proceso primero inicializa un elemento de sum. Luego repetidamente computa sumas
parciales. En el algoritmo, barrier representa un punto de sincronización barrier implementado usando
uno de los algoritmos ya vistos.
Se necesita que las barreras en este algoritmo eviten interferencia. Por ejemplo, todos los
elementos de sum necesitan ser inicializados antes de que cualquier proceso los examine. Además, cada
proceso necesita hacer una copia del viejo valor de sum[i] antes de actualizar ese valor. El invariante
SUM especifica cuánto del prefijo de a ha sumado cada proceso en cada iteración.
Como dijimos, podemos modificar este algoritmo para usar cualquier operador binario asociativo.
Todo lo que necesitamos cambiar es el operador en la sentencia que modifica sum. Dado que hemos
escrito la expresión como old[i-d] + sum[i], el operador binario no necesita ser conmutativo. También
podemos adaptar el algoritmo para usar menos de n procesos. En este caso, cada proceso sería
responsable de computar las sumas parciales de una parte del arreglo.
Al trabajar con estructuras de datos enlazados tales como árboles, los programadores con
frecuencia usan estructuras balanceadas tales como árboles binarios para ser capaces de buscar e
insertar ítems en tiempo logarítmico. Sin embargo, usando algoritmos paralelos de datos aún muchas
operaciones sobre listas lineales pueden ser implementadas en tiempo logarítmico. Aquí se muestra
cómo encontrar el final de una lista enlazada serialmente. La misma clase de algoritmo puede usarse
para otras operaciones sobre listas, como computar todas las sumas parciales, insertar un elemento en
una lista con prioridades, o hacer matching entre elementos de dos listas.
Supongamos que tenemos una lista de hasta n elementos. Los links son almacenados en el
arreglo link[1:n], y los valores de los datos en data[1:n]. La cabeza de la lista es apuntada por la variable
head. Si el elemento i es parte de la lista, entonces head = i o link[j] = i para algún j, 1 ≤ j ≤ n. El campo
link del último elemento es un puntero nulo, el cual representaremos con 0. También asumimos que los
campos link de los elementos que no están en la lista son punteros nulos y que la lista ya está
inicializada.
Página N° 22
Programación Concurrente - Cap. 3. ---------------------------------------------
El problema es encontrar el final de la lista. El algoritmo secuencial comienza en el elemento
head y sigue los links hasta encontrar un enlace nulo; el último elemento visitado es el final de la lista. El
tiempo de ejecución del algoritmo secuencial es proporcional a la longitud de la lista. Sin embargo,
podemos encontrar el final en un tiempo proporcional al logaritmo de la longitud de la lista usando un
algoritmo paralelo y la técnica de doblar introducida en la sección previa.
Asignamos un proceso Find a cada elemento de la lista. Sea end[1:n] un arreglo compartido de
enteros. Si el elemento i es una parte de la lista, el objetivo de Find[i] es setear end[i[ en el índice del final
del último elemento de la lista; en otro caso Find[i] debería setear end[i] en 0. Para evitar casos
especiales, asumiremos que la lista contiene al menos dos elementos.
Inicialmente cada proceso setea end[i] en link[i], es decir, al índice del próximo elemento en la
lista (si lo hay). Así, end inicialmente reproduce el patrón de links en la lista. Luego los procesos ejecutan
una serie de rondas. En cada ronda, un proceso mira end[end[i]]. Si tanto esto como end[i] son no nulos,
entonces el proceso setea end[i] a end[end[i]]. Después de la primera ronda, end[i] apuntará a un
elemento de la lista a dos links de distancia (si lo hay). Después de dos rondas, end[i] apuntará a un
elemento a 4 links de distancia (si hay uno). Luego de log2n rondas, cada proceso habrá encontrado el
final de la lista.
Computación de grillas
inicializar la matriz do
no terminó →
computar un nuevo valor para cada punto chequear
terminación od
Página N° 23
Programación Concurrente - Cap. 3. ---------------------------------------------
Típicamente, en cada iteración los nuevos valores de los puntos pueden computarse en paralelo.
Como ejemplo, se presenta una solución a la ecuación de Laplace en dos dimensiones: ∆2(φ) =
0. Sea grid(0:n+1, 0:n+1) una matriz de puntos. Los bordes de grid representan los límites de una región
bidimensional. Los elementos interiores de grid corresponden a una red que se superpone a la región. El
objetivo es computar los valores de estado seguro de los puntos interiores. Para la ecuación de Laplace,
podemos usar un método diferencial finito tal como la iteración de Jacobi. En particular, en cada iteración
computamos un nuevo valor para cada punto interior tomando el promedio de los valores previos de sus
4 vecinos más cercanos. Este método es estacionario, por lo tanto podemos terminar la computación
cuando el nuevo valor para cada punto está dentro de alguna constante EPSILON de su valor previo. El
siguiente algoritmo presenta una computación de grilla que resuelve la ecuación de Laplace:
Nuevamente usamos barreras para sincronizar los pasos de la computación. En este caso hay
tres pasos por iteración: actualizar newgrid, chequear convergencia, y luego mover los contenidos de
newgrid a grid. Se usan dos matrices para que los nuevos valores de los puntos de la grilla dependa solo
de los viejos valores. La computación termina cuando los valores de newgrid están todos dentro de
EPSILON de los de grid. Estas diferencias obviamente pueden chequearse en paralelo, pero los
resultados necesitan ser combinados. Esto puede hacerse usando una computación de prefijos paralela.
Aunque hay un alto grado de paralelismo potencial en una computación de grilla, en la mayoría
de las máquinas no puede aprovecharse totalmente. En consecuencia, es típico particionar la grilla en
bloques y asignar un proceso (y procesador) a cada bloque. Cada proceso maneja su bloque de puntos;
los procesos interactúan como en el algoritmo anterior.
Multiprocesadores sincrónicos
Aunque las máquinas MIMD son los multiprocesadores más usados y flexibles, también hay
disponibles máquinas SIMD como la Connection Machene. Una SIMD tiene múltiples flujos de datos pero
sólo un flujo de instrucción. En particular, cada procesador ejecuta exactamente la misma secuencia de
instrucciones, y lo hacen en un lock step. Esto hace a las máquinas SIMD especialmente adecuadas para
ejecutar algoritmos paralelos de datos. Por ejemplo, en una SIMD, el algoritmo para computar todas las
sumas parciales de un arreglo se simplifica a:
Página N° 24
Programación Concurrente - Cap. 3. ---------------------------------------------
No necesitamos programar las barreras pues cada proceso ejecuta las mismas instrucciones al
mismo tiempo; entonces cada instrucción, y por lo tanto cada referencia a memoria está seguida por una
barrera implícita. Además, no necesitamos usar variables extra para mantener los viejos valores. En la
asignación a sum[i], cada proces(ad)o(r) busca los valores viejos de sum antes de que cualquiera asigne
nuevos valores. Por lo tanto una SIMD reduce algunas fuentes de interferencia haciendo que la
evaluación de expresiones paralela aparezca como atómica.
Es tecnológicamente mucho más fácil construir una máquina SIMD con un número masivo de
procesadores que construir una MIMD masivamente paralela. Esto hace a las SIMD atractivas para
grandes problemas que pueden resolverse usando algoritmos paralelos de datos. Sin embargo, el
desafío para el programador es mantener a los procesadores ocupados haciendo trabajo útil. En el
algoritmo anterior, por ejemplo, cada vez menos procesadores actualizan sum[i] en cada iteración. Los
procesadores para los que la guarda de la sentencia if es falsa simplemente se demoran hasta que los
otros completen la sentencia if. Aunque las sentencias condicionales son necesarias en la mayoría de los
algoritmos, reducen la eficiencia en las máquinas SIMD.
IMPLEMENTACION DE PROCESOS
Hemos usado la sentencia co y procesos y los seguiremos usando. Esta sección muestra cómo
implementarlos. Primero, damos una implementación para un único procesador. Luego generalizamos la
implementación para soportar la ejecución de procesos en sobre un multiprocesador con memoria
compartida.
S0 co P1 : S1 // ..... // Pn : Sn oc Sn+1 Los Pi son nombres de procesos. Los Si son listas de sentencias y
declaraciones opcionales de variables locales del proceso. Necesitamos tres mecanismos diferentes para
implementar (3.26):
Una primitiva es una rutina que es implementada por un kernel de manera tal que aparece como
una instrucción atómica. Crearemos y destruiremos procesos con dos primitivas del kernel: fork y quit.
Cuando un proceso invoca fork, es creado otro proceso y se hace elegible para ejecución. Los
argumentos de fork indican las direcciones de la primera instrucción a ser ejecutada por el nuevo
proceso y cualquier otro dato necesario para especificar su estado inicial (por ejemplo, parámetros).
Cuando un proceso invoca quit, deja de existir.
Página N° 25
Programación Concurrente - Cap. 3. ---------------------------------------------
Un kernel usualmente provee una tercera primitiva para permitirle a un proceso demorarse hasta
que otro proceso termina. Sin embargo, dado que en este capítulo los procesos se sincronizan por medio
de busy waiting, detectaremos terminación usando un arreglo global de variables booleanas. En
particular, podemos usar estas variables globales, fork y quit para implementar (3.26) como sigue:
fa i := 1 to n → fork(Pi) af fa i := 1 to n → do not
el siguiente código:
Asumimos que el proceso principal en (3.27) es creado implícitamente de modo tal que
automáticamente comienza su ejecución. También asumimos que el código y los datos para todos los
procesos ya están almacenados en memoria cuando comienza el proceso principal.
Se presenta un kernel monoprocesador que implementa fork y quit. También se describe cómo
hacer el scheduling de procesos de modo de que cada uno tenga una chance periódica de ejecutar.
Un Kernel Monoprocesador
Todo kernel contiene estructuras de datos que representan procesos y tres tipos básicos de
rutinas: manejadores de interrupción, las primitivas en sí mismas, y un dispatcher. El kernel puede
contener otras estructuras de datos y funcionalidad, como descriptores de archivos y rutinas de acceso a
archivos. Enfocamos sólo las partes de un kernel que implementan procesos.
• como una unidad monolítica en la cual cada primitiva se ejecuta como una acción
atómica
• como una colección de procesos especializados que interactúan para
implementar primitivas del kernel (por ejemplo, uno puede manejar la E/S de
archivos y otro el manejo de memoria), o
• como un programa concurrente en el cual más de un proceso usuario puede estar
ejecutando una primitiva del kernel al mismo tiempo
Usaremos aquí la primera aproximación pues es la más simple para un kernel monoprocesador
pequeño. Usaremos la tercera en un kernel multiprocesador.
El kernel comienza a ejecutar cuando ocurre una interrupción. Las interrupciones pueden ser
divididas en dos grandes categorías: interrupciones externas de los dispositivos periféricos e
interrupciones internas de los procesos ejecutantes. Cuando ocurre una interrupción, el procesador
automáticamente salva la información de estado suficiente para que el proceso interrumpido pueda ser
reanudado. Luego el procesador entra un interrupt handler; normalmente hay un manejador para cada
clase de interrupción.
Página N° 26
Programación Concurrente - Cap. 3. ---------------------------------------------
Para invocar una primitiva del kernel, un proceso causa una interrupción interna ejecutando una
instrucción de máquina normalmente llamada supervisor call (SVC) o trap. El proceso pasa un argumento
con la instrucción SVC para indicar qué primitiva debe ejecutarse y pasa otros argumentos en registros.
El manejador de interrupción del SVC primero salva el estado completo del proceso ejecutante. Luego
llama a la primitiva apropiada, la cual es implementada dentro del kernel por un procedure. Cuando se
completa la primitiva, se llama al dispatcher (scheduler del procesador). El dispatcher selecciona un
proceso para su ejecución y luego carga su estado.
Para asegurar que las primitivas son ejecutadas atómicamente, la primera acción de un interrupt
handler es inhibir otras interrupciones; la última acción del dispatcher es habilitar las interrupciones.
Cuando ocurre una interrupción, las futuras interrupciones son inhibidas automáticamente por el
hardware; el kernel rehabilita las interrupciones como un efecto lateral de cargar un estado de proceso.
Podemos ver las componentes del kernel y el flujo de control a través del kernel de la siguiente manera:
interrupciones
externas ⇒
manejadores ⇒ primitivas ⇒ dispatcher ⇒ interrupciones ⇒
internas
El control fluye en una dirección desde los manejadores a través de las primitivas hasta el
dispatcher y luego nuevamente a un proceso activo.
Con esta representación para las estructuras de datos del kernel, la primitiva fork toma un
descriptor de la free list, lo inicializa y lo inserta al final de la ready list. La primitiva quit pone el descriptor
del proceso ejecutante en la free list y setea executing en 0 para indicarle al dispatcher que el proceso ya
no quiere ejecutar.
Página N° 27