0% encontró este documento útil (0 votos)
154 vistas93 páginas

Libro - Programacion Concurrente (Traducido) (Andrews)

1) El capítulo introduce conceptos básicos de programación concurrente como procesos, comunicación entre procesos, sincronización y propiedades de programas concurrentes. 2) Explora diferentes mecanismos para especificar la ejecución concurrente como semáforos, monitores y pasaje de mensajes. 3) Describe paradigmas comunes para programación concurrente como estrategias de solución y técnicas de programación.

Cargado por

Micaela peralta
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
0% encontró este documento útil (0 votos)
154 vistas93 páginas

Libro - Programacion Concurrente (Traducido) (Andrews)

1) El capítulo introduce conceptos básicos de programación concurrente como procesos, comunicación entre procesos, sincronización y propiedades de programas concurrentes. 2) Explora diferentes mecanismos para especificar la ejecución concurrente como semáforos, monitores y pasaje de mensajes. 3) Describe paradigmas comunes para programación concurrente como estrategias de solución y técnicas de programación.

Cargado por

Micaela peralta
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
Está en la página 1/ 93

Programación Concurrente - Cap. 1.

-----------------------------------------

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.

Las herramientas formales están basadas en razonamiento asercional. El estado de un


programa está caracterizado por un predicado llamado aserción, y los efectos de ejecutar sentencias están
caracterizados por transformadores de predicado. El razonamiento asercional provee la base para un
método sistemático para derivar programas que satisfacen propiedades especificadas.

Se han propuesto muchos mecanismos diferentes para especificar la ejecución concurrente,


la comunicación y la sincronización. Se describirán los más importantes (incluyendo semáforos, monitores
y pasaje de mensajes) y también nociones de los lenguajes de programación que emplean diferentes
combinaciones. Para cada mecanismo de sincronización se muestra cómo desarrollar programas correctos
y eficientes, se presentan numerosos ejemplos, y se muestra cómo implementar el mecanismo.

Finalmente se describen paradigmas identificados para programación concurrente. Estos


incluyen estrategias de solución y técnicas de programación que son aplicables a una gran variedad de
problemas. La mayoría de los programas concurrentes resultan de combinar un pequeño número de
estructuras y pueden verse como instancias de unos pocos problemas standard. Aprendiendo estas
estructuras y técnicas de solución es fácil resolver problemas adicionales.

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.

Los sistemas operativos fueron los primeros ejemplos de programas concurrentes y


permanecen entre los más interesantes. Con el advenimiento de los controladores de dispositivos
independientes en los ‘ 60, se volvió natural organizar un SO como un programa concurrente, con
procesos que manejan dispositivos y la ejecución de tareas de usuario. Los procesos en un sistema
monoprocesador son implementados por multiprogramación, ejecutando un proceso por vez en el tiempo y
usando interleaving.

La tecnología evolucionó para producir una variedad de sistemas multiprocesador. En un


multiprocesador de memoria compartida, múltiples procesadores comparten una memoria común; el
tiempo de acceso a memoria es uniforme (UMA) o no uniforme (NUMA). En un multicomputador, varios
procesadores llamados nodos se conectan por hardware de alta velocidad. En un sistema en red, varios
nodos mono o multiprocesador comparten una red de comunicaciones (por ej, Ethernet). También existen
combinaciones híbridas como redes de workstations multiprocesador. Los SO para multiprocesadores son
programas concurrentes en los cuales al menos algunos procesos pueden ejecutar en paralelo. Los
procesadores varían desde microcomputadoras a supercomputadoras.

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:

• sistemas de ventanas en computadoras personales o workstations


• procesamiento de transacciones en sistemas de BD multiusuario
• file servers en una red

Página N° 1
Programación Concurrente - Cap. 1. -----------------------------------------

• computaciones científicas que manipulan grandes arreglos de datos

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.

Sincronización Como se mencionó, los procesos en un programa concurrente se comunican usando


variables compartidas o mensajes. La comunicación provoca la necesidad de sincronización. En los
programas concurrentes se dan dos formas de sincronización: exclusión mutua y sincronización por
condición. La primera consiste en asegurar que las secciones críticas de sentencias que acceden a
objetos compartidos no se ejecutan al mismo tiempo. La sincronización por condición asegura que un
proceso se demora si es necesario hasta que sea verdadera una condición dada. Por ej, la comunicación
entre un proceso productor y uno consumidor es implementado con frecuencia usando un buffer
compartido. El que envía escribe en un buffer; el que recibe lee del buffer. Se usa exclusión mutua para
asegurar que ambos no accedan al buffer al mismo tiempo; y se usa sincronización por condición para
asegurar que un mensaje no sea recibido antes de que haya sido enviado y que un mensaje no es
sobrescrito antes de ser recibido.

El estado de un programa concurrente en cualquier instante de tiempo consta de los valores


de las variables de programa. Estas incluyen variables explícitas declaradas por el programador y
variables implícitas (por ej, el contador de programa para cada proceso) que contiene información de
estado oculta. Un programa concurrente comienza la ejecución en algún estado inicial. Cada proceso en el
programa se ejecuta a una velocidad desconocida. A medida que se ejecuta, un proceso transforma el
estado ejecutando sentencias. Cada sentencia consiste en una secuencia de una o más acciones
atómicas que hacen transformaciones de estado indivisibles. Ejemplos de acciones atómicas son
instrucciones de máquina ininterrumpibles que cargan y almacenan valores de registros.

La ejecución de un programa concurrente genera una secuencia de acciones atómicas que


es algún interleaving de las secuencias de acciones atómicas de cada proceso componente. El trace de
una ejecución particular de un programa concurrente puede verse como una historia:

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.

Dada esta visión, el rol de la sincronización es restringir las posibles historias de un


programa concurrente a aquellas que son deseables. La exclusión mutua concierne a combinar las
acciones atómicas fine-grained que son implementadas directamente por hardware en secciones críticas
que deben ser atómicas para que su ejecución no sea interleaved con otras secciones críticas que
referencian las mismas variables. La sincronización por condición concierne a la demora de un proceso
hasta que el estado conduzca a una ejecución posterior. Ambas formas de sincronización causan que los
procesos se demoren y por lo tanto restringen el conjunto de acciones atómicas que son elegibles para su
ejecución.

Propiedades de programa

Una propiedad de un programa es un atributo que es verdadero en cualquier posible historia


del programa, y por lo tanto de todas las ejecuciones del programa. Cada propiedad puede ser formulada
en términos de dos clases especiales de propiedades: seguridad (safety) y vida (liveness). Una propiedad
de seguridad asegura que el programa nunca entra en un estado malo (es decir uno en el que algunas
variables tienen valores indeseables). Una propiedad de vida asegura que el programa eventualmente
entra en un estado bueno (es decir, uno en el cual todas las variables tiene valores deseables).

Página N° 2
Programación Concurrente - Cap. 1. -----------------------------------------

La corrección parcial es un ejemplo de una propiedad de seguridad. Asegura que si un


programa termina, el estado final es correcto (es decir, se computó el resultado correcto). Si un programa
falla al terminar, puede nunca producir la respuesta correcta, pero no hay historia en la cual el programa
terminó sin producir la respuesta correcta. Terminación es un ejemplo de propiedad de vida. Asegura que
un programa eventualmente terminará (es decir, cada historia del programa es finita). Corrección total es
una propiedad que combina la corrección parcial y la terminación. Asegura que un programa siempre
termina con una respuesta correcta.

La exclusión mutua es otro ejemplo de propiedad de seguridad. Asegura que a lo sumo un


proceso a la vez está ejecutando su sección crítica. El estado malo en este caso sería uno en el cual las
acciones en las regiones críticas en distintos procesos fueran ambas elegibles para su ejecución. La
ausencia de deadlock es otro ejemplo de propiedad de seguridad. El estado malo es uno en el que todos
los procesos están bloqueados, es decir, no hay acciones elegibles. Finalmente, un entry eventual a una
sección crítica es otro ejemplo de propiedad de vida. El estado bueno para cada proceso es uno en el cual
su sección crítica es elegible.

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.

Una segunda aproximación es usar razonamiento operacional, el cual puede ser


caracterizado como “análisis exhaustivo de casos”. Todas las posibles historias de un programa son
enumeradas considerando todas las maneras en que las operaciones de cada proceso podrían ser
interleaved. Desafortunadamente, el número de historias en un programa concurrente es generalmente
enorme (por eso es “exhaustivo”). Para un programa concurrente con n procesos y cada uno con m
acciones atómicas, el número de historias diferentes del programa es (n*m)! / (m!)2. Para n=3 y m=2, hay
90 historias diferentes.

Una tercera aproximación es emplear razonamiento asercional, que puede caracterizarse


como “análisis abstracto”. En esta aproximación, se usan aserciones (fórmulas de predicados lógicos) para
caracterizar conjuntos de estados (por ej, todos los estados en que x > 0). Las acciones se ven como
transformadores de predicado que cambian el estado de uno que satisface un predicado a uno que
satisface otro. La virtud de este método es que lleva a una representación compacta de estados y
transformaciones. Además lleva a una manera de desarrollar y analizar programas en la cual el trabajo
involucrado es directamente proporcional al número de acciones atómicas en el programa.

Se empleará la aproximación asercional como herramienta para construir y entender


soluciones a una variedad de problemas no triviales. Sin embargo, también se usarán las otras
aproximaciones. se usarán razonamiento operacional para guiar el desarrollo de varios algoritmos. Y
muchos de los programas en el texto fueron testeados ya que ayuda a incrementar la confianza en la
corrección de un programa. Los programas concurrentes son muy difíciles de testear y debuggear ya que
es difícil detener todos los procesos a la vez para examinar su estado, y cada ejecución del mismo
programa puede producir una historia diferente porque las acciones podrían ser interleaved en orden
diferente.

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.

La primera sección presenta la notación que se usará para programación secuencial. La


notación es similar a Pascal, pero las construcciones de control, especialmente para iteración, son más
poderosas. Luego se revén conceptos de lógica formal, proposiciones y predicados. Luego se presenta un
sistema lógico formal específico que contiene axiomas y reglas de inferencia para probar propiedades de
programas secuenciales. Finalmente, se presenta un método (basado en lo que llamamos precondiciones
débiles) para derivar un programa y su prueba de corrección total partiendo solo de una especificación del
resultado deseado.

Página N° 3
Programación Concurrente - Cap. 1. -----------------------------------------

NOTACION DEL LENGUAJE

Un programa secuencial contiene declaraciones, sentencias, y procedimientos. Las


declaraciones definen tipos, variables y constantes. Las sentencias se usan para asignar valores a
variables y controlar el flujo de ejecución dentro del programa. Los procedimientos definen subrutinas y
funciones parametrizadas. Los elementos básicos de un programa son identificadores, palabras claves,
literales y operadores.

Declaraciones Los tipos básicos son bool, int, real, char, string y enumeración.

Las variables son introducidas por declaraciones var.

var id1 : tipo1 := valor1, ....., idn : tipon := valorn

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.

Un arreglo se declara agregando una especificación de rango a una declaración de


variable.

Un tipo registro define un conjunto de valores de datos de tipos potencialmente diferentes

Sentencias

La sentencia skip, skip, es la sentencia “vacía”. Termina inmediatamente y no tiene efecto


sobre ninguna variable de programa. Se usa en las sentencias guardadas y las sentencias await cuando
no hay que tomar ninguna acción cuando una condición booleana se vuelve verdadera.

La sentencia de asignación, x := e, evalúa la expresión e y asigna su resultado a x. Los


tipos de x y e deben ser los mismos.

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

intercambia sus valores.

Una sentencia compuesta consta de una secuencia de sentencias, ejecutadas en orden

secuencial. Por ej: x := x + 1; y := y - 1

Las sentencias de alternativa (if) e iterativa (do) contienen una o más sentencias
guardadas, cada una de la forma:

B→S

Aquí, B es una expresión booleana llamada guarda y S es una sentencia simple o


compuesta. La expresión booleana B “guarda” a S en el sentido de que S no se ejecuta a menos que B
sea verdadera.

Una sentencia alternativa contiene una o más sentencias guardadas:

if B1 → S1
...

Página N° 4
Programación Concurrente - Cap. 1. -----------------------------------------

Bn → Sn fi

Las guardas son evaluadas en algún orden arbitrario. Si la guarda Bi es verdadera,

entonces se ejecuta la sentencia Si. Por ejemplo:

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

El cuerpo del fa es una o más sentencias. Cada cuantificador especifica un rango de


valores para una variable de iteración:

variable := expr_inicial to expr_final st B

Una variable de iteración es un entero declarado implícitamente; su alcance es limitado al


cuero de la sentencia for-all. El cuerpo del for-all se ejecuta una vez por cada valor de la variable de
iteración, comenzando con el valor de la expr_inicial y finalizando con el valor de la expr_final. Si la
cláusula opcional such-that (st) está presente, la variable de iteración toma sólo los valores para los que la
expresión B es verdadera. Si el rango de cuantificadores es vacío, el cuerpo no se ejecuta.

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

ejemplo, la siguiente sentencia for-all transpone una matriz m:

fa i := 1 to n, j := i + 1to n → m[i,j] :=: m[j,i] af

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. -----------------------------------------

fa i := 1 to n, j := i+1 to n st a[i] > a[j] → a[i] :=: a[j] af

Procedures Un procedure define un patrón parametrizado para una operación. Su forma general es:

procedure p( f1 : t1 ; .....; fn : tn ) returns r : tr

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:

call p( e1, ...., en )

Un procedure que tiene parte de retorno se llama función. Se invoca explícitamente


apareciendo en una expresión, por ejemplo, dentro de una sentencia de asignación:

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.

LOGICA, PROPOSICIONES Y PREDICADOS

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.

Una lógica de programación es un sistema formal que soporta la aproximación asercional


para desarrollar y analizar programas. Incluye predicados que caracterizan estados de programa y
relaciones que caracterizan el efecto de la ejecución del programa. Esta sección resume aspectos
relevantes de los sistemas lógicos formales, lógica proposicional, y lógica de predicados, la cual es una
extensión de la lógica proposicional. La próxima sección presenta una lógica de programación para
programas secuenciales.

Sistemas Lógicos Formales

Cualquier sistema lógico formal consta de reglas definidas en términos de:

• 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. -----------------------------------------

• un conjunto de fórmulas distinguidas llamadas axiomas, y


• un conjunto de reglas de inferencia

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.

Las reglas de inferencia tienen la forma:

H1, H2, ....., Hn


-------

C Las Hi son hipótesis; C es una conclusión. Ambos son o fórmulas o representaciones


esquemáticas de fórmulas. El significado de una regla de inferencia es que si todas las hipótesis son
verdaderas, entonces podemos inferir que la conclusión también lo es.

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.

Completitud es el dual de soundness. Una lógica es completa con respecto a una


interpretación si toda fórmula que es mapeada a verdadero es un teorema; es decir, la fórmula es probable
en la lógica. Una lógica que es sound y completa permite que todas las sentencias exresables en la lógica
sean probadas. Si un teorema no puede ser probado en tal lógica, no es el resultado de una debilidad de
la lógica.

Desafortunadamente, el dominio de discurso que nos ocupa (las propiedades de los


programas concurrentes) no pueden tener una axiomatización sound y completa como un sistema lógico.
Esto es porque el comportamiento de un programa incluye aritmética, y un resultado conocido en lógica
(teorema de incompletitud de Godel) establece que ningún sistema lógico formal que axiomatiza la
aritmética puede ser completo. Pero una lógica que extiende otra lógica puede ser relativamente completa,
es decir que no introduce fuente de incompletitud más allá de la que se encuentra en la lógica que
extiende. Afortunadamente, la completitud relativa es lo suficientemente buena ya que las propiedades
aritméticas que emplearemos son verdaderas, aún si no todas pueden ser probadas formalmente.

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:

Constantes Proposicionales: true y false


Variables Proposicionales: p, q, r, ....
Operadores Proposicionales: ¬, ∧, ∨, ⇒, =

Página N° 7
Programación Concurrente - Cap. 1. -----------------------------------------

Las fórmulas de la lógica son constantes y variables proposicionales simples, o están


construidas usando los operadores proposicionales (conectivos). Hay 5 operadores: negación, conjunción,
disyunción, implicación y equivalencia. Sus interpretaciones son las tablas de verdad correspondientes.

Dado un estado s, interpretamos una fórmula proposicional P como sigue. Primero,


reemplazamos cada variable proposicional en P por su valor en s. Luego se usan las tablas para simplificar
el resultado. El orden de precedencia es negación, conjunción y disyunción, implicación y equivalencia.

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.

Una forma de decidir si una fórmula es válida es determinar si su interpretación es


verdadera en todo estado posible. Sin embargo, si la fórmula contiene n variables proposicionales, esto
requiere chequear 2n casos. Una mejor aproximación es emplear tautologías que permiten que las
fórmulas sean simplificadas transformándolas en fórmulas equivalentes.

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 del Medio Excluido: P ∨ ¬P = true

Ley de Contradicción: P ∧ ¬P = false

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

Para ilustrar el uso de las leyes de equivalencia, consideremos la siguiente caracterización


del problema de la sección crítica. Sean InA e InB las proposiciones:

InA: Proceso A ejecutando en su SC


InB: Proceso B ejecutando en su SC

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:

InA ⇒ ¬ InB = ( ¬ InA ∨ ¬ InB ) por Ley de Implicación = ¬ (


InA ∧ InB ) por Ley de De Morgan

Página N° 8
Programación Concurrente - Cap. 1. -----------------------------------------

Dado que la equivalencia es transitiva, tenemos:

InA ⇒ ¬ InB = ¬ ( InA ∧ InB )

Por lo tanto, no es el caso en que los procesos A y B puedan estar en su SC al mismo


tiempo.

Otras dos tautologías útiles son:

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

• Se proveen cuantificadores existenciales (∃) y universales (∀) para


caracterizar conjuntos de valores

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.

Una expresión relacionales es true o false, dependiendo de si la relación se da entre los


argumentos. Asumimos que cada expresión relacional está bien definida (no hay problemas de tipos).

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.

El cuantificador existencial ∃ permite asegurar que algún elemento de un conjunto


satisface una propiedad. Aparece en expresiones de la forma:

(1.1) (∃ b1, ...., bn : R: P)

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 )

es true si algún elemento de a[1:n] es 0.


El cuantificador universal ∀ permite asegurar que todos los elementos de un conjunto
satisfacen una propiedad. Aparece en expresiones de la forma:

(1.2) (∀ b1, ...., bn : R: P)

Nuevamente, las bi son variables ligadas, R especifica sus valores, y P es un predicado. La


interpretación de (1.2) es true si P es verdadera para todas las combinaciones de los valores de las
variables ligadas. Por ejemplo:

(∀i : 1 ≤ i ≤ n : a[i] = 0 )

es true si todos los elementos de a[1:n] son 0.

En una expresión cuantificada, el alcance de una variable ligada es la expresión en sí


misma.

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:

i = j ∧ ( ∃ i, k : i, k > 0 : a[i] = a[k] )

También, la ocurrencia de j es libre, las ocurrencias de k son bound, y las ocurrencias de a


son libres.

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 )

Si la interpretación es true en un estado, entonces alguna combinación de los valores de las


variables ligadas satisface P. Análogamente, si la interpretación es false, entonces P es false para todas
las combinaciones de valores de B. En cualquier caso, la siguiente expresión tiene la misma interpretación:

¬ ( ∀B: R: ¬P )

Una dualidad similar existe en la otra dirección. Las siguientes son algunas leyes (axiomas)
que emplearemos:

Leyes de De Morgan para cuantificadores: (∃B: R: P ) = ¬ ( ∀B: R: ¬P )


(∀B: R: P ) = ¬ ( ∃B: R: ¬P )

Ley de Conjunción: (∀B: R: P∧Q ) = (∀B: R: P ) ∧ (∀B: R: Q )

Ley de Disyunción: (∃B: R: P∨Q ) = (∃B: R: P ) ∨ (∃B: R: Q


)

Leyes de Rango Vacío: (∀B: ∅: P ) = true


(∃B: ∅: P ) = false

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:

(∀i : 1 ≤ i ≤ k-1 : a[i] ≤ a[i+1] )

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

todas las historias son finitas. Página N° 11


Programación Concurrente - Cap. 1. -----------------------------------------

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 }

Pero, la siguiente no sería un teorema, ya que asignar un valor a x no puede


“milagrosamente” setear y a 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

Se presentan axiomas y reglas de inferencia de PL y justificaciones informales de su


soundness y completitud relativa. Asumiremos que la evaluación de expresiones no causa efectos
laterales, es decir, ninguna variable cambia de valor.

La sentencia skip no cambia ninguna variable. Luego, si el predicado P es true antes de


ejecutarse skip, sigue siendo true cuando skip termina:

(1.6) Axioma de Skip: { P } skip { P }

Una sentencia de asignación asigna un valor e a una variable x y en general cambia el


estado del programa. Parecería que el axioma para la asignación debiera empezar con alguna
precondición P, y que la postcondición debiera ser P más un predicado para indicar que x ahora tiene el
valor e. Pero, resulta un axioma más simple si vamos en la otra dirección. Asumamos que la postcondición
de una asignación es satisfacer P. Entonces, qué debe ser true antes de la asignación? Primero, una
asignación cambia solo la variable destino x, entonces todas las otras variables tienen el mismo valor
antes y después. Segundo, x tiene un nuevo valor e, y así toda relación que pertenece a x que es true
después de asignar tiene que haberlo sido antes de reemplazar x por e. La sustitución textual (1.3) hace
exactamente esta transformación:
(1.7) Axioma de Asignación: {Pex } x := e { P }

Para ilustrar el uso de este axioma, consideremos la siguiente tripla:

{ true } x := 5 { x = 5 }

Es un teorema dado que:

(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 también es un teorema, ya que:


= 1 ∧ true ) = ( y = 1 )
(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

El estado de programa cambia como resultado de ejecutar sentencias de asignación y


swap. Luego, los axiomas para estos elementos usan sustitución textual para introducir nuevos valores en
los predicados. Las reglas de inferencia de PL permiten que los teoremas resultantes de estos axiomas
sean manipulados y combinados. Hay una regla de inferencia para cada una de las sentencias que afectan
el flujo de control en un programa secuencial: composición, alternativa e iteración. Hay una regla de
inferencia adicional que usamos para conectar triplas una con otra.

La primera regla de inferencia, la Regla de Consecuencia, nos permite manipular


predicados en triplas. Consideremos la siguiente tripla:

(1.10) { x=3 } x := 5 { x=5 }

Claramente sería un teorema dado que x vale 5 luego de la asignación,


independientemente del valor que tuviera antes. Sin embargo, esto no se desprende directamente del
axioma de asignación (1.7). Como ya dijimos, lo que se desprende del axioma es el teorema:

(1.11) { true } x := 5 { x=5 }

Recordemos que true caracteriza cualquier estado de programa, incluyendo x=3; en


particular, ( x=3 ) ⇒ true. La Regla de Consecuencia nos permite hacer esta conexión y concluir a partir de
la validez de (1.11) que (1.10) también es válida.

(1.12) Regla de Consecuencia: P’ ⇒ P, {P} S {Q}, Q ⇒ Q’


{P’} S {Q’}
La regla de consecuencia nos permite fortalecer una precondición, debilitarla o ambas.

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.

(1.12) Regla de Composición: {P} S1 {Q}, {Q} S2 {R}


{P} S1; S2 {R}

Por ejemplo, consideremos la sentencia compuesta:

x := 1; y := 2

A partir del axioma de asignación, los siguientes son teoremas:

{ true } x := 1 { x=1 } { x=1 } y :=


2 { x=1 ∧ y=2 }

Por la regla de composición, el siguiente también es un teorema:

{ true } x := 1; y := 2 { x=1 ∧ y=2 }

Una sentencia alternativa (if) contiene n sentencias guardadas:

IF: if B1 → S1 ..... Bn → Sn fi

Recordemos que asumimos en PL que la evaluación guardada no tiene efectos laterales, es

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.

Supongamos que la precondición de IF es P y la postcondición deseada es Q. Luego, si P

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:

(1.14) Regla de Alternativa: P ∧ ¬( B1 ∨ ..... ∨ Bn ) ⇒ Q

{ P ∧ Bi } Si { Q } , 1 ≤ i ≤ n
------------- { P } IF { Q }

Como ejemplo del uso de esta regla, consideremos el siguiente programa:

if x ≥ y → m := x y ≥ x → m := y fi

Esto asigna a m el máximo de x e y. Supongamos inicialmente que x=X e y=Y. Luego, la


siguiente tripla sería un teorema:

(1.15) { P: x=X ∧ y=Y }


if x ≥ y → m := x y ≥ x → m’ := y fi { P ∧
MAX }

Donde MAX es el predicado:

MAX: ( m=X ∧ X ≥ Y ) ∨ ( m=Y ∧ Y ≥ X )

Para concluir que (1.15) es un teorema, debemos satisfacer la hipótesis de la Regla de


Alternativa. La primera hipótesis es trivial de satisfacer para esta alternativa ya que al menos una guarda
es true. Para satisfacer la segunda hipótesis, tenemos que considerar cada una de las sentencias
guardadas. Para la primera, aplicamos el axioma de asignación a la sentencia m := x con postcondición P
∧ MAX y tenemos el teorema:

{ R: P ∧ ( ( x=X ∧ X ≥ Y ) ∨ ( x=Y ∧ Y ≥ X ) ) } m := x { P ∧ MAX }

Si la primera sentencia guardada es seleccionada, entonces el estado debe satisfacer:

(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

hipótesis de la regla de alternativa. Arriba lo usamos para demostrar que P ∧ Bi ⇒ pre(Si).

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,

podemos usar la regla de consecuencia para concluir que lo siguiente es válido:

{ 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. -----------------------------------------

Supongamos que el predicado I es true antes de la ejecución de DO y luego de cada

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:

(1.16) Regla Iterativa: { I ∧ Bi } Si { I }, 1 ≤ i ≤ n

--------------- { I } DO { I ∧ ¬ ( B1 ∨

....∨ Bn ) }

La clave para usar la regla iterativa es el invariante. Como ejemplo, consideremos el


siguiente programa, que computa el factorial de un entero n, asumiendo n > 0:

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.

Para ilustrar una prueba completa en PL consideremos nuevamente el problema de


búsqueda lineal. Se tiene un arreglo a[1:n] para algún positivo n. También se tiene un valor x que es un
elemento de a. El problema es encontrar el índice de la primera ocurrencia de x en a. Más precisamente, el
estado inicial se asume que satisface el predicado:

P: n > 0 ∧ ( ∃j: 1 ≤ j ≤ n: a[j] = x )

Y el estado final del programa debe satisfacer:

LS: a[i] = x ∧ ( ∀ j : 1 ≤ j < i: a[j] ≠ x )

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

Por simplicidad lo omitiremos en la prueba.

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. -----------------------------------------

Construimos la prueba considerando primero el efecto de la primera asignación, luego


trabajando dentro del loop para considerar el efecto del loop, y finalmente considerando el efecto de la
sentencia compuesta. El loop invariant I es el segundo conjuntor de LS; aparece en el paso 4 de la pureba.
1. { P ∧ 1=1 } por Axioma de Asignación
i := 1 { P ∧
i=1 }

2. ( P ∧ 1=1 ) = P por Lógica de Predicados

3. { P } por regla de consecuencia con 1 y 2


i := 1 { P ∧
i=1 }

4. { P ∧ ( ∀ j : 1 ≤ j < i+1: a[j] ≠ x ) } por axioma de asignación


i := i + 1 { I: P ∧ ( ∀ j : 1 ≤ j < i: a[j]
≠x)}

5. ( I ∧ a[i] ≠ x ) = por Lógica de Predicados


( P ∧ ( ∀ j : 1 ≤ j < i+1: a[j] ≠ x ) )

6. { I ∧ a[i] ≠ x } por regla de consecuencia con 4 y 5


i := i + 1 { I }

7. { I } por regla iterativa con 6


do a[i] ≠ x → i := i + 1 od { I
∧ a[i] = x }

8. ( P ∧ i=1 ) ⇒ I por Lógica de predicados

9. { P } por regla de composición con 7 y 8


i := 1 do a[i] ≠ x → i := i + 1
od { I ∧ a[i] = x }

10. ( I ∧ a[i] = x ) ⇒ LS por Lógica de Predicados

11. { P } por regla de consecuencia con 9 y 10


i := 1 do a[i] ≠ x → i := i + 1
od { LS }

Dado que la tripla en el paso 11 es un teorema, el programa de búsqueda lineal es


parcialmente correcto. Dado que P postula la existencia de algún x tal que a[i] = x, el loop termina. Así, el
programa también satisface la propiedad de corrección total.

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. -----------------------------------------

{ I } od { I ∧ a[i] = x } { LS: x=a[i] ∧ ( ∀ j : 1


≤ j < i: a[j] ≠ x ) }

La correspondencia entre la ejecución del programa y un proof outline es que, cuando el


control del programa está al comienzo de una sentencia, el estado de programa satisface la aserción
correspondiente. Si el programa termina, la aserción final eventualmente se vuelve true. Dado que el
programa de búsqueda lineal termina, eventualmente el control de programa está en el final, y el estado
satisface LS.

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.

• Las aserciones antes de la primera y después de la última de una secuencia de


sentencias, junto con las sentencias intervinientes (pero no las aserciones
intervinientes), forman una tripla que representa una aplicación de la regla de
composición.

• 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.

• Finalmente, las aserciones adyacentes representan aplicaciones de la regla de


consecuencia.

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

Ocasionalmente, es interesante saber si dos programas son intercambiables, es decir, si


computan exactamente los mismos resultados. En otras oportunidades interesa saber si un programa
simula a otro, es decir, el primero computa al menos todos los resultados del segundo.

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. -----------------------------------------

S1: v1 :=: v2 S2: v2 :=: v1

Esto es porque, para cualquier postcondición P, la sustitución simultánea da la misma


precondición independiente del orden en el cual aparecen las variables. Las siguientes sentencias también
son parcialmente equivalentes:

S1: x :=1 ; y := 1 S2: y := 1 ; x := 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.

Por ejemplo, las siguientes sentencias simulan x :=: y

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.

Esta sección presenta un método de programación sistemático (un cálculo de


programación) para construir programas secuenciales totalmente correctos. El método se basa en ver las
sentencias como transformadores de predicados: funciones que mapean predicados en predicados.
Involucra desarrollar un programa y su proof outline en conjunto, con el cálculo y el proof outline guiando la
derivación del programa.

Precondiciones Weakest

La programación es una actividad “goal-directed”. Los programadores siempre tienen algún


resultado que tratan de obtener. Supongamos que el objetivo de un programa S es terminar en un estado
que satisface el predicado Q. La precondición weakest wp es un transformador de predicado que mapea
un objetivo Q en un predicado wp(S,Q) de acuerdo a la siguiente definición:

(1.19) Precondición Weakest. La precondición weakest de la lista de sentencias S y el predicado Q,


denotado wp(S,Q) es un predicado que caracteriza el mayor conjunto de estados tal que, si la ejecución de
S comenzó en cualquier estado que satisface wp(S,Q), entonces se garantiza que la ejecución termina en
un estado que satisface Q.

La relación wp es llamada la precondición weakest dado que caracteriza el mayor conjunto


de estados que llevan a un programa totalmente correcto.

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:

(1.20) Relación entre wp y PL. Si P ⇒ wp(S,Q), entonces { P } S { Q } es un teorema de PL

La diferencia esencial entre wp y PL es que wp requiere terminación, mientras PL no. Esta


diferencia se reduce a requerir que todos los loops terminen dado que los loops son las únicas sentencias
no terminantes en nuestra notación.

Página N° 19
Programación Concurrente - Cap. 1. -----------------------------------------

Se deducen varias leyes útiles directamente de la definición de wp. Primero, un programa S


no puede terminar en un estado que satisface false dado que no hay tal estado. Así,

(1.21) Ley del milagro excluido. wp(S,false) = false


Por otro lado, todos los estados satisfacen true. Así, wp(S,true) caracteriza todos los
estados para los cuales S se garantiza que termina, independiente del resultado final producido por S.

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:

(1.22) Ley Distributiva de la Conjunción. wp(S,Q) ∧ wp(S,R) = wp(S,Q∧R)

Consideremos la disyunción. Supongamos que un programa comienza en un estado que


satisface wp(S,Q) o wp(S,R). Luego por (1.20), S terminará en un estado que satisface Q∨R, por lo tanto:

(1.23) Ley Distributiva de la Disyunción. wp(S,Q) ∨ wp(S,R) ⇒ wp(S,Q∨R)

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:

flip: if true → outcome := HEADS


true → outcome := TAILS fi

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,

wp(flip, outcome=HEADS) = wp(flip, outcome=TAILS) = false

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:

wp(flip, outcome=HEADS ∨ outcome=TAILS) = true

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:

(1.23) Ley Distributiva de la Disyunción Determinística. Para S determinística, wp(S,Q) ∨ wp(S,R) =


wp(S,Q∨R)

Esta ley vale para lenguajes de programación secuenciales, tal como Pascal, que no
contienen sentencias no determinísticas.

Precondiciones weakest de sentencias

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 de asignación termina si la expresión y la variable destino están bien


definidas. Asumimos esto true. Si la ejecución de x := e debe terminar en un estado que satisface Q, debe
comenzar en un estado en el cual cada variable excepto x tiene el mismo valor y x es reemplazada por e.
Como en el axioma de asignación, esta es exactamente la transformación provista por la sustitución
textual:

(1.26) wp(x:=e,Q) = Qex

En este caso, wp y la precondición para el axioma de asignación son idénticos.


Similarmente, wp para una sentencia swap es la misma que la precondición en el axioma de swap.

Una secuencia de sentencias S1 y S2 termina en un estado que satisface Q si S2 termina


en un estado que satisface Q. Esto requiere que la ejecución de S2 comience en un estado que satisfaga
wp(S2,Q), el cual debe ser el estado en que termina S1. Así, la composición de sentencias lleva a la
composición de wp:

(1.27) wp(S1;S2,Q) = wp(S1,wp(S2,Q))

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

⇒ wp(Si,Q); es decir, o B es falsa o la ejecución de S termina en un estado que satisface Q. Lo mismo se


requiere para todas las sentencias guardadas. Entonces:

(1.28) wp(IF,Q) = ¬(B1 ∨ ..... ∨ Bn) ⇒ Q ∧ (B1 ⇒ wp(S1,Q) ∧ .... ∧ Bn ⇒ wp(Sn,Q) )

Como ejemplo, consideremos el programa y postcondición para computar el máximo de x e


y dado en (1.15): if x ≥ y → m := x y ≥ x → m := y fi
{ Q: x=X ∧ y=Y ∧ ( ( m=x ∧ X ≥ Y ) ∨ ( m=Y ∧ Y ≥ X ) ) }

Aplicando la definición (1.28) a esta sentencia y predicado (usando (1.26) para computar wp
de la asignación) se tiene:

¬( x≥y ∨ y≥x ) ⇒ Q ∧ ( x≥y ⇒ ( x=X ∧ y=Y ∧ ( ( x=X ∧ X≥Y )


∨ ( x=Y ∧ Y≥X ) ) ) ) ∧ ( y≥x ⇒ ( x=X ∧ y=Y ∧ ( ( y=X ∧ X≥Y
) ∨ ( y=Y ∧ Y≥X ) ) ) )

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<y ) ∨ ( x=X ∧ y=Y ∧ X≥Y ) ) ∧


( ( y<x ) ∨ ( x=X ∧ y=Y ∧ Y≥X ) )

Usando lógica de predicados y proposicional, esto se simplifica a:

x=X ∧ y=Y

Esta es exactamente la precondición P en la tripla de (1.15). Esto nuevamente muestra la


dualidad entre precondiciones weakest y teoremas en PL.

La sentencia do tiene la precondición weakest más complicada ya que es la única sentencia


que podría no terminar. Sea DO una sentencia do:

(1.29) DO: do B1 → S1 ..... Bn → Sn od

Y sea BB el predicado:

(1.30) BB: B1 ∨ ...... ∨ Bn

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:

DO: do BB → IF: if B1 → S1 ..... Bn → Sn fi od

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,

H0(Q) = ¬BB ∧ Q Hk(Q) = H0(Q) ∨

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.31) wp(DO,Q) = ( ∃ k : 0 ≤ k: Hk(Q) )

Desafortunadamente, la definición (1.31) no provee ninguna guía para computar la


precondición weakest de una sentencia do. Además, la relación entre (1.31) y la correspondiente Regla
Iterativa (1.16) de PL es mucho menos clara que para cualquier otra sentencia. Por ejemplo, la regla
iterativa emplea un loop invariant I que no aparece en (1.31). Sin embargo, la siguiente definición
caracteriza la relación; también provee una técnica para establecer que un loop termina.

(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:

(1) ( I ∧ Bi ) ⇒ wp(Si, I ), 1 ≤ i ≤ n (2) ( I ∧ BB ) ⇒ bound > 0 (3) ( I ∧

Bi ∧ bound =BOUND) ⇒ wp(Si, bound < BOUND), 1 ≤ i ≤ n

La primera condición afirma que I es invariante con respecto a la ejecución de una


sentencia guardada. La segunda y tercera tratan con la terminación. En ellas, bound es una expresión
(llamada bounding expression) cuyo rango son los enteros no negativos. La segunda condición en (1.32)
dice que el valor de bound es positivo si alguna guarda es true. La tercera dice que cada iteración
decrementa el valor de bound. Dado que el valor de bound es un entero no negativo, las condiciones (2) y
(3) juntas garantizan que el loop termina.

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.

Búsqueda Lineal Revisitada

Consideremos el problema de búsqueda lineal nuevamente. Recordemos que el estado


final del programa debe satisfacer:

LS: a[i] = x ∧ ( ∀ j : 1 ≤ j < i: a[j] ≠ x )

Dado que se debe buscar en a, se requiere un loop. Cuando la postcondición de un loop es


en forma de conjunción, una técnica para encontrar un invariante es borrar uno de los conjuntores. El que
se borra generalmente es el que expresa el hecho de que se debe alcanzar terminación; en este caso, a[i]
= x. Así, un candidato a invariante para este problema es:

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:

var i:=1 { I: ( ∀ j : 1 ≤ j < i: a[j] ≠


x ) } do a[i] ≠ x → ? od

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:

var i:=1 { I: ( ∀ j : 1 ≤ j < i: a[j] ≠


x ) } do a[i] ≠ x → i := i + 1 od { I
∧ a[i]=x }

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

condiciones. Para el algoritmo de ordenación, se asume que el estado inicial satisface:

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:

SORT: ( ∀ k: 1 ≤ k ≤ n: a[k] ≤ a[k+1] ) ∧ a es una permutación de A

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:

I: ( ∀ k: 1 ≤ k < i: a[k] ≤ a[k+1] ) ∧ a es una permutación de A

Esto servirá como un invariante útil si i es inicializada a 1: es verdadero inicialmente, será


verdadero luego de cada iteración si un elemento más de a es puesto en su lugar correcto, llevará a
terminación si i es incrementada en cada iteración (con bounding expression n-i), y sugiere una guarda de
loop fácilmente computada de i<n. Gráficamente, el invariante es:

a[1] ... ordenado ... a[i-1] a[i] ... no ordenado ... a[n]

Un esqueleto del algoritmo de ordenación con este invariante es:

{ I } do i<n → poner valor correcto en a[i]; i:=i+1 od


{ I ∧ i ≥ n } { SORT }

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

sea true: Q: ( ∀ k: 1 ≤ k < j-1: a[k] ≤ a[k+1] ) ∧


( ∀ k: j ≤ k < i: a[k] ≤ a[k+1] ) ∧ ( j = 1 ∨ a[j-1] ≤ a[j]
∧ a es una permutación de A

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:

II: ( ∀ k: 1 ≤ k < j-1: a[k] ≤ a[k+1] ) ∧


( ∀ k: j ≤ k < i: a[k] ≤ a[k+1] ) ∧ a es una permutación de A

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

{ I: ( ∀ k: 1 ≤ k < i: a[k] ≤ a[k+1] ) ∧ a es una permutación de A }


do i < n → j := i
{ II: ( ∀ k: 1 ≤ k < j-1: a[k] ≤ a[k+1] ) ∧
( ∀ k: j ≤ k < i: a[k] ≤ a[k+1] ) ∧ a
es una permutación de A } do j > 1
and a[j-1] > a[j] →
a[j-1] :=: a[j]; j := j - 1 od { II ∧ ( j = 1 ∨ a[j-1] ≤ a[j] ) } i := i + 1 { I } od { I ∧ i ≥ n } {
SORT: ( ∀ k: 1 ≤ k < n: a[k] ≤ a[k+1] ) ∧ a es una permutación de A }

Página N° 24

Programación Concurrente - Cap. 2. ---------------------------------------------


Concurrencia y

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.

Este capítulo introduce notaciones de programación para concurrencia y sincronización. Por


ahora, los procesos interactúan leyendo y escribiendo variables compartidas. Este capítulo también
examina los conceptos semánticos fundamentales de la programación concurrente y extiende la lógica de
programación PL para incorporar estos conceptos.

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.

ESPECIFICACION DE LA EJECUCION CONCURRENTE

Cuando se ejecuta un programa secuencial, hay un único thread de control: el contador de


programa comienza en la primera acción atómica del proceso y se mueve a través del proceso a medida
que las acciones atómicas son ejecutadas. La ejecución de un programa concurrente resulta en múltiples
thread de control, uno por cada proceso concurrente.

En nuestra notación, especificaremos la ejecución concurrente con la sentencia co (con

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

co termina cuando todas las Si teminaron.

Como ejemplo, consideremos el siguiente fragmento de programa:

(2.2) x := 0; y := 0
co x := x + 1 // y := y + 1 oc z
:= x + y

Esto asigna secuencialmente 0 a x e y, luego incrementa x e y concurrentemente (o en algún


orden arbitrario), y finalmente asigna la suma de x e y a z. Las variables x e y son globales a los procesos
en la sentencia co; son declaradas en el proceso que engloba y son heredadas por los procesos en el co
siguiendo las reglas de alcance normales de los lenguajes estructurados. Un proceso también puede
declarar variables locales cuyo alcance se limita a ese proceso.

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]:

var a[1:n], b[1:n] : int


Largest :: var max : int
max := a[1] fa j := 2 to n st max < a[j] → max := a[j] af
Sum [i:1..n] :: b[i] := a[1]
fa j := 2 to i → b[i] := b[i] + a[j] af

Largest es el nombre de un solo proceso. Sum es un arreglo de n procesos; cada elemento


Sum[i] tiene un valor diferente para i, el cual es una variable entera local declarada implícitamente. Los
procesos en este ejemplo son los mismos que si hubiéramos hecho:

co cuerpo de Largest // i := 1 to n → cuerpo de Sum oc

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.

ACCIONES ATOMICAS Y SINCRONIZACION


Como ya dijimos, podemos ver la ejecución de un programa concurrente como un interleaving de
las acciones atómicas ejecutadas por procesos individuales. Cuando los procesos interactúan, no todos
los interleavings son aceptables. El rol de la sincronización es prevenir los interleavings indeseables.
Esto se hace combinando acciones atómicas fine-grained en acciones (compuestas) coarse grained, o
demorando la ejecución de un proceso hasta que el estado de programa satisfaga algún predicado. La
primera forma de sincronización se llama exclusión mutua; la segunda, sincronización por condición.

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

Si x := y + z es implementada cargando un registro con y, luego sumándole z, el valor final de x


podría ser 0, 1, 2 o 3. Esto es porque podríamos ver los valores iniciales para y y z, sus valores finales, o
alguna combinación, dependiendo de cuan rápido se ejecuta el segundo proceso. Otra particularidad del
programa anterior es que el valor final de x podría ser 2, aunque y+z no es 2 en ningún estado de
programa.

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).

* Todo resultado intermedio de evaluar una expresión compleja se almacena en registros o en


memoria privada del proceso ejecutante (por ejemplo, en una pila privada).

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.

Desafortunadamente, los programas concurrentes más interesantes no cumplen el requerimiento


de ser disjuntos. Sin embargo, con frecuencia se cumple un requerimiento más débil. Definimos una
variable simple como una variable escalar, elemento de arreglo o campo de registro que es almacenada
en una posición de memoria única. Entonces si una expresión o asignación satisface la siguiente
propiedad, la evaluación aún será atómica:

(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.

Especificaremos sincronización por medio de la sentencia await:

〈 await B → S 〉

La expresión booleana B especifica una condición de demora; S es una secuencia de sentencias


que se garantiza que termina. Una sentencia await se encierra en corchetes angulares para indicar que
es ejecutada como una acción atómica. En particular, se garantiza que B es true cuando comienza la
ejecución de S, y ningún estado interno de S es visible para los otros procesos. Por ejemplo:

(2.5) 〈 await s > 0 → s := s - 1〉

se demora hasta que s es positiva, y luego la decrementa. El valor de s se garantiza que es


positivo antes de ser decrementado.

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〉

Por ejemplo, lo siguiente incrementa x e y atómicamente:

〈 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:

〈 await count > 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.

Cada proceso comprende sentencias secuenciales, más posiblemente sentencias de


sincronización. Los axiomas y reglas de prueba para sentencias secuenciales son las que ya vimos. Con
respecto a la corrección parcial, una sentencia await es como una sentencia if para la cual la guarda B
es true cuando comienza la ejecución de S. Por lo tanto, la regla de inferencia para await es similar a la
Regla de Alternativa:

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 }

Luego, por la regla de sincronización

{ 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.

Supongamos que para cada proceso Si en una sentencia co de la forma (2.1),


{ Pi } Si { Qi }

es un teorema de PL. De acuerdo a la Interpretación de Triplas (1.5), esto significa que, si Si se

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:

{ P1 ∧ .... ∧ Pn } co S1 // .... // Sn oc { Q1 ∧ .... ∧ Qn }

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 }

la siguiente es una conclusión sound:

{ x = 0 ∧ y = 0 } co x := x + 1 // y := y + 1 oc { x = 1 ∧ y = 1 }

Pero qué sucede con el siguiente programa?

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 }

no es generalmente válido cuando otro proceso ejecuta concurrentemente y altera la variable


compartida x. El problema es que un proceso podría interferir con una aserción en el otro; es decir, podría
convertir en falsa la aserción.

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 }

P es una aserción crítica, pero Q no, ya que el estado siguiente a la ejecución de S1 no es


visible a los otros procesos.

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 }

En resumen, C es invariante con respecto a la ejecución de la acción de asignación a. La


precondición de a se incluye en (2.9) ya que a puede ser ejecutada solo si el proceso está en un estado
que satisface pre(a).

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.

(2.10) Libertad de Interferencia. Los teoremas { Pi } Si { Qi }, 1 ≤ i ≤ n, son libres de interferencia si:

Para todas las acciones de asignación a en la prueba de Si,

Para todas las aserciones críticas C en la prueba de Sj, i ≠ j,


NI(a, C) es un teorema.

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.

(2.11) Regla de Concurrencia:

{ Pi } Si { Qi } son teoremas libres de interferencia, 1 ≤ i ≤ n ---------------------------

{ P1 ∧ .... ∧ Pn } co S1 // .... // Sn oc { Q1 ∧ .... ∧ Qn }

TECNICAS PARA EVITAR INTERFERENCIA

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.

Presentaremos 4 técnicas para evitar interferencia: variables disjuntas, aserciones weakened,


invariantes globales y sincronización. Todas involucran aserciones y acciones de asignación puestas en
una forma que asegure que las fórmulas de no interferencia (2.9) son verdaderas.

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).

Como ejemplo, consideremos nuevamente el siguiente programa:

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 }

debe ser un teorema. Esto es porque


(x=0)yy+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:

{ x=0 ∧ y=0 } co x := x + 1 // y := y + 1 oc { x=1 ∧ y=1 }

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

la siguiente tripla es válida para cada proceso:

{true} a[i] := i { a[i]=i }

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:

(2.12) co P1: 〈 x := x + 1 〉 // P2: 〈 x := x + 2 〉 oc

Si x es inicialmente 0, las siguientes triplas son ambas válidas aisladamente:

{ 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 }

Sustituyendo x+1 por x en la postcondición de (2.15) se tiene ( x = -1 ∨ x = 0 ). La precondición


en (2.15) se simplifica a x=0, lo cual implica ( x = -1 ∨ x = 0 ). Aplicando la regla de consecuencia para
librarnos de x = -1, (2.15) es un teorema.

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) }

La precondición se simplifica a x = 0, y la postcondición se simplifica a x = 3, como queríamos.

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

donde L es un predicado sobre variables privadas. En particular, todas las variables


referenciadas en L o son locales al proceso j o son variables globales que solo asigna j.

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:

var buf : int, p : int, c : int := 0


Productor :: var a[1:n] : int
do p < n → 〈 await p = c 〉
buf := a[p+1] p := p + 1 od Consumidor
:: var b[1:n] : int
do c < n → 〈 await p > c 〉
b[c+1] := buf c := c + 1 od

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:

PC: c ≤ p ≤ c + 1 ∧ a[1:n] = A[1:n] ∧ (p = c + 1) ⇒ ( buf = A[p] )

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:

var buf : int, p : int, c : int := 0 { PC: c ≤ p ≤ c + 1 ∧ a[1:n] = A[1:n] ∧


(p = c + 1) ⇒ ( buf = A[p] ) } Productor :: var a[1:n] : int
{ IP: PC ∧ p ≤ n } do p < n → { PC ∧ p < n } 〈
await p = c 〉 { PC ∧ p < n ∧ p = c} buf := a[p+1] {
PC ∧ p < n ∧ p = c ∧ buf = A[p+1] } p := p + 1 { IP
} od

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}

La única variable en A1 que es alterada por Consumidor es c. Una sentencia de asignación en


Consumidor altera c, y esa sentencia tiene precondición:

A2: { IC ∧ c < n ∧ p > c ∧ b[c+1] = A[c+1] }

Aplicando el requerimiento de no interferencia (2.19), tenemos la siguiente obligación de


prueba:

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:

{ x=0 ∧ y=0 } 〈 x := x + 1 { x=1 ∧ y=0 } y := y + 1〉 { x=1 ∧ y=1 }

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:

(2.17) P1 :: ... { pre(a) } a ....

P2 :: ... S1 { C } S2 ...

Aquí, a es una sentencia de asignación en el proceso P1, y S1 y S2 son sentencias en el


proceso P2. Supongamos que a interfiere con la aserción crítica C. Una manera de evitar interferencia es
usar exclusión mutua para “ocultar” C en a. Esto se hace construyendo una única acción atómica: 〈 S1;

S2 〉

que ejecuta S1 y S2 atómicamente y hace a C invisible para los otros procesos.

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.

Así, podemos reemplazar a en (2.17) por la siguiente acción atómica condicional:

〈 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.

var account[1:n] : int Transfer :: { account[x] = X


∧ account[y] = Y }
〈 account[x] := account[x] - 100

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:

〈 await ( x < i and y < i ) or ( x > i and y > i ) →


account[x] := account[x] - 100; account[y] := account[y] + 100〉

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.

Para ilustrar el problema y motivar su solución, consideremos el siguiente programa:

(2.18) co P1: 〈 x := x + 1 〉 // P2: 〈 x := x + 1 〉 oc

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:

{ x=0 } P1: x := x + 1 { x=1 } {


x=0 } P2: x := x + 1 { x=1 }

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:

{ x=0 ∨ x=1 } P1: x := x + 1 { x=1 ∨ x=2 }

Pero P1 aún interfiere con las aserciones de P2. Podemos tratar de debilitar las aserciones en
P2:

{ x=0 ∨ x=1 } P2: x := x + 1 { x=1 ∨ x=2 }

Desafortunadamente aún tenemos interferencia, y debilitar más las aserciones no ayudará.


Además, las aserciones ya son demasiado débiles para concluir que x=2 en el estado final del programa
dado que la conjunción de las postcondiciones es (x=1 ∨ x=2).

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

El proceso P1 setea t1 en 1 para indicar que incrementó x; P2 usa t2 de manera similar.


En el estado inicial de (2.19), x=t1+t2. El mismo predicado es true en el estado final. Así, si este
predicado fuera un invariante global, podríamos concluir que x es 2 en el estado final dado que t1 y t2 son
ambos 1 en ese estado. Pero x=t1+t2 no es un invariante global pues no es true justo luego de que cada
proceso incrementó x. Sin embargo, podemos ocultar este estado combinando las dos asignaciones en
cada proceso en una única acción atómica:

(2.20) var x := 0, t1 := 0, t2 := 0 { I: x=t1+t2 }


{ I ∧ t1=0 ∧ t2=0 } co P1: { I ∧ t1=0 } 〈 x := x + 1;
t1 := 1 〉 { I ∧ t1=1 } // P2: { I ∧ t2=0 } 〈 x := x + 1; t2
:= 1 〉 { I ∧ t2=1 } oc { I ∧ t1=1 ∧ t2=1 }

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.

Sean P y Q predicados que no referencian variables auxiliares. Sea la sentencia S obtenida a


partir de la sentencia S’ borrando todas las sentencias que asignan variables auxiliares. Entonces, la
siguiente regla de inferencia es sound:

(2.22) Regla de Variable Auxiliar. { P } S’ { Q } ------ {


P}S{Q}

Puede usarse esta regla para probar (2.18).

PROPIEDADES DE SEGURIDAD Y VIDA

Recordemos que una propiedad de un programa es un atributo que es verdadero en cada


posible historia de ese programa. Toda propiedad interesante puede ser formulada en términos de dos
clases de propiedades: seguridad y vida. Una propiedad de seguridad asegura que nada malo ocurre
durante la ejecución; una propiedad de vida afirma que algo bueno eventualmente ocurre. En los
programas secuenciales, la propiedad de seguridad clave es que el estado final es correcto, y la clave de
la propiedad de vida es terminación. Estas propiedades son igualmente importantes para programas
concurrentes. Además, hay otras propiedades interesantes de seguridad y vida que se aplican a los
programas concurrentes.

Dos propiedades de seguridad importantes en pro

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.

Prueba de propiedades de seguridad

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.

Muchas propiedades de seguridad interesantes pueden ser formuladas en términos de


predicados que caracterizan estados en que los procesos no deberían estar simultáneamente. Por
ejemplo, en exclusión mutua, las precondiciones de las secciones críticas de los dos procesos no
deberían ser simultáneamente verdaderas. Si el predicado P caracteriza algún estado de un proceso y el
predicado Q caracteriza algún estado de un segundo proceso, entonces P ∧ Q será true si ambos
procesos están en sus respectivos estados. Además, si (P ∧ Q) = false, entonces los procesos no
pueden estar simultáneamente en los dos estados ya que ningún estado satisface false.

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

De manera similar, el proceso Consumidor es demorado si está en su sentencia await y la


condición de demora es falsa; ese estado satisface:

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:

var continue := true Loop :: do


continue → skip od Stop ::
continue := false

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.

Cuando un programa contiene acciones atómicas condicionales, necesitamos hacer


suposiciones más fuertes para garantizar que los procesos progresarán. Esto es porque una acción
atómica condicional, aún si es elegible, es demorada hasta que la guarda es true.

(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.

En síntesis, si 〈 await B → S 〉 es elegible y B se vuelve true y permanece true, entonces la


acción atómica eventualmente se ejecuta. Round-robin y timeslicing son políticas débilmente fair si todo
proceso tiene chance de ejecutar. Esto es porque cualquier proceso demorado eventualmente verá que
su condición de demora es 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:

(2.29) var continue := true, try := false


Loop :: do continue → try := true; try := false od
Stop :: 〈 await try → continue := false 〉

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.

Desafortunadamente, es imposible tener una política de scheduling de procesador general que


sea práctica y fuertemente fair. Consideremos el programa (2.29) nuevamente. En un solo procesador, un
scheduler que alterna las acciones de los dos procesos sería fuertemente fair ya que Stop vería un
estado en el cual try es true; pero, tal scheduler es impráctico de implementar. Por otro lado, round-robin
y timeslicing son prácticos pero no fuertemente fair en general. Un scheduler multiprocesador que ejecuta
los procesos en (2.29) en paralelo es también práctico, pero no es fuertemente fair. En los últimos casos,
Stop podría siempre examinar try cuando es falso. Por supuesto esto es difícil, pero teóricamente posible.

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.

Para clarificar las distintas clases de políticas de scheduling, consideremos nuevamente el


programa que copia un arreglo. Como vimos, ese programa está libre de deadlock. Así, el programa
terminará si cada proceso tiene chance de progresar. Cada uno lo hará ya que la política es débilmente
fair. Esto es porque, cuando un proceso hace true la condición de demora del otro, esa condición se
mantiene true hasta que otro proceso continue y cambie las variables compartidas.

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

En este caso, el programa terminará si la política de scheduling es incondicionalmente fair dado


que no hay acciones atómicas condicionales y los procesos se alternan el acceso al buffer compartido.
Sin embargo, este no es en general el caso en que una política incondicionalmente fair asegurará
terminación de un loop busy waiting; por ejemplo, ver (2.29). Esto es porque una política
incondicionalmente fair podría siempre schedule la acción atómica que examina la guarda del loop
cuando la guarda es true. Cuando un loop busy waiting nunca termina, un programa se dice que sufre
livelock, la analogía de deadlock. La ausencia de livelock es una propiedad de vida (la cosa buena sería
la eventual terminación del loop) dado que con busy waiting un proceso siempre tiene alguna acción que
puede ejecutar.

Página N° 19

Programación Concurrente - Cap. 3. ---------------------------------------------


Variables

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.

El rol de la sincronización es evitar interferencia, o haciendo conjuntos de sentencias atómicas y


ocultando estados intermedios (exclusión mutua) o demorando un proceso hasta que se de una
condición (sincronización por condición). Siempre podemos evitar interferencia usando sentencias await,
pero estas no siempre pueden ser implementadas eficientemente. En esta parte mostraremos cómo usar
primitivas de sincronización de más bajo nivel que pueden ser implementadas eficientemente en
hardware o software. Usaremos las técnicas ya introducidas (variables disjuntas, aserciones debilitadas,
invariantes globales).

Un propósito es ilustrar un método para resolver problemas de sincronización de manera


sistemática. La base del método es ver a los procesos como mantenedores de invariantes. En particular,
para cada problema de sincronización definimos un invariante global que caracteriza relaciones claves
entre variables locales y globales. Con respecto a sincronización, el rol de cada proceso es asegurar que
se mantiene el invariante. También particionaremos variables para resolver distintos problemas en
conjuntos disjuntos, cada uno con su propio invariante. El método de solución consiste de 4 pasos:

1. Definir el problema en forma precisa. Identificar los procesos y especificar el


problema de sincronización. Introducir las variables necesarias y escribir un
predicado que especifica la propiedad invariante que debe mantenerse.

2. “Outline” de la solución. Escribir en los procesos las asignaciones a variables.


Inicializar las variables para que el invariante sea inicialmente verdadero. Encerrar
entre corchetes las secuencias de asignaciones cuando deben ser ejecutadas
atómicamente.

3. Asegurar el invariante. Cuando sea necesario, poner guardas a las acciones


atómicas incondicionales para asegurar que cada acción mantiene el invariante
global. Dada la acción S y el invariante I, esto involucra computar wp(S,I) para
determinar el conjunto de estados a partir de los cuales la ejecución de S garantiza
que termina con I verdadero. Dado que S es una secuencia de sentencias de
asignación, terminará una vez que empezó.

4. Implementar las acciones atómicas. Transformar las acciones atómicas


resultantes de los pasos 2 y 3 en código que emplea sólo sentencias secuenciales
y primitivas de sincronización disponibles.

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

Programación Concurrente - Cap. 3. ---------------------------------------------


Sincronización

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.

También se examina una clase de programas paralelos y un mecanismo de sincronización


asociado. Muchos problemas pueden resolverse con algoritmos iterativos paralelos en los cuales varios
procesos manipulan repetidamente un arreglo compartido. Esta clase de algoritmos se llama algoritmo
paralelo de datos ya que los datos compartidos se manipulan en paralelo. En tal algoritmo, cada iteración
depende típicamente de los resultados de la iteración previa. Luego, al final de cada iteración, cada
proceso necesita esperar a los otros antes de comenzar la próxima iteración. Esta clase de punto de
sincronización se llama barrier. También se describen multiprocesadores sincrónicos (SIMD), especiales
para implementar algoritmos paralelos de datos. Esto es porque los SIMD ejecutan instrucciones en lock
step en cada procesador, entonces proveen sincronización por barrier automático.

EL PROBLEMA DE LA SECCION CRITICA

El problema de la sección crítica es uno de los problemas clásicos de la programación


concurrente. Fue el primer problema estudiado y mantiene su interés dado que las soluciones pueden
usarse para implementar sentencias await.

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.

(3.1) P[i:1..n] :: do true →


entry protocol critical
section exit protocol
non-critical section od

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.

Una manera trivial de resolver el problema es encerrar cada SC en corchetes angulares, es


decir, usar await incondicionales. La exclusión mutua se desprende automáticamente de la semántica de
los corchetes angulares. Las otras tres propiedades deberían satisfacerse si la política de scheduling es
incondicionalmente fair ya que ésta asegura que un proceso que intenta ejecutar la acción atómica
correspondiente a su SC eventualmente lo hará, sin importar qué hicieron los otros procesos. Sin
embargo, esta “solución” trae el tema de cómo implementar los corchetes angulares.

Una solución Coarse-Grained


Para el problema de la SC, las 4 propiedades son importantes, pero la más crítica es la de la
exclusión mutua. Para especificar la propiedad de exclusión mutua, necesitamos tener alguna manera de
indicar que un proceso está dentro de su SC. Así, necesitamos introducir variables adicionales. Para
simplificar la notación, desarrollamos una solución para dos procesos; rápidamente se generaliza a n
procesos.

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:

MUTEX: ¬(in1 ∧ in2)

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:

var in1 := false, in2 := false { MUTEX: ¬(in1 ∧ in2) }


P1 :: do true → in1 := true # entry protocol
critical section in1 := false # exit protocol non-critical
section od P2 :: do true → in2 := true # entry
protocol
critical section in2 := false # exit protocol
non-critical section od

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:

wp(in1 := true, MUTEX) = ¬(true ∧ in2) = ¬in2

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:

〈 await not in2 → in1 := true 〉

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.

Reemplazando los protocolos de entrada por acciones atómicas condicionales tenemos la


solución coarse-grained y proof outline siguiente:

var in1 := false, in2 := false {


MUTEX: ¬(in1 ∧ in2) } P1 :: do true
→ { MUTEX ∧ ¬in1 }
〈 await not in2 → in1 := true 〉 # entry protocol { MUTEX ∧ in1 }
critical section in1 := false # exit protocol { MUTEX ∧ ¬in1 }
non-critical section od P2 :: do true → { MUTEX ∧ ¬in2 }
〈 await not in1 → in2 := true 〉 # entry protocol { MUTEX ∧
in2 } critical section in2 := false # exit protocol { MUTEX ∧
¬in2 } non-critical section od

Por construcción, la solución satisface la propiedad de exclusión mutua. La ausencia de


deadlock y demora innecesaria se deduce del método de Exclusión de Configuraciones (2.25). Si los
procesos están en deadlock, cada uno está tratando de entrar a su SC pero no lo puede hacer. Esto
significa que las precondiciones de los protocolos de entrada son ambas true, pero ninguna guarda es
true. Así, el siguiente predicado caracteriza un estado de deadlock:

¬in1 ∧ in2 ∧ ¬in2 ∧ in1

Como este predicado es false, no puede ocurrir deadlock en el programa anterior.

Consideremos la ausencia de demora innecesaria. Si el proceso P1 está fuera de su SC o


terminó, entonces in1 es false. Si P2 está tratando de entrar a su SC pero no puede hacerlo, entonces la
guarda en su entry protocol debe ser falsa. Dado que

¬in1 ∧ in1 = false

P2 no puede ser demorado innecesariamente. Algo análogo sucede con P1.

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.

Spin Locks: Una solución Fine-Grained

La solución coarse-grained vista emplea dos variables. Para generalizar la solución a n


procesos, deberíamos usar n variables. Pero, hay solo dos estados que nos interesan: algún proceso
está en su SC o ningún proceso lo está. Una variable es suficiente para distinguir entre estos dos
estados, independiente del número de procesos.

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:

lock = (in1 ∨ in2)

Usando lock en lugar de in1 e in2, tenemos:

var lock := false P1 :: do true → 〈 await not lock → lock := true 〉 #


entry protocol
critical section lock := false # exit protocol non-critical section od P2
:: do true → 〈 await not lock → lock := true 〉 # entry protocol
critical section lock := false # exit protocol non-critical section
od

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):

La instrucción TS toma dos argumentos booleanos: un lock compartido y un código de condición


local cc. Como una acción atómica, TS setea cc al valor de lock, luego setea lock a true:

(3.6) TS(lock,cc): 〈 cc := lock; lock := true 〉

Usando TS podemos implementar la solución coarse-grained anterior:

var lock := false P1 ::


var cc : bool
do true → TS(lock,cc) # entry protocol
do cc → TS(lock,cc) od critical section lock := false # exit
protocol non-critical section od P2 :: var cc : bool
do true → TS(lock,cc) # entry protocol
do cc → TS(lock,cc) od critical section lock :=
false # exit protocol

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.

Los dos programas anteriores resuelven correctamente el problema de la SC. Se asegura la


exclusión mutua pues solo uno de los procesos puede ver que lock es false. La ausencia de deadlock
resulta del hecho de que, si ambos procesos están en sus entry protocols, lock es false, y entonces uno
de los procesos tendrá éxito para entrar a su SC. Se evita la demora innecesaria porque, si ambos
procesos están fuera de su SC, lock es false, y por lo tanto uno puede entrar si el otro está ejecutando su
SNC o terminó. Además, un proceso que trata de entrar a su SC eventualmente tendrá éxito si el
scheduling es fuertemente fair pues lock será true con infinita frecuencia.

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.

Una solución al problema de la SC similar a la última puede emplearse en cualquier máquina


que tenga alguna instrucción que testea y altera una variable compartida como una única acción atómica.
Por ejemplo, algunas máquinas tienen una instrucción de incremento que incrementa un valor entero y
también toma un código de condición indicando si el resultado es positivo o negativo. Usando esta
instrucción, el entry protocol puede basarse en la transición de cero a uno. Algo a tener en cuenta al
construir una solución busy waiting al problema de la SC es que en la mayoría de los casos el protocolo
de salida debería retornar las variables compartidas a su estado inicial.

Aunque la última solución es correcta, se demostró que en multiprocesadores puede llevar a


baja performance si varios procesos están compitiendo por el acceso a una SC. Esto es porque lock es
una variable compartida y todo proceso demorado continuamente la referencia. Esto causa “memory
contention”, lo que degrada la performance de las unidades de memoria y las redes de interconexión
procesador-memoria.

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:

(3.7) do lock → skip od # spin mientras se setea lock


TS(lock,cc) do cc → do lock → skip od # nuevamente spin
TS(lock,cc) od

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

La implementación preserva la semántica de las acciones atómicas condicionales, asumiendo


que los protocolos de SC garantizan exclusión mutua. Si el scheduling es débilmente fair, el proceso que
ejecuta (3.8) eventualmente terminará el loop, asumiendo que B se convierte en true y permanece true.
Este tipo de scheduling también es suficiente para asegurar entrada eventual a una SC. Si el scheduling
es fuertemente fair, el loop se terminará si B se convierte en true con infinita frecuencia.

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.

Si S es la sentencia skip, el protocolo (3.9) puede simplificarse omitiendo S. Si además B


satisface los requerimientos de la propiedad de A Lo Sumo Una Vez (2.5), la sentencia 〈 await B 〉 puede
implementarse como:

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.

SECCIONES CRITICAS: ALGORITMO TIE-BREAKER

Cuando una solución al problema de la SC emplea una instrucción como Test-and-Set, el


scheduling debe ser fuertemente fair para asegurar la eventual entrada. Este es un requerimiento fuerte
pues las políticas de scheduling prácticas son solo débilmente fair. Aunque es improbable que un proceso
que trata de entrar a su SC nunca tenga éxito, podría suceder si dos o más procesos están siempre
compitiendo por la entrada. Esto es porque la solución spin lock no controla el orden en el cual lo
procesos demorados entran a sus SC si dos o más están tratando de hacerlo.

El algoritmo tie-breaker (o algoritmo de Peterson) es un protocolo de SC que requiere solo


scheduling incondicionalmente fair para satisfacer la propiedad de eventual entrada. Además no requiere
instrucciones especiales del tipo Test-and-Set. Sin embargo, el algoritmo es mucho más complejo que la
solución spin lock.

Solución Coarse-Grained

Consideremos nuevamente el programa coarse-grained usando in1 e in2. Ahora el objetivo es


implementar las acciones atómicas condicionales usando solo variables simples y sentencias
secuenciales. Por ejemplo, queremos implementar el entry protocol en el proceso P1,

〈 await not in2 → in1 := true 〉

en términos de acciones atómicas fine-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:

do in2 → skip od # entry protocol para P1

Página N° 8
Programación Concurrente - Cap. 3. ---------------------------------------------
in1 := true
Análogamente, el entry protocol para P2 sería:

do in1 → skip od # entry protocol para P1 in2 :=


true

El exit protocol para P1 setearía in1 en false, y el de P2, in2 en false.

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:

in1 := true # entry protocol para P1 do in2 → skip


od

in2 := true # entry protocol para P2 do in1 → skip


od

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:

var in1 := false; in2 := false; last := 1 P1:: do true → in1 :=


true; last := 1 # entry protocol
〈 await not in2 or last = 2 〉 critical section in1 := false # exit
protocol non-critical section od P2:: do true → in2 := true; last
:= 2 # entry protocol
〈 await not in1 or last = 1 〉 critical section in2 := false #
exit protocol non-critical section od

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:

var in1 := false; in2 := false; last := 1 P1:: do true → in1 :=


true; last := 1 # entry protocol do in2 and last = 1 → skip od
critical section in1 := false # exit protocol non-critical section od
P2:: do true → in2 := true; last := 2 # entry protocol do in1 and
last = 2 → skip od 〈 await not in1 od last = 1 〉 critical section
in2 := false # exit protocol non-critical section od

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:

〈 in1 := true; mid1 := true 〉 〈


last := 1; mid1 := false 〉

Haciendo un cambio similar en P2, la siguiente aserción es verdadera cuando P1 está en su SC:

{ in1 ∧ ¬mid1 ∧ ( ¬in2 ∨ last=2 ∨ mid2 ) }

Con esta y la aserción correspondiente antes de la SC de P2, podemos usar Exclusión de


Configuraciones (2.25) para concluir que P1 y P2 no pueden ejecutar sus SC simultáneamente.

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:

var in[1:n] := ( [n] 0 ); last[1:n] := ( [n] 0 )


P[i: 1..n] :: do true →
fa j := 1 to n-1 → # entry protocol
# registra que el proceso i está en la etapa j y es el último
in[i] := j; last[j] := i fa k := 1 to n st i ≠ k →

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.

SECCIONES CRITICAS: ALGORITMO TICKET

El algoritmo tie-breaker n-proceso es bastante complejo y difícil de entender. Esto es en parte


porque no es obvio cómo generalizar el algoritmo de 2 procesos a n. Desarrollaremos una solución al
problema de la SC para n-procesos que es mucho más fácil de entender. La solución también ilustra
cómo pueden usarse contadores enteros para ordenar procesos. El algoritmo se llama ticket algorithm
pues se basa en repartir tickets (números) y luego esperar turno.
Solución Coarse-Grained

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:

{ 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] ) }

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

El algoritmo anterior emplea tres acciones atómicas coarse-grained. Es fácil implementar la


sentencia await usando un loop busy-waiting ya que la expresión booleana referencia solo una variable
compartida. Aunque la última acción atómica (que incrementa next) referencia next dos veces, también
puede ser implementada usando instrucciones load y store regulares. Esto es porque a lo sumo un
proceso a la vez puede ejecutar el protocolo de salida. Desafortunadamente, es difícil en general
implementar la primera acción atómica, la cual lee number y luego la incrementa.

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:

FA(var,incr): 〈 temp := var; var := var + incr; return(temp) 〉

El siguiente es el algoritmo ticket implementado usando FA. (Como en la solución coarse-


grained, podemos evitar overflow reseteando los contadores cuando alcanzan un límite mayor que n).

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] := FA(number,1) do turn[i]
≠ next → skip od critical section
next := next + 1 non-critical
section od

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:

(3.10) CSenter; turn[i] := number; number := number + 1; CSexit

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.

SECCIONES CRITICAS: ALGORITMO BAKERY

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:

BAKERY: ( P[i] está ejecutando su SC) ⇒ ( turn[i] ≠ 0 ∧


( ∀,j : 1 ≤ j ≤ n, j ≠ i: turn[j] = 0 ∨ turn[i] < turn[j] ) )

Luego de completar su SC, P[i] resetea turn[i] a 0.


La siguiente es una solución coarse-grained del algoritmo bakery:

var turn[1:n] : int := ( [n] 0 ) { BAKERY: ( P[i] está ejecutando


su SC) ⇒ ( turn[i] ≠ 0 ∧
( ∀,j : 1 ≤ j ≤ n, j ≠ i: turn[j] = 0 ∨ turn[i] < turn[j] ) ) } P[i: 1..n] :: do true →

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

El algoritmo bakery coarse-grained no puede ser implementado directamente en máquinas


contemporáneas. La asignación a turn[i] requiere computar el máximo de n valores, y la sentencia await
referencia una variable compartida dos veces. Estas acciones podrían ser implementadas atómicamente
usando otro protocolo de SC tal como el algoritmo tie-breaker, pero sería bastante ineficiente.
Afortunadamente, hay una aproximación más simple.

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:

(3.11) var turn1 := 0, turn2 := 0


P1 :: do true → turn1 := turn2 + 1
do turn2 ≠ 0 and turn1 > turn2 → skip od critical section
turn1 := 0 non-critical section od P2 :: do true → turn2 :=
turn1 + 1
do turn1 ≠ 0 and turn2 > turn1 → skip od critical
section turn2 := 0 non-critical section od

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:

var turn1 := 0, turn2 := 0 P1 :: do true → turn1


:= 1; turn1 := turn2 + 1
do turn2 ≠ 0 and turn1 > turn2 → skip od critical section
turn1 := 0 non-critical section od P2 :: do true → turn2 := 1;
turn2 := turn1 + 1
do turn1 ≠ 0 and turn2 ≥ turn1 → skip od critical
section turn2 := 0 non-critical section od

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:

turn1 > 0 ∧ ( turn2 = 0 ∨ turn1 ≤ turn2 )


Similarmente, cuando P2 está en su SC,

turn2 > 0 ∧ ( turn1 = 0 ∨ turn2 < turn1 )

La exclusión mutua de las SC se desprende del método de Exclusión de Configuraciones (2.25)


ya que la conjunción de las precondiciones de las SC es false. El algoritmo bakery para dos procesos
también satisface las otras propiedades de la SC.

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:

(a,b) > (c,d) = true si a > c o si a = c y b > d


false en otro caso

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:

var turn[1:n] : int := ( [n] 0 ) { BAKERY: ( P[i] está ejecutando


su SC) ⇒ ( turn[i] ≠ 0 ∧

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

Desafortunadamente, esta aproximación es bastante ineficiente dado que co produce n


procesos en cada iteración. Es mucho más costoso crear y destruir procesos que implementar
sincronización entre procesos. Así, una estructura alternativa resultará en un algoritmo más eficiente. En
particular, crear los procesos una vez al comienzo de la computación, y luego sincronizarlos al final de
cada iteración:

Worker[i: 1..n] :: do true →


código para implementar la tarea i esperar a que
se completen las n tareas 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.

Desarrollaremos varias implementaciones busy-waiting de sincronización barrier. Cada una


emplea una técnica distinta de interacción entre procesos. También se describe cuándo es apropiado
usar cada clase de barrera.

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:

(3.12) var count := 0, passed[1:n] : bool := ( [n] false )


Worker[i: 1..n] :: do true →
código para implementar la tarea i 〈 count :=
count + 1 〉 〈 await count = n → passed[i] :=
true 〉 od

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

Una manera de evitar el problema de contención de memoria es distribuir la implementación de


count usando n variables que sumen el mismo valor. En particular, sea arrive[1:n] un arreglo de enteros
inicializado en 0. Reemplacemos el incremento de count en (3.12) por arrive[i] := 1. Con este cambio, el
siguiente predicado es un invariante global:

(3.13) count = arrive[1] + .... + arrive[n]


La contención de memoria se evita si los elementos de arrive son almacenados en distintos
bancos de memoria.

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:

〈 await (arrive[1] + .... + arrive[n]) = n 〉

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:

(3.14) fa i := 1 to n → 〈 await arrive[i] = 1 〉 af


fa i := 1 to n → continue[i] := 1 af

Luego, arrive y continue tienen la siguiente interpretación:

( ∀ i : 1 ≤ i ≤ n : ( arrive[i] = 1 ) ⇒ Worker[i] alcanzó la barrera ) ∧ ( ∀ i : 1


≤ i ≤ n : ( continue[i] = 1 ) ⇒ Worker[i] puede pasar la barrera )

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:

var arrive[1:n] : int := ( [n] 0 ), continue[1:n] : int := ( [n] 0 )


Worker[i: 1..n] :: do true →
código para implementar la tarea i
arrive[i] := 1 〈 await continue[i] = 1 〉

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”.

El segundo problema es que el tiempo de ejecución de cada iteración de Coordinator (y por lo


tanto cada instancia de barrier synchronization) es proporcional al número de procesos Worker. En
algoritmos iterativos, el código ejecutado por cada Worker es típicamente idéntico, y por lo tanto cada uno
va a arribar casi al mismo tiempo a la barrera si cada Worker es ejecutado en su propio procesador. Así,
todos los flags arrive serían seteados casi al mismo tiempo. Sin embargo, Coordinator cicla a través de
los flags, esperando que cada uno sea seteado en turno.

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):

leaf node l : arrive[l] := 1


〈 await continue[l] = 1 〉; continue[l] := 0

interior node i : 〈 await arrive[left] = 1 〉; arrive[left] := 0


〈 await arrive[right] = 1 〉; arrive[right] := 0
arrive[i] := 1 〈 await continue[i] = 1 〉;
continue[i] := 0 continue[left] := 1;
continue[right] := 1

root node r : 〈 await arrive[left] = 1 〉; arrive[left] := 0


〈 await arrive[right] = 1 〉; arrive[right] := 0
continue[left] := 1; continue[right] := 1

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:

(3.17) P[i] :: 〈 await arrive[i] = 0 〉 P[i] :: 〈 await arrive[j] = 0 〉


arrive[i] := 1 arrive[j] := 1 〈 await arrive[j] = 1 〉 〈 await arrive[i]
= 1 〉 arrive[j] := 0 arrive[i] := 0

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 1 └───┘ └───┘ └───┘ └───┘

Etapa 2 └───────┘ └───────┘ └───────┘

└───────┘

└───────────────┘ 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

Worker disemina la noticia de su arribo a la barrera.

ALGORITMOS PARALELOS DE DATOS

Un algoritmo paralelo de datos es un algoritmo iterativo que repetidamente y en paralelo


manipula un arreglo compartido. Esta clase de algoritmo está muy asociado con multiprocesadores
sincrónicos, es decir máquinas SIMD. Sin embargo, son algoritmos también útiles en multiprocesadores
asincrónicos.

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.

Computación de prefijos paralela

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:

sum[1] := a[1] fa i := 2 to n → sum[i] :=


sum[i-1] + a[i] af

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:

valores iniciales de a[1:6] 1 2 3 4 5 6 sum después de


distancia 1 1 3 5 7 9 11 sum después de distancia 2 1 3 6 10
14 18 sum después de distancia 4 1 3 6 10 15 21

La siguiente es una implementación del algoritmo:

var a[1:n] : int, sum[1:n] : int, old[1:n] : int


Sum[i: 1..n] :: var d := 1
sum[i] := a[i] # inicializa los elementos de sum
barrier { SUM: sum[i] = a[i-d+1] + ... + a[i] } do d < n

old[i] := sum[i] # salva el viejo valor barrier if (i-d)
≥ 1 → sum[i] := old[i-d] + sum[i] fi barrier d := 2 *
d od

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.

Operaciones sobre listas enlazadas

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.

La siguiente es una implementación de este algoritmo:

var link[1:n] : int, end[1:n] : int


Find[i: 1..n] :: var new : int, d := 1
end[i] := link[i] # inicializa los elementos de end barrier {
FIND: end[i] = el índice del final de la lista a lo sumo 2d-1
links de distancia del elemento i } do d < n →
new := 0 # ve si end[i] debe ser actualizado if end[i] ≠ 0 and
end[end[i]] ≠ 0 → new := end[end[i]] fi barrier if new ≠ 0 →
end[i] := new fi # actualiza end[i] barrier d := 2 * d od

Dado que la técnica de programación es la misma que en la computación de prefijos el algoritmo


es estructuralmente idéntico. Nuevamente barrier especifica puntos de sincronización barrier, necesarios
para evitar interferencia. El invariante FIND especifica a qué apunta end[i] antes y después de cada
iteración. Si el final de la lista está a menos de 2d-1 links del elemento i, entonces end[i] no cambiará en
futuras iteraciones.

Computación de grillas

Muchos problemas de procesamiento de imágenes y resolución de ecuaciones diferenciales


parciales pueden resolverse usando lo que se llama grid computations o mesh computations. La idea
básica es usar una matriz de puntos que superpone una grilla o red en una región espacial. En un
problema de procesamiento de imágenes, la matriz es inicializada con los valores de los pixels, y el
objetivo es hacer algo como encontrar conjuntos de pixels vecinos que tengan la misma intensidad. Para
ecuaciones diferenciales, los bordes de la matriz son inicializados con condiciones de borde, y el objetivo
es computar una aproximación para el valor de cada punto interior, lo cual corresponde a encontrar un
estado que sea solución para la ecuación. En cualquier caso, el esqueleto básico de una computación de
grilla es:

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:

var grid[0:n+1, 0:n+1], newgrid[0:n+1, 0:n+1] : real


var converged : bool := false Grid[i:1..n, j:1..n] ::
do not converged →
newgrid[i,j] := ( grid[i-1,j] + grid[i+1,j] + grid[i,j-1] + grid[i,j+1] ) / 4
barrier chequear convergencia barrier grid[i,j] := newgrid[i,j] barrier
od

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

En un multiprocesador asincrónico, cada procesador ejecuta un proceso separado y los


procesos ejecutan posiblemente a distintas velocidades. Los multiprocesadores asincrónicos son
ejemplos de máquinas MIMD (en las cuales puede haber múltiples procesos independientes). Este es el
modelo de ejecución que hemos asumido.

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:

var a[1:n] : int, sum[1:n] : int


Sum[i: 1..n] :: var d := 1
sum[i] := a[i] # inicializa los elementos de sum do d <
n→
if (i-d) ≥ 1 → sum[i] := old[i-d] + sum[i] fi d := 2 *
d od

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.

Ambas implementaciones emplean una colección de estructuras de datos y rutinas llamada


kernel (con frecuencia llamado núcleo: indica que este software es común para cada procesador y es el
módulo de software central). El rol del kernel es proveer un procesador virtual para cada proceso de
modo que éste tenga la sensación de estar ejecutando en su propio procesador. Dado que apuntamos
solo a implementar procesos, no cubrimos varios temas se sistemas operativos que ocurren en la
práctica, como alocación dinámica, scheduling de prioridades, memoria virtual, control de dispositivos,
acceso a archivos, o protección.
Recordemos que los procesos son sólo abreviaciones de las sentencias co. Así, es suficiente
mostrar cómo implementar las sentencias co. Consideremos el siguiente fragmento de programa:

(3.26) var variables compartidas

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):

• uno para crear procesos y comenzar a ejecutarlos


• uno para detener un proceso
• un tercero para determinar que la sentencia co fue completada

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:

(3.27) var done[1:n] : bool := ( [n] false ), otras variables compartidas

S0 # crear los procesos, luego esperar que terminen

fa i := 1 to n → fork(Pi) af fa i := 1 to n → do not

done[i] → skip od af Sn+1 Cada uno de los Pi ejecuta

el siguiente código:

Pi : Si ; done[i] := true; quit( )

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.

Hay tres maneras básicas de organizar un kernel:

• 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.

Cada proceso es representado en el kernel por un descriptor de proceso. Cuando un proceso


está ocioso, su descriptor contiene toda la información necesaria para ejecutar el proceso. Esto incluye la
dirección de la próxima instrucción que ejecutará el proceso y los contenidos de los registros del
procesador.

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.

Representaremos los descriptores de proceso por un arreglo:

var process_descriptor[1:n] : process_type

El process_type es un registro que describe los campos de un descriptor. Cuando se le pide al


kernel crear un nuevo proceso, aloca e inicializa un descriptor vacío. Cuando el dispatcher del kernel
schedules un proceso, necesita encontrar el descriptor de un proceso que es elegible para ejecutar.
Ambas funciones podrían ser implementadas buscando a través del arreglo process_descriptor,
asumiendo que cada registro contiene un campo indicando si el entry está libre o en uso. Sin embargo,
normalmente se mantienen dos listas: una free list de descriptores vacíos y una ready list de descriptores
de procesos que están esperando turno para ejecutarse. Usaremos esta representación. Hay una
variable adicional, executing, que contiene el índice del descriptor del proceso que está ejecutando.

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.

Cuando el dispatcher es llamado al final de una primitiva, chequea el valor de executing. Si es 0,


remueve el primer descriptor de la ready list y setea executing para que apunte a él. (Si executing no es
0. el proceso que está ejecutando continúa ejecutando). Luego el dispatcher carga el estado del proceso
que ejecutará a continuación. Asumimos que la ready list es una cola FIFO.

Un tema que falta es asegurar fairness en la ejecución de procesos. Si el proceso ejecutante


siempre terminara en un tiempo finito. la implementación anterior del kernel aseguraría fairness pues
asume que la ready list es una cola FIFO. Sin embargo, si algún proceso (tal como el proceso principal
en (3.27)) espera una condición que no será nunca verdadera, se quedará dando vueltas para siempre
excepto que sea forzado a ceder el procesador. Podemos usar un interval timer para asegurar que los
procesos liberan periódicamente el control del procesador, asumiendo que tal timer está provisto por
hardware. Esto, más el hecho de que la ready list es una cola FIFO, garantiza que cada proceso tiene
una chance periódica de ejecutar.

Página N° 27

También podría gustarte