Programación Concurrente. - Profesor MC Miguel Rodríguez

Anuncio
Benemérita Universidad Autónoma de Puebla
Facultad de Ciencias de la Computación
Notas del Curso:
Programación Concurrente.
Tomado del curso de
M.C. Mireya Toval Vidal.
Dra. Darnes Vilariño Ayala
El objetivo de esta asignatura es presentar al alumno el paradigma de la programación
concurrente en contraposición a la programación secuencial estudiada en cursos
anteriores. La asignatura se desarrollará mediante la presentación de problemas típicos de
la programación concurrente (productor/consumidor, panadería, filósofos,
lectores/escritores, etc.) y la búsqueda de soluciones en modelos de memoria compartida
y memoria distribuida. El alumno deberá analizar las distintas soluciones con respecto a
requisitos de seguridad y viveza fundamentales en este tipo de programación. Es
asimismo importante que el alumno conozca lenguajes de programación concurrente en
los que pueda implementar las soluciones a los problemas antes mencionados. En
particular en el curso se trabajara en Lenguaje Java.
Concepto de Proceso y Concurrencia.
La concurrencia está presente en casi todas las actividades que realiza el ser humano por
lo que, intuitivamente, todos tenemos una idea de lo que significa el concepto
concurrencia, independientemente del conocimiento que podamos tener sobre
programación. Trataremos, por tanto, de clarificar dicho concepto para entender lo que
es un programa concurrente y que características tiene.
Normalmente, los primeros principios de programación se introducen estableciendo una
analogía entre un programa y la consecución de tareas que se realizan en una actividad
diaria cualquiera, como por ejemplo preparar una comida:
abrir refrigerador
si refrigerador está vacío
entonces
comer en el restaurante
sin
preparar entremeses
preparar entrada
preparar postre
comer en casa
fsi
Esta analogía introduce el concepto de programa secuencial como la descripción de una
secuencia de acciones. La ejecución de dicho programa la realiza un procesador y el
patrón de funcionamiento resultante se conoce como procesador secuencial, o
simplemente proceso.
Un gran número de programas se puede expresar aceptablemente de una forma
secuencial. Sin embargo, hay ciertas clases de problemas donde es esencial, o
simplemente más apropiado, desarrollar un programa como un conjunto de procesos
cooperativos que pueden ejecutarse en paralelo, o concurrentemente, para resolver dicho
problema. En particular, resultará esencial el desarrollar un programa de esta forma
cuando la concurrencia de actividades es un aspecto esencial del problema a resolver.
Considérese, por ejemplo, la descripción de la forma en que dos amigos preparan una
comida:
Juan
preparar entremeses (1)
preparar postres (3)
comer
Pablo
preparar entrada (2)
preparar la mesa (4)
comer (5)
Obviamente, el tiempo consumido por el problema así descrito es menor que en el caso
secuencial; concretamente y, según la representación anterior, sería la mitad del caso
secuencial. Sin embargo, para obtener esa reducción temporal, que es la máxima ya que
se está suponiendo que todas las tareas solapadas comienzan y terminan a la vez –igual
duración, se ha duplicado el consumo de recursos. Por tanto, se debe establecer un
compromiso entre el espacio y el tiempo para determinar si es o no adecuado el
planteamiento concurrente frente al secuencial.
Aplicaciones Inherentemente Concurrentes
Si nos centramos ahora dentro del campo de la Informática (Computer Science)
observamos que el número de áreas en las cuales la concurrencia es un aspecto
fundamental del problema a resolver es elevado, máxime si incorporamos el reciente
auge de Internet. Por enumerar algunas, las clásicas o más relevantes, podemos citar:
Sistemas Operativos
Las razones por las que un sistema operativo debe diseñarse como un programa
concurrente son conocidas suficientemente, cabe destacar:
1. La necesidad de soportar operaciones en paralelo para proveer servicios a uno o más
usuarios.
2. La interacción entre procesos debida a la gestión de recursos compartidos (por
ejemplo ficheros), o al intercambio de información entre estos (por ejemplo en el
correo electrónico).
3. La secuencia en la que se producen los eventos no es determinista.
Sistemas de Tiempo Real
Los programas concurrentes también son habituales en los procesos utilizados en
diversos tipos de control. A los sistemas computerizados de control se les denomina
sistemas de tiempo real, ya que reciben directamente las entradas de su entorno y deben
responder rápidamente para influir, y posiblemente, controlar este entorno. En la mayor
parte de los casos el corazón de estos sistemas son programas concurrentes de diversos
grados de complejidad. La necesidad de que sean programas concurrentes se deriva del
hecho de que deben responder a eventos que pueden ocurrir en cualquier instante de
tiempo.
Sistemas de Simulación
Otra forma de programa concurrente es el que permite modelar, o simular, un sistema
complejo con varias actividades que interaccionan entre si. El objetivo del modelo es el
de ayudar a comprender dicho sistema y experimentar con variaciones del mismo. En este
tipo de aplicaciones la concurrencia es necesaria y clave para modelar la posible
secuencia de eventos que se produce en una situación del mundo real sujeta a
investigación.
Arquitecturas que soportan la Concurrencia
La simulación, el tiempo real y los sistemas operativos son clases de programas
inherentemente concurrentes porque los sistemas físicos que controlan, o reflejan, son en
si mismos concurrentes. Sin embargo la concurrencia también puede utilizarse en un
programa para mejorar su eficiencia, para lo cual será necesario que la máquina sobre la
que se va a ejecutar provea y permita la ejecución simultanea (paralela) de varios
procesos. De hecho, es este segundo principio, mejorar la eficacia de los algoritmos
explotando desde el diseño sus posibilidades de paralelismo, en el que nos centraremos
mayoritariamente en este curso.
Según el tipo de problema que se esté resolviendo nos encontraremos con problemas
donde:
1. sus componentes (procesos) se ejecutan concurrentemente con tiempos de ejecución
relativamente grandes,
2. los que el paralelismo es próximo al nivel de expresión o sentencia.
De los primeros diremos que tienen concurrencia de grano grueso, mientras que los
segundos tienen concurrencia de grano fino. Obviamente, las arquitecturas demandadas
en cada caso pueden ser diferentes.
Ejemplo de concurrencia de grano grueso
Ejemplos típicos de esta categoría son la predicción del tiempo, el diseño y modelado de
estructuras relevantes en dispositivos aéreos, etc. Se suelen caracterizar por constar de
pocas unidades o bloques funcionales que interaccionan relativamente poco con el resto
(escaso intercambio de información) con unas demandas computacionales elevadas. Por
tanto, la arquitectura necesaria deberá constar de pocos pero muy potentes núcleos de
proceso donde la red de interconexión o el modelo de gestión de la memoria tiene poco
peso o influencia.
Obviamente cada uno de los bloques pueden ser susceptibles de ser paralelizados, con lo
que podríamos alcanzar descomposiciones de grano fino dentro de modelos teóricamente
gruesos.
Ejemplo de concurrencia de grano fino
Son muchos los problemas cotidianos para el alumno que podrían incluirse en esta
categoría: búsqueda, algoritmos numéricos, ordenación, etc. Consideraremos el problema
de ordenar una lista de N elementos. Un modo simple de introducir la concurrencia en la
resolución del problema es dividir la lista en varias sublistas de igual tamaño, ordenar
cada una de estas en paralelo (mediante algún algoritmo secuencial) y posteriormente
mezclarlas. El programa se ejecuta en dos fases:
a) las P sublistas se ordenan en paralelo
b) las sublistas ordenadas se mezclan en una lista ordenada.
Teniendo en cuenta que una lista de N elementos se puede ordenar en un tiempo O(N log
N) (en el mejor de los casos) y que la mezcla en una lista de longitud N es de O(N), el
tiempo de ejecución del algoritmo concurrente es de máximo(O(N/P log N/P),O(N)).
Consecuentemente, el número de sublistas P en las que se divida la lista inicial decidirá
que término domina en la expresión anterior. Así, si el número de sublistas P es igual a N
la fase de ordenación es despreciable y habríamos alcanzado la descomposición más
fina.
Aunque el tiempo de ejecución obtenido a priori es mejor que el del correspondiente
algoritmo secuencial, será necesario que el hardware permita implementar la
concurrencia especificada para lo cual deberá disponer al menos de P procesadores. En
otro caso podría ocurrir que el algoritmo concurrente fuese menos eficiente que su
correspondiente algoritmo secuencial. Estamos pues, necesitando computadores con gran
cantidad de nodos de proceso que no tienen que ser individualmente muy potentes.
Además y, dado que habrá gran cantidad de intercambio (dependencia) de información,
la forma de conectarlos (comunicarlos) será muy importante, llegando a ser un factor
decisivo en la eficiencia del algoritmo.
Pero no solo el grosor del grano de los procesos es la única forma de clasificar los
algoritmos concurrentes. Podemos, por ejemplo, fijarnos en el número de instrucciones y
el número de datos que se ejecutan/acceden simultáneamente, lo que nos lleva a la
clasificación clásica de las arquitecturas paralelas:
SIMD (single instruction, multiple data),
MISD (multiple instruction, single data) y
MIMD (multiple instruction, multiple data).
Conceptos básicos
Un programa concurrente contiene componentes (procesos) que pueden ejecutarse
simultáneamente. La materia de este curso pretende enseñar a construir tales programas,
sin prestar especial atención a ningún lenguaje de programación en particular, ni a la
máquina concreta en la que se ejecutan dichos programas.
La materia se centrará en la descripción de los principios y metodologías de la
programación concurrente, en los problemas que genera la ejecución en paralelo de los
procesos y en las técnicas y herramientas existentes para afrontar tales problemas.
Programas Concurrentes
En términos operativos un programa concurrente se puede definir como un programa que
contiene partes que están diseñadas para ejecutarse en paralelo. La palabra clave en esta
definición es diseñadas porque la ejecución de cualquier programa tiene un
comportamiento concurrente. Así, en un programa secuencial la concurrencia está
presente en el sistema operativo y en la implementación hardware de las instrucciones
individuales de la máquina.
En general, hay dos razones para que un programador desarrolle un programa concurrente
para resolver un problema dado:
1. Porque el problema a resolver sugiera de forma natural una solución concurrente, tal
y como ocurre si el programa debe realizar operaciones que deben desarrollarse en
paralelo, o si se deben gestionar eventos que se pueden producir en cualquier
instante.
2. Porque el hardware del computador sobre el que se va a ejecutar el programa soporte
ejecución de operaciones en paralelo y el tiempo de ejecución del programa pueda
reducirse si este se expresa de forma concurrente.
Como se ha comentado, un programa concurrente contiene un conjunto de procesos que
pueden ejecutarse en paralelo. Si en la máquina sobre la que se va a ejecutar el programa
están disponibles tantos procesadores como procesos entonces la concurrencia puede
realizarse completamente, en caso contrario, los procesos deben ejecutarse en tiempo
compartido sobre el procesador o procesadores disponibles.
De lo anterior se deriva la necesidad de asignar procesos lógicos a procesadores en el
momento de la ejecución de un programa concurrente. Evidentemente, tanto el número de
procesos como el de procesadores puede variar durante la ejecución del programa.
La asignación de procesos a procesadores dependerá en gran medida del entorno de
ejecución disponible. Dicho entorno puede clasificarse en tres tipos:
1. Entornos secuenciales. En este entorno la ejecución de programas concurrentes está
controlada por un sistema operativo multiusuario de propósito general. Tales
entornos están diseñados, en principio, para ejecutar programas secuenciales pero
puede simularse la ejecución de programas concurrentes.
2. Entornos con un único procesador central y múltiples procesadores de entrada/salida.
Este entorno es el que se da en la mayor parte de los ordenadores monousuario. En
este caso se pueden ejecutar en paralelo, o asíncronamente, las instrucciones
máquina y las operaciones de entrada/salida.
3. Entorno con múltiples procesadores centrales y procesadores de entrada/salida.
Dentro de este entorno podemos distinguir entre procesadores con acceso a memoria
compartida y procesadores distribuidos cada uno de ello con su propia memoria.
Entre estos últimos se puede citar el transputer de Inmos el cuál tiene un procesador
central y memoria principal en un chip. Un ordenador puede construirse a partir de un
sólo transputer o desde varios conectados mediante canales físicos en un único
sistema multiprocesador.
Propiedades de la Programación Concurrente
Una de las principales diferencias entre un algoritmo secuencial y uno concurrente es que
el primero impone un orden total en el conjunto de tareas (instrucciones) que establece
mientras que el segundo únicamente especifica un orden parcial. En la preparación de la
comida, por ejemplo, si el refrigerador no está vacío los amigos deben tenerlo todo
preparado antes de comenzar a comer; sin embargo, la entrada puede prepararse antes
que los entremeses o después, y lo mismo puede decirse con respecto al postre.
Como un algoritmo concurrente especifica únicamente un orden parcial de las tareas a
realizar, debe tenerse en cuenta el hecho de que los tiempos de ejecución de las mismas
no está predeterminado. Así, si una comida se prepara varias veces siguiendo los
algoritmos descritos anteriormente, la ejecución puede completarse de varias formas
sin violar los requerimientos. En los programas concurrentes se necesita un cierto grado
de flexibilidad porque el tiempo de algunas operaciones puede no ser conocido o
constante, como por ejemplo cuando se trabaja con dispositivos periféricos, y en
cualquier caso siempre existen pequeñas variaciones de tiempo en la ejecución de las
instrucciones individuales de la máquina.
La falta de certeza sobre el orden preciso en que se producen algunos eventos en un
sistema concurrente es una propiedad denominada indeterminismo. La presencia de
dicha propiedad en un programa puede crear problemas al programador ya que se pueden
producir fallos provenientes de errores transitorios. Un error transitorio es aquél que
puede ocurrir dependiendo del orden en que se ejecuten las tareas en una activación
concreta del programa. Así, uno de los aspectos más importantes del diseño de
programas concurrentes es expresar estos de forma que se garantice su funcionamiento
correcto independientemente del orden en el que se puedan realizar algunas de sus
acciones individuales.
Por otra parte, el segundo algoritmo de la sección visto con anterioridad es incompleto
en lo siguiente: no
identifica la interacción entre los dos procesos que lo componen. Es precisamente esta
interacción (cooperación) la que de hecho hace el algoritmo concurrente, pues en otro
caso simplemente tendríamos un conjunto de algoritmos secuenciales.
En general, la interacción entre procesos se produce en tres circunstancias diferentes:
1. Cuando los procesos compiten por acceder a un recurso compartido, tal y como
sucede en el ejemplo al utilizar cuchillos y otros utensilios compartidos durante la
preparación de la comida.
2. Cuando los procesos necesitan suspender temporalmente su ejecución, tal y como
sucede en el ejemplo cuando uno cualquiera de los amigos tiene que esperar para
comenzar a comer a que todos los platos estén preparados.
3. Cuando los procesos intercambian datos, tal y como ocurre cuando los amigos
intercambian ideas y bromas durante la comida.
En los tres casos es necesario que los procesos involucrados sincronicen sus
actividades, para evitar conflictos como en el caso 1, o establecer comunicaciones como
en los casos 2 y 3. En el caso de la gestión de recursos es esencial tener restricciones
sobre la forma en que estos se administran para asegurar la equidad y evitar o recuperar
el bloqueo (lockout). El bloqueo es una situación en la cual un proceso tiene asignado un
recurso solicitado por otro, rechazando o negándose a liberarlo, lo que produce que el
otro proceso pueda estar esperando indefinidamente. La equidad es necesaria para
asignar recursos a procesos en el mismo orden en el que se solicitan, de no ser así, puede
producirse la suspensión indefinida de algún proceso (starvation). Esta es una situación
que se produce cuando la petición de recurso de un proceso queda a la espera de
asignación del mismo y este se asigna continuamente a otros procesos, no garantizándose
la asignación del recurso en un tiempo finito.
En general los procesos de un programa concurrente tienen un tiempo de vida muy largo
(normalmente infinito, salvo que se produzcan interbloqueos).
Exclusión Mutua. Algoritmos de Dekker y Peterson
Como ya se ha comentado, los programas concurrentes deben diseñarse de forma que
sean deterministas y los procesos que lo componen deben sincronizar sus actividades,
entre otros motivos, para acceder a un recurso compartido.
En general, un recurso compartido será un objeto (fichero, cola, ...) con una cierta
representación interna, y por tanto, dicho recurso estará identificado por una variable.
Así, dado que habitualmente los procesos de un programa concurrente accederán a
recursos (objetos) compartidos deberá evitarse la posibilidad de que un proceso pueda
acceder a la información de un objeto mientras otro proceso la está modificando. Es
decir, deberá conseguirse la exclusión mutua de los procesos respecto al recurso
compartido.
El caso más simple que permite ilustrar la necesidad de la exclusión mutua es el de dos
procesos que acceden al valor de una variable compartida (ver figura 2.2). En los
procesos de la figura 2.2 el valor de x que se imprime depende de las velocidades
relativas de ambos procesos, por tanto, el programa concurrente compuesto por dichos
procesos no será determinista.
Proceso 1
si x>250
entonces escribir(x)
sino escribir(x-100)
fsi
Proceso 2
x:=(x+10) mod 500
Figura 2.2 Acceso a una variable compartida
Una primera aproximación para resolver la exclusión mutua entre dos procesos podría
ser utilizar una variable global para gestionar el acceso a las secciones críticas, como se
muestra en la siguiente figura:
acceso: entero en el rango 1..2 con valor inicial 1
Proceso 1
Proceso 2
sección no crítica
sección no crítica
repetir hasta que acceso = 1
repetir hasta que acceso = 2
sección critica
sección critica
acceso = 2
acceso = 1
Figura 2.3 Primera aproximación al algoritmo de Dekker
La solución anterior es incorrecta porque ambos procesos examinan y actualizan una
única variable global. Así, si uno de los procesos “muere” se producirá una situación de
bloqueo en el otro.
Para solventar esta situación, podemos pensar en que cada proceso actúe sobre su propia
variable, como se muestra en la figura 2.4.
C1, C2: enteros en el rango 0..1 con valor inicial 1
Proceso 1
Proceso 2
sección no crítica
sección no crítica
repetir hasta que C2 = 1
repetir hasta que C1 = 1
C1 = 0
C2 = 0
sección critica
sección critica
C1 = 1
C2 =1
Figura 2.4 Segunda aproximación al algoritmo de Dekker
Cada proceso Pi asigna a la variable Ci el valor 0 cuando desea entrar en su sección
crítica y el valor 1 Ci cuando la ha concluido. De esta manera, mientras un proceso no
está en su sección crítica el valor de las variables de control es 1, con lo que si el
proceso entra en un estado de halt el resto puede seguir trabajando.
Ahora bien, aunque este segundo algoritmo garantiza la ausencia de bloqueos, incumple
la propiedad de la seguridad, ya que los dos procesos pueden alcanzar sus secciones
críticas simultáneamente.
En la figura 2.4 cuando un proceso concluye el bucle de espera, inicia una secuencia de
instrucción que permiten alcanzar sin ninguna prevención su sección crítica. Este
conjunto de acciones pueden no ser, no lo son de hecho, atómicas. Por tanto el error está
en no considerar las actuaciones sobre las variables de control como sección crítica.
Para solucionarlo se puede utilizar el siguiente algoritmo,
C1, C2: enteros en el rango 0..1 con valor inicial 1
Proceso 1
Proceso 2
sección no crítica
sección no crítica
C1 = 0
C2 = 0
repetir hasta que C2 = 1
repetir hasta que C1 = 1
sección critica
sección critica
C1 = 1
C2 =1
Figura 2.5 Tercera aproximación al algoritmo de Dekker
que si bien garantiza que los dos proceso no entran en sus secciones críticas
simultáneamente, no garantiza la ausencia de bloqueos si los dos procesos insisten en
entrar sus secciones críticas simultáneamente; lo que podemos solucionar con:
C1, C2: enteros en el rango 0..1 con valor inicial 1
Proceso 1
sección no crítica
C1 = 0
Repetir
C1 = 1
C1 = 0
hasta que C2 = 1
sección critica
C1 = 1
Proceso 2
sección no crítica
C2 = 0
Repetir
C2 = 1
C2 = 0
hasta que C1 = 1
sección critica
C2 =1
Figura 2.6 Cuarta aproximación al algoritmo de Dekker
Sin embargo, el algoritmo de la figura 2.6 tiene dos defectos:
Starvation y
livelock, una variante o clase de bloqueo donde según la casuística temporal es
posible alternar ejecuciones satisfactorias con escasos intentos fallidos.
Ahora bien, si el número de colisiones por starvation o livelock es razonable (baja
probabilidad) el programador los puede asumir y así evitar soluciones más complejas.
Combinando las filosofías de la primera y cuarta aproximación (figuras 2.3 y 2.6
respectivamente) Dekker propuso un algoritmo para resolver la exclusión mutua entre dos
procesos que solventa los inconvenientes del anteriormente descrito. El algoritmo es:
C1, C2: enteros en el rango 0..1 con valor inicial 1
Acceso: entero en el rango 1..2 con valor inicial 1
Proceso 1
sección no crítica
C1 = 0
repetir
if Acceso = 2 entonces
C1 = 1
repetir hasta Acceso = 1
C1 = 0
fin si
hasta que C2 = 1
sección critica
C1 = 1
Proceso 2
sección no crítica
C2 = 0
repetir
if Acceso = 1 entonces
C2 = 1
repetir hasta Acceso = 2
C2 = 0
fin si
hasta que C1 = 1
sección critica
C2 =1
Acceso = 2
Acceso = 1
Figura 2.7 Algoritmo de Dekker
Como se ha visto, el algoritmo de Dekker resuelve la exclusión mutua para dos procesos.
Es posible construir algoritmos que resuelvan este problema para N procesos, donde N
toma un valor arbitrario mayor que 2.
Mecanismos de Comunicación y Sincronización en Memoria Compartida
En este tema se verán los mecanismos o herramientas disponibles para controlar la
ejecución concurrente de procesos. Dado que el uso de estos mecanismos persigue un
mismo objetivo, existe algún tipo de equivalencia entre ellos. De hecho, es posible
implementar unas herramientas con otras, si bien, cada una de ellas tendrá una semántica
específica que la hace más apropiada para resolver cierto tipo de problemas.
Regiones Críticas
En el tema anterior se presentó el problema de la exclusión mutua. También se vieron
varias formas de conseguir la exclusión mutua entre procesos (algoritmos de Dekker),
Sin embargo estos algoritmos conllevan una espera activa de los
procesos; es decir, cuando un proceso está intentando acceder a un recurso que ya ha sido
asignado a otro proceso, continua consumiendo tiempo del procesador en su intento de
conseguir el recurso. Además, la extensión de dicho algoritmo al caso de más de dos
procesos puede resultar excesivamente compleja.
Brinch Hansen propuso un constructor, la región crítica, para conseguir la exclusión
mutua.
El acceso a una variable v declarada como compartida debe efectuarse siempre dentro de
la región crítica (RC en adelante) asociada a v; de lo contrario el compilador dará un
mensaje de error.
La semántica de la RC establece que:
1. Los procesos concurrentes sólo pueden acceder a las variables compartidas dentro de
sus correspondientes RC.
2. Un proceso que quiera entrar a una RC lo hará en un tiempo finito.
3. En un instante t de tiempo sólo un proceso puede estar dentro de una RC determinada.
Sin embargo, las RCs que hacen referencia a variables distintas pueden ejecutarse
concurrentemente.
4. Un proceso está dentro de una RC un tiempo finito, al cabo del cual la abandona.
Dicha semántica implica que:
1. Si el número de procesos dentro de una RC es igual a 0, un proceso que lo desee
puede entrar a dicha RC.
2. Si el número de procesos en una RC es igual a 1 y k procesos quieren entrar, esos k
procesos deben esperar.
3. Cuando un proceso sale de una RC se permite que entre uno de los procesos que
esperan.
4. Las decisiones de quien entra y cuando se abandona una RC se toman en un tiempo
finito.
5. Se supone que la puesta en cola de espera es justa.
Debe resaltarse que la espera que realizan los procesos es una espera pasiva; es decir,
que cuando un proceso intenta acceder a una RC y está ocupada, abandona el procesador
en favor de otro proceso. Con esto se evita que un proceso ocupe el procesador en un
trabajo inútil. Por otra parte se supone que la puesta en cola es justa; es decir, un proceso
no espera indefinidamente para entrar en una RC.
La utilización de una RC en el acceso a la variable compartida de los procesos de la
figura 2.2 del tema anterior permite resolver el problema de indeterminación que allí se
producía (ver figura 3.3)
Proceso 1
región x hacer
x:=(x+10) mod 500
fin región
Proceso 2
región x hacer
si x>250
entonces escribir(x)
sino escribir(x-100)
fin si
fin región
Figura 3.3 Acceso a una variable compartida utilizando RCs
Las RCs se pueden anidar, pero debe tenerse en cuenta que las RCs no se deben anidar en
orden inverso en los procesos, de lo contrario podría producirse un interbloqueo.
Una implementación que refleja fielmente la semántica de las RCs es el tipo SpinLock
del Multi-Pascal.
Semáforos
Con una RC no resulta sencillo expresar que un proceso deba esperar a que otro termine
para comenzar o continuar su ejecución. Para resolver este problema Dijkstra introdujo
un mecanismo de intercambio de señales de sincronización: el semáforo.
Un semáforo es un TAD caracterizado por:
1. Estructura de datos
1. un contador entero no negativo
2. una cola de procesos esperando por ese semáforo
2. Operaciones
1. P(s) ò Wait(s)
2. V(s) ò Signal(s)
3. Init(s,valor)
siendo s una variable de tipo semáforo.
Las operaciones wait y signal se excluyen mutuamente en el tiempo. La operación Init
permite dar un valor inicial al contador de un semáforo y sólo está permitida en el cuerpo
principal del programa en la parte que no es concurrente. Por el contrario, las otras dos
operaciones sólo se pueden utilizar en procesos concurrentes.
Las operaciones sobre semáforos tienen el siguiente significado:
Wait(s)
si el contador del semáforo S es igual a 0
entonces
se lleva el proceso que realiza la operación a la cola asociada con el
semáforo S suspendiendo su ejecución y abandonando el procesador
a favor de otro proceso
sino
se decrementa el valor del contador asociado a S en una unidad y el
proceso que realiza la operación sigue ejecutándose
fin si
Signal(s)
si la cola asociada al semáforo S está vacía
entonces
se incrementa el valor del contador asociado a S en una unidad y el
proceso que realiza la operación sigue ejecutándose
sino
se toma uno de los procesos que esperan en la cola del semáforo S
y se le pone en un estado de preparado para ejecutarse. El proceso
que realiza la operación sigue ejecutándose
fin si
Invariante de los semáforos
Los semáforos se pueden considerar como un mecanismo de comunicación de procesos
en el cual los mensajes son vacíos. Sea e(s) el número de señales enviadas a un semáforo
s y r(s) el número de señales recibidas, los semáforos descritos deben cumplir el
siguiente invariante:
0 £ r(s) £ e(s) £ r(s) + máximo_entero
si a esto le añadimos que un semáforo puede tener un valor inicial que denotamos con
i(s), obtenemos:
0 £ r(s) £ e(s) + i(s) £ r(s) + máximo_entero
En definitiva el invariante de los semáforos expresa que:
1. No se pueden recibir señales más rápidamente de lo que se envían.
2. El Número de señales enviadas a un semáforo y no recibidas no puede exceder de
la capacidad del semáforo.
Con este invariante las operaciones wait y signal se pueden expresar de la siguiente
forma:
Wait(s)
si r(s) £ e(s) + i(s)
entonces
r(s) = r(s) + 1 y el proceso continua
fin si
si r(s) = e(s) + i(s)
entonces
el proceso espera en la cola
fin si
Signal(s)
e(s) = e(s) + 1
si la cola de procesos no está vacía
entonces
selecciona un proceso de la cola
r(s) = r(s) + 1
fin si
Implementación de RCs con semáforos
Los semáforos binarios (aquellos en los que el contador sólo toma los valores 0 y 1)
permiten implementar fácilmente las RCs. Para ello es necesario poner el contador
inicialmente a 1 y rodear la sección crítica (cuerpo de la RC) con las operaciones wait y
signal de la siguiente forma:
wait(s) {entrada a la RC}
sección crítica o cuerpo de la RC
signal(s) {salida de la RC}
siendo S un semáforo cuyo contador está puesto inicialmente a 1.
Se puede comprobar fácilmente que implementando una RC de la forma indicada se
garantiza su semántica. En cualquier caso existen razones para no implementar RCs con
semáforos (salvo que el lenguaje utilizado no permita definir RCs), entre ellas:
1. El compilador no reconoce qué variable protege un semáforo, con lo cual no nos
ayudaría si estamos utilizando una variable compartida fuera de su RC (tendría que
controlarlo el usuario).
2. El compilador no podrá distinguir procesos disjuntos y por tanto debe permitir el
acceso a cualquier variable.
También hay que tener sumo cuidado cuando se combinan los semáforos y las RCs, pues
debe tenerse en cuenta que si un proceso que está dentro de una RC realiza una operación
wait sobre un semáforo cuyo contador vale 0 este proceso se parará dentro de la RC
bloqueándola.
Ejemplo
Supóngase que existen 4 montones de papeles y que hay que coger uno de cada montón y
grapar los cuatro juntos. El proceso debe repetirse hasta que se acaben los montones (que
contienen el mismo número de papeles). Este problema lo podemos programar con dos
procesos: uno que se encargue de formar los grupos de 4 papeles y otro que tome estos
grupos y los vaya grapando.
Evidentemente, el proceso que grapa no pude hacerlo si no tiene nada que grapar, tendrá
que esperar a que exista algún montón de 4 papeles. Precisamente, este es el punto de
sincronización de los dos procesos y lo implementaremos con un semáforo. El proceso
que hace los montones ejecutará una operación signal cada vez que haya hecho uno y el
que grapa ejecutará una operación wait cada vez que quiera grapar uno; esto quiere decir,
que el contador del semáforo está actuando como contador del número de montones de 4
papeles que quedan por grapar (número de señales enviadas al semáforo y no recibidas).
Por otro lado, deberá tenerse en cuenta que la mesa es un recurso compartido por ambos
procesos (el proceso que amontona no puede dejar un grupo sobre la mesa a la vez que el
que grapa coge un grupo), y por tanto, el acceso a dicho recurso deberá programarse
mediante RCs.
programa GraparHojas
var
s: semáforo
mesa: Tipo T
proceso amontonar
repetir
Toma una hoja de cada montón
región mesa hacer
deja el grupo de 4 hojas en la mesa
fin región
signal(s)
hasta que se acaben las hojas
proceso grapar
repetir
wait(s)
región mesa hacer
toma un grupo de la mesa y grápalo
fin región
hasta que no queden montones que grapar
inicio
init(s, 0) {inicialmente no hay grupo que grapar}
en paralelo
amontonar
grapar
fin en paralelo
Una posible alternativa al programa anterior que puede estudiarse es la de considerar que
existen 4 procesos cada uno de los cuales contribuye con una hoja (de uno de los cuatro
montones y siempre del mismo) para formar un grupo de cuatro hojas. Un quinto proceso
sería el encargado de grapar dichos grupos.
Regiones Críticas Condicionales.
Con los mecanismos vistos hasta ahora (RCs y semáforos) el hecho de que un proceso
que quiere acceder a un objeto compartido deba esperar a que se cumpla una cierta
condición (espera condicional) no resulta fácil de implementar, al menos si se pretende
que dicha espera sea pasiva. La condición, habitualmente, hará referencia al objeto
compartido.
Supóngase que un proceso quiere acceder a una variable compartida x pero sólo si se
cumple la condición B(x). Una forma simple de implementar la espera condicional pero
que implica una espera activa es la siguiente:
dentro = falso
repetir
región x hacer
si B(x)
entonces
dentro = cierto
{acceso a x}
fin si
fin región
hasta que dentro sea cierto
Obsérvese que la RC debe estar dentro del bucle y no al revés, de lo contrario un
proceso que acceda a la RC monopolizará dicha región, así si resulta que no se cumple
B(x) se producirá un interbloqueo.
A principios de los 70 Hoare y Brinch Hansen propusieron un mecanismo de alto nivel
que permite realizar la espera condicional de forma pasiva: la región crítica
condicional (RCC en adelante).
Una RCC sólo se diferencia de una RC en que dentro de la RCC existe una sentencia
espera_a_que B. Dicha primitiva sólo puede estar dentro de una RC. Si existen varias
RCs anidadas, espera_a_que se asocia con la más próxima. Esta sentencia produce una
espera pasiva. Su semántica es la siguiente:
1. Si la condición B es cierta el proceso continúa por la siguiente sentencia a la
espera_a_que.
2. Si la condición B es falsa el proceso detiene su ejecución, abandona la RC para
permitir a otros procesos entrar en ella y pasa a una cola Q s de espera asociada con
la RC.
Cuando un proceso, que había evaluado la condición B a falso, vuelve a entrar a su RC lo
hace ejecutando de nuevo la sentencia espera_a_que, repitiéndose el comportamiento
antes descrito.
Un proceso que haya evaluado la condición del espera_a_que a falso, no vuelve a entrar
en su RC hasta que otro proceso abandone esta. Esto significa que un proceso espera a
que se cumpla una condición de forma pasiva (sin ejecutarse). Vuelve a ejecutarse
cuando es probable que se haya modificado dicha condición; esto es, cuando algún otro
proceso entra en una RC asociada a la misma variable compartida y sale de ella. Es en
este momento cuando a los procesos que estaban esperando en la cola Q s de la RC se les
da la oportunidad de ejecutarse. Evidentemente puede que la condición no haya cambiado
por lo que dichos procesos se suspenderán de nuevo cuando ejecuten la sentencia
espera_a_que. Es más, si estaban esperando N procesos, sólo uno de ellos puede
conseguir acceder a la RC y puede que después de evaluar a cierto la condición del
espera_a_que cambie dicha condición haciéndola falsa de nuevo. En estas circunstancias
los N-1 procesos restantes se dormirán de nuevo después de ejecutar la sentencia
espera_a_que.
Una RCC (entiéndase la variable compartida a la que está ligada) tiene asociada dos
colas
Q v que es donde espera un proceso cuando quiere entrar a una RC que está ocupada.
Constituye la cola de entrada a la RC.
Q s que es donde esperan los procesos que evaluaron la condición de la sentencia
espera_a_que a falso.
La transición de Q s a Q v se produce cuando un proceso abandona la RC. La razón para
tener otra cola Q s , además de la de entrada, estriba en que los procesos que evaluaron a
falso la condición del espera_a_que no pueden volver a entrar a la RC hasta que la
condición haya podido ser modificada. Si estos procesos se suspendieran directamente
en la cola Q v de entrada podrían intentar entrar de nuevo a la RC sin que la condición
hubiera podido modificarse, con la consiguiente pérdida de tiempo del procesador. Sin
embargo, teniendo dos colas separamos a los procesos que están esperando entrar por
una condición de los que quieren entrar a la RC por primera vez.
Implementación de semáforos con CRS
Para implementar semáforos lo primero que debemos elegir es una representación para
estos objetos, los cuales, preferiblemente deben crearse de forma dinámica (en tiempo de
ejecución).
Tad Semáforo_RCC
Exportar semáforo, init, wait, signal
const
maxsem = 100
tipo
semáforo = 1..maxsem
datos_semaforo = compartida registro
contador: entero
fin registro
var
disponible: entero
esp: vector[1..maxsem] of datos_semáforo
acción init(var s: semáforo; valor: entero)
si (disponible > maxsem)
entonces error
sino
s = disponible
esp[s].contador = valor
fin si
fin acción
acción signal(s: semáforo)
región esp[s] hacer
contador = contador + 1
fin región
fin acción
acción wait(s: semáforo)
región esp[s] hacer
espera_a_que (contador > 0)
contador = contador - 1
fin región
fin acción
inicio
disponible = 1
fin Tad
Implementación de RCCs con semáforos.
Se necesita disponer de dos colas de procesos suspendidos. Ya que un semáforo tiene
asociada una única cola de procesos esperando por el semáforo es obvio que, para
implementar una RCC, necesitaremos utilizar dos semáforos por cada variable
compartida a la que se acceda mediante una RCC:
Un semáforo v para disponer de la cola Q v y un semáforo s para disponer de la cola Q s .
El semáforo v debe ser un semáforo binario que garantice la exclusión mutua del cuerpo
de la RCC (sección crítica), por lo que el valor inicial de su contador asociado debe ser
1. Por otra parte, un proceso que evalúe la condición del espera_a_que a falso debe
suspenderse sobre la cola Q s del semáforo s, para ello es necesario hacer una operación
wait(s) y que el contador del semáforo s sea 0. El valor 0 del contador deberá
mantenerse constante, de lo contrario un proceso podría continuar su ejecución incluso
aunque evaluará la condición del espera_a_que a falso.
Finalmente, para obtener la implementación de una RCC mediante semáforos, debe
tenerse en cuenta que antes de suspender un proceso sobre la cola Q s debe indicarse que
dicho proceso sale momentáneamente de la RCC y que cuando vuelve a entrar lo hace
ejecutando de nuevo el equivalente a la sentencia espera_a_que.
Con las consideraciones hechas anteriormente la implementación de la RCC asociada a
la variable compartida x sería la siguiente:
{inicio de la RCC}
wait(v)
{ejecuta el equivalente del espera_a_que hasta que se cumpla B(x)}
mientras no B(x) hacer
suspendidos_s = suspendidos_s + 1 {inicialmente 0}
signal(v) {salida momentánea de la RCC}
wait(s)
wait(v) {intento de reentrada a la RCC (espera sobre Q v )}
fin mientras
{en este punto un proceso ha completado la RCC por lo que deben
pasarse los procesos suspendidos sobre la cola Q s a la cola Q v }
mientras (suspendidos_s > 0) hacer
suspendidos_s = suspendidos_s - 1
signal(s)
fin mientras
signal(v)
{final de la RCC}
La implementación previa puede dar lugar a que un proceso este continuamente
esperando para ejecutar la parte de la RCC que sigue a la sentencia espera_a_que
(starvation), cuando hay más de dos procesos que necesitan acceder a una misma
variable compartida. Esta situación se produce cuando un proceso evalúa siempre a falso
la condición del espera_a_que, pero no porque esta no sea cierta en algún momento
(situación de interbloqueo) sino porque se de la circunstancia de que siempre entre antes
a la RCC otro proceso que modifica la condición y la hace falsa. Evidentemente, la
posibilidad de que se produzca la situación descrita depende en gran medida de la forma
en que se gestione la cola de procesos suspendidos sobre un semáforo. Así, por ejemplo,
en el caso de que la gestión de la cola sea de tipo FIFO (lo habitual) dicha situación
podría producirse.
Una forma de asegurar que no se va a producir la situación anteriormente descrita cuando
la gestión de las colas de procesos suspendidos es de tipo FIFO es llevar los procesos
que quieren entrar de nuevo a la RCC, porque estaban esperando por una condición, a
una cola distinta de la cola Q v . La nueva cola la denotaremos por Q urgente y tendrá
más
prioridad que la cola Q v , de modo que cuando un proceso salga de la RCC tendrán
prioridad para acceder a la misma los procesos que estaban esperando por una condición
sobre los procesos que estaban esperando a entrar por vez primera.
En este caso para implementar una RCC con semáforos necesitaremos un semáforo más,
urgente, para disponer de la cola Q urgente . El contador de este semáforo deberá de ser
siempre 0 para que los procesos esperen a volver intentar a entrar a la RCC cuando esta
quede libre. La implementación de la RCC es la siguiente:
{inicio de la RCC}
wait(v)
{ejecuta el equivalente del espera_a_que hasta que se cumpla B(x)}
mientras no B(x) hacer
suspendidos_s = suspendidos_s + 1 {inicialmente 0}
signal(v) {salida momentánea de la RCC}
wait(s)
wait(urgente) {intento de reentrada a la RCC (espera sobre Q urgente )}
fin mientras
{en este punto un proceso ha completado la RCC por lo que deben
pasarse los procesos suspendidos sobre la cola Q s a la cola Q urgente }
mientras (suspendidos_s > 0) hacer
suspendidos_s = suspendidos_s – 1
signal(s)
suspendidos_urgente = suspendidos_urgente + 1
fin mientras
{final de la RCC}
si (suspendidos_urgente > 0)
entonces {pemite la reentrada a un proceso}
suspendidos_urgente = suspendidos_urgente - 1
signal(urgente)
sino {permite que un proceso entre por primera vez}
signal(v)
fin si
Ejemplos
Ejemplo 1
En un hotel hay 10 vehículos automáticos pequeños y otros 10 grandes. Todos ellos están
controlados por un programa con procesos concurrentes (uno por vehículo). Estamos
interesados en la parte del programa que controla la entrada en un montacargas en el que
cogen hasta 4 vehículos pequeños o 2 vehículos pequeños y 1 grande
La definición del montacargas simplemente será la cantidad de vehículos de cada clase
que están dentro de él.
programa montacargas
var
montacargas: compartida registro
numVg: 0..1
numVp: 0..4
fin registro
proceso Vg(n: entero)
{otras cosas}
región montacargas hacer {entrada al montacargas}
espera_a_que ((numVg = 0) Ù (numVp £ 2))
numVg = numVg + 1
fin región
{otras cosas}
fin proceso
proceso Vp(n: entero)
{otras cosas}
región montacargas hacer {entrada al montacargas}
espera_a_que
(((numVp < 4) Ù (numVg = 0)) Ú ((numVp < 2) Ù (numVg = 1)))
numVp = numVp + 1
fin región
{otras cosas}
fin proceso
inicio
con montacargas hacer
numVp = 0
numVg = 0
fin con
en paralelo hacer
para i = 1 hasta 10 hacer
Vp(i)
Vg(i)
fin para
fin en paralelo
fin
Ejemplo 2
Una posible solución basada en RCCs al problema de grapar hojas sería la siguiente:
programa GraparHojas
var
numhojas: compartida of entero {número de hojas sobre la mesa}
turno: vector[0..3] compartida of booleano
proceso p(n: entero)
repetir
toma 1 hoja del montón n
región turno[n] hacer
{espera que toque el turno a la hoja del montón n}
espera_a_que turno[n]
turno[n] = falso
fin región
{deja la hoja sobre la mesa}
región numhojas hacer
numhojas = numhojas + 1
fin región
{indica al proceso siguiente, (n+1) mod 4, que es su turno}
región turno[(n+1) mod 4] hacer
turno[(n+1) mod 4] = cierto
fin región
hasta que se acaben las hojas del montón n
fin proceso
proceso grapar
repetir
región numhojas hacer
{espera a que haya un grupo completo de 4 hojas sobre la mesa}
espera_a_que ((numhojas > 0) Ù (numhojas mod 4 = 0))
numhojas = numhojas - 4
fin región
grapa el grupo de 4 hojas
hasta que no queden hojas que grapar
fin proceso
inicio
numhojas = 0
turno[0] = cierto
para i = 1 to 3 hacer
turno[i] = falso
fin para
en paralelo hacer
para i = 0 hasta 3 hacer p(i)
grapar
fin en paralelo
fin
En el algoritmo se ha utilizado una variable compartida por todos los procesos,
numhojas (número de hojas sobre la mesa, y cuatro variables booleanas compartidas
únicamente por los procesos que forman los grupos de hojas, turno[i] (i=0,1,2,3), que
indica a cual de ellos le toca poner una hoja sobre la mesa. La solución indicada es
válida porque el proceso grapar sabe cuando puede coger un grupo de la mesa (condición
del espera_a_que de su RCC). Sin embargo, en muchos problemas similares a este el
proceso pendiente de la actuación de otros, y no necesariamente de todos ellos, necesita
ser informado de que puede comenzar o continuar su ejecución (en nuestro caso el
proceso grapador). En estos casos suele ser habitual la presencia de otro proceso que es
el que se encarga de gestionar o controlar la ejecución sincronizada de todos los demás.
Ejemplo 3
Sea una carretera por la que circulan coches en los dos sentidos. La carretera cruza un río
donde sólo es posible la circulación de coches en un sentido (ver figura 3.8). Sobre el
puente pueden estar varios coches del mismo sentido.
Se pide diseñar un protocolo que permita circular a los coches sobre el puente y realizar
un programa siguiendo tal protocolo. En este programa cada coche estará representado
por un proceso. Se debe conseguir que:
a) No haya interbloqueos. Dos coches de diferentes sentidos no pueden bloquearse en
medio del puente.
b) El protocolo que permite el paso de los coches sobre el puente sea justo. No se puede
favorecer a los coches de algún sentido a costa de perjudicar a los otros.
El protocolo será el siguiente:
1. El puente será una variable compartida por los procesos. Cada vehículo (proceso)
deberá indicar el sentido de circulación (Norte->Sur ò Sur->Norte). Así,
programando el acceso al puente como RC se evitaran los interbloqueos si se
controla el número de vehículos de cada sentido dentro del puente.
2. Cada 10 vehículos que pasen en un sentido (cada cierto tiempo) se invertirá el
sentido que tiene preferencia de paso por el puente. Habrá que controlar, por tanto, el
número de vehículos de cada sentido que pasan el puente. Este número debe
incrementarse cada vez que un vehículo entra en el puente, pues en caso contrario el
número de vehículos que cruzan el puente entre dos cambios consecutivos del sentido
de preferencia de paso dependería de la longitud del puente.
3. Un vehículo que llega al puente en el sentido que no tiene preferencia, podrá cruzarlo
si dentro del puente no hay ningún vehículo en el sentido preferente (contrario) y si no
hay ningún vehículo de dicho sentido esperando entrar al puente.
programa Obras_Públicas
const
max_seguidos = 10
tipo
sentido = (norte, sur)
var
puente: compartida registro
dentro vector[sentido] de enteros {vehículos sobre el puente}
pasan vector[sentido] de enteros {vehículos que cruzan el puente}
esperando: vector[sentido] de enteros
turno: vector[sentido] de booleano
fin registro
proceso vehículo(n: entero; ent, sal: sentido)
{entrada al puente por el sentido ent}
región puente hacer
esperando[ent] = esperando[ent] + 1
espera_a_que (dentro[sal] = 0) Ù (turno[ent] Ú (esperando[sal] = 0))
esperando[ent] = esperando[ent] - 1
dentro[ent] = dentro[ent] + 1
pasan[ent] = (pasan[ent] + 1) mod max_seguidos
si (pasan[ent] = 0)
entonces
turno[ent] = falso
turno[sal] = cierto
fin si
fin región
{cruza el puente}
{salida del puente por el sentido sal}
región puente hacer
dentro[ent] = dentro[ent] - 1
fin región
fin proceso
inicio
con puente hacer
dentro[norte] = 0; dentro[sur] = 0
pasan[norte] = 0; pasan[sur] = 0
esperando[norte] = 0; esperando[sur] = 0
turno[norte] = cierto; turno[sur] = cierto
fin con
en paralelo hacer
para i = 1 hasta 10 hacer
vehículo(i, norte, sur)
vehículo(i, sur, norte)
fin para
fin en paralelo
fin
Sucesos
Vimos en el apartado anterior que todos los procesos que entran en una RCC y no
cumplen la condición del espera_a_que se suspenden sobre la misma cola (Q s ) y todos
vuelven a intentar continuar la ejecución de la RCC cuando otro proceso termina la
misma. Esto es un inconveniente cuando no todos los procesos que entran en la RCC son
equivalentes (la condición del espera_a_que no es la misma) y es posible distinguir que
procesos podrían verificar la condición de otros que no la verificarían en función del
proceso que ha completado la RCC. Así, en el ejemplo del montacargas visto
anteriormente había 4 vehículos pequeños y salía uno de ellos, entonces
podía entrar uno de los vehículos pequeños que estuvieran esperando, pero no podía
entrar uno grande, por lo que no tiene sentido despertar a este tipo de proceso.
Para conseguir una gestión explícita de la cola de entrada a una RC y decidir que
procesos pueden entrar y cuales no es necesario introducir una nueva herramienta: los
sucesos.
Un suceso siempre debe ir asociado a una RC.
Por ejemplo, se puede declarar un suceso ev asociado a la variable compartida v como:
var
v:compartida registro
..............
ev: evento v
fin registro
El suceso es un TAD compuesto por una estructura de datos (una cola) que soporta las
siguientes operaciones:
.Espera_a_que(ev). El proceso que ejecuta esta operación abandona la RC donde se
ejecutó, pasa a la cola del suceso ev asociada con dicha RC y se suspende cediendo
la CPU a otro proceso.
Causa(ev). Los procesos encolados en este suceso pasan a la cola principal de la RC
y entrarán a la misma cuando esta quede libre. Si no hay nadie en la cola del suceso
esta operación no tiene efecto. En cualquier caso el proceso que ejecutó la operación
continua.
Estas dos operaciones se excluyen mutuamente en el tiempo y tan sólo se pueden ejecutar
dentro de una RC. Puede haber varios sucesos asociados a una misma variable
compartida. Si hay N sucesos, existen N + 1 colas (las de los N sucesos y la cola de
entrada a la RC).
Normalmente una variable compartida tendrá tantos sucesos como condiciones de espera
distintas, referidas a esta variable, se puedan producir.
Implementación de sucesos con semáforos
La implementación de sucesos con semáforos es muy similar a la implementación de
RCCs, si bien, aquí cada variable compartida tendrá asociada en lugar de la cola Q s
tantas colas como sucesos tenga. Así, si la variable compartida tiene N sucesos serán
necesarios N semáforos, además del semáforo v de exclusión mutua de la RC (para la
cola Q v ).
Para evitar los problemas que se indicaron en la sección 3.3.2, utilizaremos una cola
adicional de entrada a la RC, Q urgente , de mayor prioridad que la Q v .
Sea la región crítica
región x hacer
s1
si no B1(x)
entonces
espera_a_que(ev1)
fin si
s2
causa(ev2)
s3
fin región
donde ev1 y ev2 son eventos correspondientes a dos condiciones de espera distintas,
B1(x) y B2(x) respectivamente. Su equivalente con semáforos es:
wait(v) {inicio de la RC}
s1
si no B1(x)
entonces
{ejecuta el equivalente del espera_a_que(ev1)}
suspendidos[1] = suspendidos[1] + 1 {inicialmente 0}
signal(v) {salida momentánea de la RCC}
wait(s[1])
wait(urgente)
fin si
s2
{ejecuta el equivalente del causa(ev2)}
si (suspendidos[2] > 0)
entonces
suspendidos[2] = suspendidos[2] - 1
signal(s[2])
suspendidos_urgente = suspendidos_urgente + 1
fin si
s3
{final de la RC}
si (suspendidos_urgente > 0)
entonces
suspendidos_urgente = suspendidos_urgente - 1
signal(urgente)
sino
signal(v)
fin si
Obsérvese que por cada suceso i de la variable compartida x es necesario un semáforo
s[i] (cuyo contador deberá ser siempre 0) y un contador, suspendidos[i], del número de
procesos suspendidos sobre el mismo.
Ejemplo
Cierto número de procesos comparten un fondo de recursos equivalentes. Cuando hay
recursos disponibles, un proceso puede adquirir uno inmediatamente; en caso contrario
debe enviar una petición y esperar a que se le conceda un recurso.
tad gestión_recursos
exportar adquirir, liberar
tipo
recurso = 1..max_recursos
proceso = 1..max_procesos
var
l: compartida registro
disponibles: Pila_Recursos
peticiones: Cola_Procesos
turno: vector[proceso] de evento l
fin registro
acción adquirir(p: proceso; var r: recurso)
región l hacer
mientras Lista_Vacía(disponibles) hacer
Guarda(peticiones, p)
espera_a_que(turno[p])
fin mientras
r = Tope(disponibles)
Desapila(disponibles)
fin región
fin acción adquirir
acción liberar(r: recurso)
var p: proceso
región l hacer
Apila(disponibles, r)
si no Cola_Vacía(peticiones)
entonces
Saca(peticiones, p)
causa(turno[p])
fin si
fin región
fin acción
inicio
con l hacer
Haz_Vacia(peticiones)
Haz_Llena(disponibles)
fin con
fin tad
Monitores
Las herramientas previamente descritas para manipular la concurrencia proporcionan un
enfoque conceptual de los problemas inherentes a la programación concurrente a la vez
que nos dotan de mecanismos para evitar problemas tales como el interbloqueo,
inanición, exclusión mutua de recursos compartidos, etc.; haciendo la programación
más fiable. De las herramientas definidas, pocas de ellas están incorporadas como
constructores en lenguajes de programación. Quizás la más extendida sea el semáforo.
Un constructor que permite controlar la cooperación entre procesos concurrentes y que
está incorporada en varios lenguajes de programación es el monitor. Fue propuesto por
Brinch Hansen y por Hoare, aunque de manera independiente.
Un monitor es un mecanismo que permite compartir de una manera fiable y efectiva tipos
abstractos de datos entre procesos concurrentes. Así pues, un monitor proporciona:
1. Abstracción de datos y
2. Exclusión mutua y mecanismos de sincronización entre procesos.
Un monitor es similar a un TAD (incluso para declararlo se utiliza la misma sintaxis que
para declarar un TAD sustituyendo la palabra reservada tad por monitor) en el sentido
de que esconde la representación interna de sus variables y proporciona al exterior sólo
el comportamiento funcional definido por las operaciones exportadas. Pero un monitor
proporciona más.
Por un lado, garantiza que el número de procesos que en un instante de tiempo están
ejecutando operaciones del monitor es como máximo 1. Esta propiedad, exclusión
mutua, asegura la consistencia de los datos del monitor.
Por otra parte, el monitor proporciona un mecanismo de sincronización entre procesos: el
constructor condición. Una operación del monitor puede suspender la ejecución de un
proceso durante una cantidad arbitraria de tiempo ejecutando una operación espera sobre
una variable de tipo condición. Cuando un proceso realiza la operación espera, pierde el
acceso al monitor (lo abandona temporalmente) y se suspende sobre la cola de espera de
la condición. Cuando un proceso realiza la operación señala sobre una variable de tipo
condición, despertará a uno de los procesos encolados en la condición en función de la
política de gestión de la cola (normalmente FIFO).
Cuando se ejecuta la operación señala se corre el peligro de que en el monitor se estén
ejecutando concurrentemente dos procesos: el que ejecutó la operación señala y el que
estaba esperando y ahora es reanudado por la siguiente sentencia al espera. Para evitar
está situación, Hoare propuso que el proceso que ejecutó señala abandone el monitor y
se suspenda en una cola de alta prioridad del mismo, de modo que el proceso que entra
en el monitor y continua ejecutándose para completar la operación del monitor que estaba
realizando es el recién despertado. Cuando el monitor queda libre se permite que el
proceso que estaba en la cola de alta prioridad reanude su ejecución dentro del monitor.
Brich Hansen también propuso que el proceso que ejecutó la operación señala
abandonará inmediatamente el monitor, pero en lugar de encolarse, Hansen obligó a que
la instrucción señala fuese siempre la última de una operación del monitor. Esto tiene el
problema de que no puede ejecutarse más de una instrucción señala en una operación del
monitor, pero tiene la ventaja de que la implementación de esta política es muy sencilla.
En cualquiera de los dos métodos, la filosofía es la misma: se reanuda inmediatamente el
proceso suspendido con lo cual se asegura que el estado de las variables del monitor no
se modifica en el intervalo transcurrido desde que se ejecuta la operación señala hasta
que el proceso despertado reanuda su ejecución.
Implementación de semáforos con monitores
Dado que un monitor dispone de un constructor, condición, que tiene ya asociado una
cola de espera, podremos implementar fácilmente los semáforos haciendo corresponder
un semáforo con una condición y un contador. Además, para poder realizar fácilmente las
operaciones necesitaremos conocer el número de procesos suspendidos sobre un
semáforo.
monitor semáforos
exportar semáforo, init, wait, signal
const
max_semáforos = 100
tipo
semáforo = 1..max_semáforos
datos_semáforo = registro
c: condición
valor, susp: entero
fin registro
var
espacio: vector [1..max_semaforos] de datos_semáforo
disponible: entero
la condición. Cuando un proceso realiza la operación señala sobre una variable de tipo
condición, despertará a uno de los procesos encolados en la condición en función de la
política de gestión de la cola (normalmente FIFO).
Cuando se ejecuta la operación señala se corre el peligro de que en el monitor se estén
ejecutando concurrentemente dos procesos: el que ejecutó la operación señala y el que
estaba esperando y ahora es reanudado por la siguiente sentencia al espera. Para evitar
está situación, Hoare propuso que el proceso que ejecutó señala abandone el monitor y
se suspenda en una cola de alta prioridad del mismo, de modo que el proceso que entra
en el monitor y continua ejecutándose para completar la operación del monitor que estaba
realizando es el recién despertado. Cuando el monitor queda libre se permite que el
proceso que estaba en la cola de alta prioridad reanude su ejecución dentro del monitor.
Brich Hansen también propuso que el proceso que ejecutó la operación señala
abandonará inmediatamente el monitor, pero en lugar de encolarse, Hansen obligó a que
la instrucción señala fuese siempre la última de una operación del monitor. Esto tiene el
problema de que no puede ejecutarse más de una instrucción señala en una operación del
monitor, pero tiene la ventaja de que la implementación de esta política es muy sencilla.
En cualquiera de los dos métodos, la filosofía es la misma: se reanuda inmediatamente el
proceso suspendido con lo cual se asegura que el estado de las variables del monitor no
se modifica en el intervalo transcurrido desde que se ejecuta la operación señala hasta
que el proceso despertado reanuda su ejecución.
Implementación de semáforos con monitores
Dado que un monitor dispone de un constructor, condición, que tiene ya asociado una
cola de espera, podremos implementar fácilmente los semáforos haciendo corresponder
un semáforo con una condición y un contador. Además, para poder realizar fácilmente las
operaciones necesitaremos conocer el número de procesos suspendidos sobre un
semáforo.
monitor semáforos
exportar semáforo, init, wait, signal
const
max_semáforos = 100
tipo
semáforo = 1..max_semáforos
datos_semáforo = registro
c: condición
valor, susp: entero
fin registro
var
espacio: vector [1..max_semaforos] de datos_semáforo
disponible: entero
acción init(var s: semáforo; n: entero)
si (disponible > max_semáforos)
entonces error
sino
s = disponible
con espacio[s] hacer
valor = n
susp = 0
fin con
disponible = disponible + 1
fin si
fin acción init
acción wait(s: semáforo)
con espacio[s] hacer
si (valor = 0)
entonces
susp = susp + 1
espera(c)
susp = susp - 1
sino
valor = valor - 1
fin si
fin con
fin acción wait
acción signal(s: semáforo)
con espacio[s] hacer
si (susp = 0)
entonces valor = valor + 1
fin si
señala(c)
fin con
fin acción signal
inicio
disponible = 1
fin monitor
Implementación de monitores con semáforos
Se dará a continuación un algoritmo para transformar un programa que utiliza monitores
en un programa que utilice semáforos. Esto nos permitirá mostrar que los monitores no
son más potentes que los semáforos y que, por tanto, la decisión de utilizar
preferiblemente monitores a semáforos se debe, exclusivamente, a su mayor contribución
a la claridad y legibilidad del sistema concurrente.
La exclusión mutua de las operaciones del monitor puede simularse fácilmente mediante
un semáforo binario, mutex (tal y como se vio en el apartado 3.2.2: Implementación de
RCs con semáforos). Por cada condición c del monitor necesitaremos un semáforo sc en
el que poder suspender los procesos (por tanto el contador del semáforo siempre debe
ser 0) y un contador, susp_sc, del número de procesos suspendidos sobre el mismo.
Cada instrucción espera(c) del monitor deberá sustituirse por el código:
susp_sc = susp_sc + 1
signal(mutex) {salida momentánea del monitor simulado}
wait(sc)
susp_sc = susp_sc - 1 {reentrada al monitor simulado}
Cada instrucción señala(c) de salida de una operación del monitor deberá sustituirse por
el código:
si (susp_sc > 0)
entonces
signal(sc)
sino
signal(mutex)
fin si
Esta implementación de monitores con semáforos es válida para los monitores definidos
por Brich Hansen; es decir, para monitores en los que la instrucción señala(c) es la
última instrucción del monitor.
La implementación de monitores con semáforos para los monitores definidos por Hoare
es algo más compleja y requiere utilizar una cola adicional de entrada al monitor de
mayor prioridad que la proporcionada por el semáforo mutex. Para disponer de la cola
de alta prioridad utilizaremos otro semáforo (urgente), ya que sobre él se pretenden
suspender los procesos que realizan la operación señala(c) el contador del mismo
deberá ser siempre 0.
En este caso cada instrucción espera(c) del monitor deberá sustituirse por el código:
susp_sc = susp_sc + 1 {salida momentánea del monitor simulado}
si (susp_urgente > 0)
entonces
signal(urgente)
sino
signal(mutex)
fin si
wait(sc)
susp_sc = susp_sc - 1 {reentrada al monitor simulado}
y cada instrucción señala(c) del monitor deberá sustituirse por el código:
susp_urgente = susp_urgente + 1
si (susp_sc > 0)
entonces
signal(sc)
wait(urgente) {se suspende a si mismo}
fin si
susp_urgente = susp_urgente – 1
Ahora la entrada a una operación del monitor vendrá dada por la instrucción
wait(mutex)
y la salida de una operación por la instrucción
si (susp_urgente > 0)
entonces
signal(urgente)
sino
signal(mutex)
fin si
Ejemplo
Obtener un monitor para definir semáforos en los que la política de gestión de la cola sea
FIFO. Se supondrá que la gestión de la cola de las condiciones del monitor es
desconocida. Ya que se debe gestionar la cola de procesos suspendidos, se supone
definida la función identificación_proceso, que retorna el número de proceso, entre 1 y
max_procesos, del proceso que la ejecuta.
monitor Semáforos_FIFO
exportar semfifo, initsem, waitfifo, signalfifo
const
max_semáforos = 100
max_procesos = 10
tipo
semfifo = 1..max_semáforos
proceso = 1..max_procesos
datos_semáforo = registro
c: vector [proceso] de condición
cola: Cola_Procesos
valor: entero
fin registro
var
espacio: vector[semfifo] de datos_semáforo
disponible: entero
acción initsem(var s: semfifo; n: entero)
si (disponible > max_semáforos)
entonces error
sino
s = disponible
con espacio[s] hacer
Hacer_Vacia(cola)
susp = 0 valor = n
fin con
disponible = disponible + 1
fin si
facción initsem
acción waitfifo(s: semfifo)
var p: proceso
con espacio[s] hacer
si (valor > 0)
entonces
valor = valor - 1
sino
p = identificación_proceso
Guardar(cola, p)
espera(c[p])
fin si
fin con
fin acción waitfifo
acción signalfifo(s: semfifo)
var p: proceso
con espacio[s] hacer
si Cola_Vacía(cola)
entonces
valor = valor + 1
sino
Sacar(cola, p)
señala(c[p])
fin si
fin con
fin acción signalfifo
inicio
disponible = 1
fin monitor.
Descargar