LibroPCTR 2016
LibroPCTR 2016
LibroPCTR 2016
ogramaci
ón
Concur
rent
ey
TiempoReal
Tercera Edición
3. Paso de Mensajes 73
3.1. Conceptos Básicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
3.1.1. El concepto de buzón o cola de mensajes . . . . . . . . . . . . . 75
3.1.2. Aspectos de diseño en sistemas de mensajes . . . . . . . . . . . . 75
3.1.3. El problema de la sección crítica . . . . . . . . . . . . . . . . . . 78
3.2. Implementación . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
3.2.1. El problema del bloqueo . . . . . . . . . . . . . . . . . . . . . . 85
3.3. Problemas Clásicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
3.3.1. El buffer limitado . . . . . . . . . . . . . . . . . . . . . . . . . . 88
3.3.2. Los filósofos comensales . . . . . . . . . . . . . . . . . . . . . . 88
3.3.3. Los fumadores de cigarrillos . . . . . . . . . . . . . . . . . . . . 90
3.3.4. Simulación de un sistema de codificación . . . . . . . . . . . . . 92
pila
instr_1
instr_2 carga del ejecutable
instr_3 en memoria
… memoria
instr_i
… datos
instr_n
texto
programa proceso
admitido
nuevo interrupción terminado
salida
preparado en ejecución
espera E/S
terminación E/S en espera
1 #include <sys/types.h>
2 #include <unistd.h>
3
4 pid_t getpid (void); // ID proceso.
5 pid_t getppid (void); // ID proceso padre.
6
7 uid_t getuid (void); // ID usuario.
8 uid_t geteuid (void); // ID usuario efectivo.
proceso_A
fork()
proceso_A proceso_B
(cont.) (hijo)
hereda_de
1 #include <sys/types.h>
2 #include <unistd.h>
3
4 pid_t fork (void);
El listado de código 1.3 muestra un ejemplo muy básico de utilización de fork() para
la creación de un nuevo proceso. No olvide que el proceso hijo recibe una copia exacta
del espacio de direcciones del proceso padre.
1.1. Concepto de Proceso [5]
1 2 3 4
1 #include <sys/types.h>
2 #include <unistd.h>
3 #include <stdlib.h>
4 #include <stdio.h>
5
6 int main (void) {
7 int *valor = malloc(sizeof(int));
8 *valor = 0;
9 fork();
10 *valor = 13;
11 printf(" %ld: %d\n", (long)getpid(), *valor);
12
13 free(valor);
14 return 0;
15 }
13243: 13
13244: 13
Después de la ejecución de fork(), existen dos procesos y cada uno de ellos mantiene
una copia de la variable valor. Antes de su ejecución, solamente existía un proceso y una
única copia de dicha variable. Note que no es posible distinguir entre el proceso padre y
el hijo, ya que no se controló el valor devuelto por fork().
Típicamente será necesario crear un número arbitrario de procesos, por lo que el uso
de fork() estará ligado al de algún tipo de estructura de control, como por ejemplo un bucle
for. Por ejemplo, el siguiente listado de código genera una cadena de procesos, tal y como
se refleja de manera gráfica en la figura 1.4. Para ello, es necesario asegurarse de que el
proceso generado por una llamada a fork(), es decir, el proceso hijo, sea el responsable de
crear un nuevo proceso.
Anteriormente se comentó que era necesario utilizar el valor devuelto por fork() para
poder asignar un código distinto al proceso padre y al hijo, respectivamente, después de
realizar dicha llamada. El uso de fork() por sí solo es muy poco flexible y generaría una
gran cantidad de código duplicado y de estructuras if-then-else para distinguir entre el
proceso padre y el hijo.
1 El valor de los ID de los procesos puede variar de una ejecución a otra.
[6] Capítulo 1 :: Conceptos Básicos
Idealmente, sería necesario algún tipo de mecanismo que posibilitara asignar un nuevo
módulo ejecutable a un proceso después de su creación. Este mecanismo es precisamente
el esquema en el que se basa la familia exec de llamadas al sistema. Para ello, la forma de
combinar fork() y alguna primitiva de la familia exec se basa en permitir que el proceso
hijo ejecute exec para el nuevo código, mientras que el padre continúa con su flujo de
ejecución normal. La figura 1.5 muestra de manera gráfica dicho esquema.
1 #include <sys/types.h>
2 #include <unistd.h>
3 #include <stdio.h>
4
5 int main (void) {
6 int i = 1, n = 4;
7 pid_t childpid;
8
9 for (i = 1; i < n; i++) {
10 childpid = fork();
11 if (childpid > 0) { // Código del padre.
12 break;
13 }
14 }
15
16 // ¿PID == 1?
17 printf("Proceso %ld con padre %ld\n", (long)getpid(), (long)getppid());
18
19 pause();
20
21 return 0;
22 }
El uso de operaciones del tipo exec implica que el proceso padre tenga que integrar al-
gún tipo de mecanismo de espera para la correcta finalización de los procesos que creó con
anterioridad, además de llevar a cabo algún tipo de liberación de recursos. Esta problemá-
tica se discutirá más adelante. Antes, se estudiará un ejemplo concreto y se comentarán
las principales diferencias existentes entre las operaciones de la familia exec, las cuales se
muestran en el listado de código 1.5.
1 #include <unistd.h>
2
3 int execl (const char *path, const char *arg, ...);
4 int execlp (const char *file, const char *arg, ...);
5 int execle (const char *path, const char *arg, ..., char *const envp[]);
6
7 int execv (const char *path, char *const argv[]);
8 int execvp (const char *file, char *const argv[]);
9 int execve (const char *path, char *const argv[], char *const envp[]);
1.1. Concepto de Proceso [7]
proceso_A
fork()
proceso_A proceso_B
(cont.) (hijo)
execl()
SIGCHLD
wait() exit()
Limpieza
tabla proc zombie
1. La ruta al archivo que contiene el código binario a ejecutar por el proceso, es decir,
el ejecutable asociado al nuevo segmento de código.
2. Una serie de argumentos de línea de comandos, terminado por un apuntador a
NULL. El nombre del programa asociado al proceso que ejecuta execl suele ser el
primer argumento de esta serie.
La llamada execlp tiene los mismos parámetros que execl, pero hace uso de la variable
de entorno PATH para buscar el ejecutable del proceso. Por otra parte, execle es también
similar a execl, pero añade un parámetro adicional que representa el nuevo ambiente del
programa a ejecutar. Finalmente, las llamadas execv difieren en la forma de pasar los
argumentos de línea de comandos, ya que hacen uso de una sintaxis basada en arrays.
El listado 1.6 muestra la estructura de un programa encargado de la creación de una
serie de procesos mediante la combinación de fork() y execl() dentro de una estructura de
bucle.
Como se puede apreciar, el programa recoge por línea de órdenes el número de proce-
sos que se crearán posteriormente2 . Note cómo se hace uso de una estructura condicional
para diferenciar entre el código asociado al proceso padre y al proceso hijo. Para ello, el
valor devuelto por fork(), almacenado en la variable childpid, actúa de discriminante.
2 Por simplificación no se lleva a cabo un control de errores
[8] Capítulo 1 :: Conceptos Básicos
El nuevo código del proceso hijo es trivial y simplemente imprime el ID del proceso
y el número de hermanos que tiene. Este tipo de esquemas, en los que se estructura el
código del proceso padre y de los hijos en ficheros fuente independientes, será el que se
utilice en los temas sucesivos.
La llamada wait() detiene el proceso que llama hasta que un hijo de dicho proceso
finalice o se detenga, o hasta que el proceso que realizó dicha llamada reciba otra señal
(como la de terminación). Por ejemplo, el estándar POSIX define SIGCHLD como la
señal enviada a un proceso cuando su proceso hijo finaliza su ejecución.
Otra señal esencial está representada por SIGINT, enviada a un proceso cuando el
usuario desea interrumpirlo. Está señal se representa típicamente mediante la combina-
ción Ctrl+C desde el terminal que controla el proceso.
El listado 1.9 muestra la inclusión del código necesario para esperar de manera ade-
cuada la finalización de los procesos hijo y gestionar la terminación abrupta del proceso
padre mediante la combinación de teclas Ctrl+C.
[10] Capítulo 1 :: Conceptos Básicos
En primer lugar, la espera a la terminación de los hijos se controla mediante las líneas
35-37 utilizando la primitiva waitpid. Esta primitiva se comporta de manera análoga a
✄ #
✂ ✁
wait, aunque bloqueando al proceso que la ejecuta para esperar a otro proceso con un pid
concreto. Note como previamente se ha utilizado un array auxiliar denominado pids (línea
✂13 ✁) para almacenar el pid de todos y cada uno de los procesos hijos creados mediante fork
✄ #
(línea ✂26 ✁). Así, sólo habrá que esperarlos después de haberlos lanzado.
✄ #
Por otra parte, note cómo se captura la señal SIGINT, es decir, la terminación median-
te Ctrl+C, mediante la función signal (línea ✂19 ✁), la cual permite asociar la captura de una
✄ #
señal con una función de retrollamada. Ésta función definirá el código que se tendrá que
ejecutar cuando se capture dicha señal. Básicamente, en esta última función, denominada
controlador, se incluirá el código necesario para liberar los recursos previamente reser-
vados, como por ejemplo la memoria dinámica, y para destruir los procesos hijo que no
hayan finalizado.
Si signal() devuelve un código de error SIG_ERR, el programador es responsable de
controlar dicha situación excepcional.
hebra
Un conjunto de registros.
Una pila.
El resultado de la ejecución de este programa para un valor, dado por línea de órdenes,
de 7 será el siguiente:
cont++
while (1) {
// Produce en nextP.
while (cont == N); r1 = cont
// No hacer nada. r1 = r1 + 1
buffer[in] = nextP; p: r1 = cont
cont = r1
in = (in + 1) % N; p: r1 = r1 + 1
cont++; c: r2 = cont
}
c: r2 = r2 - 1
while (1) { cont--
while (cont == 0); p: cont = r1
// No hacer nada. c: cont = r2
nextC = buffer[out]; r2 = cont
out = (out + 1) % N; r2 = r2 - 1
cont--;
// Consume nextC. cont = r2
}
Las soluciones propuestas deberían ser independientes del número de procesos, del
orden en el que se ejecutan las instrucciones máquinas y de la velocidad relativa de eje-
cución de los procesos.
Una posible solución para N procesos sería la que se muestra en el listado de có-
digo 1.13. Sin embargo, esta solución no sería válida ya que no cumple el principio de
exclusión mutua, debido a que un proceso podría entrar en la sección crítica si se produce
un cambio de contexto justo después de la sentencia while (línea ✂5 ✁).
✄ #
Otro posible planteamiento para dos procesos se podría basar en un esquema de al-
ternancia entre los mismos, modelado mediante una variable booleana turn, la cual indica
qué proceso puede entrar en la sección crítica, es decir, si turn es igual a i, entonces el
proceso pi podrá entrar en su sección crítica (j == (i − 1)).
Esta solución no cumpliría con el principio de exclusión mutua, ya que los dos pro-
cesos podrían ejecutar su sección crítica de manera concurrente.
Los dos procesos comparten tanto el array flag, que determina los procesos que están
listos para acceder a la sección crítica, como la variable turn, que sirve para determinar el
proceso que accederá a su sección crítica. Esta solución es correcta para dos procesos,
ya que satisface las tres condiciones anteriormente mencionadas.
Para entrar en la sección crítica, el proceso pi asigna true a flag[i] y luego asigna a
turn el valor j, de manera que si el proceso pj desea entrar en su sección crítica, entonces
puede hacerlo. Si los dos procesos intentan acceder al mismo tiempo, a la variable turn se
le asignarán los valores i y j (o viceversa) en un espacio de tiempo muy corto, pero sólo
prevalecerá una de las asignaciones (la otra se sobreescribirá). Este valor determinará qué
proceso accederá a la sección crítica.
1. p1 en SC (premisa)
2. p1 en SC --> flag[1]=true y (flag[2]=false o turn!=2) (premisa)
3. flag[1]=true y (flag[2]=false o turn!=2) (MP 1,2)
4. flag[2]=false o turn!=2 (A o B) (EC 3)
Demostrando A
5. flag[2]=false (premisa)
6. flag[2]=false --> p2 en SC (premisa)
7. p2 en SR (p2 no SC) (MP 5,6)
Demostrando B
8. turn=!2 (premisa)
9. flag[1]=true (EC 3)
10. flag[1]=true y turn=1 (IC 8,9)
11. (flag[1]=true y turn=1) --> p2 en SE (premisa)
12. p2 en SE (p2 no SC) (MP 10,11)
[20] Capítulo 1 :: Conceptos Básicos
Soluciones hardware
Consideraciones
Las soluciones estudiadas hasta ahora no son, por lo general, legibles y presentan
dificultadas a la hora de extenderlas a problemas más generales en los que se manejen n
procesos.
Por otra parte, los procesos que intentan acceder a la sección crítica se encuentra en
un estado de espera activa, normalmente modelado mediante bucles que comprueban el
valor de una variable de manera ininterrumpida. Este planteamiento es muy ineficiente y
no se puede acoplar en sistemas multiprogramados.
La consecuencia directa de estas limitaciones es la necesidad de plantear mecanismos
que sean más sencillos y que sean independientes del número de procesos que intervienen
en un determinado problema.
Semáforos
Por otra parte, wait y signal son mutuamente excluyentes, es decir, si se ejecutan de
manera concurrente, entonces se ejecutarán secuencialmente y en un orden no conocido
a priori.
Si el valor de un semáforo es positivo, dicho valor representa el número de procesos
que pueden decrementarlo a través de wait sin quedarse bloqueados. Por el contrario, si
su valor es negativo, representa el número de procesos que están bloqueados. Finalmente,
un valor igual a cero implica que no hay procesos esperando, pero una operación wait
hará que un proceso se bloquee.
Los semáforos se suelen clasificar en contadores y binarios, en función del rango de
valores que tome las variables que encapsulan. Un semáforo binario es aquél que sólo
toma los valores 0 ó 1 y también se conoce como cerrojo mutex (mutual exclusion). Un
semáforo contador no cumple esta restricción.
Las principales ventajas de los semáforos con respecto a otro tipo de mecanismos de
sincronización se pueden resumir en las tres siguientes: i) simplicidad, ya que facilitan
el desarrollo de soluciones de concurrencia y son simples, ii) correctitud, ya que los
soluciones son generalmente limpias y legibles, siendo posible llevar a cabo demostracio-
nes formales, iii) portabilidad, ya que los semáforos son altamente portables en diversas
plataformas y sistemas operativos.
La figura 1.12 muestra una posible solución al problema del productor/consumidor.
Típicamente los semáforos se utilizan tanto para gestionar el sincronismo entre procesos
como para controlar el acceso a fragmentos de memoria compartida, como por ejemplo
el buffer de productos.
Esta solución se basa en el uso de tres semáforos:
Message
Message
Mensaje
Proceso1 Proceso2
Message
Message
Mensaje
Paso de mensajes
Además, es necesario un canal de comunicación entre los propios procesos para ga-
rantizar la entrega y recepción de los mensajes.
Resulta importante distinguir entre varios tipos de comunicación, en base a los con-
ceptos directa/indirecta y simétrica/asimétrica. En esencia, la comunicación directa im-
plica que en el mensaje se conocen, implícitamente, el receptor y el receptor del mismo.
Por el contrario, la comunicación indirecta se basa en el uso de buzones. La comunicación
simétrica se basa en que el receptor conoce de quién recibe un mensaje. Por el contrario,
en la comunicación asímetrica el receptor puede recibir de cualquier proceso.
1.2. Fundamentos de P. Concurrente [25]
El listado de código 1.22 muestra un ejemplo básico de sincronización entre dos pro-
cesos mediante el paso de mensajes. En el capítulo 3 se discutirá más en detalle el concep-
to de paso de mensajes y su utilización en problemas clásicos de sincronización. Además,
se estudiará una posible implementación utilizando las primitivas que el estándar POSIX
proporciona.
Monitores
Aunque los semáforos tienen ciertas ventajas que garantizan su éxito en los proble-
mas de sincronización entre procesos, también sufren ciertas debilidades. Por ejemplo,
es posible que se produzcan errores de temporización cuando se usan. Sin embargo, la
debilidad más importante reside en su propio uso, es decir, es fácil que un programador
cometa algún error al, por ejemplo, intercambiar erróneamente un wait y un signal.
Con el objetivo de mejorar los mecanismos de sincronización y evitar este tipo de
problemática, en los últimos años se han propuesto soluciones de más alto nivel, como
es el caso de los monitores.
Básicamente, un tipo monitor permite que el programador defina una serie de ope-
raciones públicas sobre un tipo abstracto de datos que gocen de la característica de la
exclusión mutua. La idea principal está ligada al concepto de encapsulación, de manera
que un procedimiento definido dentro de un monitor sólo puede acceder a las variables
que se declaran como privadas o locales dentro del monitor.
Esta estructura garantiza que sólo un proceso esté activo cada vez dentro del monitor.
La consecuencia directa de este esquema es que el programador no tiene que implementar
de manera explícita esta restricción de sincronización. Esta idea se traslada también al
concepto de variables de condición.
En esencia, un monitor es un mecanismo de sincronización de más alto nivel que, al
igual que un cerrojo, protege la sección crítica y garantiza que solamente pueda existir un
hilo activo dentro de la misma. Sin embargo, un monitor permite suspender un hilo dentro
de la sección crítica posibilitando que otro hilo pueda acceder a la misma. Este segundo
[26] Capítulo 1 :: Conceptos Básicos
Datos
compartidos
x
Condiciones
y
Operaciones
Código
inicialización
hilo puede abandonar el monitor, liberándolo, o suspenderse dentro del monitor. De cual-
quier modo, el hilo original se despierta y continua su ejecución dentro del monitor. Este
esquema es escalable a múltiples hilos, es decir, varios hilos pueden suspenderse dentro
de un monitor.
Los monitores proporcionan un mecanismo de sincronización más flexible que los
cerrojos, ya que es posible que un hilo compruebe una condición y, si ésta es falsa, el
hijo se pause. Si otro hilo cambia dicha condición, entonces el hilo original continúa su
ejecución.
El siguiente listado de código muestra el uso del mecanismo de tipo monitor que el
lenguaje Java proporciona para la sincronización de hebras. En Java, cualquier objeto
tiene asociado un cerrojo. Cuando un método se declara como synchronized, la llamada
al método implica la adquisición del cerrojo, evitando el acceso concurrente a cualquier
otro método de la clase que use dicha declaración.
En el ejemplo que se expone en el listado 1.23, el monitor se utiliza para garanti-
zar que el acceso y la modificación de la variable de clase _edad sólo se haga por otro
hilo garantizando la exclusión mutua. De este modo, se garantiza que no se producirán
inconsistencias al manipular el estado de la clase Persona.
P1 R1 P2 R2
Figura 1.16: Condición de espera circular con dos procesos y dos recursos.
Los interbloqueos pueden surgir si se dan, de manera simultánea, las cuatro condi-
ciones de Coffman [12]:
R2 R4
Los interbloqueos se pueden definir de una forma más
precisa, facilitando su análisis, mediante los grafos de
Figura 1.17: Ejemplo de grafo de
asignación de recursos. asignación de recursos. El conjunto de nodos del grafo
se suele dividir en dos subconjuntos, uno para los pro-
cesos activos del sistema P = {p0 , p1 , ..., pn−1 } y otro
formado por los tipos de recursos R = {r1 , r2 , ..., rn−1 }.
Por otra parte, el conjunto de aristas del grafo dirigido permite establecer las relacio-
nes existentes entre los procesos y los recursos. Así, una arista dirigida del proceso pi
al recurso rj significa que el proceso pi ha solicitado una instancia del recurso rj . Re-
cuerde que en el modelo de sistema planteado cada recurso tiene asociado un número de
instancias. Esta relación es una arista de solicitud y se representa como pi → rj .
1.2. Fundamentos de P. Concurrente [29]
R1 R3
P2
R1
P1 P2 P3 P1 P3
P4
R2 R4 R2
Figura 1.18: Grafos de asignación de recursos con deadlock (izquierda) y sin deadlock (derecha).
Por el contrario, una arista dirigida del recurso rj al proceso pi significa que se ha asig-
nado una instancia del recurso rj al proceso pi . Esta arista de asignación se representa
como rj → pi . La figura 1.17 muestra un ejemplo de grafo de asignación de recursos.
Los grafos de asignación de recursos que no contienen ciclos no sufren interbloqueos.
Sin embargo, la presencia de un ciclo puede generar la existencia de un interbloqueo,
aunque no necesariamente. Por ejemplo, en la parte izquierda de la figura 1.18 se muestra
un ejemplo de grafo que contiene un ciclo que genera una situación de interbloqueo,
ya que no es posible romper el ciclo. Note cómo se cumplen las cuatro condiciones de
Coffman.
La parte derecha de la figura 1.18 muestra la existencia de un ciclo en un grafo de
asignación de recursos que no conduce a un interbloqueo, ya que si los procesos p2 o p4
finalizan su ejecución, entonces el ciclo se rompe.
Desde un punto de vista general, existen tres formas para llevar a cabo el tratamiento
de los interbloqueos o deadlocks:
3. Ignorar los interbloqueos, es decir, asumir que nunca van a ocurrir en el sistema.
La figura 1.19 muestra un esquema general con las distintas alternativas existentes a
la hora de tratar los interbloqueos. A continuación se discutirán los fundamentos de cada
una de estas alternativas, planteando las ventajas y desventajas que tienen.
[30] Capítulo 1 :: Conceptos Básicos
1
Prevención Evasión 2
Info sobre
Algoritmo
el estado de
detección
recursos
Figura 1.19: Diagrama general con distintos métodos para tratar los interbloqueos.
Prevención de interbloqueos
¿Qué desventajas presentan los esquemas planteados para evitar la condición de re-
tener y esperar?
Estos dos planteamientos presentan dos desventajas importantes: i) una baja tasa de
uso de los recursos, dado que los recursos pueden asignarse y no utilizarse durante un
largo periodo de tiempo, y ii) el problema de la inanición, ya que un proceso que necesite
recursos muy solicitados puede esperar de forma indefinida.
La condición de no apropiación se puede incumplir desalojando los recursos que
un proceso retiene en el caso de solicitar otro recurso que no se puede asignar de forma
inmediata. Es decir, ante una situación de espera por parte de un proceso, éste liberaría los
recursos que retiene actualmente. Estos recursos se añadirían a la lista de recursos que el
proceso está esperando. Así, el proceso se reiniciará cuando pueda recuperar sus antiguos
recursos y adquirir los nuevos, solucionando así el problema planteado.
Este tipo de protocolos se suele aplicar a recursos cuyo estado pueda guardarse y
restaurarse fácilmente, como los registros de la CPU o la memoria. Sin embargo, no se
podría aplicar a recursos como una impresora.
Finalmente, la condición de espera circular se puede evitar estableciendo una re-
lación de orden completo a todos los tipos de recursos. De este modo, al mantener los
recursos ordenados se puede definir un protocolo de asignación de recursos para evitar la
espera circular. Por ejemplo, cada proceso sólo puede solicitar recursos en orden creciente
de enumeración, es decir, sólo puede solicitar un recurso mayor al resto de recursos ya
solicitados. En el caso de necesitar varias instancias de un recurso, se solicitarían todas a
la vez. Recuerde que respetar la política de ordenación planteada es responsabilidad del
programador.
[32] Capítulo 1 :: Conceptos Básicos
Algoritmo
del banquero
Peticiones de recursos
Algoritmo de ¿Estado No
Deshacer
seguridad seguro?
Sí
Asignar recursos
Evasión de interbloqueos
Available, un vector de longitud m, definido a nivel global del sistema, que indica
el número de instancias disponibles de cada tipo de recurso. Si available[j] = k,
entonces el sistema dispone actualmente de k instancias del tipo de recurso rj .
Max, una matriz de dimensiones nxm que indica la demanda máxima de cada
proceso. Si max[i][j] = k, entonces el proceso pi puede solicitar, como máximo,
k instancias del tipo de recurso rj .
Allocation, una matriz de dimensiones nxm que indica el número de instancias de
cada tipo de recurso asignadas a cada proceso. Si allocation[i][j] = k, entonces el
proceso pi tiene actualmente asignadas k instancias del recurso rj .
Need, una matriz de dimensiones nxm que indica la necesidad restante de recursos
por parte de cada uno de los procesos. Si need[i][j] = k, entonces el proceso pi
puede que necesite k instancias adicionales del recurso rj . need[i][j] = max[i][j]−
allocation[i][j].
Allocation Max
p0 0012 0012
p1 1000 1750
p2 1354 2356
p3 0632 0652
p4 0014 0656
needo = [0, 0, 0, 0]
need1 = [0, 7, 5, 0]
need2 = [1, 0, 0, 2]
need3 = [0, 0, 2, 0]
need4 = [0, 6, 4, 2]
En primer lugar, se hace uso de dos vectores: i) work = [1, 1, 0, 0] y ii) f inish =
[f, f, f, f, f ] y se calcula la necesidad de cada uno de los procesos (max − allocation):
needo = [0, 0, 0, 0]
need1 = [0, 3, 3, 0]
need2 = [1, 0, 0, 2]
need3 = [0, 0, 2, 0]
need4 = [0, 6, 4, 2]
[36] Capítulo 1 :: Conceptos Básicos
Tradicionalmente, los sistemas de tiempo real se han utilizado para el control de pro-
cesos, la fabricación y la comunicación. No obstante, este tipo de sistemas también se
utilizan en un contexto más general con el objetivo de proporcionar una monitorización
continua de un determinado entorno físico.
[38] Capítulo 1 :: Conceptos Básicos
1. Al crear un semáforo, éste se puede inicializar con cualquier valor entero no nega-
tivo. Sin embargo, una vez creado, sólo es posible incrementar y decrementar en
uno su valor. De hecho, no se debe leer el valor actual de un semáforo.
2. Cuando un proceso decrementa el semáforo, si el resultado es negativo entonces
dicho proceso se bloquea a la espera de que otro proceso incremente el semáforo.
3. Cuando un proceso incrementa el semáforo, si hay otros procesos bloqueados en-
tonces uno de ellos se desbloqueará.
Las operaciones de sincronización de los semáforos son, por lo tanto, dos: wait y
signal. Estas operaciones también se suelen denominar P (del holandés Proberen) y V
(del holandés Verhogen).
Todas las modificaciones del valor entero asociado a un semáforo se han de ejecutar
de manera atómica, es decir, de forma indivisible. En otras palabras, cuando un proce-
so modifica el valor de un semáforo, ningún otro proceso puede modificarlo de manera
simultánea.
Los semáforos sólo se deben manipular mediante las operaciones atómicas wait y
signal.
2 4
wait signal
[decremento] [incremento]
Figura 2.2: Modelo de funcionamiento de un semáforo. El proceso p1 se bloquea al ejecutar wait sobre el
semáforo hasta que p2 emita un signal sobre el mismo.
P1 P2 P3 P1 P2 P3
r1 s1 t1 r1 s1 t1
a r2 S(a) t2
r2 s2 t2 W(a) s2 S(c)
c
r3 W(c) t3
r3 s3 t3
b d S(b) s3 S(d)
r4 W(b) t4
r4 s4 t4
r5 W(d) t5
r5 s5 t5 s4
s5
El uso de los semáforos como mecanismo de sincronización plantea una serie de ven-
tajas, derivadas en parte de su simplicidad:
2.2. Implementación
2.2.1. Semáforos
En esta sección se discuten las primitivas POSIX más importantes relativas a la crea-
ción y manipulación de semáforos y segmentos de memoria compartida. Así mismo, se
plantea el uso de una interfaz que facilite la manipulación de los semáforos mediantes
funciones básicas para la creación, destrucción e interacción mediante operaciones wait y
signal.
En POSIX, existe la posibilidad de manejar semáforos nombrados, es decir, semá-
foros que se pueden manipular mediante cadenas de texto, facilitando así su manejo e
incrementando el nivel semántico asociado a los mismos.
En el listado de código 2.3 se muestra la interfaz de la primitiva sem_open(), utilizada
para abrir un semáforo nombrado.
Recuerde que, cuando haya terminado de usar el descriptor del archivo, es necesario
utilizar close() como si se tratara de cualquier otro descriptor de archivo.
[48] Capítulo 2 :: Semáforos y Memoria Compartida
Sección crítica
Productor Consumidor
mutex, semáforo binario que se utiliza para proporcionar exclusión mutua para el
acceso al buffer de productos. Este semáforo se inicializa a 1.
empty, semáforo contador que se utiliza para controlar el número de huecos vacíos
del buffer. Este semáforo se inicializa a n, siendo n el tamaño del buffer.
full, semáforo contador que se utiliza para controlar el número de huecos llenos del
buffer. Este semáforo se inicializa a 0.
En esencia, los semáforos empty y full garantizan que no se puedan insertar más ele-
mentos cuando el buffer esté lleno o extraer más elementos cuando esté vacío, respecti-
vamente. Por ejemplo, si el valor interno de empty es 0, entonces un proceso que ejecute
wait sobre este semáforo se quedará bloqueado hasta que otro proceso ejecute signal, es
decir, hasta que otro proceso produzca un nuevo elemento en el buffer.
La figura 2.5 muestra una posible solución, en pseudocódigo, del problema del buffer
limitado. Note cómo el proceso productor ha de esperar a que haya algún hueco libre antes
de producir un nuevo elemento, mediante la operación wait(empty)). El acceso al buffer
se controla mediante wait(mutex), mientras que la liberación se realiza con signal(mutex).
Finalmente, el productor indica que hay un nuevo elemento mediante signal(empty).
Patrones de sincronización
En la solución planteada para el problema del buffer limitado se pueden extraer dos
patrones básicos de sincronización con el objetivo de reusarlos en problemas similares.
[52] Capítulo 2 :: Semáforos y Memoria Compartida
while (1) {
// Produce en nextP.
wait (empty); while (1) {
wait (mutex); wait (full);
// Guarda nextP en buffer. wait (mutex);
signal (mutex); // Rellenar nextC.
signal (full); signal (mutex);
} signal (empty);
// Consume nextC.
}
Proceso A Proceso B
wait(mutex); wait(mutex);
// Sección crítica // Sección crítica
cont = cont + 1; cont = cont - 1;
signal(mutex); signal(mutex);
Figura 2.6: Aplicación del patrón mutex para acceder a la variable compartida cont.
Uno de los patrones utilizados es el patrón mutex [5], que básicamente permite con-
trolar el acceso concurrente a una variable compartida para que éste sea exclusivo. En este
caso, dicha variable compartida es el buffer de elementos. El mutex se puede entender co-
mo la llave que pasa de un proceso a otro para que este último pueda proceder. En otras
palabras, un proceso (o hilo) ha de tener acceso al mutex para poder acceder a la variable
compartida. Cuando termine de trabajar con la variable compartida, entonces el proceso
liberará el mutex.
El patrón mutex se puede implementar de manera sencilla mediante un semáforo bina-
rio. Cuando un proceso intente adquirir el mutex, entonces ejecutará wait sobre el mismo.
Por el contrario, deberá ejecutar signal para liberarlo.
Por otra parte, en la solución del problema anterior también se ve reflejado un pa-
trón de sincronización que está muy relacionado con uno de los principales usos de los
semáforos: el patrón señalización [5]. Básicamente, este patrón se utiliza para que un
proceso o hilo pueda notificar a otro que un determinado evento ha ocurrido. En otras
palabras, el patrón señalización garantiza que una sección de código de un proceso se
ejecute antes que otra sección de código de otro proceso, es decir, resuelve el problema
de la serialización.
En el problema del buffer limitado, este patrón lo utiliza el consumidor para notificar
que existe un nuevo item en el buffer, posibilitando así que el productor pueda obtenerlo,
mediante el wait correspondiente sobre el semáforo empty.
2.3. Problemas Clásicos [53]
Proceso A Proceso B
sentencia a1 wait(sem);
signal(sem); sentencia b1;
wait(mutex); wait(acceso_esc);
num_lectores++; /* SC: Escritura */
if num_lectores == 1 then /* ... */
wait(acceso_esc); signal(acceso_esc);
signal(mutex);
/* SC: Lectura */
/* … */
wait(mutex);
num_lectores--;
if num_lectores == 0 then
signal(acceso_esc);
signal(mutex);
acceso_esc, un semáforo binario, inicializado a 1, que sirve para dar paso a los
escritores.
En esta primera versión del problema se prioriza el acceso de los lectores con respecto
a los escritores, es decir, si un lector entra en la sección crítica, entonces se dará prioridad
a otros lectores que estén esperando frente a un escritor. La implementación garantiza esta
prioridad debido a la última sentencia condicional del código del proceso lector, ya que
sólo ejecutará signal sobre el semáforo acceso_esc cuando no haya lectores.
Cuando un proceso se queda esperando una gran cantidad de tiempo porque otros
procesos tienen prioridad en el uso de los recursos, éste se encuentra en una situación
de inanición o starvation.
Priorizando escritores
En esta sección se plantea una modificación sobre la solución anterior con el objetivo
de que, cuando un escritor llegue, los lectores puedan finalizar pero no sea posible que
otro lector tenga prioridad sobre el escritor.
Para modelar esta variación sobre el problema original, será necesario contemplar de
algún modo esta nueva prioridad de un escritor sobre los lectores que estén esperando
para acceder a la sección crítica. En otras palabras, será necesario integrar algún tipo
de mecanismo que priorice el turno de un escritor frente a un lector cuando ambos se
encuentren en la sección de entrada.
2.3. Problemas Clásicos [55]
wait(turno); wait(turno);
signal(turno); wait(acceso_esc);
wait(mutex); // SC: Escritura
num_lectores++; // ...
if num_lectores == 1 then signal(turno);
wait(acceso_esc); signal(acceso_esc);
signal(mutex);
// SC: Lectura
// ...
wait(mutex);
num_lectores--;
if num_lectores == 0 then
signal(acceso_esc);
signal(mutex);
Una posible solución consiste en añadir un semáforo turno que gobierne el acceso
de los lectores de manera que los escritores puedan adquirirlo de manera previa. En la
imagen 2.9 se muestra el pseudocódigo de los procesos lector y escritor, modificados a
partir de la versión original.
Si un escritor se queda bloqueado al ejecutar wait sobre dicho semáforo, entonces
forzará a futuros lectores a esperar. Cuando el último lector abandona la sección crítica,
se garantiza que el siguiente proceso que entre será un escritor.
Como se puede apreciar en el proceso escritor, si un escritor llega mientras hay lec-
tores en la sección crítica, el primero se bloqueará en la segunda sentencia. Esto implica
que el semáforo turno esté cerrado, generando una barrera que encola al resto de lectores
mientras el escritor sigue esperando.
Respecto al lector, cuando el último abandona y efectúa signal sobre acceso_esc,
entonces el escritor que permanecía esperando se desbloquea. A continuación, el escritor
entrará en su sección crítica debido a que ninguno de los otros lectores podrá avanzar
debido a que turno los bloqueará.
Cuando el escritor termine realizando signal sobre turno, entonces otro lector u otro
escritor podrá seguir avanzando. Así, esta solución garantiza que al menos un escritor
continúe su ejecución, pero es posible que un lector entre mientras haya otros escritores
esperando.
En función de la aplicación, puede resultar interesante dar prioridad a los escritores,
por ejemplo si es crítico tener los datos continuamente actualizados. Sin embargo, en
general, será el planificador, y no el programador el responsable de decidir qué proceso o
hilo en concreto se desbloqueará.
[56] Capítulo 2 :: Semáforos y Memoria Compartida
Patrones de sincronización
Finalmente, el uso del semáforo turno está asociado a patrón barrera [5] debido a que
la adquisición de turno por parte de un escritor crea una barrera para el resto de lectores,
bloqueando el acceso a la sección crítica hasta que el escritor libere el semáforo mediante
signal, es decir, hasta que levante la barrera.
Listado 2.14: Solución del problema de los filósofos comensales con riesgo de interbloqueo
1 while (1) {
2 // Pensar
3 wait(palillos[i]);
4 wait(palillos[(i + 1) % 5]);
5 // Comer
6 signal(palillos[i]);
7 signal(palillos[(i + 1) % 5]);
8
9 }
[58] Capítulo 2 :: Semáforos y Memoria Compartida
Evitando el interbloqueo
Listado 2.15: Solución del problema de los filósofos comensales sin riesgo de interbloqueo
1 while (1) {
2 // Pensar
3 wait(sirviente);
4 wait(palillos[i]);
5 wait(palillos[(i + 1) % 5]);
6 // Comer
7 signal(palillos[i]);
8 signal(palillos[(i + 1) % 5]);
9 signal(sirviente);
10
11 }
Además de evitar el deadlock, esta solución garantiza que ningún filósofo se muera
de hambre. Para ello, imagine la situación en la que los dos vecinos de un filósofo están
comiendo, de manera que este último está bloqueado por ambos. Eventualmente, uno de
los dos vecinos dejará su palillo. Debido a que el filósofo que se encontraba bloqueado
era el único que estaba esperando a que el palillo estuviera libre, entonces lo cogerá. Del
mismo modo, el otro palillo quedará disponible en algún instante.
Otra posible modificación, propuesta en el libro de A.S. Tanenbaum Sistemas Opera-
tivos Modernos [15], para evitar la posibilidad de interbloqueo consiste en permitir que un
filósofo coja sus palillos si y sólo si ambos palillos están disponibles. Para ello, un filó-
sofo deberá obtener los palillos dentro de una sección crítica. En la figura 2.12 se muestra
el pseudocódigo de esta posible modificación, la cual incorpora un array de enteros, deno-
minado estado compartido entre los filósofos, que refleja el estado de cada uno de ellos. El
acceso a dicho array ha de protegerse mediante un semáforo binario, denominado mutex.
2.3. Problemas Clásicos [59]
Proceso filósofo i
Figura 2.12: Pseudocódigo de la solución del problema de los filósofos comensales sin interbloqueo.
Compartiendo el arroz
Una posible variación del problema original de los filósofos comensales consiste en
suponer que los filósofos comerán directamente de una bandeja situada en el centro
de la mesa. Para ello, los filósofos disponen de cuatro palillos que están dispuestos al
lado de la bandeja. Así, cuando un filósofo quiere comer, entonces tendrá que coger un
par de palillos, comer y, posteriormente, dejarlos de nuevo para que otro filósofo pueda
comerlos. La figura 2.13 muestra de manera gráfica la problemática planteada.
Esta variación admite varias soluciones. Por ejemplo, se podría utilizar un esquema
similar al discutido en la solución anterior en la que se evitaba el interbloqueo. Es decir,
se podría pensar una solución en la que se comprobara, de manera explícita, si un filósofo
puede coger dos palillos, en lugar de coger uno y luego otro, antes de comer.
Sin embargo, es posible plantear una solución más simple y elegante ante esta varia-
ción. Para ello, se puede utilizar un semáforo contador inicializado a 2, el cual representa
los pares de palillos disponibles para los filósofos. Este semáforo palillos gestiona el ac-
ceso concurrente de los filósofos a la bandeja de arroz.
[60] Capítulo 2 :: Semáforos y Memoria Compartida
Proceso filósofo
while (1) {
// Pensar...
wait(palillos);
// Comer...
signal(palillos);
}
Figura 2.13: Variación con una bandeja al centro del problema de los filósofos comensales. A la izquierda se
muestra una representación gráfica, mientras que a la derecha se muestra una posible solución en pseudocódigo
haciendo uso de un semáforo contador.
wait(multiplex);
// Sección crítica
signal(multiplex);
Patrones de sincronización
Norte
Sur
Figura 2.15: Esquema gráfico del problema del puente de un solo carril.
Si no hay ningún coche circulando por el puente, entonces el primer coche en llegar
cruzará el puente.
Si un coche está atravesando el puente de norte a sur, entonces los coches que estén
en el extremo norte del puente tendrán prioridad sobre los que vayan a cruzarlo
desde el extremo sur.
Del mismo modo, si un coche se encuentra cruzando de sur a norte, entonces los
coches del extremo sur tendrán prioridad sobre los del norte.
En este problema, el puente se puede entender como el recurso que comparten los co-
ches de uno y otro extremo, el cual les permite continuar con su funcionamiento habitual.
Sin embargo, los coches se han de sincronizar para acceder a dicho puente.
Por otra parte, en la solución se ha de contemplar la cuestión de la prioridad, es decir,
es necesario tener en cuenta que si, por ejemplo, mientras un coche del norte esté en el
puente, entonces si vienen más coches del norte podrán circular por el puente. Es decir,
un coche que adquiere el puente habilita al resto de coches del norte a la hora de cruzarlo,
manteniendo la preferencia con respecto a los coches que, posiblemente, estén esperando
en el sur.
Por lo tanto, es necesario controlar, de algún modo, el número de coches en circulación
desde ambos extremos. Si ese número llega a 0, entonces los coches del otro extremo
podrán empezar a circular.
Una posible solución manejaría los siguientes elementos:
Este planteamiento se aplica del mismo modo a los coches que provienen del extremo
sur, cambiando los semáforos y la variable compartida.
En el caso particular del problema del barbero dormilón se distinguen claramente dos
recursos compartidos:
1 2 3
Identificación Identificación Identificación de
de procesos de clases recursos compartidos
4 5
Identificación de
Implementación
eventos de sincronización
Figura 2.16: Secuencia de pasos para diseñar una solución ante un problema de sincronización.
Para modelar las sillas se ha optado por utilizar un segmento de memoria compar-
tida que representa una variable entera. Con dicha variable se puede controlar fácilmente
el número de clientes que se encuentran en la barbería, modelando la situación específica
en la que todas las sillas están ocupadas y, por lo tanto, el cliente ha de marcharse. La
variable denominada num_clientes se inicializa a 0. El acceso a esta variable ha de ser
exclusivo, por lo que una vez se utiliza el patrón mutex.
Otra posible opción hubiera sido un semáforo contador inicializado al número inicial
de sillas libres en la sala de espera de la barbería. Sin embargo, si utilizamos un semáforo
no es posible modelar la restricción que hace que un cliente abandone la barbería si no
hay sillas libres. Con un semáforo, el cliente se quedaría bloqueado hasta que hubiese un
nuevo hueco. Por otra parte, para modelar el recurso compartido representado por el sillón
del barbero se ha optado por un semáforo binario, denominado sillón, con el objetivo de
garantizar que sólo pueda haber un cliente sentado en él.
Respecto a los eventos de sincronización, en este problema existe una interacción
directa entre el cliente que pasa a la sala del barbero, es decir, el proceso de despertar
al barbero, y la notificación del barbero al cliente cuando ya ha terminado su trabajo. El
primer evento se ha modelado mediante un semáforo binario denominado barbero, ya
que está asociado al proceso de despertar al barbero por parte de un cliente. El segundo
evento se ha modelado, igualmente, con otro semáforo binario denominado fin, ya que
está asociado al proceso de notificar el final del trabajo a un cliente por parte del barbero.
Ambos semáforos se inicializan a 0.
En la figura 2.17 se muestra el pseudocódigo de una posible solución al problema
del barbero dormilón. Como se puede apreciar, la parte relativa al proceso barbero es
trivial, ya que éste permanece de manera pasiva a la espera de un nuevo cliente, es decir,
permanece bloqueado mediante wait sobre el semáforo barbero. Cuando terminado de
cortar, el barbero notificará dicha finalización al cliente mediante signal sobre el semáforo
fin. Evidentemente, el cliente estará bloqueado por wait.
2.3. Problemas Clásicos [65]
wait(sillón);
wait(mutex);
num_clientes--;
signal(mutex);
signal(barbero);
wait(fin);
signal(sillón);
El pseudocódigo del proceso cliente es algo más complejo, ya que hay que controlar
si un cliente puede o no pasar a la sala de espera. Para ello, en primer lugar se ha de
comprobar si todas las sillas están ocupadas, es decir, si el valor de la variable compartida
num_clientes a llegado al máximo, es decir, a las N sillas que conforman la sala de espera.
En tal caso, el cliente habrá de abandonar la barbería. Note cómo el acceso a num_clientes
se gestiona mediante mutex.
Si hay alguna silla disponible, entonces el cliente esperará su turno. En otras palabras,
esperará el acceso al sillón del barbero. Esta situación se modela ejecutando wait sobre el
semáforo sillón. Si un cliente se sienta en el sillón, entonces deja una silla disponible.
A continuación, el cliente sentado en el sillón despertará al barbero y esperará a que
éste termine su trabajo. Finalmente, el cliente abandonará la barbería liberando el sillón
para el siguiente cliente (si lo hubiera).
[66] Capítulo 2 :: Semáforos y Memoria Compartida
Proceso A Proceso B
Figura 2.18: Patrón rendezvous para la sincronización de dos procesos en un determinado punto.
Patrones de sincronización
La solución planteada para el problema del barbero dormilón muestra el uso del pa-
trón señalización para sincronizar tanto al cliente con el barbero como al contrario. Esta
generalización se puede asociar al patrón rendezvous [5], debido a que el problema de
sincronización que se plantea se denomina con ese término. Básicamente, la idea con-
siste en sincronizar dos procesos en un determinado punto de ejecución, de manera que
ninguno puede avanzar hasta que los dos han llegado.
En la figura 2.18 se muestra de manera gráfica esta problemática en la que se desea
garantizar que la sentencia a1 se ejecute antes que b2 y que b1 ocurra antes que a2.
Note cómo, sin embargo, no se establecen restricciones entre las sentencias a1 y b1, por
ejemplo.
Por otra parte, en la solución también se refleja una situación bastante común en pro-
blemas de sincronización: la llegada del valor de una variable compartida a un determi-
nado límite o umbral. Este tipo de situaciones se asocia al patrón scoreboard o marcador
[5].
Si un caníbal que quiere comer se encuentra con la olla vacía, entonces se lo notifica
al cocinero para que éste cocine.
Cuando el cocinero termina de cocinar, entonces se lo notifica al caníbal que lo
despertó previamente.
Un posible planteamiento para solucionar este problema podría girar en torno al uso de
un semáforo que controlara el número de raciones disponibles en un determinado momen-
to (de manera similar al problema del buffer limitado). Sin embargo, este planteamiento
no facilita la notificación al cocinero cuando la olla esté vacía, ya que no es deseable
acceder al valor interno de un semáforo y, en función del mismo, actuar de una forma u
otra.
2.3. Problemas Clásicos [67]
Una alternativa válida consiste en utilizar el patrón marcador para controlar el núme-
ro de raciones de la olla mediante una variable compartida. Si ésta alcanza el valor de 0,
entonces el caníbal podría despertar al cocinero.
La sincronización entre el caníbal y el cocinero se realiza mediante rendezvous:
5. El caníbal come.
El pseudocódigo del proceso caníbal es algo más complejo, debido a que ha de con-
sultar el número de raciones disponibles en la olla antes de comer.
Como se puede apreciar, el proceso caníbal comprueba el número de raciones dispo-
nibles en la olla (línea ✂4 ✁). Si no hay, entonces despierta al cocinero (línea ✂5 ✁) y espera a
✄ # ✄ #
que éste rellene la olla (línea ✂6 ✁). Estos dos eventos de sincronización guían la evolución
✄ #
de los procesos involucrados.
Note cómo el proceso caníbal modifica el número de raciones asignando un valor
constante. El lector podría haber optado porque fuera el cocinero el que modificara la
variable, pero desde un punto de vista práctico es más eficiente que lo haga el caníbal, ya
que tiene adquirido el acceso a la variable mediante el semáforo mutex (líneas ✂2 ✁y ✂12 ✁).
✄ # ✄ #
[68] Capítulo 2 :: Semáforos y Memoria Compartida
1. Que todos los renos de los que dispone, nueve en total, hayan vuelto de vacaciones.
Para permitir que Santa Claus pueda descansar, los duendes han acordado despertar-
le si tres de ellos tienen problemas a la hora de fabricar un juguete. En el caso de que
un grupo de tres duendes estén siendo ayudados por Santa, el resto de los duendes con
problemas tendrán que esperar a que Santa termine de ayudar al primer grupo.
En caso de que haya duendes esperando y todos los renos hayan vuelto de vacaciones,
entonces Santa Claus decidirá preparar el trineo y repartir los regalos, ya que su entrega es
más importante que la fabricación de otros juguetes que podría esperar al año siguiente.
El último reno en llegar ha de despertar a Santa mientras el resto de renos esperan antes
de ser enganchados al trineo.
Para solucionar este problema, se pueden distinguir tres procesos básicos: i) Santa
Claus, ii) duende y iii) reno. Respecto a los recursos compartidos, es necesario controlar
el número de duendes que, en un determinado momento, necesitan la ayuda de Santa y el
número de renos que, en un determinado momento, están disponibles. Evidentemente, el
acceso concurrente a estas variables ha de estar controlado por un semáforo binario.
Respecto a los eventos de sincronización, será necesario disponer de mecanismos
para despertar a Santa Claus, notificar a los renos que se han de enganchar al trineo y
controlar la espera por parte de los duendes cuando otro grupo de duendes esté siendo
ayudado por Santa Claus.
2.3. Problemas Clásicos [69]
En resumen, se utilizarán las siguientes estructuras para plantear la solución del pro-
blema:
Duendes, variable compartida que contiene el número de duendes que necesitan la
ayuda de Santa en un determinado instante de tiempo.
Renos, variable compartida que contiene el número de renos que han vuelto de
vacaciones y están disponibles para viajar.
Mutex, semáforo binario que controla el acceso a Duendes y Renos.
SantaSem, semáforo binario utilizado para despertar a Santa Claus.
RenosSem, semáforo contador utilizado para notificar a los renos que van a em-
prender el viaje en trineo.
DuendesSem, semáforo contador utilizado para notificar a los duendes que Santa
los va a ayudar.
DuendesMutex, semáforo binario para controlar la espera de duendes cuando San-
ta está ayudando a otros.
En el listado 2.19 se muestra el pseudocódigo del proceso Santa Claus. Como se
puede apreciar, Santa está durmiendo a la espera de que lo despierten (línea ✂3 ✁). Si lo
✄ #
despiertan, será porque los duendes necesitan su ayuda o porque todos los renos han
vuelto de vacaciones. Por lo tanto, Santa tendrá que comprobar cuál de las dos condiciones
se ha cumplido.
Si todos los renos están disponibles (línea ✂8 ✁), entonces Santa preparará el trineo y no-
✄ #
tificará a todos los renos (líneas ✂9-12 ✁). Note cómo tendrá que hacer tantas notificaciones
✄ #
(signal) como renos haya disponibles. Si hay suficientes duendes para que sean ayuda-
dos (líneas ✂15-20 ✁), entonces Santa los ayudará, notificando esa ayuda de manera explícita
✄ #
(signal) mediante el semáforo DuendesSem.
El proceso reno es bastante sencillo, ya que simplemente despierta a Santa cuando
todos los renos estén disponibles y, a continuación, esperar la notificación de Santa. Una
vez más, el acceso a la variable compartida renos se controla mediante el semáforo binario
mutex.
El duende invocará a obtenerAyuda y esperará a que Santa notifique dicha ayuda me-
diante DuendesSem. Note cómo después de solicitar ayuda, el duende queda a la espera
de la notificación de Santa.
2.3. Problemas Clásicos [71]
Info a Copia
Mensaje
compartir de info
Mecanismo de comunicación
entre procesos (IPC) del
Espacio de direcciones sistema operativo Espacio de direcciones
de p0 de p1
Cola de mensajes
Figura 3.2: Esquema de paso de mensajes basado en el uso de colas o buzones de mensajes.
Sincronización
Figura 3.3: Aspectos básicos de diseño en sistemas de paso de mensajes para la comunicación y sincronización
entre procesos.
Del mismo modo, la operación de envío de mensajes se puede plantear de dos formas
posibles:
Direccionamiento
Desde el punto de vista del envío y recepción de mensajes, resulta muy natural enviar
y recibir mensajes de manera selectiva, es decir, especificando de manera explícita qué
proceso enviará un mensaje y qué proceso lo va a recibir. Dicha información se podría
especificar en las propias primitivas de envío y recepción de mensajes.
1. Designar de manera explícita al proceso emisor para que el proceso receptor sepa
de quién recibe mensajes. Esta alternativa suele ser la más eficaz para procesos
concurrentes cooperativos.
2. Hacer uso de un direccionamiento directo implícito, con el objetivo de modelar
situaciones en las que no es posible especificar, de manera previa, el proceso de
origen. Un posible ejemplo sería el proceso responsable del servidor de impresión.
uno a uno, para llevar a cabo una comunicación privada entre procesos.
muchos a uno, para modelar situaciones cliente/servidor. En este caso, el buzón se
suele conocer como puerto.
uno a muchos, para modelar sistemas de broadcast o difusión de mensajes.
muchos a muchos, para que múltiples clientes puedan acceder a un servicio concu-
rrente proporcionado por múltiples servidores.
Formato de mensaje
PID origen
Longitud del mensaje mensajes tienen una longitud fija con el objetivo de no
Info. de control sobrecargar el procesamiento y almacenamiento de infor-
mación. En otros, los mensajes pueden tener una longitud
variable con el objetivo de incrementar la flexibilidad.
Cuerpo
Contenido
Disciplina de cola
1. Si el buzón está vacío, entonces el proceso se bloquea. Esta situación implica que,
de manera previa, otro proceso accedió al buzón y recuperó el único mensaje del
mismo. El primer proceso se quedará bloqueado hasta que pueda recuperar un men-
saje del buzón.
2. Si el buzón tiene un mensaje, entonces el proceso lo recupera y puede acceder a la
sección crítica.
3.2. Implementación
En esta sección se discuten las primitivas POSIX más importantes relativas al paso
de mensajes. Para ello, POSIX proporciona las denominadas colas de mensajes (POSIX
Message Queues).
En el siguiente listado de código se muestra la primitiva mq_open(), utilizada para
abrir una cola de mensajes existente o crear una nueva. Como se puede apreciar, dicha
primitiva tiene dos versiones. La más completa incluye los permisos y una estructura para
definir los atributos, además de establecer el nombre de la cola de mensajes y los flags.
Si la primitiva mq_open() abre o crea una cola de mensajes de manera satisfactoria,
entonces devuelve el descriptor de cola asociada a la misma. Si no es así, devuelve −1 y
establece errno con el error generado.
[80] Capítulo 3 :: Paso de Mensajes
Las colas de mensajes en POSIX han de empezar por el carácter ’/’, como por ejem-
plo ’/BuzonMensajes1’.
Las primitivas relativas al cierre y a la eliminación de una cola de mensajes son muy
simples y se muestran en el siguiente listado de código. La primera de ellas necesita el
descriptor de la cola, mientras que la segunda tiene como parámetro único el nombre
asociado a la misma.
Por otra parte, la primitiva receive permite recibir mensajes de una cola. Note cómo
los parámetros son prácticamente los mismos que los de la primitiva send. Sin embargo,
ahora msg_ptr representa el buffer que almacenará el mensaje a recibir y msg_len es el
tamaño de dicho buffer. Además, la primitiva de recepción implica obtener la prioridad
asociada al mensaje a recibir, en lugar de especificarla a la hora de enviarlo.
El listado de código 3.6 muestra un ejemplo muy representativo del uso del paso de
mensajes en POSIX, ya que refleja la recepción bloqueante, una de las operaciones más
representativas de este mecanismo.
En las líneas ✂6-7 ✁se refleja el uso de la primitiva receive, utilizando buffer como va-
✄ #
riable receptora del contenido del mensaje a recibir y prioridad para obtener el valor de
la misma. Recuerde que la semántica de las operaciones de una cola, como por ejemplo
que la recepción sea no bloqueante, se definen a la hora de su creación, tal y como se
mostró en uno de los listados anteriores, mediante la estructura mq_attr. Sin embargo, es
posible modificar y obtener los valores de configuración asociados a una cola mediante
las primitivas mq_setattr y mq_getattr, respectivamente.
1. Por defecto, un proceso se bloqueará al enviar un mensaje a una cola llena, hasta
que haya espacio disponible para encolarlo.
Llegados a este punto, el lector se podría preguntar cómo es posible incluir tipos o
estructuras de datos específicas de un determinado dominio con el objetivo de inter-
cambiar información entre varios procesos mediante el uso del paso de mensajes. Esta
duda se podría plantear debido a que tanto la primitiva de envío como la primitiva de
recepción de mensajes manejan cadenas de caracteres (punteros a char) para gestionar el
contenido del mensaje.
En realidad, este enfoque, extendido en diversos ámbitos del estandar POSIX, permite
manejar cualquier tipo de datos de una manera muy sencilla, ya que sólo es necesario
aplicar los moldes necesarios para enviar cualquier tipo de información. En otras palabras,
el tipo de datos char * se suele utilizar como buffer genérico, posibilitando el uso de
cualquier tipo de datos definido por el usuario.
Muchos APIs utilizan char * como buffer genérico. Simplemente, considere un pun-
tero a buffer, junto con su tamaño, a la hora de manejar estructuras de datos como
contenido de mensaje en las primitivas send y receive.
Buzón Beeper
)
1 ei ve ( rec
ei
receive ()
rec ve (
)
Buzón Beeper
rece
receive ()
ive (
)
Buzón A Buzón B
Figura 3.7: Mecanismo de notificación basado en un buzón o cola tipo beeper para gestionar el problema del
bloqueo.
Desde el punto de vista de las soluciones generales, en la sección 5.18 de [9] se discute
una solución al problema del bloqueo denominada Unified Event Manager (gestor de
eventos unificado). Básicamente, este enfoque propone una biblioteca de funciones que
cualquier aplicación puede usar. Así, una aplicación puede registrar un evento, causando
que la biblioteca cree un hilo que se bloquea. La aplicación está organizada alrededor
de una cola con un único evento. Cuando un evento llega, la aplicación lo desencola, lo
procesa y vuelve al estado de espera.
El enfoque general que se plantea en esta sección se denomina esquema beeper (ver
figura 3.7) y consiste en utilizar un buzón específico que los procesos consultarán para
poder recuperar información relevante sobre la próxima tarea a acometer.
Por ejemplo, un proceso que obtenga un mensaje del buzón beeper podrá utilizar la in-
formación contenido en el mismo para acceder a otro buzón particular. De este modo, los
procesos tienen más información para acometer la siguiente tarea o procesar el siguiente
mensaje.
En base a este esquema general, los procesos estarán bloqueados por el buzón beeper
a la hora de recibir un mensaje. Cuando un proceso lo reciba y, por lo tanto, se desbloquee,
entonces podrá utilizar el contenido del mensaje para, por ejemplo, acceder a otro buzón
que le permita completar una tarea. En realidad, el uso de este esquema basado en dos
niveles evita que un proceso se bloquee en un buzón mientras haya tareas por atender en
otro.
Así, los procesos estarán típicamente bloqueados mediante la primitiva receive en el
buzón beeper. Cuando un cliente requiera que uno de esos procesos atienda una petición,
entonces enviará un mensaje al buzón beeper indicando en el contenido el tipo de tarea
o el nuevo buzón sobre el que obtener información. Uno de los procesos bloqueados se
desbloqueará, consultará el contenido del mensaje y, posteriormente, realizará una tarea
o accederá al buzón indicado en el contenido del mensaje enviado al beeper.
while (1) {
// Producir...
producir();
// Nuevo item...
send (buzón, msg);
}
while (1) {
recibir (buzón, &msg)
// Consumir...
consumir();
}
Figura 3.8: Solución al problema del buffer limitado mediante una cola de mensajes.
Listado 3.10: Solución a los filósofos (sin interbloqueo) con colas de mensajes
1 while (1) {
2 /* Pensar... */
3 receive(mesa, &m);
4 receive(palillo[i], &m);
5 receive(palillo[i + 1], &m);
6 /* Comer... */
7 send(palillo[i], m);
8 send(palillo[i + 1], m);
9 send(mesa, m);
10 }
Por otra parte, la solución planteada también hace uso de un buzón, denominado mesa,
que tiene una capacidad de cuatro mensajes. Inicialmente, dicho buzón se rellena con
cuatro mensajes que representan el número máximo de filósofos que pueden coger palillos
cuando se encuentren en el estado hambriento. El objetivo es evitar una situación de
interbloqueo que se podría producir si, por ejemplo, todos los filósofos cogen a la vez el
palillo que tienen a su izquierda.
Al manejar una semántica bloqueante a la hora de recibir mensajes (intentar adquirir
un palillo), la solución planteada garantiza el acceso exclusivo a cada uno de los palillos
por parte de un único filósofo. La acción de dejar un palillo sobre la mesa está represen-
tada por la primitiva send, posibilitando así que otro filósofo, probablemente bloqueado
por receive, pueda adquirir el palillo para comer.
En el anexo B se muestra una implementación al problema de los filósofos comen-
sales utilizando las colas de mensajes POSIX. En dicha implementación, al igual que en
la solución planteada en la presente sección, se garantiza que no exista un interbloqueo
entre los filósofos.
[90] Capítulo 3 :: Paso de Mensajes
Tabaco
Papel Agente Tabaco Papel
Fósforos
Figura 3.11: Solución en pseudocódigo al problema de los fumadores de cigarrillos usando paso de mensajes.
Buzón
en el contenido del mensaje de algún modo el tipo
asociado al mismo con el objetivo de que lo recu-
pere el proceso adecuado. Este tipo podría ser A, T,
F_Tab F_Pap F_Fósf
P o F en función del destinatario del mensaje.
Figura 3.12: Solución al problema
Recuperación no selectiva utilizando múltiples bu- de los fumadores de cigarrillos con
zones (ver figura 3.13). En este caso, es necesario un único buzón de recuperación se-
emplear tres buzones para que el agente pueda co- lectiva.
municarse con los tres tipos de agentes y otro buzón
para que los fumadores puedan indicarle al agente
que ya terminaron de liarse un cigarrillo.
rios para fumar a través de un buzón específico, asociado Figura 3.13: Solución al problema
a cada tipo de fumador. Posteriormente, el agente se que- de los fumadores de cigarrillos con
da bloqueado (mediante receive sobre buzon_agente) has- múltiples buzones sin recuperación
selectiva (A=Agente, T=Fumador
ta que el fumador notifique que ya ha terminado de liarse con tabaco, P=Fumador con papel,
el cigarrillo. En esencia, la sincronización entre el agente F=Fumador con fósforos).
y el fumador se realiza utilizando el patrón rendezvous.
[92] Capítulo 3 :: Paso de Mensajes
Código
97 98 ... 121 122 65 66 ... 89 90 46 44 33 63 95
ASCII
Figura 3.14: Esquema con las correspondencias de traducción. Por ejemplo, el carácter codificado con el valor
1 se corresponde con el código ASCII 97, que representa el carácter ’a’.
Cadena de entrada 34 15 12 1 55
La cadena a descodificar estará formada por una secuencia de números enteros, entre
1 y 57, separados por puntos, que los procesos de traducción tendrán que traducir utili-
zando el sistema mostrado en la figura 3.14. El tamaño de cada una de las subcadenas de
traducción vendrá determinado por el tercer elemento mencionado en el listado anterior,
es decir, el tamaño máximo de subcadena a descodificar.
Por otra parte, el sistema contará con un único proceso de puntuación, encargado de
traducir los símbolos de puntuación correspondientes a los valores enteros comprendidos
en el rango 53-57, ambos inclusive. Así, cuando un proceso de traducción encuentre uno
de estos valores, entonces tendrá que enviar un mensaje al único proceso de puntuación
para que éste lo descodifique y, posteriormente, se lo envíe al cliente mediante un mensaje.
3.3. Problemas Clásicos [93]
Puntuación
4 5
Buzón Buzón
Puntuación Notificación
3 6
1 2
Buzón
Cliente Traducción
Traducción
8 7
Buzón
Cliente
Figura 3.16: Esquema gráfico con la solución planteada, utilizando un sistema de paso de mensajes, para sin-
cronizar y comunicar a los procesos involucrados en la simulación planteada. Las líneas punteadas indican pasos
opcionales (traducción de símbolos de puntuación).
Los procesos de traducción atenderán peticiones hasta que el cliente haya enviado to-
das las subcadenas asociadas a la cadena de traducción original. En la siguiente figura se
muestra un ejemplo concreto con una cadena de entrada y el resultado de su descodifica-
ción.
La figura 3.16 muestra de manera gráfica el diseño, utilizando colas de mensajes PO-
SIX (sin recuperación selectiva), planteado para solucionar el problema propuesto en esta
sección. Básicamente, se utilizan cuatro colas de mensajes en la solución planteada:
Buzón de traducción, utilizado por el proceso cliente para enviar las subcadenas a
descodificar. Los procesos de traducción estarán bloqueados mediante una primitiva
de recepción sobre dicho buzón a la espera de trabajo.
Buzón de puntuación, utilizado por los procesos de traducción para enviar mensa-
jes que contengan símbolos de puntuación al proceso de puntuación.
Buzón de notificación, utilizado por el proceso de puntuación para enviar la infor-
mación descodificada al proceso de traducción que solicitó la tarea.
Buzón de cliente, utilizado por los procesos de traducción para enviar mensajes
con subcadenas descodificadas.
En la solución planteada se ha aplicado el patrón rendezvous en dos ocasiones para
sincronizar a los distintos procesos involucrados. En concreto,
El proceso cliente envía una serie de órdenes a los procesos de traducción, quedan-
do posteriormente a la espera hasta que haya obtenido todos los resultados parciales
(mismo número de órdenes enviadas).
El proceso de traducción envía un mensaje al proceso de puntuación cuando en-
cuentra un símbolo de traducción, quedando a la espera, mediante la primitiva re-
ceive, de que este último envíe un mensaje con la traducción solicitada.
[94] Capítulo 3 :: Paso de Mensajes
Figura 3.17: Solución en pseudocódigo que muestra la funcionalidad de cada uno de los procesos del sistema
de codificación.
4.1. Motivación
Los mecanismos de sincronización basados en el uso de semáforos y colas de mensa-
jes estudiados en los capítulos 2 y 3, respectivamente, presentan ciertas limitaciones que
han motivado la creación de otros mecanismos de sincronización que las solventen o las
mitiguen. De hecho, hoy en día existen lenguajes de programación con un fuerte soporte
nativo de concurrencia y sincronización.
En su mayor parte, estas limitaciones están vinculadas a la propia naturaleza de bajo
nivel de abstracción asociada a dichos mecanismos. A continuación se discutirá la pro-
blemática particular asociada al uso de semáforos y colas de mensajes como herramientas
para la sincronización.
En el caso de los semáforos, las principales limitaciones son las siguientes:
Aunque los semáforos y las colas de mensajes pueden presentar ciertas limitaciones
que justifiquen el uso de mecanismos de un mayor nivel de abstracción, los primeros
pueden ser más adecuados que estos últimos dependiendo del problema a solucionar.
Considere las ventajas y desventajas de cada herramienta antes de utilizarla.
Para poder compilar y ejecutar los ejemplos implementados en Ada 95 se hará uso de
la herramienta gnatmake, la cual se puede instalar en sistemas GNU/Linux mediante el
siguiente comando:
$ gnatmake holamundo.adb
Como se puede apreciar, la tarea está compuesta por dos elementos distintos:
El problema del buffer limitado, también conocido como problema del productor/-
consumidor, plantea la problemática del acceso concurrente al mismo por parte de una
serie de productores y consumidores de información. En la sección 1.2.1 se describe esta
problemática con mayor profundidad, mientras que la sección 2.3.1 discute una posible
implementación que hace uso de semáforos.
En esta sección, tanto el propio buffer en el que se almacenarán los datos como los
procesos productor y consumidor se modelarán mediante tareas. Por otra parte, la gestión
del acceso concurrente al buffer, así como la inserción y extracción de datos, se realizará
mediante dos entries, denominados respectivamente escribir y leer.
Debido a la necesidad de proporcionar distintos servicios, Ada 95 proporciona la ins-
trucción select con el objetivo de que una tarea servidora pueda atender múltiples entries.
Este esquema proporciona un mayor nivel de abstracción desde el punto de vista de la
tarea que solicita o invoca un servicio, ya que el propio entorno de ejecución garanti-
za que dos entries, gestionadas por una sentencia select, nunca se ejecutarán de manera
simultánea.
El listado de código 4.4 muestra una posible implementación de la tarea Buffer para
llevar a cabo la gestión de un único elemento de información.
Como se puede apreciar, el acceso al contenido del buffer se realiza mediante Escribir
y Leer. Note cómo el parámetro de este último entry es de salida, ya que Leer es la
operación de extracción de datos.
El buffer de la versión anterior sólo permitía gestionar un elemento que fuera accedido
de manera concurrente por distintos consumidores o productores. Para manejar realmente
un buffer limitado, es decir, un buffer con una capacidad máxima de N elementos, es
necesario modificar la tarea Buffer añadiendo un array de elementos, tal y como muestra
el listado de código 4.6.
Como se puede apreciar, el buffer incluye lógica adicional para controlar que no se
puedan insertar nuevos elementos si el buffer está lleno o, por el contrario, que no se
puedan extraer elementos si el buffer está vacío. Estas dos situaciones se controlan por
medio de las denominadas barreras o guardas, utilizando para ello la palabra reservada
when en Ada 95. Si la condición asociada a la barrera no se cumple, entonces la tarea que
invoca la función se quedará bloqueada hasta que se cumpla, es decir, hasta que la barrera
se levante.
En este contexto, estas construcciones son más naturales que el uso de semáforos
contadores o buzones de mensajes con un determinado tamaño máximo. De nuevo, el
control concurrente sobre las operaciones leer y escribir se gestiona mediante el uso de
entries.
En Ada, un objeto protegido es un tipo de módulo protegido que encapsula una es-
tructura de datos y exporta subprogramas o funciones [3]. Estos subprogramas operan
sobre la estructura de datos bajo una exclusión mutua automática. Del mismo modo que
ocurre con las tareas, en Ada es posible definir barreras o guardas en las propias entra-
das, que deben evaluarse a verdadero antes de que a una tarea se le permita entrar. Este
planteamiento se basa en la definición de regiones críticas condicionales.
El listado de código 4.7 muestra un ejemplo sencillo de tipo protegido para un entero
compartido [3]. La declaración de V en la línea ✂13 ✁declara una instancia de dicho tipo
✄ #
protegido y le asigna el valor inicial al dato que éste encapsula. Este valor sólo puede ser
accedido mediante Leer, Escribir e Incrementar.
Las barreras se evalúan siempre que una nueva tarea evalúa la barrera y ésta haga
referencia a una variable que podría haber cambiado desde que la barrera fue evalua-
da por última vez. Así mismo, las barreras también se evalúan si una tarea abandona
un procedimiento o entry y existen tareas bloqueadas en barreras asociadas a una
variable que podría haber cambiado desde la última evaluación.
En esta sección se discute una posible implementación del problema de los filósofos
comensales utilizando Ada 95 y objetos protegidos2 .
En la sección 2.3.3, los palillos se definían como recursos indivisibles y se modelaban
mediante semáforos binarios. De este modo, un palillo cuyo semáforo tuviera un valor de
1 podría ser utilizado por un filósofo, simulando su obtención a través de un decremento
(primitiva wait) del semáforo y bloqueando al filósofo adyacente en caso de que este
último quisiera cogerlo.
En esta solución, el palillo se modela mediante un objeto protegido. Para ello, el
listado de código 4.8 define el tipo protegido Cubierto.
Como se puede apreciar, dicho tipo protegido mantiene el estado del cubierto como
un elemento de tipo Status (línea ✂13 ✁), que puede ser LIBRE u OCUPADO, como un dato
✄ #
privado, mientras que ofrece dos entries protegidos: Coger y Soltar (líneas ✂16-17 ✁). Note
✄ #
cómo el estado del tipo Cubierto se inicializa a LIBRE (línea ✂15 ✁).
✄ #
Por otra parte, el objeto protegido Cubierto está integrado dentro del paquete Cubier-
tos, cuya funcionalidad está compuesta por los procedimientos Coger y Soltar (líneas
✂8-9 ✁). Dichos procedimientos actúan sobre los cubiertos de manera individual (observe el
✄ #
parámetro de entrada/salida C, de tipo Cubierto en ambos).
El listado 4.10 muestra el modelado de un filósofo. La parte más importante está repre-
sentada por el procedimiento Pensar (líneas ✂11-18 ✁), en el que el filósofo intenta obtener los
✄ #
palillos que se encuentran a su izquierda y a su derecha, respectivamente, antes de comer.
El procedimiento Pensar (líneas ✂20-24 ✁) simplemente realiza una espera de 3 segundos.
✄ #
Finalmente, el listado 4.11 muestra la lógica necesaria para simular el problema de los
filósofos comensales. Básicamente, lleva a cabo la instanciación de los cubiertos como
objetos protegidos (líneas ✂25-27 ✁) y la instanciación de los filósofos (líneas ✂30-35 ✁).
✄ # ✄ #
Si desea llevar a cabo una simulación para comprobar que, efectivamente, el acceso
concurrente a los palillos es correcto, recuerde que ha de ejecutar los siguientes comandos:
$ gnatmake problema_filosofos.adb
$ ./problema_filosofos
[108] Capítulo 4 :: Otros Mecanismos de Sincronización
Ac Cc Ap Ec Cp Bc Ep Dc Bp Dp Ac Cc Ap Ec Cp Bc Ep Dc Bp Dp Ac Cc Ap
Ec Cp Bc Ep Dc Bp Dp Ac Cc Ap Ec Cp Bc Dc Ep Bp Dp Ac Cc Ap Cp Ec Bc
Ep Dc Bp Dp Ac Cc Ap Cp Bc Ec Bp Ep Dc Dp Ac Cc Ap Cp Bc Ec Bp Ep Dc
Dp Ac Cc Ap Cp Bc Ec Bp Ep Dc Dp Ac Cc Cp Ap Ec Bc Ep Bp Dc Dp Cc Ac
Cp Ap Bc Ec Bp Ep Dc Dp Cc Ac Cp Ap Ec Bc Ep Bp Dc Dp Cc Ac Ap Ec Cp
Bc Ep Bp Dc Dp Ac Cc Ap Bc Cp Ec Bp Ep Dc Dp Ac Cc Ap Cp Bc Ec Ep Bp
Dc Dp Cc Ac Cp Ap Bc Ec Bp Dc Ep Dp Cc Ac Cp Ap Bc Ec Bp Ep Dc Dp Cc
Ac Cp Ap Bc Ec Bp Ep Dc Dp Cc Ac Cp Ap Bc Ec Ep Bp Dc
donde la primera letra de cada palabra representa el identificador del filósofo (por ejemplo
A) y la segunda representa el estado (pensando o comiendo). Note cómo el resultado de
la simulación no muestra dos filósofos adyacentes (por ejemplo A y B o B y C) comiendo
de manera simultánea.
4.3. El concepto de monitor [109]
En este contexto, el monitor permite modelar el buffer de una manera más natural me-
diante un módulo independiente de manera que las llamadas concurrentes para insertar o
extraer elementos del buffer se ejecutan en exclusión mutua. En otras palabras, el moni-
tor es, por definición, el responsable de ejecutarlas secuencialmente de manera correcta.
El listado 4.12 muestra la sintaxis de un monitor. En la siguiente sección se discutirá cómo
se puede utilizar un monitor haciendo uso de las facilidades que proporciona el estándar
POSIX.
Sin embargo, y aunque el monitor proporciona exclusión mutua en sus operaciones,
existe la necesidad de la sincronización dentro del mismo. Una posible opción para abor-
dar esta problemática podría consistir en hacer uso de semáforos. No obstante, las imple-
mentaciones suelen incluir primitivas que sean más sencillas aún que los propios semáfo-
ros y que se suelen denominar variables de condición.
[110] Capítulo 4 :: Otros Mecanismos de Sincronización
Las variables de condición, sin embargo, se manejan con primitivas muy parecidas a
las de los semáforos, por lo que normalmente se denominan wait y signal. Así, cuando
un proceso o hilo invoca wait sobre una variable de condición, entonces se quedará blo-
queado hasta que otro proceso o hilo invoque signal. Note que wait siempre bloqueará al
proceso o hilo que lo invoque, lo cual supone una diferencia importante con respecto a su
uso en semáforos.
Al igual que ocurre con los semáforos, una variable de condición tiene asociada al-
guna estructura de datos que permite almacenar información sobre los procesos o hilos
bloqueados. Típicamente, esta estructura de datos está representada por una cola FIFO
(first-in first-out).
Tenga en cuenta que la liberación de un proceso bloqueado en una variable de con-
dición posibilita que otro proceso pueda acceder a la sección crítica. En este contexto,
la primitiva signal es la que posibilita este tipo de sincronización. Si no existe ningún
proceso bloqueado en una variable de condición, entonces la ejecución de signal sobre la
misma no tiene ningún efecto. Esta propiedad también representa una diferencia impor-
tante con respecto a la misma primitiva en un semáforo, ya que en este último caso sí que
tiene un efecto final (incremento del semáforo).
Con el objetivo de encapsular el estado del buffer, siguiendo la filosofía planteada por
el concepto de monitor, se ha definido la clase Buffer mediante el lenguaje de programa-
ción C++, tal y como se muestra en el listado 4.13.
1 7 3
primero último
Figura 4.1: Esquema gráfico del buffer limitado modelado mediante un monitor.
2. El mutex se libera, con el objetivo de que otro hilo, productor o consumidor, pueda
ejecutar anyadir o extraer, respectivamente. Note cómo el segundo parámetro de
pthread_cond_wait es precisamente el mutex del propio buffer. Así, con un monitor
es posible suspender varios hilos dentro de la sección crítica, pero sólo es posible
que uno se encuentre activo en un instante de tiempo.
La actualización del estado del buffer mediante la función anyadir consiste en alma-
cenar el dato en el buffer (línea ✂13 ✁), actualizar el apuntador para el siguiente elemento a
✄ #
insertar (línea ✂14 ✁) e incrementar el número de elementos del buffer (línea ✂15 ✁).
✄ # ✄ #
[114] Capítulo 4 :: Otros Mecanismos de Sincronización
El listado de código 4.16 muestra la otra función relevante del buffer: extraer. Note
cómo el esquema planteado es exactamente igual al de anyadir, con las siguientes salve-
dades:
Note cómo la primitiva pthread_create se utiliza para asociar código a los hilos pro-
ductores y consumidor, mediante las funciones productor_func (línea ✂21 ✁) y consumi-
✄ #
dor_func (línea ✂23 ✁), respectivamente. El propio puntero al buffer se pasa como parámetro
✄ #
en ambos casos para que los hilos puedan interactuar con él. Cabe destacar igualmente el
uso de la primitiva pthread_join (línea ✂27 ✁) con el objetivo de esperar a los hilos creados
✄ #
anteriormente.
Herramienta adecuada
Benjamin Franklin llegó a afirmar que
si tuviera que emplear 8 horas en ta-
5.2. El concepto de tiempo real
lar un árbol, usaría 6 para afilar el ha-
cha. Elija siempre la herramienta más Tradicionalmente, la noción de tiempo real ha
adecuada para solucionar un problema. estado vinculada a sistemas empotrados como as-
En el contexto de la programación en
tiempo real, existen entornos y lengua- pecto esencial en su funcionamiento. Como ejem-
jes de programación, como por ejem- plo clásico, considere el sistema de airbag de un
plo Ada, que proporcionan un gran so-
porte al desarrollo de este tipo de solu- coche. Si se produce un accidente, el sistema ha de
ciones. garantizar que empleará como máximo un tiempo
∆t. Si no es así, dicho sistema será totalmente in-
eficaz y las consecuencias del accidente pueden ser
catastróficas.
Hasta ahora, y dentro del contexto de una asignatura vinculada a la Programación
Concurrente y Tiempo Real, este tipo de restricciones no se ha considerado y se ha pos-
puesto debido a que, normalmente, el control del tiempo real suele estar construido sobre
el modelo de concurrencia de un lenguaje de programación [3].
En el ámbito del uso de un lenguaje de programación, la noción de tiempo se puede
describir en base a tres elementos independientes [3]:
5.2. El concepto de tiempo real [119]
La notación Landau O(f (x)) se refiere al conjunto de funciones que acotan supe-
riormente a f (x). Es un planteamiento pesimista que considera el comportamiento
de una función en el peor caso posible. En el ámbito de la planificación en tiempo
real se sigue esta filosofía.
Relojes
Dentro de un contexto temporal, una de los aspectos esenciales está asociado al con-
cepto de reloj. Desde el punto de vista de la programación, un programa tendrá la nece-
sidad de obtener información básica vinculada con el tiempo, como por ejemplo la hora
o el tiempo que ha transcurrido desde la ocurrencia de un evento. Dicha funcionalidad se
puede conseguir mediante dos caminos distintos:
servicios será diferente (aunque probablemente mínima). Para abordar esta problemática,
se suele definir un servidor de tiempo que proporcione una referencia temporal común al
resto de servicios. En esencia, el esquema se basa en la misma solución que garantiza un
reloj interno o externo, es decir, se basa en garantizar que la información temporal esté
sincronizada.
Por otra parte, el paquete Real_Time de Ada es similar a Calendar pero con algunas
diferencias reseñables. Por ejemplo, mantiene un nivel de granularidad mayor, hace uso
de una constante Time_Unit, que es la cantidad menor de tiempo representada por Time,
y el rango de Time ha de ser de al menos 50 años. En resumen, este paquete representa un
nivel de resolución mayor con respecto a Calendar.
5.2. El concepto de tiempo real [121]
Retardos
El reloj representa el elemento básico a utilizar por los procesos para poder obtener
información temporal. Sin embargo, otro aspecto esencial reside en la capacidad de un
proceso, tarea o hilo para suspenderse durante un determinado instante de tiempo. Esta
situación está asociada al concepto de retardo o retraso. Desde un punto de vista general,
los retardos pueden clasificarse en dos tipos:
Relativos, los cuales permiten la suspensión respecto al instante actual. Por ejem-
plo, es posible definir un retardo relativo de 2 segundos.
Absolutos, los cuales permiten retrasar la reanudación de un proceso hasta un ins-
tante de tiempo absoluto. Por ejemplo, podría ser necesario garantizar que una ac-
ción ha de ejecutarse 5 segundos después del comienzo de otra acción.
La primitiva sleep suspende el hilo de ejecución que realiza la llamada hasta que
pasa el tiempo indicado o hasta que recibe una señal no ignorada por el programa.
No olvide contemplar este último caso en sus programas.
Por otra parte, un retardo absoluto tiene una complejidad añadida en el sentido de que
puede ser necesario calcular el periodo a retrasar o, en función del lenguaje de programa-
ción utilizado, emplear una primitiva adicional.
Considere la situación en la que una acción ha de tener lugar 7 segundos después del
comienzo de otra. El fragmento de código 5.4 plantea una posible solución.
Este primer planteamiento sería incorrecto, debido a que los 7 segundos de espera se
realizan después de la finalización de Accion_1, sin considerar el instante de comienzo de
la ejecución de ésta.
El siguiente listado contempla esta problemática. Como se puede apreciar, la variable
Transcurrido de la línea ✂3 ✁almacena el tiempo empleado en ejecutar Accion_1. De este
✄ #
modo, es posible realizar un retraso de 7,0 − T ranscurrido segundos (línea ✂5 ✁) antes de
✄ #
ejecutar Accion_2.
Desafortunadamente, este planteamiento puede no ser correcto ya que la instrucción
de la línea ✂5 ✁no es atómica, es decir, no se ejecuta en un instante de tiempo indivisible.
✄ #
Por ejemplo, considere la situación en la que Accion_1 consume 2 segundos. En este caso
en concreto, 7,0 − T ranscurrido debería equivaler a 5,0. Sin embargo, si justo después
de realizar este cálculo se produce un cambio de contexto, el retraso se incrementaría más
allá de los 5,0 segundos calculados.
Figura 5.3: Ejemplo de control de la deriva acumulada. Las llamadas a update se realizan cada 4 segundos de
media.
La granularidad del retardo y la del reloj pueden ser distintas. Por ejemplo, PO-
SIX soporta una granularidad hasta el nivel de los nanosegundos, mientras que es
posible que el reloj de algún sistema no llegue hasta dicho nivel.
El propio reloj interno puede verse afectado si se implementa mediante un meca-
nismo basado en interrupciones que puede ser inhibido durante algún instante de
tiempo.
[124] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
Recuerde que cuando un proceso está ejecutando una sección crítica, es bastante
probable que otros procesos se encuentren bloqueados para poder acceder a la mis-
ma. En determinadas situaciones, será deseable controlar el tiempo máximo que un
proceso puede estar en una sección crítica.
El listado de código 5.10 muestra un ejemplo en Ada que limita el tiempo máximo de
ejecución de una tarea a 0.25 segundos mediante un evento disparador.
Recuerde que los tiempos límite de espera se suelen asociar con condiciones de error.
1. Un algoritmo para ordenar el uso de los recursos del sistema, típicamente las CPU.
2. Un mecanismo para predecir el comportamiento del sistema cuando se aplica el
algoritmo anterior. Normalmente, la predicción se basa en un planteamiento pesi-
mista, es decir, considera el peor escenario posible para establecer una cota superior.
Este tipo de mecanismos permiten conocer si los requisitos temporales establecidos
para el sistema se cumplirán o no.
Antes de llevar a cabo una discusión de distintas alternativas existentes para realizar
la planificación de un sistema, resulta importante enumerar ciertos parámetros que son
esenciales para acometer dicha tarea.
Por otra parte, las tareas se pueden clasificar en periódicas, si se ejecutan con un
periodo determinado, o esporádicas, si se ejecutan atendiendo a un evento temporal que
las activa.
[128] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
Tm = 200 ms
Tc = 50 ms
a b c a b d e a b c a b d
Figura 5.5: Esquema gráfico de planificación según el enfoque de ejecutivo cíclico para el conjunto de procesos
de la tabla 5.1.
mientras que el cálculo del ciclo secundario (Ts ) se realiza mediante aproximación con el
menor periodo de todos los procesos a planificar.
La planificación mediante ejecutivo cíclico se basa en colocar en cada ciclo secunda-
rio las tareas para garantizar que cumplan sus deadlines. La figura 5.5 muestra de manera
gráfica el resultado de planificar el conjunto de procesos que contiene la tabla 5.1.
Como se puede apreciar, el esquema de planificación es simple y consiste en ir asig-
nando los procesos a los distintos slots temporales que vienen determinados por el valor
del ciclo secundario. En este ejemplo en concreto, el valor del ciclo principal es de 200
ms (mínimo común múltiplo de los procesos), mientras que el valor del ciclo secundario
es de 50 ms (menor periodo de un proceso).
[130] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
Note cómo el esquema de planificación garantiza que todos los procesos cumplan
sus deadlines. Además, la planificación de un ciclo completo (200 ms) garantiza que el
esquema es completo en sucesivos hiperperiodos (en sucesivos bloques de 200 ms), ya
que la ejecución de los procesos no variará. Desde el punto de vista del funcionamiento
interno, se producirá una interrupción de reloj cada 50 ms, de manera que el planificador
realiza rondas entre los cuatro ciclos secundarios que componen el hiperperiodo.
El uso de un enfoque de ejecutivo cíclico presenta consecuencias relevantes asocia-
das a las características inherentes al mismo [3]:
En conclusión, el ejecutivo cíclico puede ser una alternativa viable para sistemas con
un número determinado de procesos periódicos. Sin embargo, su baja flexibilidad hace
que los sistemas basados en la planificación de procesos, como los que se discuten en la
siguiente sección, sean más adecuados.
El esquema FPS es uno de los más utilizados y tiene su base en calcular, antes de la
ejecución, la prioridad Pi de un proceso. Dicha prioridad es fija y estática. Así, una vez
calculada la prioridad de todos los procesos, en función de un determinado criterio, es
posible establecer una ejecución por orden de prioridad. En sistemas de tiempo real, este
criterio se define en base a los requisitos de temporización.
Por otra parte, el esquema EDF se basa en que el próximo proceso a ejecutar es aquél
que tenga un menor deadline Di absoluto. Normalmente, el deadline relativo se conoce
a priori (por ejemplo, 50 ms después del comienzo de la ejecución del proceso), pero el
absoluto se calcula en tiempo de ejecución, concediendo una naturaleza dinámica a este
tipo de esquema de planificación.
Antes de pasar a discutir los aspecto relevantes de un planificador, es importante consi-
derar la noción de apropiación en el contexto de una planificación basada en prioridades.
Recuerde que, cuando se utiliza un esquema apropiativo, es posible que un proceso en
ejecución con una baja prioridad sea desalojado por otro proceso de más alta prioridad,
debido a que este último ha de ejecutarse con cierta urgencia. Típicamente, los esquemas
apropiativos suelen ser preferibles debido a que posibilitan la ejecución inmediata de los
procesos de alta prioridad.
Existen modelos de apropiación híbridos que permiten que un proceso de baja prio-
ridad no sea desalojado por otro de alta hasta que no transcurra un tiempo limitado,
como el modelo de apropiación diferida.
[132] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
Figura 5.6: El principal cometido de un modelo de análisis del tiempo de respuesta es determinar si un sistema
es planificable en base a sus requisitos temporales.
Factor de utilización
!N
Ci 1
( ) ≤ N (2 N − 1) (5.3)
i=1
T i
Para ejemplificar este sencillo test de planificabilidad, la tabla 5.3 muestra 3 procesos
con sus respectivos atributos.
[134] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
En el ejemplo de la tabla 5.3, la utilización total es de 0,7, valor menor a 0,78 (ver
tabla 5.2), por lo que el sistema es planificable.
Esquema gráfico
Aunque los tests basados en la utilización y los esquemas gráficos discutidos en las
anteriores secciones son mecanismos sencillos y prácticos para determinar si un sistema
es planificable, no son escalables a un número de procesos relevante y plantean ciertas
limitaciones que hacen que sean poco flexibles. Por lo tanto, es necesario plantear algún
otro esquema que sea más general y que posibilite añadir más complejidad al modelo
simple de tareas introducido en la sección 5.3.1.
En esta sección se discute un esquema basado en dos pasos bien diferenciados:
1. Cálculo del tiempo de respuesta de todos los procesos mediante una aproximación
analítica.
2. Comparación del tiempo de respuesta de todos los procesos con sus respectivos
deadlines.
5.4. Aspectos relevantes de un planificador [135]
c b a c a c b a
0 10 20 30 40 50
Tiempo
(a)
Proceso
0 10 20 30 40 50
Tiempo
Instante de Instante de
En ejecución Desalojado
activación finalización
(b)
Figura 5.7: Distintos esquemas gráficos utilizados para mostrar patrones de ejecución para los procesos de la
tabla 5.3. (a) Diagrama de Gantt. (b) Líneas de tiempo.
Ri ≤ Di ∀i ∈ {1, 2, . . . , n} (5.4)
Ri = Ci + I i (5.5)
donde Ii es el tiempo asociado a la interferencia máxima que pi puede sufrir, como con-
secuencia de algún desalojo, en el intervalo de tiempo [t, t + Ri ].
[136] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
1
Para todo pi...
Cálculo Ri
Sistema
planificable
2
¿Ri <= Di?
Figura 5.8: Esquema gráfico de un esquema de análisis del tiempo de respuesta para un conjunto de procesos o
tareas.
La interferencia máxima sufrida por un proceso se da cuando todos los procesos que
tienen una mayor prioridad se activan. Esta situación se denomina instante crítico.
Ri
Activaciones = ⌈ ⌉ (5.6)
Tj
Ri
Ii = ⌈ ⌉ ∗ Cj (5.7)
Tj
Tj Tj
#Activaciones pj
Figura 5.9: Esquema gráfica del número de activaciones de un proceso pj con periodo Tj que interrumpe a un
proceso pi con un tiempo de respuesta total Ri .
Debido a que cada proceso de más alta prioridad que pi puede interrumpirlo (al igual
que hace pj ), la expresión general de Ii es la siguiente:
! Ri
Ii = (⌈ ⌉ ∗ Cj ) (5.8)
Tj
j∈hp(i)
donde hp(i) representa el conjunto de procesos con mayor prioridad (higher priority) que
pi .
Si se sustituye el resultado de la ecuación 5.8 en la ecuación 5.5, entonces se obtiene
la expresión final de Ri :
! Ri
R i = Ci + (⌈ ⌉ ∗ Cj ) (5.9)
Tj
j∈hp(i)
! wi0
wi1 = Ci + (⌈ ⌉ ∗ Cj )
Tj
j∈hp(i)
! wi1
wi2 = Ci + (⌈ ⌉ ∗ Cj )
Tj
j∈hp(i)
O en general:
! win−1
win = Ci + (⌈ ⌉ ∗ Cj ) (5.10)
Tj
j∈hp(i)
[138] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
Proceso T C P
a 80 40 1
b 40 10 2
c 20 5 3
Tabla 5.4: Periodo (T), tiempo de ejecución en el peor de los casos (C) y prioridad (P) de un conjunto de tres
procesos para el cálculo del tiempo de respuesta (R).
wb0 = Cb + Cc = 10 + 5 = 15
! wb0 15
wb1 = Cb + (⌈ ⌉ ∗ Cj ) = 10 + ⌈ ⌉ ∗ 5 = 15
Tj 40
j∈hp(b)
Debido a que wb0 = wb1 , no es necesario realizar otra iteración. Por lo tanto, Rb =
15 ≤ Db = 40 (recuerda que en el modelo simple, T = D), siendo planificable dentro
del sistema.
El proceso a recibirá interrupciones de b y de c al ser el proceso de menor prioridad.
De nuevo, se repite el procedimiento,
5.4. Aspectos relevantes de un planificador [139]
c b a c a c b a c a
20 40 60 80
wa0 = Ca + Cb + Cc = 40 + 10 + 5 = 55
! wa0 55 55
wa1 = Ca + (⌈ ⌉ ∗ Cj ) = 40 + ⌈ ⌉ ∗ 10 + ⌈ ⌉ ∗ 5 = 40 + 20 + 15 = 75
Tj 40 20
j∈hp(a)
! wa1 75 75
wa2 = Ca + (⌈ ⌉ ∗ Cj ) = 40 + ⌈ ⌉ ∗ 10 + ⌈ ⌉ ∗ 5 = 40 + 20 + 20 = 80
Tj 40 20
j∈hp(a)
Finalmente
! wa2 80 80
wa3 = Ca + (⌈ ⌉ ∗ Cj ) = 40 + ⌈ ⌉ ∗ 10 + ⌈ ⌉ ∗ 5 = 40 + 20 + 20 = 80
Tj 40 20
j∈hp(a)
Inversión de prioridad
del proceso d
Tiempo de bloqueo de d
a1 aR c1 cS d1 d2 cS c2 b1 b2 aR aR aR dR dS d3 a2
0 2 4 6 8 10 12 14 16
Figura 5.11: Diagrama de Gantt para el ejemplo de inversión de prioridad para el conjunto de procesos de la
tabla 5.5.
Ri = Ci + I i + B i (5.11)
En la figura 5.11 se puede observar cómo se comporta el sistema en base a los atributos
definidos en la tabla anterior.
5.4. Aspectos relevantes de un planificador [141]
Para llevar a cabo un cálculo del tiempo de bloqueo de un proceso utilizando este
esquema simple de herencia de prioridad se puede utilizar la siguiente fórmula [3]:
!
R
Bi = (utilizacion(r, i) ∗ Cr ) (5.12)
r∈1
donde R representa el número de recursos o secciones críticas, mientras que, por otra par-
te, utilizacion(r, i) = 1 si el recurso r se utiliza al menos por un proceso cuya prioridad
es menor que la de pi y, al menos, por un proceso con prioridad mayor o igual a pi . Si no
es así, utilizacion(r, i) = 0. Por otra parte, Cr es el tiempo de ejecución del recurso o
sección crítica r en el peor caso posible.
La figura 5.12 muestra las líneas temporales y los tiempos de respuesta para el conjun-
to de procesos de la tabla 5.5 utilizando un esquema simple en el que se refleja el problema
de la inversión de prioridad y el protocolo de herencia de prioridad, respectivamente.
[142] Capítulo 5 :: Planificación en Sistemas de Tiempo Real
Proceso
d E E R S E Rd=12
c E S S E Rc=6
b E E Rb=8
a E R R R R E Ra=17
0 4 8 12 16 20
t
(a)
Proceso
d E E R S E Rd=9
c E S S E Rc=12
b E E Rb=14
a E R R R R E Ra=17
0 4 8 12 16 20
t
(b)
Instante de Instante de
En ejecución Desalojado
activación finalización
Figura 5.12: Líneas de tiempo para el conjunto de procesos de la tabla 5.5 utilizando a) un esquema de priori-
dades fijas con apropiación y b) el protocolo de herencia de prioridad. A la derecha de cada proceso se muestra
el tiempo de respuesta asociado.
En el caso del esquema simple, se puede apreciar de nuevo cómo el tiempo de res-
puesta del proceso d, el cual tiene la mayor prioridad, es de 17 unidades de tiempo, siendo
el tiempo de respuesta más alto de todos los procesos. El extremo opuesto está represen-
tado, curiosamente, por el proceso de menor prioridad, es decir, el proceso a, cuyo tiempo
de respuesta es el menor de todos (9 unidades de tiempo). Una vez más, se puede apreciar
cómo el bloqueo del recurso S por parte del proceso a penaliza enormemente al proceso
d.
En el caso del protocolo de herencia de prioridad, este problema se limita considera-
blemente. Observe cómo el proceso a hereda la prioridad del proceso d cuando el primero
adquiere el recurso R. Este planteamiento permite que a libere R tan pronto como sea po-
sible para que d pueda continuar su ejecución.
De este modo, Rd se decrementa hasta las 9 unidades de tiempo, mientras que Ra
se mantiene en 17. El proceso de mayor prioridad ha finalizado antes que en el caso con
inversión de prioridad.
5.4. Aspectos relevantes de un planificador [143]
Ba = 0
Bb = Ca,R = 4
Bc = Ca,R = 4
Bd = Cc,S + Ca,R = 2 + 3 = 5
Un proceso de prioridad alta sólo puede ser bloqueado una vez por procesos de
prioridad inferior.
Los interbloqueos se previenen.
Los bloqueos transitivos se previenen.
El acceso exclusivo a los recursos está asegurado.
Cada proceso pi tiene asignada una prioridad Pi por defecto (por ejemplo, utilizan-
do un esquema DMS).
Cada recurso ri tiene asociado un valor cota estático o techo de prioridad T Pi ,
que representa la prioridad máxima de los procesos que hacen uso del mismo.
Cada proceso pi puede tener una prioridad dinámica, que es el valor máximo entre
su propia prioridad estática y los techos de prioridad de los recursos que tenga
bloqueados.
La figura 5.13 muestra la línea de tiempo del conjunto de procesos de la tabla 5.5.
En primer lugar, es necesario calcular el techo de prioridad de los recursos R y S,
considerando el máximo de las prioridades de los procesos que hacen uso de ellos:
T PR = max{Pa , Pd } = max{1, 4} = 4
T PS = max{Pc , Pd } = max{3, 4} = 4
5.4. Aspectos relevantes de un planificador [145]
Proceso
d E E R S E Rd=6
c E S S E Rc=12
b E E Rb=14
a E R R R R E Ra=17
0 4 8 12 16 20 t
Instante de Instante de
En ejecución Desalojado
activación finalización
Figura 5.13: Línea de tiempo para el conjunto de procesos de la tabla 5.5 utilizando el protocolo de techo de
prioridad inmediato.
Tarea T C D r1 r2 r3 r4
a 80 10 80 3 - - 5
b 150 20 150 2 2 1 -
c 100 10 15 1 1 - -
d 500 12 30 2 - 4 -
Tabla 5.6: Periodo (T), tiempo de ejecución en el peor de los casos (C), deadline, y tiempo de uso de los
recursos r1 , r2 , r3 y r4 para un conjunto de cuatro procesos.
Pa = 2, Pb = 1, Pc = 4, Pd = 3
Bb = 0
Recuerde que para contabilizar el tiempo de uso de un recurso ri por parte de una
tarea pi , dicho recurso ha de usarse al menos por una tarea con menor prioridad que Pi y
al menos por una tarea con prioridad mayor o igual a Pi .
Finalmente, para calcular el tiempo de respuesta de la tarea a es necesario hacer uso
de la ecuación 5.11 (considerando no sólo el tiempo de bloqueo Bi sino también el de
interferencia Ii ) e iterando según la ecuación 5.10. Así,
wa0 = Ca + Ba + Ia = Ca + Ba + Cc + Cd = 10 + 5 + 10 + 12 = 37
wa0 w0
wa1 = Ca + Ba + Ia = Ca + Ba + ⌈ ⌉ ∗ Cc + ⌈ a ⌉ ∗ Cd =
Tc Td
37 37
10 + 5 + ⌈ ⌉ ∗ 10 + ⌈ ⌉ ∗ 12 = 37
100 50
Tarea T C D r1 r2 r3 r4
a 100 20 100 3 - - 5
b 200 20 200 2 2 - -
c 100 5 15 1 - - -
d 50 12 20 2 - 4 -
Tabla 5.7: Periodo (T), tiempo de ejecución en el peor de los casos (C), deadline, y tiempo de uso de los
recursos r1 , r2 , r3 y r4 para un conjunto de cuatro procesos.
Calcule las prioridades de las tareas y el techo de prioridad de los recursos. Obtenga
el tiempo de bloqueo de las tareas y discuta si el sistema es planificable o no.
En primer lugar, se procede a realizar el cálculo de las prioridades de las tareas,
considerando que la tarea con un menor deadline tiene una mayor prioridad. De este
modo,
Pa = 2, Pb = 1, Pc = 4, Pd = 3
A la hora de calcular Bd , Note cómo en la tabla 5.8 se resaltan los dos procesos (a
y b) que cumplen la anterior restricción. Por lo tanto, habría que obtener el máximo de
utilización de dichos procesos con respecto a los recursos que tienen un techo mayor o
igual que Pd , tal y como muestra la tabla 5.9.
Tarea T C D r1 r2 r3 r4
a 100 20 100 3 - - 5
b 200 20 200 2 2 - -
c 100 5 15 1 - - -
d 50 12 20 2 - 4 -
Tabla 5.8: Obteniendo bd (tiempo de bloqueo de la tarea d). Las tareas a y d son las que tienen menor prioridad
que d (únicas que pueden bloquearla).
Tarea T C D r1 r2 r3 r4
a 100 20 100 3 - - 5
b 200 20 200 2 2 - -
c 100 5 15 1 - - -
d 50 12 20 2 - 4 -
Tabla 5.9: Obteniendo bd (tiempo de bloqueo de la tarea d). Los recursos r1 y r3 tiene un techo de prioridad
mayor o igual que la prioridad de d (las tareas de menos prioridad sólo pueden bloquear a d si acceden a estos
recursos).
Ba = max{Cb,r1 } = max{2} = 2
Bb = max{0} = 0
Rc = C c + B c + I c = 5 + 3 + 0 = 8
wb1 w1 w1
wb2 = Cb + Bb + ⌈ ⌉ ∗ Cd + ⌈ b ⌉ ∗ Cc + ⌈ b ⌉ ∗ Ca =
Td Tc Ta
69 69 69
20 + 0 + ⌈ ⌉ ∗ 12 + ⌈ ⌉∗5+⌈ ⌉ ∗ 20 =
50 100 100
20 + 0 + 24 + 5 + 20 = 69
Debido a que wb1 = wb2 , entonces Rb = wa2 = 69. Note que Rb ≤ Db (69 ≤ 200).
Una vez obtenidos todos los tiempos de respuesta de todos los procesos y comparados
éstos con los respectivos deadline, se puede afirmar que el sistema crítico es planificable.
58
59 // Lanzamiento de procesos filósofo.
60 for (i = 0; i < FILOSOFOS ; i++)
61 if ((pids[i] = fork()) == 0) {
62 sprintf(filosofo, " %d", i);
63 sprintf (buzon_palillo_izq, " %s %d", BUZON_PALILLO, i);
64 sprintf (buzon_palillo_der, " %s %d", BUZON_PALILLO, (i + 1) % FILOSOFOS);
65 execl("./exec/filosofo", "filosofo", filosofo, BUZON_MESA,
66 buzon_palillo_izq, buzon_palillo_der, NULL);
67 }
68
69 for (i = 0; i < FILOSOFOS; i++) waitpid(pids[i], 0, 0);
70 finalizarprocesos(); liberarecursos();
71 printf ("\n Fin del programa\n");
72 return 0;
73 }
74
75 void controlador (int senhal) {
76 finalizarprocesos(); liberarecursos();
77 printf ("\n Fin del programa (Control + C)\n");
78 exit(EXIT_SUCCESS);
79 }
80
81 void liberarecursos () {
82 int i; char caux[30];
83
84 printf ("\n Liberando buzones... \n");
85 mq_close(qHandlerMesa); mq_unlink(BUZON_MESA);
86
87 for (i = 0; i < FILOSOFOS; i++) {
88 sprintf (caux, " %s %d", BUZON_PALILLO, i);
89 mq_close(qHandlerPalillos[i]); mq_unlink(caux);
90 }
91 }
92
93 void finalizarprocesos () {
94 int i;
95 printf ("-------------- Terminando ------------- \n");
96 for (i = 0; i < FILOSOFOS; i++) {
97 if (pids[i]) {
98 printf ("Finalizando proceso [ %d]... ", pids[i]);
99 kill(pids[i], SIGINT); printf ("<Ok>\n");
100 }
101 }
102 }
B.2. Código fuente [163]
Para instalar ICE en sistemas operativos Debian GNU/Linux, ejecute los siguientes
comandos:
ICE maneja las peticiones a los servidores mediante un pool de hilos con el objetivo
de incrementar el rendimiento de la aplicación. El desarrollador es el responsable de
gestionar el acceso concurrente a los datos.
Como se puede apreciar en el listado de código C.1, Thread es una clase abstracta
con una función virtual pura denominada run(). El desarrollador ha de implementar esta
función para poder crear un hilo, de manera que run() se convierta en el punto de inicio de
la ejecución de dicho hilo. Note que no es posible arrojar excepciones desde esta función.
El núcleo de ejecución de ICE instala un manejador de excepciones que llama a la función
::std::terminate() si se arroja alguna excepción.
C.2. Manejo de hilos [167]
Cuando un filósofo piensa, entonces se abstrae del mundo y no se relaciona con ningún
otro filósofo. Cuando tiene hambre, entonces intenta coger a los palillos que tiene a su
izquierda y a su derecha (necesita ambos). Naturalmente, un filósofo no puede quitarle un
palillo a otro filósofo y sólo puede comer cuando ha cogido los dos palillos. Cuando un
filósofo termina de comer, deja los palillos y se pone a pensar.
La solución que se discutirá en esta sección se basa en implementar el filósofo como
un hilo independiente. Para ello, se crea la clase FilosofoThread que se expone en el
listado C.2.
ICE proporciona la clase Mutex para modelar esta problemática de una forma sencilla
y directa.
Las funciones miembro más importantes de esta clase son las que permiten adquirir y
liberar el cerrojo:
lock(), que intenta adquirir el cerrojo. Si éste ya estaba cerrado, entonces el hilo
que invocó a la función se suspende hasta que el cerrojo quede libre. La llamada a
dicha función retorna cuando el hilo ha adquirido el cerrojo.
tryLock(), que intenta adquirir el cerrojo. A diferencia de lock(), si el cerrojo está
cerrado, la función devuelve false. En caso contrario, devuelve true con el cerrojo
cerrado.
unlock(), que libera el cerrojo.
Es importante considerar que la clase Mutex proporciona un mecanismo de exclusión
mutua básico y no recursivo, es decir, no se debe llamar a lock() más de una vez desde
un hilo, ya que esto provocará un comportamiento inesperado. Del mismo modo, no se
debería llamar a unlock() a menos que un hilo haya adquirido previamente el cerrojo
mediante lock(). En la siguiente sección se estudiará otro mecanismo de sincronización
que mantiene una semántica recursiva.
La clase Mutex se puede utilizar para gestionar el acceso concurrente a los palillos.
En la solución planteada en el listado C.6, un palillo es simplemente una especialización
de IceUtil::Mutex con el objetivo de incrementar la semántica de dicha solución.
Para que los filósofos puedan utilizar los palillos, habrá que utilizar la funcionali-
dad previamente discutida, es decir, las funciones lock() y unlock(), en las funciones co-
ger_palillos() y dejar_palillos(). La solución planteada garantiza que no se ejecutarán dos
llamadas a lock() sobre un palillo por parte de un mismo hilo, ni tampoco una llamada so-
bre unlock() si previamente no se adquirió el palillo.
La solución planteada es poco flexible debido a que los filósofos están inactivos du-
rante el periodo de tiempo que pasa desde que dejan de pensar hasta que cogen los dos
palillos. Una posible variación a la solución planteada hubiera sido continuar pensando
(al menos hasta un número máximo de ocasiones) si los palillos están ocupados. En esta
variación se podría utilizar la función tryLock() para modelar dicha problemática.
Si todos los filósofos cogen al mismo tiempo el palillo que está a su izquierda se
producirá un interbloqueo ya que la solución planteada no podría avanzar hasta que
un filósofo coja ambos palillos.
Para evitar este tipo de problemas, la clase Mutex proporciona las definiciones de tipo
Lock y TryLock, que representan plantillas muy sencillas compuestas de un constructor en
el que se llama a lock() y tryLock(), respectivamente. En el destructor se llama a unlock()
si el cerrojo fue previamente adquirido cuando la plantilla quede fuera de ámbito. En el
ejemplo anterior, sería posible garantizar la liberación del cerrojo al ejecutar return, ya que
quedaría fuera del alcance de la función. El listado de código C.9 muestra la modificación
realizada para evitar interbloqueos.
Sin embargo, existe una diferencia fundamental entre ambas. Internamente, el cerrojo
recursivo está implementado con un contador inicializado a cero. Cada llamada a lock()
incrementa el contador, mientras que cada llamada a unlock() lo decrementa. El cerrojo
estará disponible para otro hilo cuando el contador alcance el valor de cero.
Las soluciones de alto nivel permiten que el desarrollador tenga más flexibilidad a la
hora de solucionar un problema. Este planteamiento se aplica perfectamente al uso
de monitores.
Para tratar este tipo de problemas, la biblioteca de hilos de ICE proporciona la clase
Monitor. En esencia, un monitor es un mecanismo de sincronización de más alto nivel
que, al igual que un cerrojo, protege la sección crítica y garantiza que solamente pueda
existir un hilo activo dentro de la misma. Sin embargo, un monitor permite suspender un
hilo dentro de la sección crítica posibilitando que otro hilo pueda acceder a la misma.
Este segundo hilo puede abandonar el monitor, liberándolo, o suspenderse dentro del
monitor. De cualquier modo, el hilo original se despierta y continua su ejecución dentro
del monitor. Este esquema es escalable a múltiples hilos, es decir, varios hilos pueden
suspenderse dentro de un monitor.
Desde un punto de vista general, los monitores proporcionan un mecanismo de sin-
cronización más flexible que los cerrojos, ya que es posible que un hilo compruebe una
condición y, si ésta es falsa, el hijo se pause. Si otro hilo cambia dicha condición, entonces
el hilo original continúa su ejecución.
El listado de código C.11 muestra la declaración de la clase IceUtil::Monitor. Note
que se trata de una clase que hace uso de plantillas y requiere como parámetro bien Mutex
o RecMutex, en función de si el monitor mantendrá una semántica no recursiva o recursiva.
lock(), que intenta adquirir el monitor. Si éste ya estaba cerrado, entonces el hilo
que la invoca se suspende hasta que el monitor quede disponible. La llamada retorna
con el monitor cerrado.
tryLock(), que intenta adquirir el monitor. Si está disponible, la llamada devuelve
true con el monitor cerrado. Si éste ya estaba cerrado antes de relizar la llamada, la
función devuelve false.
[176] Anexo C :: La biblioteca de hilos de ICE
unlock(), que libera el monitor. Si existen hilos bloqueados, entonces uno de ellos
se despertará y cerrará de nuevo el monitor.
wait(), que suspende al hilo que invoca a la función y, de manera simultánea, libera
el monitor. Un hilo suspendido por wait() se puede despertar por otro hilo que invo-
que a la función notify() o notifyAll(). Cuando la llamada retorna, el hilo suspendido
continúa su ejecución con el monitor cerrado.
timedWait(), que suspende al hilo que invoca a la función hasta un tiempo espe-
cificado por parámetro. Si otro hilo invoca a notify() o notifyAll() despertando al
hilo suspendido antes de que el timeout expire, la función devuelve true y el hilo
suspendido resume su ejecución con el monitor cerrado. En otro caso, es decir, si
el timeout expira, timedWait() devuelve false.
notify(), que despierta a un único hilo suspendido debido a una invocación sobre
wait() o timedWait(). Si no existiera ningún hilo suspendido, entonces la invocación
sobre notify() se pierde. Llevar a cabo una notificación no implica que otro hilo
reanude su ejecución inmediatamente. En realidad, esto ocurriría cuando el hilo
que invoca a wait() o timedWait() o libera el monitor.
notifyAll(), que despierta a todos los hilos suspendidos por wait() o timedWait(). El
resto del comportamiento derivado de invocar a esta función es idéntico a notify().
get ()
wait ()
notify put ()
return item
En este contexto, el uso de los monitores proporciona gran flexibilidad para modelar
una solución a este supuesto. El listado de código C.12 muestra una posible implemen-
tación de la estructura de datos que podría dar soporte a la solución planteada, haciendo
uso de los monitores de la biblioteca de hilos de ICE.
Como se puede apreciar, la clase definida es un tipo particular de monitor sin semánti-
ca recursiva, es decir, definido a partir de IceUtil::Mutex. Dicha clase tiene como variable
miembro una cola de doble entrada que maneja tipos de datos genéricos, ya que la clase
definida hace uso de una plantilla. Además, esta clase proporciona las dos operaciones
típicas de put() y get() para añadir y obtener elementos.
Hay, sin embargo, dos características importantes a destacar en el diseño de esta es-
tructura de datos:
No olvide... usar Lock y TryLock para evitar posibles interbloqueos causados por la
generación de alguna excepción o una terminación de la función no prevista inicial-
mente.
Recuerde que para que un hilo bloqueado por wait() reanude su ejecución, otro hilo
ha de ejecutar notify() y liberar el monitor mediante unlock(). Sin embargo, en el anterior
listado de código no existe ninguna llamada explícita a unlock(). ¿Es incorrecta la solu-
ción? La respuesta es no, ya que la liberación del monitor se delega en Lock cuando la
función put() finaliza, es decir, justo después de ejecutar la operación notify() en este caso
particular.
Volviendo al ejemplo anterior, considere dos hilos distintos que interactúan con la es-
tructura creada, de manera genérica, para almacenar los slots que permitirán la activación
de habilidades especiales por parte del jugador virtual. Por ejemplo, el hilo asociado al
productor podría implementarse de la siguiente forma:
Suponiendo que el código del consumidor sigue la misma estructura, pero extrayen-
do elementos de la estructura de datos compartida, entonces sería posible lanzar distintos
hilos para comprobar que el acceso concurrente sobre los distintos slots se realiza de
manera adecuada. Además, sería sencillo visualizar, mediante mensajes por la salida es-
tándar, que efectivamente los hilos consumidores se suspenden en wait() hasta que hay al
menos algún elemento en la estructura de datos compartida con los productores.
A continuación, resulta importante volver a discutir la solución planteada inicialmente
para el manejo de monitores. En particular, la implementación de las funciones miembro
put() y get() puede generar sobrecarga debido a que, cada vez que se añade un nuevo
elemento a la estructura de datos, se realiza una invocación. Si no existen hilos esperando,
la notificación se pierde. Aunque este hecho no conlleva ningún efecto no deseado, puede
generar una reducción del rendimiento si el número de notificaciones se dispara.
C.4. Introduciendo monitores [179]
El uso de alguna estructura de datos adicional puede facilitar el diseño de una so-
lución. Este tipo de planteamientos puede incrementar la eficiencia de la solución
planteada aunque haya una mínima sobrecarga.
Como se puede apreciar en el listado C.14, el hilo productor sólo llevará a cabo una
notificación en el caso de que haya algún hilo consumidor en espera. Para ello, consulta
el valor de la variable miembro _consumidoresEsperando. Por otra parte, los hilos consu-
midores, es decir, los que invoquen a la función get() incrementan dicha variable antes de
realizar un wait(), decrementándola cuando se despierten.
Note que el acceso a la variable miembro _consumidoresEsperando es exclusivo y se
garantiza gracias a la adquisición del monitor justo al ejecutar la operación de generación
o consumo de información por parte de algún hilo.
[180] Anexo C :: La biblioteca de hilos de ICE