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.