Programación Concurrente CAPITULO 4. EXCLUSION MUTUA A continuación se presentan soluciones al problema de la exclusión mutua de procesos en sistemas con memoria común. Como es sabido, en estos sistemas dos o más procesos ejecutándose concurrentemente pueden pretender acceder a un mismo recurso compartido al mismo tiempo, situación que se conoce como problema de la exclusión mutua. De este intento de acceso pueden derivarse problemas indeseables. Con objeto de evitar estos problemas, definimos el segmento de código de un proceso donde este pide un recurso compartido como una sección crítica (ver Figura 4.1). Figura 4.1 Modelo de proceso concurrente. El objetivo puede ahora reformularse: se pretende evitar que dos o más procesos concurrentes se encuentren ejecutando su sección crítica al mismo tiempo. Una de las soluciones al problema de la exclusión mutua propuestas en la literatura en el conocido algoritmo de Dekker. Se analizará este algoritmo proponiendo previamente al mismo varias soluciones que demostrarán ser incorrectas, hasta llegar a él. En ocasiones se insistirá en la demostración formal de la incorrección de los algoritmos. Igualmente veremos la extensión de la exclusión mutua a N procesos en base a los algoritmos de Lamport y Peterson. BUAP FCC. Mary carmen Cerón Garnica 53 Programación Concurrente 4.1. El problema de la exclusión mutua para N procesos Puede ser expuesto mediante la siguiente relación de condiciones, que nos proporcionan una abstracción teórica sobre la cual comenzar a construir nuestras propuestas de solución y realizar el análisis de las mismas. 1. N procesos se encuentran ejecutando un lazo idealmente infinito de instrucciones, que podemos considerar dividido en dos partes: la sección crítica y el resto. Los programas deben satisfacer la condición de exclusión mutua: las instrucciones incluidas en las secciones críticas de dos o más procesos no deben ejecutarse entrelazadas. 2. Evidentemente, la única manera de conseguir esto es permitir que sólo un proceso se encuentre ejecutando su sección crítica, mientras los demás esperan para ejecutarla a que éste termine. Nuestras soluciones irán encaminadas a conseguir este objetivo. Todo proceso que desee ejecutar su sección crítica deberá antes de entrar en ella ejecutar un pre-protocolo que impide a cualquier otro proceso ejecutar la suya, y un post-protocolo al salir que libera a otro proceso para ejecutarla. La implantación de estos protocolos requerirá en general variables adicionales en el código Además: 1. Un proceso puede pararse por cualquier motivo en zona no crítica de instrucciones, pero no le estará permitido parar durante la ejecución de los protocolos o de la sección crítica1. 2. El programa tampoco debe bloquearse. Si varios procesos del programa están intentando entrar en sus secciones críticas, no se producirá un bloqueo, sino que alguno de ellos conseguirá entrar en ella eventualmente. No obstante, si ninguno de los procesos tiene éxito en pasar del preprotocolo a la sección crítica, diremos que el programa se ha bloqueado. 3. Tampoco debe haber permanencia indefinida de ninguno de los procesos en su pre-protocolo, sino que en algún instante cada proceso tendrá éxito en el acceso a su región crítica. Es decir, los procesos no estarán ansiosos. BUAP FCC. Mary carmen Cerón Garnica 54 Programación Concurrente 4. En ausencia de contenciones (circunstancias que lo retengan), un único proceso que desee entrar en su sección crítica tendrá éxito y entrará en ella. Un punto muy importante a tener en cuenta es el hecho de que las soluciones que propondremos asumen que las instrucciones de lectura y escritura en la memoria común son atómicas2. Sintácticamente indicaremos la lectura de una variable común poniendo la variable en la parte derecha de una sentencia de asignación o en una sentencia condicional. 4.1.1. Primer Intento de solución Se define una variable global turno que puede tomar dos valores, uno y dos, puesto que vamos a tener dos procesos en ejecución concurrente. Inicialmente damos a la variable el valor uno. El objetivo es indicar con esta variable qué proceso tiene derecho a entrar en su sección crítica, y qué proceso no puede hacerlo. Los procesos ejecutarán un pre-protocolo donde comprobarán si el valor de la variable de turno coincide con su número de proceso. Si es así pasarán a ejecutar su sección crítica y tras ella un post-protocolo donde modificarán la variable de turno para dar acceso a su sección crítica a otro proceso. En caso contrario, entrarán en un lazo y continuarán en él hasta que tengan derecho a entrar en su sección crítica. BUAP FCC. Mary carmen Cerón Garnica 55 Programación Concurrente Analizamos a continuación la bondad de la solución propuesta mediante el establecimiento y prueba de varios teoremas. Proposición 1. Esta solución satisface el requerimiento de exclusión mutua. Demostración: Supongamos que no es así, y tratemos de derivar una contradicción. En este caso, en algún momento ambos procesos estarán al mismo tiempo en sus secciones críticas. Supongamos que P1 entró antes4 que P2. Esto es, P1 entró en tiempo t1,P2 entró en tiempo t2 y t1< t2. Si ello es así, en tiempo t1 se tuvo que turno = 1 e igualmente en tiempo t2, turno = 2. Pero durante el intervalo de t1 a t2 el proceso P1 permaneció en su sección crítica y no ejecutó el post-protocolo, por lo cual la variable de turno no pudo cambiar su valor a dos, y en consecuencia el segundo proceso no está en su sección crítica. Esto es una contradicción, luego la suposición inicial era errónea.¤ Si estudiamos detenidamente la demostración efectuada, ¿Qué ocurre con las diferentes aserciones teóricas del modelo de concurrencia que tenemos propuesto? ¿Rompe el entrelazado de instrucciones la exclusión mutua? Hemos probado que no. Proposición 2. La solución no da lugar al bloqueo del programa. Demostración: Para que haya bloqueo, ambos procesos deben ejecutar el test sobre la variable de turno y permanecer en el lazo del pre-protocolo por ser la condición de acceso falsa. Pero si el primer proceso no puede entrar, es que turno = 2 y si el segundo tampoco puede hacerlo, es que turno = 1 lo cual no es posible.¤ Proposición 3. La solución no da lugar a procesos ansiosos. Demostración: Para que exista esta situación, algún proceso como P1 debe entrar en su sección crítica mientras los otros permanecen estancados en el preprotocolo de acceso a esta. Pero si esto es así, P1 modificará el valor de turno en su post-protocolo, liberando del pre-protocolo a cualquier otro proceso, que tendrá por tanto éxito en el acceso a su sección crítica. Proposición 4. La solución falla en ausencia de contención. Demostración: Si el proceso P2 para en su zona no crítica de código, P1 podrá ejecutar su sección crítica como máximo una vez. Después de ella, ajustará el valor de turno a dos, y no podrá volver a ejecutarla. Este último teorema muestra que no hemos resuelto el problema de la exclusión mutua, puesto que una de las condiciones requeridas no se da. Incluso aunque BUAP FCC. Mary carmen Cerón Garnica 56 Programación Concurrente ninguno de los procesos se parase, si un proceso entra varias veces por segundo en su sección crítica, y otra una vez cada hora, la solución sigue sin ser práctica. Ello es así porque la hemos basado en una técnica de programación conocida como co-rutinas, que pasa el derecho de ejecución de la sección crítica de un proceso en forma explícita, y que funciona bien si un problema sencillo es resuelto por varios módulos trabajando juntos. Los problemas en que trabajamos pueden involucrar a procesos trabajando en problemas independientes o sobre diferentes aspectos de un mismo problema, y por tanto con un nivel de acoplamiento débil 4.1.2 Segundo intento de solución La solución previa falla porque a ambos procesos se les permite comprobar y ajustar el valor de la misma variable. Además, si uno de los procesos muere, o es eliminado del sistema por alguna circunstancia, el otro proceso se bloquea en su sección crítica. Vamos a corregir la situación dando a cada proceso su propia variable Ci, con la siguiente interpretación: si el proceso Pi desea entrar en su sección crítica, hace Ci = 0 la ejecuta y hace Ci = 1 cuando sale de ella. Si mientras tanto algún otro proceso desea entrar en su sección crítica comprueba que las variables de los demás procesos sean distintas de cero, y espera en caso de que no ocurra así. Sin embargo, la solución propuesta no satisface el requerimiento de exclusión mutua. La solución en código es la siguiente: BUAP FCC. Mary carmen Cerón Garnica 57 Programación Concurrente Veamos por qué esta solución no satisface el requerimiento de exclusión mutua. Proposición 5. P1 y P2 pueden ejecutar sus secciones críticas simultáneamente, quebrantando el requerimiento de exclusión mutua. Demostración: La siguiente secuencia de instrucciones entrelazadas de ambos procesos lleva a la rotura de la exclusión mutua: 1. P1 comprueba C2 y encuentra que C2 = 1. 2. P2 comprueba C1 y encuentra que C1 = 1. 3. P1 pone C1 a cero. 4. P2 pone C2 a cero. 5. Ambos procesos entran en sus secciones críticas.¤ Es muy importante estudiar las diferencias entre esta demostración y las del primer intento. Puesto que aquí pretendemos demostrar un requerimiento fundamental, se requeriría demostrar la corrección de todas las posibles secuencias entrelazadas de instrucciones. Como son infinitas, necesitaríamos argumentos matemáticos. Aquí, como pretendemos probar la falsedad del requerimiento, basta dar una secuencia concreta que sirva como contraejemplo. BUAP FCC. Mary carmen Cerón Garnica 58 Programación Concurrente 4.1.3. Tercer intento de solución En el segundo intento de solución se introducían las variables Ci las cuales intentaban indicar a los procesos en ejecución concurrente que un proceso concreto Pi estaba en su sección crítica. No obstante, una vez que un proceso había completado con éxito su lazo de control de entrada, no podía de ninguna forma ser prevenido de que otro proceso había ya entrado en su sección crítica. Esto, como vimos, llevaba a violar el requerimiento de exclusión mutua. Ello ocurría porque el lazo debería ejecutarse habiendo sido avisado previamente por cualquier otro proceso de su intención de entrar a sección crítica. En este intento conseguimos esto desplazando la sentencia de asignación de valor a Ci donde se indica esto fuera del lazo. BUAP FCC. Mary carmen Cerón Garnica 59 Programación Concurrente Sin embargo, el programa puede bloquearse después de la ejecución concurrente y entrelazada de algunas instrucciones. Proposición 6. La solución puede bloquearse. Demostración: Basta considerar para probarlo la siguiente secuencia de instrucciones: 1. P1 asigna 0 a C1. 2. P2 asigna 0 a C2. 3. P1 comprueba C2 y permanece en el bucle. 4. P2 comprueba C1 y permanece en el bucle. Nos encontramos pues en una situación en que dos procesos desean entrar en una sección crítica pero ninguno tiene éxito en conseguirlo. 4.1.4. Cuarto intento de solución En el intento anterior, cuando un proceso Pi ajusta a cero la variable Ci, no solo indicaba su intención de entrar en la sección crítica, sino que insistía en su derecho para hacerlo. Si dos procesos insistían simultáneamente en este derecho, llegábamos a un bloqueo. El cuarto intento pretende remediar el problema requiriendo a un proceso que abandone su intención de entrar en su sección crítica si descubre que puede existir un estado de contención con otro proceso. La secuencia de asignaciones {C1:= 1;C1:= 0} es significativa en un programa concurrente, pero indiferente en un programa secuencial. Por ello, el proceso P2 puede ejecutar ahora un número arbitrario de instrucciones entrelazadas con las dos asignaciones de C1. En este caso, cuando C1 abandona el intento de entrar en su sección crítica ajustando C1 a 1, el proceso P2 puede ejecutar el bucle y entrar con éxito en su sección crítica. Esta solución satisface claramente el requerimiento de exclusión mutua, por lo mismo que la anterior también lo cumplía. Sin embargo, puede dar lugar a procesos ansiosos, y a bloqueos livelock, que se producen cuando existen secuencias de computación que pueden tener éxito, pero existen otras que llevan a uno o más procesos a no entrar en su sección crítica. Veamos primeramente su código, para luego pasar a un análisis del mismo. BUAP FCC. Mary carmen Cerón Garnica 60 Programación Concurrente Proposición 7. Un proceso puede estar ansioso. Demostración: puesto que la ejecución entrelazada de instrucciones es posible, entre dos asignaciones a la variable C2, el proceso P1 puede completar un ciclo completo de su lazo externo, y hacer un intento de entrar en su sección crítica justo cuando P2 también lo hace. 1. P1 asigna 0 a C1. 2. P2 asigna 0 a C2. 3. P2 comprueba C1 y entonces ajusta C1 a 1. 4. P1 efectúa un ciclo completo. 5. P2 ajusta C2 a 0. BUAP FCC. Mary carmen Cerón Garnica 61 Programación Concurrente Ello lleva a una situación en la que P1 entra un número infinito de veces en su sección crítica, mientras que P2 permanece siempre ejecutando su pre-protocolo. Proposición 9. La solución puede llevar a un livelock. Demostración: Podemos considerar una secuencia de ejecución que alterna perfectamente instrucciones de ambos procesos, que lleva a un bloqueo (deadlock). Sin embargo, la más ligera desviación de esa secuencia permitiría que un proceso entrase en la sección crítica, y por ello se halla presente un livelock. Búsquese como ejercicio tal secuencia. De lo visto hasta ahora debería quedar claro que en un sistemade cómputo real: a) la conservación de la exclusión mutua y la libertad de bloqueos son requerimientos irrenunciables. b) Los procesos ansiosos y los bloqueos livelock son tolerables si su probabilidad de aparición es despreciable. Por tanto, el diseñador de un sistema deberá evaluar cómo de probables son la ansiedad y la aparición de bloqueos livelock en el sistema, validando una solución si esta probabilidad es muy pequeña pero a cambio cumple de manera estricta la primera de las condiciones expuestas. 4.1.5. El algoritmo de Dekker Este algoritmo es una combinación del primer y el cuarto intento de solución. Mientras que el primer intento pasaba en forma explícita el derecho a entrar en la sección crítica mediante el uso de la variable de turno, pero fallaba en ausencia de contenciones, en el cuarto cada proceso tenía su propia variable para evitar los problemas en ausencia de contención, pero en presencia de ella ningún proceso tenía derecho a insistir para entrar en su sección crítica. El algoritmo de Dekker funciona igual que la cuarta solución, excepto que incorpora el derecho explícito de insistir para entrar en la sección crítica. Las variables individuales de los procesos permitirán conseguir la exclusión mutua, pero se usará una variable de turno común a ambos procesos, de manera que en presencia de contención entre en su sección crítica el proceso que tenga el turno. Un análisis cuidadoso del pseudocódigo del algoritmo de Dekker muestra claramente la situación. El proceso P1 anuncia su deseo de entrar en sección crítica. Sin embargo, antes de hacerlo comprueba si tiene derecho a insistir en ello, comprobando el valor de la variable de turno. Si no tiene derecho, desactiva el anuncio de entrada en sección crítica haciendo C1:=1 y queda en espera hasta que recibe el turno, cosa que ocurre cuando P2 completa su sección crítica. En ese momento, vuelve a ajustar la variable a cero y entra en la sección crítica. Al salir de ella hace C1:=1 y devuelve el turno al proceso P2. BUAP FCC. Mary carmen Cerón Garnica 62 Programación Concurrente El algoritmo de Dekker es correcto, y satisface los requerimientos de exclusión mutua y ausencia de bloqueos. Ningún proceso puede volverse ansioso, y en ausencia de contención un proceso entra en su sección inmediatamente si lo desea. BUAP FCC. Mary carmen Cerón Garnica 63 Programación Concurrente 4.2. Exclusión mutua de N procesos Como hemos visto, el algoritmo de Dekker resuelve el problema de la exclusión mutua para dos procesos. No obstante, es posible diseñar métodos que la generalicen a N procesos en ejecución concurrente. Estas soluciones no son utilizadas en la práctica debido a su relativa complejidad, pero sobre todo a la existencia de soluciones más simples basadas en el hardware. No obstante damos un ejemplo de solución utilizando el algoritmo de la panadería de Lamport. Tal y como lo presentamos, el algoritmo no es muy práctico. No obstante, las ideas subyacentes han sido utilizadas en algoritmos más avanzados. En este algoritmo cada proceso que desea ejecutar su sección crítica toma un número. El proceso que tiene el número menor es el que accede a su sección crítica. De ahí el nombre del algoritmo. Se presenta a continuación como paso previo el algoritmo de la panadería para dos procesos. BUAP FCC. Mary carmen Cerón Garnica 64 Programación Concurrente Proposición 10. El algoritmo de la panadería satisface el requerimiento de exclusión mutua. Demostración: Inténtela el lector trabajando sobre las variables. Una extensión de este algoritmo a N procesos puede construirse fácilmente. Cada proceso escoge un número mayor que todos los números que ya han sido escogidos. Se utiliza un array choosing donde se indica a otros procesos que un proceso concreto está escogiendo su número. Al igual que antes, un proceso entra en su sección crítica cuando su número es el más bajo. Se utiliza un segundo array number donde se mantienen los números escogidos por los procesos. El esquema del proceso genérico Pi es Proposición 11. El algoritmo de la panadería para N procesos satisface el requerimiento de exclusión mutua. BUAP FCC. Mary carmen Cerón Garnica 65 Programación Concurrente Demostración: Se deja como ejercicio al lector, bastando para efectuarla el utilizar un argumento de reducción al absurdo y suponiendo a dos procesos dentro de sus secciones críticas, tratando entonces de derivar una contradicción. 4.2.1 El algoritmo de Peterson Otro medio de conseguir la exclusión mutua generalizada a N procesos es utilizar el algoritmo propuesto por Peterson, que utiliza variables comunes a los procesos concurrentes que solo pueden tomar un número acotado de valores. El algoritmo de Peterson desarrollado se especifica a continuación. No procederemos a desarrollar el conjunto de teoremas que prueben la bondad del algoritmo de Peterson, prefiriendo dejar esta tarea al lector, por cuanto todos ellos siguen esquemas parecidos a resultados previamente probados con otros algoritmos. BUAP FCC. Mary carmen Cerón Garnica 66 Programación Concurrente Nótese no obstante que la prueba de la exclusión mutua en el algoritmo pasaría por un esquema de reducción al absurdo, resultado de suponer a ambos procesos en sus secciones críticas al mismo tiempo (exclusión mutua rota), y tratar de derivar una contradicción entre variables. 4.2.2. Exclusión mutua mediante hardware Como ya hemos visto, bajo un modelo en el cual las instrucciones individuales pueden entrelazarse individualmente, el conseguir la exclusión mutua es difícil. La dificultad desaparece cuando las instrucciones de acceso a memoria son atómicas. Un ejemplo de instrucción de esta clase es la instrucción Test and Set, cuya ejecución es equivalente a la de las dos siguientes instrucciones, sin permitir entrelazado entre ellas. Li:=C; C:=1; Aquí C es una variable global que inicialmente tiene valor 0. Cualquier proceso puede acceder a ella. Li es una variable local a un proceso Pi. El problema de la exclusión mutua en la ejecución de las secciones críticas de N procesos puede ahora ser resuelto a entera satisfacción con el siguiente programa: Otra instrucción válida para implementar un protocolo de exclusión mutua válido es Exchange (A,B) que intercambia el contenido de las variables A y B, y cuya ejecución es equivalente a la realización en forma atómica de la siguiente secuencia de instrucciones: Temp := A A := B; B := Temp; BUAP FCC. Mary carmen Cerón Garnica 67 Programación Concurrente Un ejemplo de algoritmo que proporciona la exclusión mutua sobre N procesos utilizando este mecanismo es el siguiente: El lector puede por sí mismo comprobar con toda facilidad que ambos métodos logran la exclusión mutua requerida sobre las secciones críticas. 4. 3. Semáforos Los algoritmos de las secciones previas se ejecutaban sobre una máquina desnuda, en el sentido de que usaban sólo las instrucciones máquina proporcionadas por la computadora. Si bien hemos conseguido implementar con ellos soluciones al problema de la exclusión mutua, son de muy bajo nivel para ser eficientes y rentables. Estudiaremos ahora los semáforos, que son mecanismos que proporcionan primitivas de control de la concurrencia a un nivel superior al de las instrucciones máquina. En general, estas estructuras aparecen implementadas como llamadas al sistema operativo. Un semáforo es una variable S entera que toma valores no negativos, y sobre la cual aparecen definidas dos operaciones: 1. Wait(S) Si S > 0 entonces S := S − 1. En caso contrario, se suspende la ejecución del proceso y éste queda bloqueado en el semáforo S. 2. Signal(S) Si hay algún proceso que ha sido suspendido en el semáforo, se le despierta y puede continuar su ejecución. En caso contrario, S := S +1. BUAP FCC. Mary carmen Cerón Garnica 68 Programación Concurrente Los semáforos tienen las siguientes propiedades: 1. Wait y Signal son instrucciones atómicas. Ninguna instrucción puede ir entrelazada entre la comprobación de si S > 0 y el decremento de la variable o la suspensión del proceso. 2. El valor inicial de un semáforo es un número no negativo. 3. La operación Signal debe despertar a uno de los procesos suspendidos en el semáforo, pero la definición no especifica a cuál. Esto podría llevar a pensar que los semáforos no garantizan la vivacidad. La siguiente hipótesis nos permite asegurarla. 4. Hipótesis de corrección de los semáforos: si un proceso P está bloqueado en un semáforo S y este toma un valor estrictamente mayor que cero con una frecuencia infinita, el proceso P eventualmente será desbloqueado. Los semáforos serán generales si pueden tomar cualquier valor no negativo, y binarios si solo pueden tomar los valores 0 y 1. En este último caso, la instrucción de incremento de la operación Signal se sustituye por S := 1. En la literatura la notación habitual para las operaciones sobre semáforos es la siguiente: Wait(S) P(S) Signal(S) V(S) 4.3.1. Ecuaciones de invariancia de un semáforo Son las siguientes, y se verifican para cualquier semáforo S: S ≥ =0 S= S0 + |Signals| − |Wai (1) donde S0 es el valor inicial no negativo del semáforo, |Signals| es el número de operaciones de Signal ejecutadas sobre él, y |Waits| el número de Wait completados. Los invariantes se siguen directamente de la definición de semáforo. Si un programa rompe estos invariantes, se ha realizado una mala implementación del semáforo. Presentaremos en lo que sigue una solución al problema de la exclusión mutua caracterizada por estar basada en el uso exclusivo de semáforos. BUAP FCC. Mary carmen Cerón Garnica 69 Programación Concurrente 4.3.2. Implementación de un semáforo Es posible realizar varias implementaciones del concepto de semáforo. La más simple asocia a la variable que representa el semáforo una cola donde quedan situados los procesos que quedan bloqueados en una operación Wait sobre el semáforo. Los procesos que efectúan una señalización Signal sobre el semáforo desbloquean a uno de lo procesos situados en la cola si los hay. A nivel de código, la sintaxis de definición de un semáforo sería algo parecido a lo siguiente Aquí, la variable S debe entenderse que almacena el valor concreto (un entero no negativo) del semáforo en un instante dado, mientras que L es una lista que almacena los descriptores de proceso actualmente bloqueados en el semáforo. 4.3.3. Exclusión mutua utilizando semáforos El siguiente programa muestra un ejemplo de control del acceso de dos procesos concurrentes a sus secciones críticas mediante semáforos, solucionando el problema de la exclusión mutua. Si un proceso como P1 desea entrar en su sección crítica, ejecuta un preprotocolo compuesto de una operación de Wait (S). Si el semáforo vale uno, se decrementa y el proceso entra en su sección crítica, y la abandona ejecutando un post-protocolo Signal (S) que restaura el valor del semáforo a uno. Si mientras el primer proceso ejecuta su sección crítica un segundo proceso desea entrar en BUAP FCC. Mary carmen Cerón Garnica 70 Programación Concurrente ella, hará una operación de Wait sobre el semáforo, y quedará bloqueado en él hasta que sea liberado por el primer proceso cuando salga de ella. La solución aquí propuesta es similar al segundo intento de solución que se dió, salvo que la implementación atómica de las operaciones de los semáforos previene el entrelazado entre la comprobación del valor de S y la asignación. La diferencia con una instrucción de Test and Set es que el proceso detenido no tiene que ejecutar un bucle de espera ocupada, sino que es situado en una cola de procesos suspendidos asociada a S. Esto disminuye la sobrecarga (overhead) del sistema. Proposición 12. mutua. La solución propuesta satisface la propiedad de exclusión Demostración: Sea |CS| el número de procesos concurrentes que están ejecutando sus secciones críticas. Hemos de probar que |CS| + S = 1 (2) es invariante, lo cual equivale a decir que hay un sólo proceso en su sección crítica. De la ecuación 1 sabemos que S ≥ 0, de lo cual se deduce que |CS| ≤ 1 como queríamos, probando ello la exclusión mutua. Veamos que además la relación es invariante. Es claro que |CS| será la diferencia entre el número de procesos bloqueados en el semáforo, y el número de los que ya han salido de su sección crítica o lo que es lo mismo: |CS| = |Waits| − |Signals| Luego utilizando la invariante de semáforos S = S0 +|Signals|−|Waits|, obtenemos que S = 1− |CS| , de lo cual |CS| + S = 1 como se pretendía. Proposición 13. El programa no puede bloquearse. Demostración: Para ello sería necesario que ambos procesos quedaran suspendidos en la ejecución de Wait (S). En este caso, será porque S = 0. Ninguno de los procesos estará ejecutando su sección crítica, luego CS = 0. En este caso se rompe el invariante 2 lo cual no es posible. En consecuencia los procesos no pueden bloquearse entre sí. BUAP FCC. Mary carmen Cerón Garnica 71 Programación Concurrente Proposición 14. No existen procesos ansiosos. Demostración: Supongamos que P1 es suspendido, y que por tanto el semáforo vale 0. Si ello es así, y por el invariante 2, el proceso P2 está ejecutando su sección crítica, y cuando acabe hará una operación de Signal sobre el semáforo liberando al único proceso bloqueado en él. Por tanto, no hay procesos ansiosos y todos acceden a sus secciones críticas 4.4. Definiciones de semáforos La bibliografía da varias definiciones de semáforos. Debemos distinguir entre ellas, ya que la corrección de nuestros programas en un sistema dado dependerá de los semáforos que implemente el sistema. Veremos aquí tres definiciones, si bien son posibles algunas más. a) Semáforos de conjunto bloqueado. El proceso señalador (que ejecuta Signal) libera a uno de los procesos suspendidos, pero no sabemos a cual. Es el semáforo que hemos definido y utilizado. b) Semáforos de cola bloqueada. Los procesos suspendidos son encolados en una estructura FIFO y liberados según el orden en que entraron en ella. c) Semáforos de espera ocupada. El valor de S es comprobado en un bucle de espera ocupada. La sentencia if completa es ejecutada en una sola operación atómica, pudiendo haber entrelazados entre los ciclos del bucle. El semáforo de espera ocupada se define en términos de una implementación, (el código que lo define), y es ineficiente, debiendo ser considerado sólo en sistemas BUAP FCC. Mary carmen Cerón Garnica 72 Programación Concurrente distribuidos donde la sobrecarga añadida por un bucle de espera ocupada es despreciable. La corrección de todas las soluciones puede comprobarse, ya que todas las definiciones satisfacen los invariantes definidos para los semáforos en general. Con los procesos ansiosos no ocurre igual, y en el caso de semáforos de espera ocupada es posible la aparición de procesos de esta clase. Proposición 15. Para un semáforo de espera ocupada, los procesos ansiosos son posibles. Demostración: Basta considerar la siguiente secuencia de instrucciones ejecutadas: 1. El proceso P1 ejecuta Wait (S) y entra en su sección crítica. 2. P2 encuentra S a cero y entra en el lazo de espera ocupada. 3. P1 realiza un ciclo completo de pos-protocolo, pre-protocolo y reentrada en sección crítica. 4. P2 vuelve a encontrar S a cero y sigue en espera ocupada. Como podía haber entrelazado entre intrucciones del ciclo, esta actúa de modo que el segundo proceso comprueba S antes de que el primero cambie su valor con Signal (S) dando lugar a que el segundo proceso nunca entre en su sección crítica. Proposición 16. Para un semáforo de cola bloqueada, los procesos ansiosos no son posibles. Demostración: Si P1 está bloqueado en el semáforo, y hay como mucho N −1 procesos delante de él en la cola, todos ellos irán ejecutando Signal al salir de sus secciones críticas, momento en que P1 entrará en la suya. 4.5. El problema del productor-consumidor El problema de la exclusión mutua puede verse como una abstracción de la necesidad de sincronizar la ejecución de procesos comunicados en sistemas reales. Veremos como realizar la sincronización de procesos utilizando dos clases de ellos, y los semáforos como primitiva de sincronización. a) Procesos productores, que ejecutan un procedimiento interno producir y que crean elementos de datos que pueden ser enviados a los procesos consumidores. BUAP FCC. Mary carmen Cerón Garnica 73 Programación Concurrente b) Procesos consumidores, que tras recibir un elemento de datos lo procesan utilizando un procedimiento interno consumir. Un ejemplo de ello sería una aplicación produciendo listados de impresión que son enviados al demonio de impresión del sistema. Cuando un proceso debe enviar un dato a otro, una solución elemental a la comunicación es la comunicación síncrona: cuando un proceso está listo para enviar y el otro para recibir, se envía el dato. Si necesitamos más flexibilidad, utilizamos un buffer de datos, y conseguimos comunicaciones asíncronas. Aquí el productor va introduciendo datos en el buffer de donde los toma el consumidor. Cada proceso va a su ritmo, y sólo hay que controlar que el productor no inserte en un buffer lleno, y que el consumidor no retire de un buffer vacío (ver Figura 2.2). Figura 2.2 Productor-consumidor con buffer. En la práctica podemos encontrar esta situación en un sistema interactivo donde el usuario envía varios comandos a la computadora sin esperar al fin de la ejecución del primero. Los comandos van a un buffer de memoria donde son recogidos por la computadora y ejecutados. Debe no obstante hacerse una precisión. Si las velocidades de los procesos comunicados son muy diferentes no debe usarse un buffer, ya no que aporta ninguna ventaja, pudiendo dar lugar a inconvenientes graves. 4.6. Productor-consumidor con buffers infinitos Solucionaremos el problema del productor-consumidor utilizando inicialmente buffers infinitos. Si bien la solución es inaplicable en la práctica, nos permitirá ponerde manifiesto algunos principios de diseño aplicables a situaciones prácticas, como es el caso de buffers finitos. BUAP FCC. Mary carmen Cerón Garnica 74 Programación Concurrente Hay dos variables de índice In_Ptr, Out_Ptr, indicando la siguiente posición del buffer donde hay que insertar y la posición más antigua de la cual se eliminó un elemento. Cuentan por lo tanto el número de elementos insertados en el buffer y el de elementos eliminados respectivamente. Luego |E| = In_Ptr − Out_Ptr es el número de elementos actualmente en el buffer. Las siguientes ecuaciones son claramente invariantes: Sin embargo, son estas ecuaciones las que definen el comportamiento de un semáforo, luego el problema del productor-consumidor puede ser resuelto utilizando un semáforo Elements que represente el número de elementos que hay en el buffer. BUAP FCC. Mary carmen Cerón Garnica 75 Programación Concurrente El semáforo Elements cuenta explícitamente el número de elementos que hay en el buffer, con lo cual inicialmente tiene valor 0. El consumidor ejecuta una operación Wait sobre este semáforo, con lo cual queda suspendido si el buffer está vacío. El productor ejecuta un Signal sobre este semáforo para desbloquear al consumidor. Por tanto, en esta implementación del problema del productor-consumidor, se dispone de información física para indicar al consumidor cuando el buffer está vacío o no utilizando la señalización proporcionada por el semáforo. Se consigue así la tan necesaria sincronización sobre el consumidor al objeto de prever una extracción de un buffer eventualmente vacío. BUAP FCC. Mary carmen Cerón Garnica 76 Programación Concurrente 4.7. Productor-consumidor con buffer acotado En la práctica un buffer debe ser finito, y existen dos métodos de manejar un buffer acotado. La primera es considerar un buffer circular, donde los índices se toman módulo la longitud del buffer. Ahora, además de la sincronización, hemos de asegurar que el consumidor no intente tomar un elemento de un buffer vacío, y que el productor no intente poner nada en un buffer lleno. Otro método es utilizar un grupo de buffers finitos. Al productor se le da un buffer para que lo llene, tras lo cual se lo pasa al consumidor. Este consume los elementos y devuelve el buffer al grupo. La sincronización vuelve a ser tan necesaria como antes. Utilizaremos un buffer circular por ser el más utilizado en la mayoría de los casos y tener una implementación sencilla y eficiente mediante una cola circular. Para controlar el buffer circular se crea un semáforo Spaces que cuenta los lugares vacíos en el buffer. Cuando no hay más espacio, el productor al intentar insertar en el buffer realiza una operación Wait sobre el semáforo y queda suspendido. Mientras, el consumidor extrae un elemento del buffer y ejecuta un Signal sobre el semáforo desbloqueando al productor. Los índices son computados módulo N, que es la longitud del buffer. Haciendo uso del ya conocido semáforo Elements prevemos la condición crítica de buffer vacío sobre el consumidor. BUAP FCC. Mary carmen Cerón Garnica 77 Programación Concurrente Analizamos a continuación la corrección de nuestra solución: el número de elementos en el buffer. Las siguientes Proposición 17. Sea fórmulas son invariantes al comienzo del ciclo del lazo de control. Demostración: Elements es un semáforo que es incrementado cuando el productor inserta en el buffer y decrementado cuando el consumidor retira. Luego indica el número de elementos que hay que en el buffer. Como N = Elements + Spaces lo cual es cierto. = Elements, resulta que Proposición 18. El programa nunca elimina un elemento de un buffer vacío ni inserta uno en un buffer lleno. Demostración: Eliminar de un buffer vacío supone ejecutar un ciclo del proceso consumidor con |E| = 0. Pero por la invariancia de |E|, resultará que Elements es igual a 0, quedando el consumidor suspendido en el semáforo sin posibilidad de consumir del buffer vacío. La demostración para la inserción en un buffer lleno es enteramente similar, y el lector puede efectuarla como ejercicio. Proposición 19. El programa no puede bloquearse. Demostración: Para que el programa se bloquease sería necesario que tanto Elements como Spaces valiesen cero. Pero entonces, por la invariancia |E| = N−Spaces resultaría que N = 0. Luego estaríamos tratando con un buffer de tamaño cero, lo cual no tiene sentido. Proposición 20. No hay procesos ansiosos. Demostración: No pueden darse procesos ansiosos porque tras acceder al buffer tanto el productor como el consumidor señalizan al semáforo que teóricamente podría bloquear a un proceso indefinidamente. 4.8. Productor-consumidor con semáforos binarios En la solución anterior al problema del productor-consumidor con buffer acotado utilizamos semáforos generales que podían tomar cualquier valor no negativo. A continuación se propone una solución basada exclusivamente en semáforos BUAP FCC. Mary carmen Cerón Garnica 78 Programación Concurrente binarios, ya que en ocasiones existen sistemas que sólo proporcionan esta modalidad. La solución lógica es utilizar un contador Count para conocer en todo momento el número de elementos que hay en el buffer. El contador será modificado por los procesos productor y consumidor pero en condiciones de exclusión mutua controladas por un semáforo binario S. Si el productor encuentra que ese contador es igual a N, se bloqueará en un semáforo binario Not_Full y será desbloqueado por el consumidor cuando este realice una operación de extracción, señalizando a ese semáforo. El consumidor será suspendido en igual forma en caso de buffer vacío sobre el semáforo Not_Empty. Las operaciones de incremento de los apuntadores pueden hacerse fuera de la sección crítica porque aparecen como tareas simples. Se introducen además las variables locales a los procesos Local_Count para permitir que un proceso compruebe el valor de Count la última vez que cambió su valor. Esto es necesario porque los semáforos tienen memoria. Esto es, para Signal debe haber habido el correspondiente Wait. Sin las variables locales, se darían secuencias de ejecución como la siguiente: 1. El proceso productor añade un elemento, ajusta Count a 1 y ejecuta Signal (Not_Empty). de modo que Not_Empty=1 . 2. El consumidor mira el valor Count, detecta que no es cero y en consecuencia no procede a ejecutar Wait (Not_Empty). El consumidor extrae un elemento del buffer y ajusta el valor de Count a 0. El estado actual de la computación es tal que el buffer está vacío, Count=0 y Not_Empty=1 . 3. El consumidor entra de nuevo en su lazo y como Count=0, ejecutará Wait (Not_Empty). Puesto que Not_Empty=1 , eliminará un elemento del buffer, lo cual no es posible. Hemos llegado por tanto a una situación incorrecta. El uso de la variable local hará que el consumidor ejecute Wait (Not_Empty) solucionando el problema. BUAP FCC. Mary carmen Cerón Garnica 79 Programación Concurrente Nota: en todas las versiones dadas, el acceso a las variables comunes (punteros y buffer) queda protegido mediante un semáforo de exclusión mutua. Aquí el semáforo sem es sustituido por el semáforo S, que realiza exactamente la misma función. BUAP FCC. Mary carmen Cerón Garnica 80 Programación Concurrente 4.9. Desventajas de los semáforos Son varias. Un semáforo puede ser utilizado tanto para exclusión mutua como para sincronización. Si el programa es grande, es muy difícil saber cual es la función que realiza. Además, la responsabilidad queda enteramente en manos del programador. Si olvida hacer un Signal, el programa puede degenerar en una situación de interbloqueo (deadlock). Por otra parte, un uso profuso de semáforos en un código relativamente complejo tiende a desestructurar el mismo, dificultando su legibilidad, y sobre todo su interpretación semántica. 4.10 Regiones críticas Son primitivas que permiten controlar la exclusión mutua y la sincronización de procesos concurrentes de manera más estructurada, buscando las variables compartidas por los distintos procesos y evitando el acceso simultáneo a las mismas mediante la región crítica. Sólo se permite que un proceso acceda a la región crítica, con lo cual se consigue la exclusión mutua. 4.10.1 Inconvientes de las regiones críticas. Fundamentalmente son dos: en primer lugar no permiten que varios procesos lean simultáneamente una variable, aún en el caso de que este acceso pueda llevarse a cabo. En segundo lugar, no preveen la sincronización. 4.10.2. Regiones críticas condicionales Hoare y Hansen proponen una solución a el problema de la sincronización introduciendo las regiones críticas condicionales, caracterizadas por la primitiva de sincronización Await(X), que bloquea a el proceso que la ejecuta, y permitiendo por tanto sincronizar a distintos procesos. Intenta expresar el hecho de que el proceso que llama a Await debe esperar hasta que se cumpla alguna condición sobre las variables compartidas. Así REGION X AWAIT(B) DO bloquearía al proceso llamante hasta que se cumpliera la condición B. BUAP FCC. Mary carmen Cerón Garnica 81 Programación Concurrente Figura 3: Procesos accediendo a un monitor. 4.11. Monitores Como se ha visto, el semáforo es una primitiva de sincronización que no requiere espera ocupada y que nos ha permitido solucionar con éxito los problemas habituales presentes en los sistemas con múltiples procesos en ejecución concurrente. Sin embargo, es un mecanismo de bajo nivel, ya que no es estructurado. Si deseáramos construir un gran sistema concurrente basado en semáforos, la responsabilidad de controlar los mismos sería algo difuso entre los programadores del sistema. Bastaría que alguien se olvidase de hacer una operación Signal cuando fuera necesario, para provocar un fallo total, y muy difícil de aislar y subsanar. Los monitores proporcionan una alternativa estructurada como primitiva de programación concurrente, introducida por Hoare. No son más potentes ni útiles que los semáforos, pero sí más estructurados y legibles. Un monitor es una construcción sintáctica de un lenguaje de programación concurrente que encapsula una estructura de datos o recurso que se ha de proteger mediante exclusión mutua, junto con un conjunto de procedimientos que manejan ese recurso (ver Figura 2.3). La interface del monitor con el exterior es el conjunto de llamadas a procedimientosdel monitor especificadas en su definición. Los procesos requieren los servicios de los monitores mediante llamadas a sus procedimientos. Si un mismo monitor es llamado por dos procesos, la implementación del monitor asegura que las llamadas son procesadas secuencialmente para asegurar la exclusión mutua en el acceso al monitor. Si un mismo proceso llama a monitores diferentes, sus ejecuciones pueden ser entrelazadas y discurrir concurrentemente. BUAP FCC. Mary carmen Cerón Garnica 82 Programación Concurrente En general, podemos decir que un monitor está caracterizado por encapsular en un único módulo datos y procedimientos, teniendo lugar por tanto: 1. Centralización de recursos. Los monitores centralizan una serie de recursos, garantizando que el acceso a ellos tiene lugar bajo exclusión mutua. 2. Estructuración de los datos. Los monitores protegen un conjunto de datos críticos comunes a varios procesos. El monitor puede verse como (y de hecho su implementación es muy parecida a) un TDA. 3. Elevación del nivel de abstracción. Los programadores de procesos que comparten recursos no han de preocuparse de proteger la exclusión mutua de sus regiones críticas. Basta con llamar al monitor que maneja esos recursos. Será este quien asegure la exclusión mutua. Como ejemplo se muestra a continuación el problema del productor-consumidor con buffer acotado resuelto mediante un monitor. Una vez que disponemos de este código, que encapsula datos y procedimientos, y que asegura la exclusión mutua por definición, la construcción de los BUAP FCC. Mary carmen Cerón Garnica 83 Programación Concurrente procedimientos Productor y Consumidor es un asunto trivial que únicamente exige la realización de llamadas a la interface de procedimientos proporcionada por el monitor. Como vemos, tanto el buffer como las variables de puntero que lo gestionan son recursos críticos, y por tanto aparecen definidos dentro del monitor. Esto quiere decir que cualquier proceso externo que desea hacer uso ellos debe hacerlo pasando necesariamente por la interface de procedimientos proporcionada por el monitor. Si la implementación de este es tal que solo admite a un proceso dentro del mismo, tenemos conseguida la deseada exclusión mutua. Sin embargo, queda aún por sincronizar a los procesos que manejan el buffer. Para conseguirlo, hacemos uso de de las variables de condición, tal y como se explica más adelante. Supuesto que el monitor proporciona la exclusión mutua tal y como hemos visto, el código de los procesos Productor y Consumidor se reduce a algo tan simple como lo siguiente: La semántica de cualquier monitor es tal que sólo a un proceso se le permite ejecutar uno de los procedimientos del monitor en un instante dado, con lo que aseguramos la exclusión mutua sobre las variables y recursos globales. La solución es mucho más estructurada que la basada en semáforos. Los procesos productor y consumidor sólo ven llamadas abstractas a los procedimientos Añadir y Coger, y no necesitan saber como están definidos ni como es manejado el buffer. BUAP FCC. Mary carmen Cerón Garnica 84 Programación Concurrente Al igual que un semáforo, un monitor tiene asociada una cola, FIFO en este caso, donde permanecen los procesos bloqueados en una llamada a unos de los procedimientos del monitor. Como hemos visto, el problema de la exclusión mutua de los datos, queda automáticamente resuelto por el hecho de usar un monitor. Sin embargo, dentro del monitor puede ser necesarios en ocasiones sincronizar a los procedimientos que lo componen. Así, en el caso del monitor productor-consumidor, hay que sincronizar a los procedimientos añadir y coger, ya que si no lo hacemos, el proceso productor podría llamar al procedimiento añadir cuando el buffer estuviera lleno, causando problemas. La sincronización a nivel interno en los monitores se consigue utilizando las variables de condición o señales, que son variables enteras no negativas sobre las que se pueden efectuar tres operaciones: 1. Wait(C): El proceso que llamó a el procedimiento del monitor que contiene esta sentencia es suspendido en una cola FIFO asociada a C. La exclusión mutua sobre el monitor es liberada, de forma que otros procesos puedan entrar en el monitor (y quizás señalizar la condición C). 2. Send(C): Se despierta al proceso situado en la cabeza de la cola si hay alguno. Si no lo hay, la operación es simplemente ignorada. 3. Awaited(C): Devuelve true si la cola de C es no vacía. En el monitor productor-consumidor se utilizaron dos señales: 1. Not_Empty: que suspende al consumidor hasta que el buffer esté no vacio”, y contenga por tanto elementos para ser consumidos. 2. Not_Full: que suspende al productor hasta que el buffer esté ”no lleno”, y se disponga de huecos para insertar elementos. Las señales definidas son llamadas restrictivas, debido a que una operación Send debe ser la última del procedimiento que la ejecuta. Esto es lógico, ya que de no ser así, tendríamos dos procesos dentro del monitor, rompiendo el requerimiento de exclusión mutua. Las señales restrictivas se implementan en la práctica como una norma de programación que debe cumplirse por parte de quien diseña el monitor. La técnica es conocida como reasunción inmediata, ya que al efectuar Send sobre una variable de condición un proceso bloqueado en ella entra inmediatamente en acción. BUAP FCC. Mary carmen Cerón Garnica 85 Programación Concurrente Existen también las señales no restrictivas, que no imponen normas de programación, pudiendo efectuar un Send donde y cuantas veces queramos, si bien nosotros no las trataremos aquí. Tal y como se ha venido comentando, los monitores proporcionan unos servicios exactamente iguales a los de los semáforos, excepto que el nivel de abstracción de estos es mucho menor. Como prueba de ella, se muestra a continuación como simular semáforos utilizando monitores y viceversa. 4.11. 1 Emulación de semáforos vía monitores Mediante esta emulación demostraremos no solo que las dos primitivas proporcionan una potencia y expresividad similares, sino que también ilustraremos por qué el monitor presenta un mayor nivel de abstracción que el semáforo, viendo que la emulación es mucho más simple en un sentido que en el otro. La emulación sirve para disponer de una primitiva cuando un sistema proporciona una pero no la otra. A continuación se muestra el código de la emulación: La variable S mantiene el valor del semáforo y es inicializada a algún valor no negativo S0. La variable de condición Not_Zero mantiene el encolamiento de procesos que esperan a que el semáforo tenga un valor no cero. A continuación mostramos que el monitor definido se comporta como un semáforo. BUAP FCC. Mary carmen Cerón Garnica 86 Programación Concurrente Proposición 21. El invariante de semáforo se verifica. Demostración: Puede hacerse sobre la secuencia de ejecución. Puesto que cada procedimiento del monitor se ejecuta bajo exclusión mutua por definición sin posibilidad de entrelazado de instrucciones, es posible simplicar la demostración. Bastará probar que el invariante se mantiene en cualquier entrelazado donde cada ejecución del procedimiento del monitor es considerada como una instrucción atómica. Asumiendo que S0 no es negativa, la primera desigualdad de verifica inicialmente. Posteriormente se mantiene ya que la única posibilidad de que S fuera menor que cero sería comenzando con valor S0 igual a uno y ejecutando Wait. Pero si ello ocurre el proceso llamante queda bloqueado en la condición Not_Zero y nunca llega a decrementar S, por lo cual se mantiene la desigualdad. La segunda desigualdad de deja como ejercicio al lector 4.11.2. Emulación de monitores vía semáforos Esta segunda emulación es más compleja, ya que tenemos que utilizar a los semáforos para construir un monitor. Para ello, usaremos un semáforo S para asegurar la exclusión mutua sobre los procedimientos del monitor y un semáforo C_Semáforo para cada variable de condición necesaria. Asumiremos que los semáforos son del tipo de cola bloqueada, para poder implementar el requerimiento de cola FIFO de los monitores. En otro caso, se podría programar una cola de procesos bloqueados en forma explícita. Asociado a cada variable de condición se tendrá un contador, ya que el significado de Signal(C) depende de si la cola está o no vacía, y un semáforo no tiene modo de descubrir esto, luego el contador ha de programarse explícitamente. Cada uno de los procedimientos del monitor efectuará Wait(S) como primera instrucción y Signal(S) como la última. Esto emula el requerimiento de exclusión mutua de los monitores. Cada llamada a Wait(C) sobre una variable de condición C del monitor se traduce en BUAP FCC. Mary carmen Cerón Garnica 87 Programación Concurrente Debe aclararse que esta implementación corresponde a señales restrictivas, que como sabemos pueden ejecutarse como máximo una vez dentro de un procedimiento del monitor y siempre como última instrucción de este. Ello implica que una operación Send(C) sobre una variable de condición C da lugar a la toma del control por un proceso que estuviera bloqueado en la condición. 4.11.3. Corrección en Monitores La prueba de corrección de un monitor pasa por asociar al mismo una relación invariante que debe ser cierta antes y después de cualquier llamada a un procedimiento del monitor, así como en su inicialización. Un ejemplo de tal relación, para el monitor Productor_Consumidor podría ser: donde m es el número de elementos que hay en el buffer en un momento dado. También podemos asegurar la correción de la sincronización implantada con el monitor estableciendo para cada variable de condición una condición que ha de cumplirse junto con el invariante inmediatamente después de operación Wait e inmediatamente antes de la operación Send. 4.12. Solución a otros problemas clásicos En esta sección y como colofón al capítulo, se comentarán dos problemas clásicos dentro de la literatura, como son los problemas de los lectores y escritores, y el de los filósofos, proponiendo para ambos soluciones basadas en semáforos y monitores. 4.12.1. El problema de los lectores y escritores Como se verá a continuación es un problema similar al más general, y ya resuelto, problema de la exclusión mutua, donde los procesos competían por el acceso a sus secciones críticas. En esta ocasión, los procesos se dividen en dos clases bien definidas: Procesos lectores: que no necesitan excluirse unos a otros. Procesos escritores: que deben excluirse entre ellos, y entre los lectores. BUAP FCC. Mary carmen Cerón Garnica 88 Programación Concurrente El problema es común a los sistemas de bases de datos, donde no hay inconveniente en que varios procesos de lectura accedan concurrentemente a los mismos datos, pero donde es necesario a toda costa garantizar que los procesos que realizan operaciones de escritura ejecuten éstas en exclusión mutua. Plantearemos la solución al problema mediante el uso de monitores, y dejaremos al lector la solución a través de semáforos. Para ello, propondremos un monitor con una interfaz de llamadas compuesta de cuatro procedimientos: Start_Read y Start_Write, que el lector (escritor) llamará cuando comience a leer (escribir). Igualmente habrá procedimientos End_Read y End_Write, mediante los cuales el lector (escritor) indica al monitor que ha terminado de leer (escribir). Como es natural, los procedimientos Start_ del monitor se han diseñado para que el proceso que los llama quede bloqueado cuando sea necesario. Para ello, el monitor dispondrá de dos variables de estado internas, que notaremos por: • OK_to_Read para suspender a los lectores y, • OK_to_Write para suspender a los escritores. Mantendremos además un contador del número de lectores que están actualmente haciendo una lectura en Readers, y una variable lógica, Writing, que valdrá True cuando un escritor esté en acción. La forma del código del monitor es sencilla: las variables Reader y Writing serán incrementadas por los procedimientos Start_ y decrementadas por los procedimientos End_. Un lector es suspendido si algún proceso está actualmente escribiendo, es decir Writing=True. También lo es si hay algún escritor esperando para escribir, y por tanto se tiene que Non_Empty(OK_to_Write). La primera condición la impone la definición del problema, mientras que la segunda da la prioridad a un proceso escritor suspendido sobre los lectores, y podría haberse especificado de otra forma. Además un escritor será suspendido si y solo si hay procesos leyendo (Readers 6= 0) o escribiendo. BUAP FCC. Mary carmen Cerón Garnica 89 Programación Concurrente BUAP FCC. Mary carmen Cerón Garnica 90 Programación Concurrente Como puede verse, el procedimiento End_Read ejecuta Signal (OK_to_Write) si no hay más lectores. Si hay escritores suspendidos, uno de ellos será activado y se le permitirá completar el procedimiento Start_Write, y los demás seguirán suspendidos. Finalmente, los procesos lector y escritor que hacen uso del monitor propuesto son: 4.12.2 El problema de los filósofos Es este otro de los problemas clásicos en el campo de la programación concurrente, mediante el cual podemos comparar soluciones basadas en las distintas estrategias propuestas para conseguir la exclusión mutua. El problema se establece en términos de una comunidad de cinco filósofos, cuya vida transcurre entre períodos de comer y de pensar. Podemos por tanto abstraer a un filósofo mediante el siguiente código: BUAP FCC. Mary carmen Cerón Garnica 91 Programación Concurrente Las comidas tienen lugar en una mesa donde hay cinco tenedores y un plato central desde el cual los fiósofos se sirven cuando comienzan su período de alimentación. Para poder comer, un filósofo necesita dos tenedores, que tomará de su derecha y de su izquierda. El problema es diseñar pre y post-protocolos tales que un filósofo sólo pueda comer si ha conseguido dos tenedores. La solución, además, debería garantizar las propiedades de corrección ya descritas y conocidas. En lo que sigue, plantearemos una solución al problema basada en semáforos, y otra en monitores, comprobando que ambas son legítimas desde el punto de vista de su corrección formal. 4.12.3. Solución basada en semáforos La solución que se propone viene implementada mediante el siguiente segmento de código, donde los tenedores son simulados mediante semáforos. Un filósofo debe completar dos operaciones de Wait sobre los tenedores situados a derecha e izquierda antes de poder comer. Se supone que se lanza un proceso por filósofo inicializado con un índice I que indica el número del filósofo. Veamos a continuación si la solución propuesta es correcta. Comencemos para ello probando el siguiente: Proposición 22. Dos filósofos no pueden coger el mismo tenedor. Demostración: Si consideramos la actividad de comer como la sección crítica, sólo podrá haber un filósofo realizándola. De acuerdo con esto, y si #Pi es el número de filósofos que tienen el tenedor i, tendremos que: BUAP FCC. Mary carmen Cerón Garnica 92 Programación Concurrente Como además el invariante de un semáforo nos dice que su valor no puede ser negativo, concluimos que #Pi ≤ 1 como queríamos. No obstante, la solución aún no es adecuada. Considérese la secuencia de ínter foliación que lleva a todos los filósofos a coger el tenedor de su izquierda, mediante la ejecución de Wait(Tenedores(I)). Es obvio que llegamos a un interbloqueo no recuperable, ya que ningún proceso señalizará sobre un semáforo. Una manera de asegurar la vivacidad de la solución es limitar el número de filósofos que pueden sentarse a la mesa a cuatro, mediante la adición de un nuevo semáforo Acceso: Proposición 23. No se dan procesos ansiosos. Demostración: Supondremos que el semáforo Acceso es de cola bloqueada de forma que culaquier filósofo esperando acceder a la mesa pueda eventualmente hacerlo. Los semáforos Tenedores, en cambio, bastará con que sean de conjunto bloqueado. Supongamos ahora que el filósofo i está ansioso. En estas condiciones estará bloqueado para siempre en algún semáforo, y podremos distinguir entre tres posibilidades: 1. Bloqueado en Acceso: como hemos supuesto que el semáforo es FIFO, i está bloqueada sobre Acceso sólo si este vale 0 indefinidamente. Esto sucederá únicamente si los otros cuatro filósofos están bloqueados en sus tenedores porque uno de ellos ha cogido dos. Como eventualmente ese BUAP FCC. Mary carmen Cerón Garnica 93 Programación Concurrente filósofo en concreto terminará y señalizará sobre Acceso, este caso es consecuencia de los dos siguientes. 2. i está bloqueado en el tenedor de su izquierda. Entonces, el filósofo i-1 ha código el tenedor i, habiendo por tanto ejecutado con éxito su última operación Wait. En consecuencia, terminará dejando i y deshaciendo el problema. 3. i está bloqueado en el tenedor de su derecha, luego el fiósofo i+1 ha tomado el tenedor de su izquierda. Proposición 24. La solución propuesta está libre de interbloqueos. Demostración: Es trivial desde el momento en que se introduce el semáforo Acceso. 4.12.4 Solución basada en monitores La dificultad principal en la búsqueda de una solución basada en semáforos al problema de los filósofos radica en el hecho de que la ejecución de una operación Wait sobre un semáforo es irrevocable, y por tanto no hay manera de comprobar el estado de dos tenedores simultáneamente. Si utilizáramos como solución un monitor, no habría problemas para dejar a un filósofo esperando hasta que los tenedores necesarios estuviesen libres. A continuación se muestra el monitor que resuelve el problema: BUAP FCC. Mary carmen Cerón Garnica 94 Programación Concurrente Como puede verse, el monitor mantiene un array Tenedores que indica el número de tenedores libres disponibles para cada filósofo. El procedimiento Coger_Tenedor espera sobre su propia variable de condición hasta que haya dos tenedores disponibles. Antes de salir, el procedimiento decrementa el número de tenedores disponibles para los vecinos. Por otra parte, tras haber comido, un filósofo llama a Dejar_Tenedor, que libera los tenedores y manda una señal sobre la variable de condición adecuada por si algún otro semáforo estuviera detenido en ella. El código del proceso que abstrae a un filósofo, tras lo expuesto, es absolutamente trivial: Veamos ahora la corrección de la solución. Teorema 25: Un filósofo solo come si tiene dos tenedores. Demostración: Es claro, ya que el monitor garantiza que un filósofo no queda bloqueado en la variable de condición OK_to_Eat sólo cuando el número de tenedores que tiene vale dos. BUAP FCC. Mary carmen Cerón Garnica 95 Programación Concurrente Programas en Java con monitores 1. Ejercicio de Filósofos implementados con monitores Filosofo.java public class Filosofo implements Runnable { Mesa mesa; int id; public Filosofo (Mesa _mesa, int _id) { mesa = _mesa; id = _id; } public void run () { for (int i=0;i<5;i++) { System.out.println ("Filosofo "+id+" pensando"); try {Thread.sleep (1000);} catch (Exception e) {} mesa.coger (id); System.out.println ("Filosofo "+id+" comiendo"); try {Thread.sleep (1000);} catch (Exception e) {} mesa.poner (id); } } } Mesa. java import ConditionVariable.CV2; public class Mesa { int tenedores[] = {2,2,2,2,2}; CV2 okParaComer[] = new CV2[5]; public Mesa () { for (int i=0;i<5;i++) { okParaComer[i]=new CV2("okParaComer"+i); } } public synchronized void coger (int i) { if (tenedores [i] != 2) { BUAP FCC. Mary carmen Cerón Garnica 96 Programación Concurrente System.out.println ("Filosofo "+i+" esperando"); okParaComer[i].DELAY (this); } tenedores[(i+1) % 5] = tenedores[(i+1) % 5] - 1; tenedores[(i+4) % 5] = tenedores[(i+4) % 5] - 1; } public synchronized void poner (int i) { System.out.println ("Filosofo "+i+" termina"); tenedores[(i+1) % 5] = tenedores[(i+1) % 5] + 1; tenedores[(i+4) % 5] = tenedores[(i+4) % 5] + 1; if (tenedores[(i+1) % 5] == 2) okParaComer[(i+1) % 5].RESUME (this); if (tenedores[(i+4) % 5] == 2) okParaComer[(i+4) % 5].RESUME (this); } } Programa Principal Filosofos con monitores MonFilos.java public class MonFilos { public static void main (String args[]) { Mesa mesa = new Mesa(); for (int i = 0;i<5;i++) { new Thread (new Filosofo (mesa,i)).start(); } } } Paquete ConditionVariable esta compuesto de: • • • CondVarSem.java CondVarSem2.java CV2.java package ConditionVariable; import Semaforo.*; public class CondVarSem { SemaforoBinario mutex; BUAP FCC. Mary carmen Cerón Garnica 97 Programación Concurrente SemaforoBinario miCondicion; int bloqueados; public CondVarSem (SemaforoBinario _mutex) { mutex = _mutex; miCondicion = new SemaforoBinario (0); bloqueados = 0; } public void DELAY () { bloqueados++; mutex.SIGNAL (); miCondicion.WAIT (); mutex.WAIT (); } public void RESUME () { if (bloqueados>0) { bloqueados--; miCondicion.SIGNAL (); } } public boolean emptyCV () { if (bloqueados > 0) return false; else return true; } } package ConditionVariable; import Semaforo.*; public class CondVarSem2 { SemaforoBinario mutex; SemaforoBinario miCondicion; int bloqueados; public CondVarSem2 (SemaforoBinario _mutex) { mutex = _mutex; miCondicion = new SemaforoBinario (0); bloqueados = 0; BUAP FCC. Mary carmen Cerón Garnica 98 Programación Concurrente } public void DELAY () { bloqueados++; mutex.SIGNAL (); miCondicion.WAIT (); bloqueados--; } public void RESUME () { if (bloqueados>0) { miCondicion.SIGNAL (); mutex.WAIT(); } } public boolean EMPTY () { if (bloqueados > 0) return false; else return true; } } package ConditionVariable; import java.util.Vector; public class CV2 { Vector bloqueados; // threads bloqueados en esta variable condición boolean condSalida; // condición para salir del DELAY String nombre; // nombre de la variable condición static Vector condicion; /* condiciones sobre las que se ha hecho resume y hay algún bloqueado pero todavía no se desbloqueó. Debe ser static para ser compartido por todas las variables condición. */ public CV2 (String _nombre) { nombre = _nombre; condSalida = false; bloqueados = new Vector (50); condicion = new Vector (10); } public void DELAY (Object monitor) { synchronized (monitor) { // adquirimos el cerrojo sobre el monitor BUAP FCC. Mary carmen Cerón Garnica 99 Programación Concurrente try { bloqueados.addElement (Thread.currentThread()); do { monitor.wait(); //System.out.println ("."); /* la condición de salida es que este thread sea el que más tiempo lleva esperando en la variable condición y que además esté esperando por una condición sobre la que se ha hecho un resume.*/ condSalida = bloqueados.firstElement().equals (Thread.currentThread()) && condicion.contains (nombre); // si este thread no puede despertarse, se despierta a otro if (!condSalida) monitor.notify (); } while (!condSalida); condSalida = false; bloqueados.removeElement (Thread.currentThread()); condicion.removeElement (nombre); } catch (Exception e) {e.printStackTrace();} } } public void RESUME (Object monitor) { synchronized (monitor) { if (bloqueados.size () > 0) { // Sólo si hay bloqueados tiene sentido entrar condSalida = false; if (!condicion.contains (nombre)) condicion.addElement (nombre); monitor.notify (); // Sólo despertamos a uno return; } } } public boolean EMPTY (Object monitor) { synchronized (monitor) { if (bloqueados.size() > 0) return false; else return true; } } } BUAP FCC. Mary carmen Cerón Garnica 100 Programación Concurrente 2. Ejercicio de Fumadores import ConditionVariable.CV2; public class Mesa { boolean hayIngredientes =false; int ingred1=0, ingred2=0; CV2 nuevos[] = new CV2[3]; CV2 tablaVacia = new CV2("tablaVacia"); public Mesa () { for (int i=0;i<3;i++) nuevos[i]=new CV2("nuevos"+i); } public synchronized void poner (int i, int j) { if (hayIngredientes) { System.out.println ("el agente se bloquea"); tablaVacia.DELAY (this); } System.out.println ("El agente ha dejado los ingredientes "+i+" y "+j); ingred1 = i; ingred2 = j; hayIngredientes = true; nuevos[3-(i+j)].RESUME (this); } public synchronized void coger (int i) { if ((!hayIngredientes) || (i == ingred1) || (i == ingred2)) { System.out.println ("se bloquea el fumador: "+i); nuevos[i].DELAY (this); } hayIngredientes = false; tablaVacia.RESUME (this); } } BUAP FCC. Mary carmen Cerón Garnica 101 Programación Concurrente Clase Fumador public class Fumador implements Runnable { int i; Mesa mesa; public Fumador (int _i, Mesa _mesa) { i=_i; mesa = _mesa; } public void run () { while (true) { mesa.coger (i); System.out.println ("Fumando el fumador: "+i); try {Thread.sleep (100);} catch (Exception e) {} } } } Main fumadores public class MonFumadores { public static void main (String args[]) { Mesa mesa = new Mesa(); for (int i=0;i<3;i++) { new Thread (new Fumador (i,mesa)).start(); } new Thread (new Agente(mesa)).start(); } } 3. Ejercicio de Lectores y Escritores public class Lector implements Runnable { private int i; BaseDatos bd; BUAP FCC. Mary carmen Cerón Garnica 102 Programación Concurrente ControladorPrefLectores controlador; public Lector (BaseDatos _bd, ControladorPrefLectores _controlador, int _i) { i=_i; bd = _bd; controlador = _controlador; } public void run () { for (int j=0;j<3;j++) { System.out.println ("Lector "+i+" quiere leer"); controlador.empezarLectura(i); bd.leer (i); controlador.terminarLectura(i); System.out.println ("Lector "+i+" usa su dato"); } } } import Utilities.*; public class Escritor implements Runnable { private int i; BaseDatos bd; ControladorPrefLectores controlador; public Escritor (BaseDatos _bd, ControladorPrefLectores _controlador, int _i) { i =_i; bd = _bd; controlador = _controlador; } public void run () { for (int j=0;j<3;j++) { System.out.println ("Escritor "+i+" quiere escribir"); controlador.empezarEscritura(i); bd.escribir (i,i); controlador.terminarEscritura(i); System.out.println ("Escritor "+i+" usa su dato"); } } } BUAP FCC. Mary carmen Cerón Garnica 103 Programación Concurrente mport ConditionVariable.CV2; public class ControladorPrefLectores { int contadorLectores = 0; boolean hayEscritor = false; CV2 okToWrite = new CV2("okToWrite"); CV2 okToRead = new CV2("okToRead"); public ControladorPrefLectores () { } public synchronized void empezarLectura (int i) { while (hayEscritor) { System.out.println ("el lector "+i+" se queda bloqueado en el monitor"); okToRead.DELAY(this); } contadorLectores++; okToRead.RESUME(this); } public synchronized void terminarLectura (int i) { contadorLectores --; if (contadorLectores == 0) okToWrite.RESUME(this); } public synchronized void empezarEscritura (int i) { while (hayEscritor || (contadorLectores != 0)) { System.out.println ("el escritor "+ i +" se queda bloqueado en el monitor"); okToWrite.DELAY(this); } hayEscritor = true; } public synchronized void terminarEscritura (int i) { hayEscritor = false; if (!okToRead.EMPTY(this)) okToRead.RESUME(this); //damos preferencia a lectores else okToWrite.RESUME(this); } } BUAP FCC. Mary carmen Cerón Garnica 104 Programación Concurrente import Utilities.*; public class BaseDatos { private int valor = 0; public BaseDatos (int v) { valor = v; } public int leer (int i) { System.out.println ("Lector "+i+" leyendo"); try {Thread.sleep (500);} catch (Exception e) {} System.out.println ("Lector "+i+" leyendo"); System.out.println ("Lector "+i+" leyendo"); return valor; } public void escribir (int v, int i) { System.out.println ("Escritor "+i+" escribiendo"); System.out.println ("Escritor "+i+" escribiendo"); System.out.println ("Escritor "+i+" escribiendo"); valor = v; } } public class MonLectoresEscritores { static BaseDatos bd = new BaseDatos(0); static ControladorPrefLectores controlador = new ControladorPrefLectores (); public static void main (String args[]) { for (int i=0;i<5;i++) new Thread (new Lector (bd, controlador, i)).start(); for (int i=0;i<5;i++) new Thread (new Escritor (bd, controlador, i)).start(); } } BUAP FCC. Mary carmen Cerón Garnica 105