Administración de procesos
Índice
1 Concepto y estados de un proceso
En un sistema multiprogramado o de tiempo compartido, un proceso es la imagen en memoria de un programa, junto con la información relacionada con el estado de su ejecución.
Un programa es una entidad pasiva, una lista de instrucciones; un proceso es una entidad activa, que –empleando al programa– define la actuación que tendrá el sistema.
En contraposición con proceso, en un sistema por lotes se habla de tareas. Una tarea requiere mucha menos estructura, típicamente basta con guardar la información relacionada con la contabilidad de los recursos empleados. Una tarea no es interrumpida en el transcurso de su ejecución. Ahora bien, esta distinción no es completamente objetiva — y se pueden encontrar muchos textos que emplean indistintamente una u otra nomenclatura.
Si bien el sistema brinda la ilusión de que muchos procesos se están ejecutando al mismo tiempo, la mayor parte de ellos típicamente está esperando para continuar su ejecución — en un momento determinado sólo puede estar ejecutando sus instrucciones un número de procesos igual o menor al número de procesadores que tenga el sistema.
En este capítulo se desarrollan los conceptos relacionados con procesos, hilos, concurrencia y sincronización — Las técnicas y algoritmos que emplea el sistema operativo para determinar cómo y en qué orden hacer los cambios de proceso que nos brindan la ilusión de simultaneidad se abordarán en el capítulo PLAN.
1.1 Estados de un proceso
Un proceso, a lo largo de su vida, alterna entre diferentes estados de ejecución. Estos son:
- Nuevo
- Se solicitó al sistema operativo la creación de un proceso, y sus recursos y estructuras están siendo creadas.
- Listo
- Está listo para iniciar o continuar su ejecución pero el sistema no le ha asignado un procesador.
- En ejecución
- El proceso está siendo ejecutado en este momento. Sus instrucciones están siendo procesadas en algún procesador.
- Bloqueado
- En espera de algún evento para poder continuar su ejecución (aun si hubiera un procesador disponible, no podría avanzar).
- Zombie
- El proceso ha finalizado su ejecución, pero el sistema operativo debe realizar ciertas operaciones de limpieza para poder eliminarlo de la lista.1
- Terminado
- El proceso terminó de ejecutarse; sus estructuras están a la espera de ser limpiadas por el sistema operativo
Diagrama de transición entre los estados de un proceso
1.2 Información asociada a un proceso
La información que debe manipular el sistema operativo relativa a cada uno de los procesos actuales se suele almacenar en una estructura llamada bloque de control de proceso (PCB - Process Control Block). El PCB incluye campos como:
- Estado del proceso
- El estado actual del proceso.
- Contador de programa
- Cuál es la siguiente instrucción a ser ejecutada por el proceso.
- Registros del CPU
- La información específica del estado del CPU mientras el proceso está en ejecución (debe ser respaldada y restaurada cuando se registra un cambio de estado).
- Información de planificación (scheduling)
- La prioridad del proceso, la cola en que está agendado, y demás información que puede ayudar al sistema operativo a planificar los procesos; se profundizará el tema en el capítulo PLAN.
- Información de administración de memoria
- La información de mapeo de memoria (páginas o segmentos, dependiendo del sistema operativo), incluyendo la pila (stack) de llamadas. Se abordará el tema en el capítulo MEM.
- Información de contabilidad
- Información de la utilización de recursos que ha tenido este proceso — Puede incluir el tiempo total empleado y otros (de usuario, cuando el procesador va avanzando sobre las instrucciones del programa propiamente, de sistema cuando el sistema operativo está atendiendo las solicitudes del proceso), uso acumulado de memoria y dispositivos, etc.
- Estado de E/S
- Listado de dispositivos y archivos asignados que el proceso tiene abiertos en un momento dado.
2 Procesos e hilos
Como se vio, la cantidad de información que el sistema operativo debe manejar acerca de cada proceso es bastante significativa. Si cada vez que el planificador elige qué proceso pasar de Listo a En ejecución debe considerar buena parte de dicha información, la simple transferencia de todo esto entre la memoria y el procesador podría llevar a un desperdicio /burocrático/2 de recursos. Una respuesta a esta problemática fue la de utilizar los hilos de ejecución, a veces conocidos como procesos ligeros (LWP, Lightweight processes).
Cuando se consideran procesos basados en un modelo de hilos, se puede proyectar en sentido inverso que todo proceso es como un sólo hilo de ejecución. Un sistema operativo que no ofreciera soporte expreso a los hilos los planificaría exactamente del mismo modo.
Pero visto desde la perspectiva del proceso hay una gran diferencia: si bien el sistema operativo se encarga de que cada proceso tenga una visión de virtual exclusividad sobre la computadora, todos los hilos de un proceso comparten un sólo espacio de direccionamiento en memoria y los archivos y dispositivos abiertos. Cada uno de los hilos se ejecuta de forma (aparentemente) secuencial y maneja su propio contador de programa y pila (y algunas estructuras adicionales, aunque mucho más ligeras que el PCB).
2.1 Los hilos y el sistema operativo
La programación basada en hilos puede hacerse completamente y de forma transparente en espacio de usuario (sin involucrar al sistema operativo). Estos hilos se llaman hilos de usuario (user threads), y muchos lenguajes de programación los denominan hilos verdes (green threads). Un caso de uso interesante es en sistemas operativos mínimos (p. ej. para dispositivos embebidos) capaces de ejecutar una máquina virtual (ver sección VIRTarqinexistentes) de alguno de esos lenguajes: si bien el sistema operativo no maneja multiprocesamiento, a través de los hilos de usuario se crean procesos con multitarea interna.
Los procesos que implementan hilos ganan un poco en el rendimiento gracias a no tener que reemplazar al PCB activo cuando intercalan la ejecución de sus diferentes hilos; pero además de esto, ganan mucho más por la ventaja de compartir espacio de memoria sin tener que establecerlo explícitamente a través de mecanismos de comunicación entre procesos (IPC — Inter Process Communications). Dependiendo de la plataforma, a veces los hilos de usuario inclusive utilizan multitarea cooperativa para pasar el control dentro de un mismo proceso. Cualquier llamada al sistema bloqueante (como obtener datos de un archivo para utilizarlos inmediatamente) interrumpirá la ejecución de todos los hilos de ese proceso, dado que el control de ejecución es entregado al sistema operativo quien en este caso no conoce nada sobre los hilos.
Continuando con el desarrollo histórico de este mecanismo, el
siguiente paso fue la creación de hilos informando al sistema
operativo, típicamente denominados hilos de kernel (kernel threads). Esto se hace a través de bibliotecas de sistema que los
implementan de forma estándar para los diferentes sistemas operativos
o arquitecturas (p. ej. pthreads
para POSIX o Win32_Thread
para
Windows). Estas bibliotecas aprovechan la comunicación con el
sistema operativo tanto para solicitudes de recursos (p. ej. un proceso
basado en hilos puede beneficiarse de una ejecución verdaderamente
paralela en sistemas multiprocesador) como para una gestión de
recursos más comparable con una situación de multiproceso estándar.
2.2 Patrones de trabajo con hilos
Hay tres patrones en los que caen generalmente los modelos de hilos; se puede emplear más de uno de estos patrones en diferentes áreas de nuestra aplicación, e incluso se pueden anidar (esto es, se podría tener una línea de ensamblado dentro de la cual uno de los pasos sea un equipo de trabajo):
- Jefe / trabajador
- Un hilo tiene una tarea distinta de todos los
demás: el hilo jefe genera o recopila tareas para realizar, las
separa y se las entrega a los hilos trabajadores.
Este modelo es el más común para procesos que implementan servidores (es el modelo clásico del servidor Web Apache) y para aplicaciones gráficas (GUIs), en que hay una porción del programa (el hilo jefe) esperando a que ocurran eventos externos. El jefe realiza poco trabajo, se limita a invocar a los trabajadores para que hagan el trabajo de verdad; como mucho, puede llevar contabilidad de los trabajos realizados.
Típicamente, los hilos trabajadores realizan su operación, posiblemente notifican al jefe de su trabajo, y finalizan su ejecución.
Patrón de hilos jefe/trabajador
- Equipo de trabajo
- al iniciar la porción multihilos del proceso,
se crean muchos hilos idénticos, que realizarán las mismas tareas
sobre diferentes datos. Este modelo es muy frecuentemente
utilizado para cálculos matemáticos (p. ej.: criptografía,
render, álgebra lineal). Puede combinarse con un estilo jefe/trabajador para irle
dando al usuario una previsualización del resultado de su
cálculo, dado que éste se irá ensamblando progresivamente, pedazo
por pedazo.
Su principal diferencia con el patrón jefe/trabajador consiste en que el trabajo a realizar por cada uno de los hilos se plantea desde principio, esto es, el paso de división de trabajo no es un hilo más, sino que prepara los datos para que éstos sean lanzados en paralelo. Estos datos no son resultado de eventos independientes (como en el caso anterior), sino partes de un sólo cálculo. Por consecuencia, resulta natural que en este modelo los resultados generados por los diferentes hilos son agregados o totalizados al terminar su procesamiento. Los hilos no terminan, sino que son sincronizados y luego continúan la ejecución lineal.
Patrón de hilos Equipo de trabajo
- Línea de ensamblado
- si una tarea larga puede dividirse en pasos
sobre bloques de la información total a procesar, cada hilo puede
enfocarse a hacer sólo un paso y pasarle los datos a otro hilo
conforme vaya terminando. Una de las principales ventajas de este
modelo es que nos ayuda a mantener rutinas simples de comprender,
y permite que el procesamiento de datos continúe incluso si parte
del programa está bloqueado esperando E/S.
Un punto importante a tener en cuenta en una línea de ensamblado es que, si bien los hilos trabajan de forma secuencial, pueden estar ejecutándose paralelamente sobre bloques consecutivos de información, eventos, etc.
Este patrón es claramente distinto de los dos anteriormente presentados; si bien en los anteriores los diferentes hilos (a excepción del hilo jefe) eran casi siempre idénticos -aunque operando sobre distintos conjuntos de datos-, en este caso son todos completamente distintos.
Patrón de hilos Línea de ensamblado
3 Concurrencia
3.1 Introducción
Desde un punto de vista formal, la concurrencia no se refiere a dos o más eventos que ocurren a la vez sino a dos o más eventos cuyo orden es no determinista, esto es, eventos acerca de los cuales no se puede predecir el orden relativo en que ocurrirán. Si bien dos procesos (o también dos hilos) completamente independientes entre sí ejecutándose simultáneamente son dos procesos concurrentes, la concurrencia se ocupa principalmente de procesos cuya ejecución está vinculada de alguna manera (p. ej.: dos procesos que comparten cierta información o que dependen uno del otro).
Aunque una de las tareas principales de los sistemas operativos es dar a cada proceso la ilusión de que se está ejecutando en una computadora dedicada, de modo que el programador no tenga que pensar en la competencia por recursos, a veces un programa requiere interactuar con otros: parte del procesamiento puede depender de datos obtenidos en fuentes externas, y la cooperación con hilos o procesos externos es fundamental.
Se verá que pueden aparecer muchos problemas cuando se estudia la interacción entre hilos del mismo proceso, la sincronización entre distintos procesos, la asignación de recursos por parte del sistema operativo a procesos simultáneos, o incluso cuando interactuan usuarios de diferentes computadoras de una red — se presentarán distintos conceptos relacionados con la concurrencia utilizando uno de esos escenarios, pero muchos de esos conceptos en realidad son independientes del escenario: más bien nos ocupa la relación entre procesos que deben compartir recursos o deben sincronizar sus tareas.
Para presentar los problemas y conceptos relacionados con la concurrencia suelen utilizarse algunos problemas clásicos, que presentan casos particulares muy simplificados, y puede encontrárseles relación con distintas cuestiones que un programador enfrentará en la vida real. Cada ejemplo presenta uno o más conceptos. Se recomienda comprender bien el ejemplo, el problema y la solución y desmenuzar buscando los casos límite como ejercicio antes de pasar al siguiente caso. También podría ser útil imaginar en qué circunstancia un sistema operativo se encontraría en una situación similar.
Para cada problema se mostrará una forma de resolverlo aunque en general hay más de una solución válida. Algunos de estos problemas fueron originalmente planteados como justificación del desarrollo de las estructuras de control presentadas o de nuevos paradigmas de concurrencia y muchos son aun objeto de debate e investigación.
Para profundizar más en este tema, se recomienda el libro «The little book of semaphores» de Allen Downey (2008). En este libro (de libre descarga) se encuentran muchos ejemplos que ilustran el uso de semáforos no sólo para resolver problemas de sincronización, sino como un mecanismo simple de comunicación entre procesos. También se desarrollan distintas soluciones para los problemas clásicos (y no tan clásicos).
3.2 Problema: el jardín ornamental
3.2.1 Descripción del problema
Un gran jardín ornamental se abre al público para que todos puedan apreciar sus fantásticas rosas, arbustos y plantas acuáticas. Por supuesto, se cobra una módica suma de dinero a la entrada para lo cual se colocan dos torniquetes, uno en cada una de sus dos entradas. Se desea conocer cuánta gente ha ingresado al jardín así que se instala una computadora conectada a ambos torniquetes: cada torniquete envía una señal cuando una persona ingresa al jardín. Se realiza un modelo simplificado de la situación, así que no se estudiarán los detalles del hardware utilizado. Aquí es importante notar que los dos torniquetes son objetos que existen y se comportan en paralelo e independientemente: los eventos que generan no tienen un orden predecible. Es decir, que cuando se escriba el software no se sabe en qué momento llegará cada visitante ni qué torniquete utilizará.
Se simulará un experimento en el que 20 visitantes ingresan por cada torniquete. Al final de la simulación deberá haber 40 visitantes contados. Una implementación tentativa podría ser la siguiente:3
int cuenta; proceso torniquete1() { int i; for(i=0;i<20;i++) { cuenta = cuenta + 1; } } proceso torniquete2() { int i; for(i=0;i<20;i++) { cuenta = cuenta + 1; } } main() { cuenta = 0; /* Lanzar ambos procesos concurrentemente*/ concurrentemente { // torniquete1(); torniquete2(); } /* Esperar a que ambos finalicen */ esperar(torniquete1); esperar(torniquete2); printf("Cuenta: %d\n", cuenta); }
Como se ve el problema es muy sencillo. Sin embargo, al
intentar ejecutar repetidas veces ese programa muy de vez en cuando el
resultado no tiene el valor 40. Si se modifica el programa para
utilizar un solo torniquete, cuenta
siempre tiene el valor correcto
(20).
¿Qué es lo que está ocurriendo? La mayor parte de los lenguajes de
programación convierten cada instrucción en una serie más o menos
larga de operaciones de máquina (instrucciones ensamblador). De modo,
que una instrucción aparentemente simple como cuenta = cuenta + 1
habitualmente implica varias operaciones de más bajo nivel (las instrucciones
de ejemplo corresponden a arquitecturas Intel x86):
LEER
- Leer
cuenta
desde la memoria (p. ej.mov $cuenta, %rax
). INC
- Incrementar el registro (p. ej.
add $1, %rax
). GUARDAR
- Guardar el resultado nuevamente en memoria (p. ej.
mov %rax, $cuenta
).
En un sistema operativo multitarea cuando un proceso agota su porción de tiempo de procesador (quantum) o detiene su ejecución por otra razón, los valores almacenados en registros se preservan (junto con la información sobre el proceso) para poder restaurarlo cuando la ejecución continúe (de esta forma se provee la ilusión de la multitarea en sistemas de un solo núcleo). Así, en el problema del Jardín Ornamental cada torniquete tiene su propia copia de los valores en los registros. Sin embargo, se supone que el resto de la memoria es compartida (en particular, se utiliza ese hecho para llevar la cuenta de personas que ingresan).
Si se considera lo que ocurre cuando dos procesos (p. ej. torniquete1
y torniquete2
) ejecutan la instrucción cuenta = cuenta + 1
en un equipo con un
solo procesador, puede darse la siguiente secuencia de eventos. Se
considera que cuenta
está inicialmente en 0
.
cuenta = 0
torniquete1
:LEER
(resultado:rax
de= 0,
cuenta = 0
)torniquete1
:INC
(resultado:rax
de= 1,
cuenta = 0
)torniquete1
:GUARDAR
(resultado:rax
de= 1,
cuenta = 1
)- El sistema operativo decide cambiar de tarea, suspende
torniquete1
y continúa contorniquete2
. torniquete2
:LEER
(resultado:rax
de= 1,
cuenta = 1
)torniquete2
:INC
(resultado:rax
de= 2,
cuenta = 1
)torniquete2
:GUARDAR
(resultado:rax
de= 2,
cuenta = 2
)
Se puede ver que ambos procesos realizaron sus instrucciones para incrementar el contador en 1 y el resultado final fue que la cuenta se incrementó en dos unidades.
Pero, también puede darse la siguiente secuencia de eventos durante la ejecución de estas instrucciones:
cuenta = 0
torniquete1
:LEER
(resultado:rax
de= 0,
cuenta = 0
)torniquete1
:INC
(resultado:rax
de= 1,
cuenta = 0
)- El sistema operativo decide cambiar de tarea, suspende
torniquete1
y continúa contorniquete2
. torniquete2
:LEER
(resultado:rax
de= 1,
cuenta = 1
)torniquete2
:INC
(resultado:rax
de= 2,
cuenta = 1
)torniquete2
:GUARDAR
(resultado:rax
de= 2,
cuenta = 2
)- El sistema operativo decide cambiar de tarea, suspende
torniquete2
y continua contorniquete1
. torniquete1
:GUARDAR
(resultado:rax
de= 1,
cuenta = 1
)
Nuevamente ambos procesos ejecutaron sus instrucciones para
incrementar en 1 el contador. Sin embargo, ¡en este caso cuenta
tiene el valor 1!. A este problema también se lo conoce como problema de las actualizaciones múltiples.
- Esto parece muy específico
-
Si bien este análisis parece muy específico es fácil ver que la misma circustancia podría darse en un sistema de reserva de vuelos (p. ej.: puede que dos operadores vean un asiento vacío en su copia local de los asientos y ambos marquen el mismo asiento como ocupado) o con dos procesos que decidan cambiar simultáneamente datos en un archivo. Aquí las operaciones ya no son necesariamente operaciones internas de la máquina.
- ¿Pero no es muy poco probable?
-
Por otro lado, uno podría pensar (con cierta cuota de razón) que la secuencia de eventos propuesta es muy poco probable: usualmente un sistema operativo ejecuta miles de instrucciones antes de cambiar de un proceso a otro. De hecho, en la práctica este problema es muy frecuentemente ignorado y los programas funcionan muy bien la mayoría de las veces. Esto permite ver una característica importante de los programas concurrentes: es muy usual que un programa funcione perfectamente la mayor parte del tiempo, pero de vez en cuando puede fallar. Subsecuentes ejecuciones con los mismos argumentos producen nuevamente el resultado correcto. Esto hace que los problemas de concurrencia sean muy difíciles de detectar y más aun de corregir. Es importante (y mucho más efectivo) realizar un buen diseño inicial de un programa concurrente en lugar de intentar arreglarlo cuando se detecta alguna falla. También es interesante notar que dependiendo del sistema, puede ser que alguna de las instrucciones sea muy lenta, en el caso de un sistema de reserva de asientos de aviones, las operaciones pueden durar un tiempo importante (p. ej.: desde que el operador muestra los asientos disponibles hasta que el cliente elige el asiento) haciendo mucho más probable que ocurra una secuencia no deseada.
- ¿Vale la pena preocuparse?
-
A modo de ilustración de la gravedad del problema, estos son algunos valores para el resultado final de la variable
cuenta
cuando se ejecuta el programa anterior en Pascal-FC4: 25 29 31 20 21 26 27 18 31 35. Notesé que incluso uno de los valores es menor que 20 (que es lo mínimo que cuenta cada torniquete). Es un ejercicio interesante pensar qué secuencia de eventos podría producir tal valor y cuál es el mínimo valor posible. - Pero tenemos muchos núcleos
-
Otra cuestión que puede parecer artificiosa es que en el ejemplo hay un solo procesador o núcleo. Sin embargo, tener más de un procesador no sólo no soluciona el problema sino que lo empeora: ahora las operaciones de lectura o escritura pueden ejecutarse directamente en paralelo y aparecen nuevos problemas de coherencia de caché. En la siguiente discusión muchas veces se presupone que hay un solo procesador, sin que eso invalide la discusión para equipos multiprocesadores.
3.2.2 Algunos conceptos de concurrencia
Antes de abordar posibles soluciones al problema presentado, se presentan las definiciones de algunos conceptos importantes.
- Operación atómica
- Operación que requiere la garantía de que se ejecutará como una sóla unidad de ejecución, o fallará completamente, sin resultados o estados parciales observables por otros procesos o el entorno. Esto no necesariamente implica que el sistema no retirará el flujo de ejecución en medio de la operación, sino que el efecto de que se le retire el flujo no llevará a comportamiento inconsistente.
- Condición de carrera
- (En inglés, Race condition) Categoría de errores de programación que involucra a dos procesos que fallan al comunicarse su estado mutuo, llevando a resultados inconsistentes. Es uno de los problemas más frecuentes y difíciles de depurar, y ocurre típicamente por no considerar la no atomicidad de una operación
- Sección (o región) crítica
- El área de código que requiere ser protegida de accesos simultáneos, donde se realiza la modificiación de datos compartidos.
- Recurso compartido
- Un recurso que puede ser accedido desde más de
un proceso. En muchos escenarios esto es un variable en memoria
(como
cuenta
en el jardín ornamental), pero podrían ser archivos, periféricos, etc…
Dado que el sistema no tiene forma de saber cuáles instrucciones (o áreas del código) deben funcionar de forma atómica, el programador debe asegurar la atomicidad de forma explícita, mediante la sincronización de los procesos. El sistema no debe permitir la ejecución de parte de esa área en dos procesos de forma simultánea (sólo puede haber un proceso en la sección crítica en un momento dado).
3.2.2.1 ¿Y qué tiene que ver esto con el problema del Jardín Ornamental?
En el problema hay claramente un recurso compartido que es la cuenta
, así la
sección que modifica la cuenta es una sección crítica y la operación
cuenta = cuenta + 1
debe ser una operación atómica. La secuencia de eventos que
se mostró es una condición de carrera: el segundo torniquete presume
un estado (cuenta = 0
) que no es el mismo que conoce el torniquete1
(cuenta = 1
).
3.2.3 Soluciones posibles (y no tanto)
El planteamiento del problema del jardín ornamental busca llevar al lector a ir encontrando, a través de sucesivos refinamientos, los mecanismos principales que se emplean para resolver –en general– los problemas que implican el acceso concurrente a una sección crítica. Se presentan a continuación, pues, los sucesivos intentos.
- Intento 1: No utilizar multitarea
-
En este sencillo ejemplo una posible solución es utilizar una sola entrada (o torniquete). Esto podría ser una solución en tanto que no haya mucha gente que haga cola para entrar. Sin embargo, en un sistema análogo de reserva de pasajes aereos no parece tener mucho sentido que todos los pasajeros deban ir a Japón a sacar su pasaje. Por otro lado, ya deberían ser claras las ventajas de la multitarea y el poseer distintos núcleos.
- Intento 2: Suspender la multitarea durante la sección crítica
-
Una versión más relajada de la alternativa anterior es suspender la multitarea durante la ejecución de la sección crítica. Así, un torniquete deberá hacer:
disable(); /* Suspender temporal las interrupciones */ cuenta = cuenta + 1; enable(); /* Habilitar nuevamente las interrupciones */
Durante el lapso en el que las interrupciones están suspendidas no puede haber un cambio de contexto pues el planificador depende de la interrupción del reloj (salvo que el proceso realice una llamada bloqueante durante la región crítica).
Esta solución puede resultar conveniente para sistemas sencillos, pero en un sistema multiusuario se torna inusable por varios motivos:
- Permitir que un programa de usuario deshabilite las interrupciones en un sistema operativo de propósito general involucra un gran problema de seguridad: cualquier usuario podría hacer un programa malicioso (o sencillamente erroneo) que deshabilite las interrupciones y suspenda indefinidamente el resto del sistema.
- No funciona para sistemas distribuídos (como el sistema de reserva de pasajes aereos), ni siquiera para sistemas multinúcleo o multiprocesador, ya que las interrupciones se deshabilitan en un sólo núcleo (si bien también es posible detener a los demás procesadores, representa un costo demasiado alto).
- Expone detalles de hardware y podría provocar mal funcionamiento de algún periférico si el procesamiento de la sección crítica es demasiado largo.
- Intento 3: Utilizar una bandera
-
Utilizar una bandera parece ser una solución muy sencilla: mediante una variable de bandera se indica si hay un proceso en la región crítica:
int bandera = 0; /* 0 => región crítica libre, 1 => ocupada */ int cuenta = 0; /* ... */ /* Torniquete1 */ /* ... */ if (bandera) wait; /* Aquí bandera=0 */ bandera = 1; /* Inicio de la sección crítica */ cuenta = cuenta + 1; bandera = 0; /* Fin de la sección crítica */
Sin embargo esto no funciona, ahora puede darse la siguiente secuencia de eventos:
bandera==0;
torniquete2
:if (bandera) wait;
- Nuevo cambio de contexto
torniquete1
:if (bandera) wait;
torniquete1
:bandera = 1;
torniquete2
:bandera = 1;
torniquete2
:cuenta = cuenta + 1;
torniquete1
:cuenta = cuenta + 1; /* Ups, no se respetó la región crítica */
Notar que el problema aquí es que la bandera también es un recurso compartido: lo único que ha cambiado es que ahora la sección crítica está en otro lugar. La solución funcionaría si se pudiera garantizar que la secuencia de operaciones se realizara atómicamente:
if (bandera) wait; bandera = 1
- Intento 4: Manejar la bandera con instrucciones atómicas
-
Algunas arquitecturas de computadoras permiten realizar determinadas operaciones sencillas (como actualizar una bandera) de forma atómica (p. ej.: VAX tiene la instrucción
test_and_set
y el i386 tiene la instrucciónINC
.Usando esto, la solución es:
int bandera; /* 0 => desocupada */ while (++bandera != 1) { bandera--; /* Debe generar "INC" */ } /* Sección crítica */ cuenta = cuenta + 1; bandera--;
Esto funciona correctamente siempre que la operación ++bandera sea atómica. Sin embargo, hay dos problemas a considerar: un proceso puede permanecer mucho tiempo repitiendo el ciclo:
while (++bandera!=1) { bandera--; }
De hecho, si el sistema operativo decide darle alta prioridad a este proceso es posible que esté un tiempo infinito en este ciclo, impidiendo que otro proceso decremente la bandera. Y aún cuando el sistema operativo decida cambiar de proceso en el siguiente tic de reloj, es evidente que se podría aprovechar el procesador para hacer algo útil durante ese tiempo y que suspender el proceso de otra manera le da más posibilidad a otros procesos para que cambien la bandera. A esto se lo conoce como espera activa o espera ocupada (busy waiting en inglés) y es una situación que se desea evitar.
El otro problema tiene que ver con el hardware: determinadas arquitecturas no permiten instrucciones que lean y actualicen en una única operación una dirección de memoria (se requiere una operación para leer y otra para escribir). En particular, ninguna arquitectura RISC lo permite (p. ej.: SPARC, RS 6000, …).
- Intento 5: Utilizar turnos
-
Una alternativa para evitar el problema de la actualización múltiple a una bandera es utilizar turnos
int turno = 1; /* Inicialmente el turno es del proceso 1 */
Ahora el código del proceso 1 contendría algo como:
while (turno != 1) { esperar(); /* ¿Otro proceso? */ } /* Sección crítica */ cuenta = cuenta + 1; turno = 2;
Y el del proceso dos:
while (turno != 2) { esperar(); } /* Sección crítica */ cuenta = cuenta + 1; turno = 1;
Esto garantiza que no hay dos procesos en sección crítica. Pero nótese que hacer esto equivale a tener un solo torniquete: sólo podrá haber una persona ingresando a la vez… o incluso peor, las personas deberán utilizar alternativamente los torniquetes. Así que si bien esto soluciona el problema de la actualización múltiple en realidad es una solución muy restrictiva: un proceso que no está en la sección crítica puede obligar a que otro proceso espere mucho tiempo para ingresar a la sección crítica. De aquí en más se buscarán soluciones en las que no ocurra esto.
- Intento 6: Indicar la intención de entrar a la sección crítica
-
Para paliar los efectos de la solución anterior se puede intentar indicar si el otro proceso también está queriendo entrar en la sección crítica. El código sería:
int b1, b2; /* ... */ /* Proceso 1: */ /* ... */ b1 = 1; if (b2) { esperar(); } /* Sección crítica */ cuenta = cuenta + 1; b1 = 0; /* ... */ /* Proceso 2: */ /* ... */ b2 = 1; if (b1) { esperar(); } /* Sección crítica */ cuenta = cuenta + 1; b2 = 0; /* ... */
Nuevamente aquí está garantizado que no puede haber dos procesos en la región crítica, pero este enfoque sufre de un problema grave: ambos procesos pueden bloquearse mutuamente (si el proceso 1 coloca su bandera en 1 y luego se cambia el control al proceso 2 quien también colocará su bandera en 1).
3.2.4 Una solución: el Algoritmo de Peterson
La primera solución a esta problema fue propuesta por Dekker en 1957. Sin embargo su explicación es bastante extensa (aunque perfectamente comprensible). Se presentará la solución planteada por Peterson unos cuantos años más tarde: en 1970.
La solución está basada en una combinación de los intentos anteriores: utilizar banderas para indicar qué proceso puede entrar, pero además usa un turno para desempatar en caso de que ambos procesos busquen entrar a la vez. En cierto modo es un algoritmo amable: si un proceso detecta que el otro proceso fue el primero en actualizar el turno, entonces lo deja pasar:
int b1, b2; int quien; /* Proceso 1: */ ... b1=1; quien=2; if ( b2 && (quien==2)) { esperar(); } /* Sección crítica */ cuenta = cuenta + 1; b1=0; /* Proceso 2: */ ... b2=1; quien=1; if ( b1 && quien==1) { esperar(); } /* Sección crítica */ cuenta = cuenta + 1; b1=0;
Cabe apuntar las siguientes notas sobre la solución de Peterson:
- Espera activa
- La solución presentada mantiene todavía el problema
de la espera activa (también llamados spinlocks):
un proceso puede consumir mucho tiempo de
procesador sólo para esperar que otro proceso
cambie una bandera, lo cual en un sistema con
manejo de prioridades, puede resultar dañino para
el desempeño global. Una forma de
mitigar los efectos es forzar (o sugerir) cambios
de contexto en esos puntos a través de una
primitiva del lenguaje o del sistema operativo
(p. ej.:
sleep
oyield
), pero debe resultar claro que de ninguna forma es una solución general. Por esta razón los sistemas operativos o lenguajes suelen proveer alguna abstracción para soportar explícitamente operaciones atómicas o implementar una solución más elegante al problema. Se verán algunas de esas abstracciones más adelante.Para mayores detalles acerca de las razones, ventajas y desventajas del uso de spinlocks en sistemas operativos reales, referirse a Spin Locks & Other Forms of Mutual Exclusion (Theodore P. Baker 2010)
- Solución para más procesos
- El algoritmo de Peterson sirve
únicamente cuando hay dos procesos que compiten para acceder a
una región crítica. ¿Qué se puede hacer si hay más de dos
entradas al jardín, o si hay más de dos puntos de venta de
pasajes aereos? La solución a este problema más general fue
propuesta por Dijkstra en 1968 y posteriormente Eisenberg y
McGuire en 1972 y Lamport en 1974 presentaron distintas
soluciones.
La más ampliamente utilizada y sencilla de entender es la propuesta por Lamport, también conocida como el algoritmo de la panadería por su semejanza con el sistema de turnos utilizado para atender a los clientes en una panadería.
- Solución para equipos multiprocesadores
- Esta solución (y también la de Lamport y todos los autores mencionadas hasta ahora) falla en equipos multiprocesadores, pues aparecen problemas de coherencia de caché. Se necesitan precauciones especiales en equipos con más de un procesador.
3.3 Mecanismos de sincronización
En la presente sección se enumeran los principales mecanismos que pueden emplearse para programar considerando a la concurrencia: Candados, semáforos y variables de condición.
3.3.1 Regiones de exlcusión mutua: candados o mutexes
Una de las alternativas que suele ofrecer un lenguaje concurrente o sistema operativo para evitar la espera activa a la que obliga el algoritmo de Peterson (o similiares) se llama mutex o candado (lock).
La palabra mutex nace de la frecuencia con que se habla de las regiones de exclusión mutua (en inglés, mutual exclusion). Es un mecanismo que asegura que cierta región del código será ejecutada como si fuera atómica.
Sincronización: La exclusión de las secciones críticas entre a varios procesos se protegen por medio de regiones de exclusión mutua
Hay que tener en cuenta que un mutex no implica que el código no se va a interrumpir mientras se está dentro de esta región — Eso sería muy peligroso, dado que permitiría que el sistema operativo pierda el control del planificador, volviendo (para propósitos prácticos) a un esquema de multitarea cooperativa. El mutex es un mecanismo de prevención, que mantiene en espera a cualquier hilo o proceso que quiera entrar a la sección crítica protegida por el mutex, reteniéndolo antes de entrar a ésta hasta que el proceso que la está ejecutando salga de ella. Si no hay ningún hilo o proceso en dicha sección crítica (o cuando un hilo sale de ella), uno sólo de los que esperan podrá ingresar.
Como se vio en el ejemplo anterior, para que un mutex sea efectivo tiene que ser implementado a través de una primitiva a un nivel inferior5, implicando al planificador.
El problema de la actualización múltiple que surge en el caso de la venta de pasajes aereos podría reescribirse de la siguiente manera empleando un mutex:
my ($proximo_asiento :shared, $capacidad :shared); $capacidad = 40; sub asigna_asiento { lock($proximo_asiento); if ($proximo_asiento < $capacidad) { $asignado = $proximo_asiento; $proximo_asiento += 1; print "Asiento asignado: $asignado\n"; } else { print "No hay asientos disponibles\n"; return 1; } return 0; }
Se debe tener en cuenta que en este caso se utiliza una implementación
de hilos, esto hace que la solución sea dependiente del lenguaje
específico de implementación, en este caso Perl. Al ser
$proximo_asiento
una variable compartida tiene algunas propiedades
adicionales, en este caso, la de poder operar como un mutex. La
implementación en Perl resulta muy limpia, dado que evita el uso
de un candado explícito — Se podría leer la línea 5 como exclusión mutua sobre $proximo_asiento
.
En la implementación de hilos de Perl, la función lock()
implementa
un mutex delimitado por el ámbito léxico de su invocación: el área
de exclusión mutua abarca desde la línea 5 en que es invocada hasta la
15 en que termina el bloque en que se invocó.
Un área de exclusion mutua debe:
- Ser mínima
- Debe ser tan corta como sea posible, para evitar que otros hilos queden bloqueados fuera del área crítica. Si bien en este ejemplo es demasiado simple, si se hiciera cualquier llamada a otra función (o al sistema) estando dentro de un área de exclusión mutua, se detendría la ejecución de todos los demás hilos por mucho más tiempo del necesario.
- Ser completa
- Se debe analizar bien cuál es el área a
proteger y no arriesgarse a proteger de menos. En este ejemplo,
se podría haber puesto
lock($asignado)
dentro delif
, dado que sólo dentro de su evaluación positiva se modifica la variable$proximo_asiento
. Sin embargo, si la ejecución de un hilo se interrumpiera entre las líneas 7 y 8, la condición delif
se podría evaluar incorrectamente.
Como comparación, una rutina equivalente en Bash (entre procesos
independientes y usando los archivos /tmp/proximo_asiento
y
/etc/capacidad/
como un mecanismo para compartir datos) sería:
asigna_asiento() { lockfile /tmp/asigna_asiento.lock PROX=$(cat /tmp/proximo_asiento || echo 0) CAP=$(cat /etc/capacidad || echo 40) if [ $PROX -lt $CAP ] then ASIG=$PROX echo $(($PROX+1)) > /tmp/proximo_asiento echo "Asiento asignado: $ASIG" else echo "No hay asientos disponibles" return 1; fi rm -f /tmp/asigna_asiento.lock }
Cabe mencionar que lockfile
no es una función implementada en Bash,
sino que envuelve a una llamada al sistema. El sistema operativo
garantiza que la verificación y creación de este candado se
efectuará de forma atómica.
Un mutex es, pues, una herramienta muy sencilla, y podría verse como la pieza básica para la sincronización entre procesos. Lo fundamental para emplearlos es identificar las regiones críticas del código, y proteger el acceso con un mecanismo apto de sincronización, que garantice atomicidad.
3.3.2 Semáforos
La interfaz ofrecida por los mutexes es muy sencilla, pero no permite resolver algunos problemas de sincronización. Edsger Dijkstra, en 1968, propuso los semáforos.6
Un semáforo es una variable de tipo entero que tiene definida la siguiente interfaz:
- Inicialización
- Se puede inicializar el semáforo a cualquier valor entero, pero después de esto, su valor no puede ya ser leído. Un semáforo es una estructura abstracta, y su valor es tomado como opaco (invisible) al programador.
- Decrementar
- Cuando un hilo decrementa el semáforo, si el valor es
negativo, el hilo se bloquea y no puede continuar
hasta que otro hilo incremente el semáforo. Según
la implementación, esta operación puede denominarse
wait
,down
,acquire
o inclusoP
(por ser la inicial de proberen te verlagen, intentar decrementar en holandés, del planteamiento original en el artículo de Dijkstra). - Incrementar
- Cuando un hilo incrementa el semáforo, si hay hilos
esperando, uno de ellos es despertado. Los nombres
que recibe esta operación son
signal
,up
,release
,post
oV
(de verhogen, incrementar).
La interfaz de hilos POSIX
(pthreads
) presenta esas primitivas
con la siguiente definición:
int sem_init(sem_t *sem, int pshared, unsigned int value); int sem_post(sem_t *sem); int sem_wait(sem_t *sem); int sem_trywait(sem_t *sem);
La variable pshared
indica si el semáforo puede ser compartido
entre procesos o únicamente entre hilos. sem_trywait
extiende la
intefaz sugerida por Dijkstra: verifica si el semáforo puede ser
decrementado y, en caso de que no, en vez de bloquearse, indica al
proceso que no puede continuar. El proceso debe tener la lógica
necesaria para no entrar en las secciones críticas (p. ej., intentar
otra estrategia) en ese caso.
sem_trywait
se sale de la definición clásica de semáforo, por lo
que no se considera en esta sección.
Un semáforo permite la implementación de varios patrones, entre los cuales se mencionarán los siguientes:
- Señalizar
- Un hilo debe informar a otro que cierta condición está
ya cumplida — Por ejemplo, un hilo prepara una conexión
en red mientras que otro calcula lo que tiene que
enviar. No se puede arriesgar a comenzar a enviar
antes de que la conexión esté lista. Se inicializa el
semáforo a 0, y:
# Antes de lanzar los hilos senal = Semaphore(0) def envia_datos(): calcula_datos() senal.acquire() envia_por_red() def prepara_conexion(): crea_conexion() senal.release()
No importa si
prepara_conexion()
termina primero — En el momento en que termine,senal
valdrá 1 yenvia_datos()
podrá proceder. - Rendezvous
- Así se denomina en francés (y ha sido adoptado al
inglés) a quedar en una cita. Este patrón busca
que dos hilos se esperen mutuamente en cierto punto
para continuar en conjunto — Por ejemplo, en una
aplicación GUI, un hilo prepara la interfaz gráfica
y actualiza sus eventos mientras otro efectúa
cálculos para mostrar. Se desea presentar al usuario
la simulación desde el principio, así que no debe
empezar a calcular antes de que el GUI esté listo,
pero preparar los datos del cálculo toma tiempo, y
no se quiere esperar doblemente. Para esto, se
implementan dos semáforos señalizándose mutuamente:
guiListo = Semaphore(0) calculoListo = Semaphore(0) threading.Thread(target=maneja_gui, args=[]).start() threading.Thread(target=maneja_calculo, args=[]).start() def maneja_gui(): inicializa_gui() guiListo.release() calculoListo.acquire() recibe_eventos() def maneja_calculo(): inicializa_datos() calculoListo.release() guiListo.acquire() procesa_calculo()
- Mutex
- El uso de un semáforo inicializado a 1 puede implementar
fácilmente un mutex. En Python:
mutex = Semaphore(1) # ...Inicializar estado y lanzar hilos mutex.acquire() # Aquí se está en la region de exclusión mutua x = x + 1 mutex.release() # Continúa la ejecucion paralela
- Multiplex
- Permite la entrada de no más de n procesos a la
región crítica. Si se lo ve como una generalización de
Mutex, basta con inicializar al semáforo al número
máximo de procesos deseado.
Su construcción es idéntica a la de un mutex, pero es inicializado al número de procesos que se quiere permitir que ejecuten de forma simultánea.
- Torniquete
- Una construcción que por sí sóla no hace mucho, pero
resulta útil para patrones posteriores. Esta
construcción garantiza que un grupo de hilos o
procesos pasa por un punto determinado de uno en uno
(incluso en un ambiente multiprocesador):
torniquete = Semaphore(0) # (...) if alguna_condicion(): torniquete.release() # (...) torniquete.acquire() torniquete.release()
En este caso, se ve primero una señalización que hace que todos los procesos esperen frente al torniquete hasta que alguno marque que
alguna_condicion()
se ha cumplido y libere el paso. Posteriormente, los procesos que esperan pasarán ordenadamente por el torniquete.El torniquete por sí sólo no es tan útil, pero su función se hará clara a continuación.
- Apagador
- Cuando se tiene una situación de exclusión categórica
(basada en categorías y no en procesos individuales —
Varios procesos de la misma categoría pueden entrar a la
sección crítica, pero procesos de dos categorías
distintas deben tenerlo prohibido), un apagador
permite evitar la inanición de una de las categorías
ante un flujo constante de procesos de la otra.
El apagador usa, como uno de sus componentes, a un torniquete. Para ver una implementación ejemplo de un apagador, referirse a la solución presentado a continuación para el problema de los lectores y los escritores (sección PROClectoresescritores).
- Barrera
- Una barrera es una generalización de rendezvous que
permite la sincronización entre varios hilos (no sólo
dos), y no requiere que el rol de cada uno de los hilos
sea distinto.
Esta construcción busca que ninguno de los hilos continúe ejecutando hasta que todos hayan llegado a un punto dado.
Para implementar una barrera, es necesario que ésta guarde algo de información adicional además del semáforo, particularmente, el número de hilos que se han lanzado (para esperarlos a todos). Esta será una variable compartida y, por tanto, requiere de un mutex. La inicialización (que se ejecuta antes de iniciar los hilos) será:
require random n = random.randint(1,10) # Número de hilos cuenta = 0 mutex = Semaphore(1) barrera = Semaphore(0)
Ahora, suponiendo que todos los hilos tienen que realizar, por separado, la inicialización de su estado, y ninguno de ellos debe comenzar el procesamiento hasta que todos hayan efectuado su inicialización:
inicializa_estado() mutex.acquire() cuenta = cuenta + 1 mutex.release() if cuenta == n: barrera.release() barrera.acquire() barrera.release() procesamiento()
Las barreras son una construcción suficientemente útil como para que sea común encontrarlas "prefabricadas". En los hilos
POSIX
(pthreads
), por ejemplo, la interfaz básica es:int pthread_barrier_init(pthread_barrier_t *barrier, const pthread_barrierattr_t *restrict attr, unsigned count); int pthread_barrier_wait(pthread_barrier_t *barrier); int pthread_barrier_destroy(pthread_barrier_t *barrier);
- Cola
- Se emplea una cola cuando se tienen dos clases de
hilos que deben proceder en pares. Este patrón es a veces
referido como baile de salón: para que una pareja baile,
hace falta que haya un líder y un seguidor. Cuando
llega una persona al salón, verifica si hay uno de la otra
clase esperando bailar. En caso de haberlo, bailan, y en
caso contrario, espera a que llegue su contraparte.
El código para implementar esto es muy simple:
colaLideres = Semaphore(0) colaSeguidores = Semaphore(0) # (...) def lider(): colaSeguidores.release() colaLideres.acquire() baila() def seguidor(): colaLideres.release() colaSeguidores.acquire() baila()
El patrón debe resultar ya familiar: es un rendezvous. La distinción es meramente semántica: en el rendezvous se necesitan dos hilos explícitamente, aquí se habla de dos clases de hilos.
Sobre este patrón base se pueden refinar muchos comportamientos. Por ejemplo, asegurar que sólo una pareja esté bailando al mismo tiempo, o asegurar que los hilos en espera vayan bailando en el orden en que llegaron.
3.3.3 Variables de condición
Las variables de condición presentan una extensión sobre el comportamiento de los mutexes, buscando darles la "inteligencia" de responder ante determinados eventos. Una variable de condición siempre opera en conjunto con un mutex, y en algunas implementaciones es necesario indicar cuál será dicho mutex desde la misma inicialización del objeto.7
Una variable de condición presenta las siguientes operaciones:
- Espera
- Se le indica una condición y un mutex. El mutex tiene que haber sido ya adquirido. Esta operación libera al mutex, y se bloquea hasta recibir una notificación de otro hilo o proceso. Una vez que la notificación es recibida, y antes de devolver la ejecución al hilo, re-adquiere el mutex.
- Espera medida
- Tiene una semántica igual a la de la espera, pero recibe un argumento adicional, indicando el tiempo de expiración. Si pasado el tiempo de expiración no ha sido notificado, despierta al hilo regresándole un error (y sin re-adquirir el mutex).
- Señaliza
- Requiere que el mutex ya haya sido adquirido. Despierta (señaliza) a uno o más hilos (algunas implementaciones permiten indicar como argumento a cuántos hilos) de los que están bloqueados en la espera asociada. No libera el mutex — Esto significa que el flujo de ejecución se mantiene en el invocante, quien tiene que salir de su sección crítica (entregar el mutex) antes de que otro de los hilos continúe ejecutando.
- Señaliza a todos
- Indica a todos los hilos que estén esperando a esta condición.
La interfaz de hilos POSIX
(pthreads
) presenta la siguiente
definición:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr); int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond); int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime); int pthread_cond_destroy(pthread_cond_t *cond);
3.4 Problema productor-consumidor
3.4.1 Planteamiento
En un entorno multihilos es común que haya una división de tareas tipo línea de ensamblado, que se puede generalizar a que un grupo de hilos van produciendo ciertas estructuras, a ser consumidas por otro grupo.
Un ejemplo de este problema puede ser un programa orientado a eventos, en que eventos de distinta naturaleza pueden producirse, y causan que se disparen los mecanismos que los puedan atender. Los eventos pueden apilarse en un buffer que será procesado por los hilos encargados conforme se vayan liberando. Esto impone ciertos requisitos, como:
- Debido a que el buffer es un recurso compartido por los hilos, agregar o retirar un elemento del buffer tiene que ser hecho de forma atómica. Si más de un proceso intentara hacerlo al mismo tiempo, se correría el riesgo de que se corrompan los datos.
- Si un consumidor está listo y el buffer está vacío, debe bloquearse (¡no realizar espera activa!) hasta que un productor genere un elemento.
Si no se tiene en cuenta la sincronización, el código sería tan simple como el siguiente:
import threading buffer = [] threading.Thread(target=productor,args=[]).start() threading.Thread(target=consumidor,args=[]).start() def productor(): while True: event = genera_evento() buffer.append(event) def consumidor(): while True: event = buffer.pop() procesa(event)
Pero el acceso a buffer
no está protegido para garantizar la
exclusión mutua, y podría quedar en un estado inconsistente si
append()
y pop()
intentan manipular sus estructuras al mismo
tiempo. Además, si bien en este ejemplo se asumió que hay un sólo hilo
productor y un sólo hilo consumidor, se puede extender el programa
para que haya varios hilos en cualquiera de estos roles.
evento
no requiere de protección, dado que es una variable local a
cada hilo.
3.4.2 Solución
Para resolver este problema se usarán dos semáforos: mutex
, que
protege el acceso a la sección crítica, y elementos
. El valor
almacenado en elementos
indica, cuando es positivo, cuántos
eventos pendientes hay por procesar, y cuando es negativo, cuántos
consumidores están listos y esperando un evento.
Una solución a este problema puede ser:
import threading mutex = threading.Semaphore(1) elementos = threading.Semaphore(0) buffer = [] threading.Thread(target=productor, args=[]).start() threading.Thread(target=consumidor, args=[]).start() def productor(): while True: event = genera_evento() mutex.acquire() buffer.append(event) mutex.release() elementos.release() def consumidor(): while True: elementos.acquire() mutex.acquire() event = buffer.pop() mutex.release() event.process()
Se puede ver que la misma construcción, un semáforo, es utilizada de
forma muy distinta por mutex
y elementos
. mutex
implementa una
exclusión mutua clásica, delimitando tanto como es posible (a una
sóla línea en este caso) al área crítica, y siempre apareando un
acquire()
con un release()
. elementos
, en cambio, es empleado
como un verdadero semáforo: como una estructura para la
sincronización. Sólo los hilos productores incrementan (sueltan) el
semáforo, y sólo los consumidores lo decrementan (adquieren). Esto
es, ambos semáforos comunican al planificador cuándo es posible
despertar a algún consumidor.
Si se supone que genera_evento()
es eficiente y no utiliza
espera activa, esta implementación es óptima: deja en manos
del planificador toda la espera necesaria, y no desperdicia recursos
de cómputo esperando a que el siguiente elemento esté listo.
Como nota al pie, la semántica del módulo threading
de Python
incluye la declaración de contexto with
. Todo objeto de
threading
que implemente acquire()
y release()
puede ser
envuelto en un bloque with
, aumentando la legibilidad y con
exactamente la misma semántica; no se utilizó en este ejemplo para
ilustrar el uso tradicional de los semáforos para implementar
regiones de exclusión mutua, sin embargo y sólo para concluir con el
ejemplo, la función consumidor()
final podría escribirse así, y
ser semánticamente idéntica:
def consumidor(): while True: elementos.acquire() with mutex: event = buffer.pop() event.process()
A pesar de ser más clara, no se empleará en este texto esta notación
por dos razones. La primera es para mantener la conciencia de la
semántica de las operaciones acquire()
y release()
, y la segunda
es mantener la consistencia a través de las distintas
implementaciones, ya que se encontrarán varios casos en que no es el
mismo hilo el que adquiere un semáforo y el que lo libera (esto es,
los semáforos no siempre son empleados como mutexes).
3.5 Bloqueos mutuos e inanición
Cuando hay concurrencia, además de asegurar la atomicidad de ciertas operaciones, se debe evitar dos problemas que son consecuencia natural de la existencia de la asignación de recursos de forma exclusiva:
- Bloqueo mutuo
- (o interbloqueo; en inglés, deadlock) Situación que ocurre cuando dos o más procesos poseen determinados recursos, y cada uno queda detenido, a la espera de alguno de los que tiene el otro. El sistema puede seguir operando normalmente, pero ninguno de los procesos involucrados podrán avanzar.
- Inanición
- (en inglés resource starvation) Situación en que un proceso no puede avanzar en su ejecución dado que necesita recursos que están (alternativamente) asignados a otros procesos.
El que se presenten estos conceptos aquí no significa que están exclusivamente relacionados con este tema: son conceptos que se enfrentan una y otra vez al hablar de asignación exclusiva de recursos — temática recurrente en el campo de los sistemas operativos.
3.6 Problema de los lectores y los escritores
3.6.1 Planteamiento
Una estructura de datos puede ser accedida simultáneamente por muchos procesos lectores, pero si algún proceso está escribiendo, se debe evitar que cualquier otro lea (dado que podría encontrarse con los datos en un estado inconsistente). Los requisitos de sincronización son
- Cualquier cantidad de lectores puede estar leyendo al mismo tiempo.
- Los escritores deben tener acceso exclusivo a la sección crítica.
- En pos de un comportamiento más justo: se debe evitar que un influjo constante de procesos lectores dejen a un escritor en situación de inanición.
3.6.2 Discusión
Este problema puede ser generalizado como una exclusión mutua categórica: se debe separar el uso de un recurso según la categoría del proceso. La presencia de un proceso en la sección crítica no lleva a la exclusión de otros, pero sí hay categorías de procesos que tienen distintas reglas — Para los escritores sí hace falta una exclusión mutua completa.
3.6.3 Primera aproximación
Un primer acercamiento a este problema permite una resolución libre de bloqueos mutuos, empleando sólo tres estructuras globales: un contador que indica cuántos lectores hay en la sección crítica, un mutex protegiendo a dicho contador, y otro mutex indicando que no hay lectores ni escritores accediendo al buffer (o cuarto). Se implementan los mutexes con semáforos.
import threading lectores = 0 mutex = threading.Semaphore(1) cuarto_vacio = threading.Semaphore(1) def escritor(): cuarto_vacio.acquire() escribe() cuarto_vacio.release() def lector(): mutex.acquire() lectores = lectores + 1 if lectores == 1: cuarto_vacio.acquire() mutex.release() lee() mutex.acquire() lectores = lectores - 1 if lectores == 0: cuarto_vacio.release() mutex.release()
El semáforo cuarto_vacio
sigue un patrón visto antes llamado apagador. El
escritor utiliza al apagador como a cualquier mutex: lo utiliza para
rodear a su sección crítica. El lector, sin embargo, lo emplea de
otra manera. Lo primero que hace es verificar si la luz está prendida, esto es, si hay algún otro lector en el cuarto (si
lectores
es igual a 1). Si es el primer lector en entrar, prende
la luz adquiriendo cuarto_vacio
(lo cual evitará que un escritor
entre). Cualquier cantidad de lectores puede entrar, actualizando su
número. Cuando el último sale (lectores
es igual a 0), apaga la
luz.
El problema con esta implementación es que un flujo constante de lectores puede llevar a la inanición de un escritor, que está pacientemente parado esperando a que alguien apague la luz.
3.6.4 Solución
Para evitar esta condición de inanición, se puede agregar un torniquete evitando que lectores adicionales se cuelen antes del escritor. Reescribiendo:
import threading lectores = 0 mutex = threading.Semaphore(1) cuarto_vacio = threading.Semaphore(1) torniquete = threading.Semaphore(1) def escritor(): torniquete.acquire() cuarto_vacio.acquire() escribe() cuarto_vacio.release() torniquete.release() def lector(): global lectores torniquete.acquire() torniquete.release() mutex.acquire() lectores = lectores + 1 if lectores == 1: cuarto_vacio.acquire() mutex.release() lee() mutex.acquire() lectores = lectores - 1 if lectores == 0: cuarto_vacio.release() mutex.release()
En la implementación de los escritores, esto puede parecer inútil:
Únicamente se agregó un mutex redundante alrededor de lo que ya
se tenía. Sin embargo, al obligar a que el lector pase por un
torniquete antes de actualizar lectores
, lo cual obligaría a que
se mantenga encendida la luz, se obliga a que espere a que el
escritor suelte este mutex exterior. Nuevamente se puede ver cómo la misma
estructura es tratada de dos diferentes maneras: para el lector es
un torniquete, y para el escritor es un mutex.
3.7 La cena de los filósofos
3.7.1 Planteamiento
Cinco filósofos se dan cita para comer arroz en una mesa redonda. En la mesa, cada uno de ellos se sienta frente a un plato. A su derecha, tiene un palito chino, y a su izquierda tiene otro.
Los filósofos sólo saben pensar()
y comer()
. Cada uno de
ellos va a pensar()
un tiempo arbitrario, hasta que le da
hambre. El hambre es mala consejera, por lo que intenta
comer()
. Los requisitos son:
- Sólo un filósofo puede sostener determinado palito a la vez, esto es los palitos son recursos de acceso exclusivo.
- Debe ser imposible que un filósofo muera de inanición estando a la espera de un palito.
- Debe ser imposible que se presente un bloqueo mutuo.
- Debe ser posible que más de un filósofo pueda comer al mismo tiempo.
3.7.2 Discusión
En este caso, el peligro no es, como en el ejemplo anterior, que una estructura de datos sea sobreescrita por ser accedida por dos hilos al mismo tiempo, sino que se presenten situaciones en el curso normal de la operación que lleven a un bloqueo mutuo.
A diferencia del caso antes descripto, ahora se utilizarán los semáforos no como una herramienta para indicar al planificador cuándo despertar a uno de los hilos, sino como una herramienta de comunicación entre los propios hilos.
3.7.3 Primer acercamiento
Se puede representar a los palillos como un arreglo de semáforos, asegurando la exclusión mutua (esto es, sólo un filósofo puede sostener un palillo al mismo tiempo), pero eso no evita el bloqueo mutuo. Por ejemplo, si la solución fuera:
import threading num = 5 palillos = [threading.Semaphore(1) for i in range(num)] filosofos = [threading.Thread(target=filosofo, args=[i]).start() for i in range(num)] def filosofo(id): while True: piensa(id) levanta_palillos(id) come(id) suelta_palillos(id) def piensa(id): # (...) print "%d - Tengo hambre..." % id def levanta_palillos(id): palillos[(id+1) % num].acquire() print "%d - Tengo el palillo derecho" % id palillos[id].acquire() print "%d - Tengo ambos palillos" % id def suelta_palillos(id): palillos[(id+1) % num].release() palillos[id].release() print "%d - Sigamos pensando..." % id def come(id): print "%d - ¡A comer!" % id # (...)
Podría pasar que todos los filósofos quieran comer al mismo tiempo, y el planificador dejara suspendidos a todos con el palillo derecho en la mano.
3.7.4 Solución
Ahora, ¿qué pasa si se hace que algunos filósofos sean zurdos? Esto es, que levanten primero el palillo izquierdo y luego el derecho:
def levanta_palillos(id): if (id % 2 == 0): # Zurdo palillo1 = palillos[id] palillo2 = palillos[(id+1) % num] else: # Diestro palillo1 = paltos[(id+1) % num] palillo2 = palillos[id] palillo1.acquire() print "%d - Tengo el primer palillo" % id palillo2.acquire() print "%d - Tengo ambos palillos" % id
Al asegurar que dos filósofos contiguos no intenten levantar el mismo palillo, se tiene la certeza de que no se producirán bloqueos mutuos. De hecho, incluso si sólo uno de los filósofos es zurdo, se puede demostrar que no habrá bloqueos:
def levanta_palillos(id): if id == 0: # Zurdo palillos[id].acquire() print "%d - Tengo el palillo izquierdo" % id palillos[(id+1) % num].acquire() else: # Diestro palillos[(id+1) % num].acquire() print "%d - Tengo el palillo derecho" % id palillos[id].acquire() print "%d - Tengo ambos palillos" % id
Cabe apuntar que ninguno de estos mecanismos asegura que en la mesa no haya inanición, sólo que no haya bloqueos mutuos.
3.8 Los fumadores compulsivos
3.8.1 Planteamiento
Hay tres fumadores empedernidos y un agente que, de tiempo en tiempo, consigue ciertos insumos. Los ingredientes necesarios para fumar son tabaco, papel y cerillos. Cada uno de los fumadores tiene una cantidad infinita de alguno de los ingredientes, pero no les gusta compartir. Afortunadamente, del mismo modo que no comparten, no son acaparadores8.
De tiempo en tiempo, el agente consigue una dosis de dos de los ingredientes — Por ejemplo, si deja en la mesa un papel y tabaco, el que trae los cerillos educadamente tomará los ingredientes, se hará un cigarro, y lo fumará.
Suhas Patil (1971) planteó este problema buscando demostrar que hay situaciones que no se pueden resolver con el uso de semáforos. Las condiciones planteadas son
- No se puede modificar el código del agente. Si el agente es un sistema operativo, ¡tiene sentido la restricción de no tenerle que notificar acerca de los flujos a cada uno de los programas que ejecuta!
- El planteamiento original de Patil menciona que no debe emplearse arreglos de semáforos o usar condicionales en el flujo. Esta segunda restricción haría efectivamente irresoluble al problema, por lo que se ignorará.
3.8.2 Primer acercamiento
Al haber tres distintos ingredientes, tiene sentido que se empleen tres distintos semáforos, para señalizar a los fumadores respecto a cada uno de los ingredientes. Un primer acercamiento podría ser:
import random import threading ingredientes = ['tabaco', 'papel', 'cerillo'] semaforos = {} semaforo_agente = threading.Semaphore(1) for i in ingredientes: semaforos[i] = threading.Semaphore(0) threading.Thread(target=agente, args=[]).start() fumadores = [threading.Thread(target=fumador, args=[i]).start() for i in ingredientes] def agente(): while True: semaforo_agente.acquire() mis_ingr = ingredientes[:] mis_ingr.remove(random.choice(mis_ingr)) for i in mis_ingr: print "Proveyendo %s" % i semaforos[i].release() def fumador(ingr): mis_semaf = [] for i in semaforos.keys(): if i != ingr: mis_semaf.append(semaforos[i]) while True: for i in mis_semaf: i.acquire() fuma(ingr) semaforo_agente.release() def fuma(ingr): print 'Fumador con %s echando humo...' % ingr
El problema en este caso es que, al tener que cubrir un número de
ingredientes mayor a uno, utilizar sólo un semáforo ya no funciona: si
agente()
decide proveer papel y cerillos, nada garantiza que no sea
el fumador['cerillo']
el que reciba la primer señal o que
fumador['tabaco']
reciba la segunda — Para que este programa avance
hace falta, más que otra cosa, la buena suerte de que las señales sean
recibidas por el proceso indicado.
3.8.3 Solución
Una manera de evitar esta situación es la utilización de intermediarios encargados de notificar al hilo adecuado. Partiendo de que, respetando la primer restricción impuesta por Patil, no se puede modificar el código del agente, se implementan los intermediarios, se reimplementan los fumadores y se agregan algunas variables globales, de esta manera:
que_tengo = {} semaforos_interm = {} for i in ingredientes: que_tengo[i] = False semaforos_interm[i] = threading.Semaphore(0) interm_mutex = threading.Semaphore(1) intermediarios = [threading.Thread(target=intermediario, args=[i]).start() for i in ingredientes] def fumador(ingr): while True: semaforos_interm[ingr].acquire() fuma(ingr) semaforo_agente.release() def intermediario(ingr): otros_ingr = ingredientes[:] otros_ingr.remove(ingr) while True: semaforos[ingr].acquire() interm_mutex.acquire() for i in otros_ingr: if que_tengo[i]: que_tengo[i] = False semaforos_interm[i].release() break que_tengo[i] = True interm_mutex.release()
Si bien se ve que el código de fumador()
se simplifica (dado que ya
no tiene que efectuar ninguna verificación), intermediario()
tiene
mayor complejidad. El elemento clave de su lógica es que, si bien el
agente()
(el sistema operativo) seguirá enviando una señal por
cada ingrediente disponible, los tres intermediarios se sincronizarán
empleando al arreglo que_tengo
(protegido por interm_mutex
), y de
este modo cada hilo (independientemente del orden en que fue invocado)
señalizará a los otros intermediarios qué ingredientes hay en la mesa,
y una vez que sepa a qué fumador notificar, dejará el estado listo
para recibir una nueva notificación.
3.9 Otros mecanismos
Más allá de los mecanismos basados en mutexes y semáforos, existen otros que emplean diferentes niveles de encapsulamiento para proteger las abstracciones. A continuación se presentan muy brevemente algunos de ellos.
3.9.1 Monitores
El principal problema con los mecanismos anteriormente descritos es que no sólo hace falta encontrar un mecanismo que permita evitar el acceso simultáneo a la sección crítica sin caer en bloqueos mutuos o inanición, sino que hay que implementarlo correctamente, empleando una semántica que requiere de bastante entrenamiento para entender correctamente.
Además, al hablar de procesos que compiten por recursos de una forma
hostil, la implementación basada en semáforos puede resultar
insuficiente. A mode de ejemplo, se mostrará por qué en el modelo
original de Djikstra (así como en los ejemplos presentados
anteriormente) sólo existen las operaciones de incrementar y
decrementar, y no se permite verificar el estado (como lo ofrece
sem_trywait()
en pthreads
):
while (sem_trywait(semaforo) != 0) {}
seccion_critica();
sem_post(semaforo);
El código presentado es absolutamente válido — Pero cae en una
espera activa que desperdicia innecesariamente y constantemente
tiempo de procesador (y no tiene garantía de tener más éxito que una
espera pasiva, como sería el caso con un sem_wait()
).
Por otro lado, algún programador puede creer que su código ejecutará suficientemente rápido y con suficientemente baja frecuencia para que la probabilidad de que usar la sección crítica le cause problemas sea muy baja. Es frecuente ver ejemplos como el siguiente:
/* Cruzamos los dedos... a fin de cuentas, ejecutaremos con baja frecuencia! */ seccion_critica();
Los perjuicios causados por este programador resultan obvios. Sin embargo, es común ver casos como este.
Los monitores son estructuras provistas por el lenguaje o entorno de desarrollo que encapsulan tanto a los datos como a las funciones que los pueden manipular, e impiden el acceso directo a las funciones potencialmente peligrosas — En otras palabras, son tipos de datos abstractos (ADTs), clases de objetos, y exponen una serie de métodos públicos, además de poseer métodos privados que emplean internamente.
Al no presentar al usuario/programador una interfaz que puedan subvertir, el monitor mantiene todo el código necesario para asegurar el acceso concurrente a los datos en un sólo lugar.
Un monitor puede implementarse utilizando cualquiera de los mecanismos de sincronización presentados anteriormente — la diferencia radica en que esto se hace en un solo lugar. Los programas que quieran emplear el recurso protegido lo hacen incluyendo el código del monitor como módulo / biblioteca, lo cual fomenta la reutilización de código.
Como ejemplo, el lenguaje de programación Java implementa sincronización vía monitores entre hilos como una propiedad de la declaración de método, y lo implementa directamente en la JVM. Si se declara un método de la siguiente manera:
public class SimpleClass { // . . . public synchronized void metodoSeguro() { /* Implementación de metodoSeguro() */ // . . . } }
Y se inicializa a un SimpleClass sc = new SimpleClass()
, cuando
se llame a sc.metodoSeguro()
, la máquina virtual verificará si ningún
otro proceso está ejecutando metodoseguro()
; en caso de que no sea
así, le permitirá la ejecución obteniendo el candado, y en caso de sí
haberlo, el hilo se bloqueará hasta que el candado sea liberado — Esto
es, la propiedad synchronized
hace que todo acceso al método en
cuestión sea protegido por una mutex.
El modelo de sincronización basado en monitores no sólo provee la
exclusión mutua. A través de variables de condición (VCs) se puede
también emplear una semántica parecida (aunque no igual) a la de los
semáforos, con los métodos var.wait()
y var.signal()
. En el caso
de los monitores, var.wait()
suspende al hilo hasta que otro hilo
ejecute var.signal()
; en caso de no haber ningún proceso esperando,
var.signal()
no tiene ningún efecto (no cambia el estado de var
, a
diferencia de lo que ocurre con los semáforos)
Aquí se presenta, a modo de ilustración, la resolución del problema de la cena de los filósofos en C9. Esto demuestra, además, que si bien se utiliza semántica de orientación a objetos, no sólo los lenguajes clásicamente relacionados con la programación orientada a objetos permiten emplear monitores.
/* Implementacion para cinco filósofos */ #define PENSANDO 1 #define HAMBRIENTO 2 #define COMIENDO 3 pthread_cond_t VC[5]; /* Una VC por filósofo */ pthread_mutex_t M; /* Mutex para el monitor */ int estado[5]; /* Estado de cada filósofo */ void palillos_init () { int i; pthread_mutex_init(&M, NULL); for (i = 0; i < 5; i++) { pthread_cond_init(&VC[i], NULL); estado[i] = PENSANDO; } } void toma_palillos (int i) { pthread_mutex_lock(&M) estado[i] = HAMBRIENTO; actualiza(i); while (estado[i] == HAMBRIENTO) pthread_cond_wait(&VC[i], &M); pthread_mutex_unlock(&M); } void suelta_palillos (int i) { estado[i] = PENSANDO; actualiza((i - 1) % 5); actualiza((i + 1) % 5); pthread_mutex_unlock(&M); } void come(int i) { printf("El filosofo %d esta comiendo\n", i); } void piensa(int i) { printf("El filosofo %d esta pensando\n", i); } /* No incluir 'actualiza' en los encabezados, */ /* es una función interna/privada */ int actualiza (int i) { if ((estado[(i - 1) % 5] != COMIENDO) && (estado[i] == HAMBRIENTO) && (estado[(i + 1) % 5] != COMIENDO)) { estado[i] = COMIENDO; pthread_cond_signal(&VC[i]); } return 0; }
Esta implementación evita los bloqueos mutuos señalizando el estado de
cada uno de los filósofos en el arreglo de variables estado[]
.
La lógica base de esta resolución marca en la verificación del estado
propia y de los vecinos siempre que hay un cambio de estado: cuando el
filósofo i
llama a la función toma_palillos(i)
, esta se limita a
adquirir el mutex M
, marcar su estado como HAMBRIENTO
, y llamar a
la función interna actualiza(i)
. Del mismo modo, cuando el filósofo
i
termina de comer y llama a suelta_palillos(i)
, esta función
marca su estado como PENSANDO
e invoca a actualiza()
dos veces:
una para el vecino izquierdo y una para el vecino derecho.
Es importante recalcar que, dado que esta solución está estructurada
como un monitor, ni actualiza()
ni las variables que determinan el
estado del sistema (VC
, M
, estado
) son expuestas a los hilos
invocantes.
La función actualiza(i)
es la que se encarga de verificar (y
modificar, de ser el caso) el estado no sólo del filósofo invocante,
sino que de sus vecinos. La lógica de actualiza()
permite resolver
este problema abstrayendo (y eliminando) a los molestos palillos: en
vez de preocuparse por cada palillo individual, actualiza()
impide
que dos filósofos vecinos estén COMIENDO
al mismo tiempo, y dado que
es invocada tanto cuando un filósofo toma_palillos()
como cuando
suelta_palillos()
, otorga el turno al vecino sin que éste tenga que
adquirir explícitamente el control.
Estas características permite que la lógica central de cada uno de los filósofos se simplifique a sólo:
void *filosofo(void *arg) { int self = *(int *) arg; for (;;) { piensa(self); toma_palillos(self); come(self); suelta_palillos(self); } } int main() { int i; pthread_t th[5]; /* IDs de los hilos filósofos */ pthread_attr_t attr = NULL; palillos_init(); for (i=0; i<5; i++) pthread_create(&th[i], attr, filosofo, (int*) &i); for (i=0; i<5; i++) pthread_join(th[i],NULL); }
Al ser una solución basada en monitor, el código que invoca a
filosofo(i)
no tiene que estar al pendiente del mecanismo de
sincronización empleado, puede ser comprendido más fácilmente por un
lector casual y no brinda oportunidad para que un mal programador
haga mal uso del mecanismo de sincronización.
3.9.2 Memoria transaccional
Un área activa de investigación hoy en día es la de la memoria transaccional. La lógica es ofrecer primitivas que protejan a un conjunto de accesos a memoria del mismo modo que ocurre con las bases de datos, en las que tras abrir una transacción, se puede realizar una gran cantidad (no ilimitada) de tareas, y al terminar con la tarea, confirmarlas (commit) o rechazarlas (rollback) atómicamente — y, claro, el sistema operativo indicará éxito o fracaso de forma atómica al conjunto entero.
Esto facilitaría mucho más aún la sincronización: en vez de hablar de secciones críticas, se podría reintentar la transacción y sólo preocuparse de revisar si fue exitosa o no — por ejemplo:
do { begin_transaction(); var1 = var2 * var3; var3 = var2 - var1; var2 = var1 / var2; } while (! commit_transaction());
Si en el transcurso de la transacción algún otro proceso modifica alguna de las variables, la transacción se abortará, pero se puede volver a ejecutar.
Claro está, el ejemplo presentado desperdicia recursos de proceso (lleva a cabo los cálculos al tiempo que va modificando las variables), por lo cual sería un mal ejemplo de sección crítica.
Hay numerosas implementaciones en software de este principio (Software Transactional Memory, STM) para los principales lenguajes, aunque el planteamiento ideal sigue apuntando a una implementación en hardware. Hay casos en que, sin embargo, esta técnica puede aún llevar a resultados inconsistentes (particularmente si un proceso lector puede recibir los valores que van cambiando en el tiempo por parte de un segundo proceso), y el costo computacional de dichas operaciones es elevado, sin embargo, es una construcción muy poderosa.
4 Bloqueos mutuos
Un bloqueo mutuo puede ejemplificarse con la situación que se presenta cuando cuatro automovilistas llegan al mismo tiempo al cruce de dos avenidas del mismo rango en que no hay un semáforo, cada uno desde otra dirección. Los reglamentos de tránsito señalan que la precedencia la tiene el automovilista que viene más por la derecha. En este caso, cada uno de los cuatro debe ceder el paso al que tiene a la derecha — Y ante la ausencia de un criterio humano que rompa el bloqueo, deberían todos mantenerse esperando por siempre.
Un bloqueo mutuo se presenta cuando (Condiciones de Coffman) (La Red, p. 185):
- Los procesos reclaman control exclusivo de los recursos que piden (condición de exclusión mutua).
- Los procesos mantienen los recursos que ya les han sido asignados mientras esperan por recursos adicionales (condición de espera por).
- Los recursos no pueden ser extraídos de los procesos que los tienen hasta su completa utilización (condición de no apropiatividad).
- Existe una cadena circular de procesos en la que cada uno mantiene a uno o más recursos que son requeridos por el siguiente proceso de la cadena (condición de espera circular).
Las primeras tres condiciones son necesarias pero no suficientes para que se produzca un bloqueo; su presencia puede indicar una situación de riesgo. Sólo cuando se presentan las cuatro se puede hablar de un bloqueo mutuo efectivo.
Otro ejemplo clásico es un sistema con dos unidades de cinta (dispositivos de acceso secuencial y no compartible), en que los procesos A y B requieren de ambas unidades. Dada la siguiente secuencia:
- A solicita una unidad de cinta y se bloquea.
- B solicita una unidad de cinta y se bloquea.
- El sistema operativo otorga la unidad 1 a A y lo vuelve a poner en ejecución.
- A continúa procesando; termina su periodo de ejecución.
- El sistema operativo otorga la unidad 2 a B y lo vuelve a poner en ejecución.
- B solicita otra unidad de cinta y se bloquea.
- El sistema operativo no tiene otra unidad de cinta por asignar. Mantiene a B bloqueado; otorga el control de vuelta a A.
- A solicita otra unidad de cinta y se bloquea
- El sistema operativo no tiene otra unidad de cinta por asignar. Mantiene bloqueado tanto A como B y otorga el control de vuelta a otro proceso (o queda en espera). En este caso ni A ni B serán desbloqueados nunca.
Esquema clásico de un bloqueo mutuo simple: Los procesos A y B esperan mutuamente para el acceso a las unidades de cinta 1 y 2.
Sin una política de prevención o resolución de bloqueos mutuos, no hay modo de que A o B continúen su ejecución. Se verán algunas estrategias para enfrentar a los bloqueos mutuos.
En el apartado de Exclusión mutua, los hilos presentados estaban diseñados para cooperar explícitamente. El rol del sistema operativo va más allá, tiene que implementar políticas que eviten, en la medida de lo posible, dichos bloqueos.
Las políticas tendientes a otorgar los recursos lo antes posible cuando son solicitadas pueden ser vistas como liberales, en tanto que las que controlan más la asignación de recursos, conservadoras.
Espectro liberal—conservador de esquemas para evitar bloqueos
Las líneas principales que describen a las estrategias para enfrentar situaciones de bloqueo (La Red, p. 188) son:
- Prevención
- se centra en modelar el comportamiento del sistema para que elimine toda posibilidad de que se produzca un bloqueo. Resulta en una utilización subóptima de recursos.
- Evasión
- busca imponer condiciones menos estrictas que en la prevención, para intentar lograr una mejor utilización de los recursos. Si bien no puede evitar todas las posibilidades de un bloqueo, cuando éste se produce busca evitar sus consecuencias.
- Detección y recuperación
- el sistema permite que ocurran los
bloqueos, pero busca determinar si ha ocurrido y tomar medidas
para eliminarlo.
Busca despejar los bloqueos presentados para que el sistema continúe operando sin ellos.
4.1 Prevención de bloqueos
Se presentan a continuación algunos algoritmos que implementan la prevención de bloqueos.
4.1.1 Serialización
Una manera de evitar bloqueos por completo sería el que un sistema operativo jamás asignara recursos a más de un proceso a la vez — Los procesos podrían seguir efectuando cálculos o empleando recursos no rivales (que no requieran acceso exclusivo — por ejemplo, empleo de archivos en el disco, sin que exista un acceso directo del proceso al disco), pero sólo uno podría obtener recursos de forma exclusiva al mismo tiempo. Este mecanismo sería la serialización, y la situación antes descrita se resolvería de la siguiente manera:
- A solicita una unidad de cinta y se bloquea
- B solicita una unidad de cinta y se bloquea
- El sistema operativo otorga la unidad 1 a A y lo vuelve a poner en ejecución
- A continúa procesando; termina su periodo de ejecución
- El sistema operativo mantiene bloqueado a B, dado que A tiene un recurso
- A solicita otra unidad de cinta y se bloquea
- El sistema operativo otorga la unidad 2 a A y lo vuelve a poner en ejecución
- A libera la unidad de cinta 1
- A libera la unidad de cinta 2 (y con ello, el bloqueo de uso de recursos)
- El sistema operativo otorga la unidad 1 a B y lo vuelve a poner en ejecución
- B solicita otra unidad de cinta y se bloquea
- El sistema operativo otorga la unidad 2 a B y lo vuelve a poner en ejecución
- B libera la unidad de cinta 1
- B libera la unidad de cinta 2
Si bien la serialización resuelve la situación aquí mencionada, el mecanismo empleado es subóptimo dado que puede haber hasta n-1 procesos esperando a que uno libere los recursos.
Un sistema que implementa una política de asignación de recursos basada en la serialización, si bien no caerá en bloqueos mutuos, sí tiene un peligro fuerte de caer en inanición.
4.1.2 Retención y espera (advance claim)
Otro ejemplo de política preventiva menos conservadora sería la retención y espera o reserva (advance claim): que todos los programas declaren al iniciar su ejecución qué recursos van a requerir. Los recursos son apartados para su uso exclusivo hasta que el proceso termina, pero el sistema operativo puede seguir atendiendo solicitudes que no rivalicen: si a los procesos A y B anteriores se suman procesos C y D, pero requieren otro tipo de recursos, podrían ejecutarse en paralelo A, C y D, y una vez que A termine, podrían continuar ejecutando B, C y D.
El bloqueo resulta ahora imposible por diseño, pero el usuario que inició B tiene una percepción de injusticia dado el tiempo que tuvo que esperar para que su solicitud fuera atendida — de hecho, si A es un proceso de larga duración (incluso si requiere la unidad de cinta sólo por un breve periodo), esto lleva a que B sufra una inanición innecesariamente prolongada.
Además, la implementación de este mecanismo preventivo requiere que el programador sepa por anticipado qué recursos requerirá — y esto en la realidad muchas veces es imposible. Si bien podría diseñarse una estrategia de lanzar procesos representantes (o proxy) solicitando recursos específicos cuando éstos hicieran falta, esto sólo transferiría la situación de bloqueo por recursos a bloqueo por procesos — y un programador poco cuidadoso podría de todos modos desencadenar la misma situación.
4.1.3 Solicitud de una vez (one-shot)
Otro mecanismo de prevención de bloqueos sería que los recursos se otorguen exclusivamente a aquellos procesos que no poseen ningún recurso. Esta estrategia rompería la condición de Coffman espera por, haciendo imposible que se presente un bloqueo.
En su planteamiento inicial, este mecanismo requería que un proceso declarara una sola vez qué recursos requeriría, pero posteriormente la estrategia se modificó, permitiendo que un proceso solicite recursos nuevamente, pero únicamente a condición de que previo a hacerlo renuncien a los recursos que tenían en ese momento — Claro, pueden volver a incluirlos en la operación de solicitud.
Al haber una renuncia explícita, se imposibilita de forma tajante que un conjunto de procesos entre en condición de bloqueo mutuo.
Las principales desventajas de este mecanismo son:
- Requiere cambiar la lógica de programación para tener puntos más definidos de adquisición y liberación de recursos.
- Muchas veces no basta con la readquisición de un recurso, sino que es necesario mantenerlo bloqueado. Volviendo al ejemplo de las unidades de cinta, un proceso que requiera ir generando un archivo largo no puede arriesgarse a soltarla, pues podría ser entregada a otro proceso y corromperse el resultado.
4.1.4 Asignación jerárquica
Otro mecanismo de evasión es la asignación jerárquica de recursos. Bajo este mecanismo, se asigna una prioridad o nivel jerárquico a cada recurso o clase de recursos.10 La condición básica es que, una vez que un proceso obtiene un recurso de determinado nivel, sólo puede solicitar recursos adicionales de niveles superiores. En caso de requerir dos dispositivos ubicados al mismo nivel, tiene que hacerse de forma atómica.
De este modo, si las unidades de cinta tienen asignada la prioridad
,
sólo puede solicitar dos unidades de cinta por medio de
una sóla operación. En caso de también requerir dos unidades de
cinta el proceso
al mismo tiempo, al ser atómicas las
solicitudes, éstas le serán otorgadas a sólo un de los dos procesos,
por lo cual no se presentará bloqueo.
Además, el crear una jerarquía de recursos permitiría ubicar los recursos más escasos o peleados en la cima de la jerarquía, reduciendo las situaciones de contención en que varios procesos compiten por dichos recursos — sólo llegarían a solicitarlos aquellos procesos que ya tienen asegurado el acceso a los demás recursos que vayan a emplear.
Sin embargo, este ordenamiento es demasiado estricto para muchas situaciones del mundo real. El tener que renunciar a ciertos recursos para adquirir uno de menor prioridad y volver a competir por ellos, además de resultar contraintuitivo para un programador, resulta en esperas frustrantes. Este mecanismo llevaría a los procesos a acaparar recursos de baja prioridad, para evitar tener que ceder y re-adquirir recursos más altos, por lo que conduce a una alta inanición.
4.2 Evasión de bloqueos
Para la evasión de bloqueos, el sistema partiría de poseer, además de la información descrita en el caso anterior, información acerca de cuándo requiere un proceso utilizar cada recurso. De este modo, el planificador puede marcar qué orden de ejecución (esto es, qué flujos) entre dos o más procesos son seguros y cuáles son inseguros
Evasión de bloqueos: Los procesos $A$ (horizontal) y $B$ (vertical) requieren del acceso exclusivo a un plotter y una impresora, exponiéndose a bloqueo mutuo.
El análisis de la interacción entre dos procesos se representa como en
la figura PROCtrayprocevasionbloqueo; el avance es marcado
en sentido horizontal para el proceso , o vertical para el proceso
; en un sistema multiprocesador, podría haber avance mutuo, y lo
se indicaría en diagonal.
En el ejemplo presentado, el proceso solicita acceso
exclusivo al scanner durante
y a la impresora
durante
, mientras que
solicita acceso
exclusivo a la impresora durante
y al scanner
durante 4 ≤
.
Al saber cuándo reclama y libera un recurso cada proceso, se puede marcar cuál es el área segura para la ejecución y cuándo se está aproximando a un área de riesgo.
En el caso mostrado, si bien el bloqueo mutuo sólo se produciría
formalmente en cualquier punto11 en , y
(indicado con el
recuadro rojo, Bloqueo mutuo).
Pero la existencia del recuadro que indica el bloqueo mutuo podría ser
revelada con anterioridad: si el flujo entra en el área marcada como
Bloqueo inminente, en color naranja (en y
), resulta ineludible caer en el bloqueo mutuo.
La región de bloqueo inminente ocurre a partir de que obtuvo el
scanner y
obtuvo la impresora. Si en
y
se cede
la ejecución a
por 0.5 unidades, se llegará al punto en que
solicita la impresora (
), y no habrá más remedio que ejecutar
; al avanzar
0.5 unidades requerirá al scanner, y se habrá
desencadenado el bloqueo mutuo. Un caso análogo ocurre, claro
está, si desde el punto de inicio se ejecutara primero
y luego
.
Dadas las anteriores condiciones, y conociendo estos patrones de uso,
el sistema operativo evitará entrar en el área de bloqueo inminente:
el sistema mantendrá en espera a si
mientras
, y mantendrá a
en espera si
cuando
.
La región marcada como inalcanzable en color amarillo, no representa
ningún peligro: sólo indica aquellos estados en que resulta imposible
entrar. Incluso una vez evadido el bloqueo (por ejemplo, si fue
suspendido en
y
avanza hasta pasar
, si el
sistema operativo vuelve a dar la ejecución a
, este sólo podrá
avanzar hasta
, punto en que
solicita la impresora. Para
que
continúe, es necesario avanzar hasta
para que
siga avanzando.
Este mecanismo proveería una mejor respuesta que los vistos en el apartado de prevención de bloqueos, pero es todavía más dificil de aplicar en situaciones reales. Para poder implementar un sistema con evasión de bloqueos, tendría que ser posible hacer un análisis estático previo del código a ejecutar, y tener un listado total de recursos estático. Estos mecanismos podrían ser efectivos en sistemas de uso especializado, pero no en sistemas operativos (o planificadores) genéricos.
4.2.1 Algoritmo del banquero
Edsger Djikstra propuso un algoritmo de asignación de recursos orientado a la evasión de bloqueos a ser empleado para el sistema operativo THE (desarrollado entre 1965 y 1968 en la Escuela Superior Técnica de Eindhoven, Technische Hogeschool Eindhoven), un sistema multiprogramado organizado en anillos de privilegios. El nombre de este algoritmo proviene de que busca que el sistema opere cuidando de tener siempre la liquidez (nunca entrar a estados inseguros) para satisfacer los préstamos (recursos) solicitados por sus clientes (quienes a su vez tienen una línea de crédito pre-autorizada por el banco).
Este algoritmo permite que el conjunto de recursos solicitado por los procesos en ejecución en el sistema sea mayor a los recursos físicamente disponibles, pero a través de un monitoreo y control en su asignación, logra este nivel de sobre-compromiso sin poner en riesgo la operación correcta del sistema.
Este algoritmo debe ejecutarse cada vez que un proceso solicita
recursos; el sistema evita caer en situaciones conducentes a un
bloqueo mutuo ya sea denegando o posponiendo la solicitud. El
requisito particular es que, al iniciar, cada proceso debe anunciar su reclamo máximo (llamese claim()
) al sistema: el número máximo
de recursos de cada tipo que va a emplear a lo largo de su ejecución —
esto sería implementado como una llamada al sistema. Una vez que un
proceso presentó su reclamo máximo de recursos, cualquier llamada
subsecuente a claim()
falla. Claro está, si el proceso anuncia una
necesidad mayor al número existente de recursos de algún tipo, también
falla dado que el sistema no será capaz de cumplirlo.
Para el algoritmo del banquero:
- Estado
- matrices de recursos disponibles, reclamos máximos y asignación de recursos a los procesos en un momento dado.
- Estado seguro
- un estado en el cual todos los procesos pueden ejecutar hasta el final sin encontrar un bloqueo mutuo.
- Estado inseguro
- todo estado que no garantice que todos los procesos puedan ejecutar hasta el final sin encontrar un bloqueo mutuo.
Este algoritmo típicamente trabaja basado en diferentes categorías de recursos, y los reclamos máximos anunciados por los procesos son por cada una de las categorías.
El estado está compuesto, por clase de recursos y por proceso, por:
- Reclamado
- número de instancias de este recurso que han sido reclamadas.
- Asignado
- número de instancias de este recurso actualmente asignadas a procesos en ejecución.
- Solicitado
- número de instancias de este recurso actualmente pendientes de asignar (solicitudes hechas y no cumplidas).
Además de esto, el sistema mantiene globalmente, por clase de recursos:
- Disponibles
- número total de instancias de este recurso disponibles al sistema.
- Libres
- número de instancias de este recurso que no están actualmente asignadas a ningún proceso.
Cada vez que un proceso solicita recursos, se calcula cuál sería el estado resultante de otorgar dicha solicitud, y se otorga siempre que:
- No haya reclamo por más recursos que los disponibles.
- Ningún proceso solicite (o tenga asignados) recursos por encima de su reclamo.
- La suma de los recursos asignados por cada categoría no sea mayor a la cantidad de recursos disponibles en el sistema para dicha categoría.
Formalmente, y volviendo a la definición de un estado seguro: un estado es seguro cuando hay una secuencia de procesos (denominada secuencia segura) tal que:
- Un proceso j puede necesariamente terminar su ejecución, incluso si solicitara todos los recursos que permite su reclamo, dado que hay suficientes recursos libres para satisfacerlo.
- Un segundo proceso k de la secuencia puede terminar si j termina y libera todos los recursos que tiene, porque sumado a los recursos disponibles ahora, con aquellos que liberaría j, hay suficientes recursos libres para satisfacerlo.
- El i-ésimo proceso puede terminar si todos los procesos anteriores terminan y liberan sus recursos.
En el peor de los casos, esta secuencia segura llevaría a bloquear todas las solicitudes excepto las del único proceso que puede avanzar sin peligro en el orden presentado.
Se presnta un ejemplo simplificando, asumiendo sólo una clase de procesos, e iniciando con 2 instancias libres:
Proceso | Asignado | Reclamando |
---|---|---|
A | 4 | 6 |
B | 4 | 11 |
C | 2 | 7 |
A puede terminar porque sólo requiere de 2 instancias adicionales para llegar a las 6 que indica en su reclamo. Una vez que termine, liberará sus 6 instancias. Se le asignan entonces las 5 que solicita a C, para llegar a 7. Al terminar éste, habrá 8 disponibles, y asignándole 7 a B se garantiza poder terminar. La secuencia (A, C, B) es una secuencia segura.
Sin embargo, el siguiente estado es inseguro (asumiendo también dos instancias libres):
Proceso | Asignado | Reclamado |
---|---|---|
A | 4 | 6 |
B | 4 | 11 |
C | 2 | 9 |
A puede terminar, pero no se puede asegurar que B o C puedan hacerlo, porque incluso una vez terminando A, se tendrían sólo 6 instancias no asignadas.
Es necesario apuntar que no hay garantía de que continuar a partir de este estado lleve a un bloqueo mutuo, dado que B o C pueden no incrementar ya su utilización hasta cubrir su reclamo, esto es, puede que lleguen a finalizar sin requerir más recursos, ya sea porque ya los emplearon y liberaron, o porque el uso efectivo de recursos requeridos sencillamente resulte menor al del reclamo inicial.
El algoritmo del banquero, en el peor caso, puede tomar ,
aunque típicamente ejecuta en
. Una implementación de este
algoritmo podría ser:
l = [1, 2, 3, 4, 5]; # Todos los procesos del sistema s = []; # Secuencia segura while ! l.empty? do p = l.select {|id| asignado[id] - reclamado[id] > libres}.first raise Exception, 'Estado inseguro' if p.nil? libres += asignado[p] l.delete(p) s.push(p) end puts "La secuencia segura encontrada es: %s" % s
Hay refinamientos sobre este algoritmo que logran resultados similares, reduciendo su costo de ejecución (se debe recordar que es un procedimiento que puede ser llamado con muy alta frecuencia), como el desarrollado por Habermann (ref: Finkel, p.136).
El algoritmo del banquero es un algoritmo conservador, dado que evita entrar en un estado inseguro a pesar de que dicho estado no lleve con certeza a un bloqueo mutuo. Sin embargo, su política es la más liberal que permite asegurar que no se caerá en bloqueos mutuos, sin conocer el orden y tiempo en que cada uno de los procesos requeriría los recursos.
Una desventaja fuerte de todos los mecanismos de evasión de bloqueos es que requieren saber por anticipado los reclamos máximos de cada proceso, lo cual puede no ser conocido en el momento de su ejecución.
4.3 Detección y recuperación de bloqueos
La detección de bloqueos es una forma de reaccionar ante una situación de bloqueo que ya se produjo y de buscar la mejor manera de salir de ella. La detección de bloqueos podría ser una tarea periódica, y si bien no puede prevenir situaciones de bloqueo, puede detectarlas una vez que ya ocurrieron y limitar su impacto.
Manteniendo una lista de recursos asignados y solicitados, el sistema operativo puede saber cuando un conjunto de procesos están esperándose mutuamente en una solicitud por recursos — al analizar estas tablas como grafos dirigidos, se representará:
- Los procesos, con cuadrados.
- Los recursos, con círculos.
- Puede representarse como un círculo grande a una clase o categoría de recursos, y como círculos pequeños dentro de éste a una serie de recursos idénticos (p. ej. las diversas unidades de cinta)
- Las flechas que van de un recurso a un proceso indican que el recurso está asignado al proceso
- Las flechas que van de un proceso a un recurso indican que el proceso solicita el recurso
Cabe mencionar en este momento que, cuando se consideran categorías de recursos, el tener la representación visual de un ciclo no implica que haya ocurrido un bloqueo — este sólo se presenta cuando todos los procesos involucrados están en espera mutua.
Al emplear categorías de recursos, un ciclo no necesariamente indica un bloqueo
En la figura PROCciclonobloqueo, si bien y
están
esperando que se liberen recursos de tipo
y
,
y
siguen operando normalmente, y es esperable que lleguen a liberar el
recurso por el cual están esperando. En el caso ilustrado, dado que el
bloqueo se presenta únicamente al ser imposible que un proceso
libere recursos que ya le fueron asignados, tendría que
presentarse un caso donde todos los recursos de una misma categoría
estuvieran involucrados en una situación de espera circular, como la
ilustrada a continuación.
Situación en que se presenta espera circular, incluso empleando categorías de recursos
Si se tiene una representación completa de los procesos y recursos en el sistema, la estrategia es reducir la gráfica retirando los elementos que no brinden información imprescindible, siguiendo la siguiente lógica (recordar que representan una fotografía del sistema en un momento dado):
- Se retiran los procesos que no están solicitando ni tienen asignado ningún recurso.
- Para todos los procesos restantes: si todos los recursos que están solicitando pueden ser concedidos (esto es, no están actualmente asignados a otro), se reduce eliminando del grafo al proceso y a todas las flechas relacionadas con éste.
- Si después de esta reducción se eliminan todos los procesos del grafo, entonces no hay interbloqueos y se puede continuar. En caso de permanecer procesos en el grafo, los procesos “irreducibles” constituyen la serie de procesos interbloqueados de la gráfica.
Detección de ciclos denotando bloqueos: Grafo de procesos y recursos en un momento dado
En la gráfica Detección de ciclos denotando bloqueos, se procede así:
- Se reduce por B, dado que actualmente no está esperando a ningún recurso.
- Se reduce por A y F, dado que los recursos por los cuales están esperando quedan libres en ausencia de B.
Y queda un interbloqueo entre C, D y E, en torno a los recursos 4, 5 y 7.
Nótese que reducir un proceso del grafo no implica que éste haya entregado sus recursos, sino únicamente que, hasta donde se tiene conocimiento, tiene posibilidad de hacerlo. Los procesos que estan esperando por recursos retenidos por un proceso pueden sufrir inanición aún por un tiempo indeterminado.
Una vez que un bloqueo es diagnosticado, dado que los procesos no podrán terminar por sí mismos (pues están precisamente bloqueados, su ejecución no avanzará más), hay varias estrategias para la recuperación:
- Terminar a todos los procesos bloqueados. Esta es la técnica más sencilla y, de cierto modo, más justa — Todos los procesos implicados en el bloqueo pueden ser relanzados, pero todo el estado del cómputo que han realizado hasta este momento se perderá.
- Retroceder a los procesos implicados hasta el último punto de control (checkpoint) seguro conocido. Esto es posible únicamente cuando
el sistema implementa esta funcionalidad, que tiene un elevado costo
adicional. Cuando el estado de uno de los procesos depende de
factores externos a éste, es imposible implementar fielmente los
puntos de control.
Podría parecer que retroceder a un punto previo llevaría indefectiblemente a que se repita la situación — pero los bloqueos mutuos requieren de un orden de ejecución específico para aparecer. Muy probablemente, una ejecución posterior logrará salvar el bloqueo — y en caso contrario, puede repetirse este paso.
- Terminar, uno por uno y no en bloque, a cada uno de los procesos
bloqueados. Una vez que se termina uno, se evalúa la situación para
verificar si logró romperse la situación de bloqueo, en cuyo caso la
ejecución de los restantes continúa sin interrupción.
Para esto, si bien podría elegirse un proceso al azar de entre los bloqueados, típicamente se consideran elementos adicionales como:
- Los procesos que demandan garantías de tiempo real son los más sensibles para detener y relanzar
- La menor cantidad de tiempo de procesador consumido hasta el momento. Dado que el proceso probablemente tenga que ser re-lanzado (re-ejecutado), puede ser conveniente apostarle a un proceso que haya hecho poco cálculo (para que el tiempo que tenga que invertir para volver al punto actual sea mínimo).
- Mayor tiempo restante estimado. Si se puede estimar cuánto tiempo de procesamiento queda pendiente, conviene terminar al proceso que más le falte por hacer.
- Menor número de recursos asignados hasta el momento. Un poco como criterio de justicia, y un poco partiendo de que es un proceso que está haciendo menor uso del sistema.
- Prioridad más baja. Cuando hay un ordenamiento de procesos o usuarios por prioridades, siempre es preferible terminar un proceso de menor prioridad o perteneciente a un usuario poco importante que uno de mayor prioridad.
- En caso de contar con la información necesaria, es siempre mejor interrumpir un proceso que pueda ser repetido sin pérdida de información que uno que la cause. Por ejemplo, es preferible interrumpir una compilación que la actualización de una base de datos.
Un punto importante a considerar es cada cuánto debe realizarse la verificación de bloqueos. Podría hacerse:
- Cada vez que un proceso solicite un recurso. pero esto llevaría a un gasto de tiempo en este análisis demasiado frecuente.
- Con una periodicidad fija, pero esto arriesga a que los procesos pasen más tiempo bloqueados.
- Cuando el nivel del uso del CPU baje de cierto porcentaje. Esto indicaría que hay un nivel elevado de procesos en espera.
- Una estrategia combinada.
Por último, si bien los dispositivos aquí mencionados requieren bloqueo exclusivo, otra estragegia es la apropiación temporal: tomar un recurso asignado a determinado proceso para otorgárselo temporalmente a otro. Esto no siempre es posible, claro, y depende fuertemente de la naturaleza del mismo — pero podría, por ejemplo, interrumpirse un proceso que tiene asignada (pero inactiva) a una impresora para otorgársela temporalmente a otro que tiene un trabajo corto pendiente. Esto último, sin embargo, es tan sensible a detalles de cada clase de recursos que rara vez puede hacerlo el sistema operativo — es normalmente hecho de acuerdo entre los procesos competidores, por medio de algún protocolo pre-establecido.
4.4 Algoritmo del avestruz
Una cuarta línea (que, por increíble que parezca, es la más común, empleada en todos los sistemas operativos de propósito general) es el llamado algoritmo del avestruz: ignorar las situaciones de bloqueo (escondiéndose de ellas como avestruz que esconde la cabeza bajo la tierra), esperando que su ocurrencia sea suficientemente poco frecuente, o si ocurre, que su impacto no afecte al sistema.
4.4.1 Justificando a los avestruces
Hay que comprender que esto ocurre porque las condiciones impuestas por las demás estrategias resultan demasiado onerosas, el conocimiento previo resulta insuficiente, o los bloqueos simplemente pueden presentarse ante recursos externos y no controlados (o conocidos siquiera) por el sistema operativo.
Ignorar la posibilidad de un bloqueo cuando su probabilidad es suficientemente baja será preferible para los usuarios (y programadores) ante la disyuntiva de afrontar restricciones para la forma y conveniencia de solicitar recursos.
En este caso, se toma una decisión entre lo correcto y lo conveniente — Un sistema operativo formalmente no debería permitir la posibilidad de que hubiera bloqueos, pero la inconveniencia presentada al usuario sería inaceptable.
Por último, cabe mencionar algo que a lo largo de todo este apartado mencionamos únicamente de forma casual, evadiendo definiciones claras: ¿qué es un recurso? La realidad es que no está muy bien definido. Se podría, como mencionan los ejemplos, hablar de los clásicos recursos de acceso rival y secuencial: impresoras, cintas, terminales seriales, etc. Sin embargo, también se pueden ver como recursos a otras entidades administradas por el sistema operativo — el espacio disponible de memoria, el tiempo de procesamiento, o incluso estructuras lógicas creadas y gestionadas por el sistema operativo, como archivos, semáforos o monitores. Y para esos casos, prácticamente ninguno de los mecanismos aquí analizados resolvería las características de acceso y bloqueo necesarias.
4.4.2 Enfrentando a los avestruces
La realidad del cómputo marca que es el programador de aplicaciones quien debe prever las situaciones de carrera, bloqueo e inanición en su código — El sistema operativo empleará ciertos mecanismos para asegurar la seguridad en general entre los componentes del sistema, pero el resto recae en las manos del programador.
Una posible salida ante la presencia del algoritmo del avestruz es adoptar un método defensivo de programar. Un ejemplo de esto sería que los programas soliciten un recurso pero, en vez de solicitarlo por medio de una llamada bloqueante, hacerlo por medio de una llamada no bloqueante y, en caso de fallar ésta, esperar un tiempo aleatorio e intentar nuevamente acceder al recurso un número dado de veces, y, tras n intentos, abortar limpiamente el proceso y notificar al usuario (evitando un bloqueo mutuo circular indefinido).
Por otro lado, hay una gran cantidad de aplicaciones de monitoreo en espacio de usuario. Conociendo el funcionamiento esperable específico de determinados programas es posible construir aplicaciones que los monitoreen de una forma inteligente y tomen acciones (ya sea alertar a los administradores o, como se lo revisa en la sección PROCdetyrecbloq (Detección y recuperación de bloqueos), abortar -y posiblemente reiniciar- la ejecución de aquellos procesos que no puedan recuperarse).
4.4.3 De avestruces, ingenieros y matemáticos
Esta misma contraposición puede leerse, hasta cierto punto en tono de broma, como un síntoma de la tensión que caracteriza a nuestra profesión: la computación nació como ciencia dentro de los departamentos de matemáticas en diversas facultades, sin embargo, al pasar de los años ha ido integrando cada vez más a la ingeniería. Y el campo, una de las áreas más jóvenes pero al mismo tiempo más prolíficas del conocimiento humano, está en una constante discusión y definición: ¿Qué somos? ¿Matemáticos, ingenieros, o… alguna otra cosa?
La asignación de recursos, pues, puede verse desde el punto de vista matemático: es un problema con un planteamiento de origen, y hay varias estrategias distintas (los mecanismos y algoritmos descritos en esta sección). Pueden no ser perfectos, pero el problema no ha demostrado ser intratable. Y un bloqueo es claramente un error — una situación de excepción, inaceptable. Los matemáticos en nuestro árbol genealógico académico nos llaman a no ignorar este problema, a resolverlo sin importar la complejidad computacional.
Los ingenieros, más aterrizados en el mundo real, tienen como parte
básica de su formación, sí, el evitar defectos nocivos — pero también
contemplan el cálculo de costos, la probabilidad de impacto, los
umbrales de tolerancia… Para un ingeniero, si un sistema típico
corre riesgo de caer en un bloqueo mutuo con una probabilidad , dejando inservibles a dos procesos en un sistema, pero debe
también considerar no sólo las fallas en hardware y en los diferentes
componentes del sistema operativo, sino que en todos los demás
programas que ejecutan en espacio de usuario, y considerando que
prevenir el bloqueo conlleva un costo adicional en complejidad para el
desarrollo o en rendimiento del sistema (dado que perderá tiempo
llevando a cabo las verificaciones ante cualquier nueva solicitud de
recursos), no debe sorprender a nadie que los ingenieros se inclinen
por adoptar la estrategia del avestruz — claro está, siempre que no
haya opción razonable.
5 Otros recursos
- Tutorial de hilos de Perl
http://perldoc.perl.org/perlthrtut.html
John Orwant (1998); The Perl Journal - Python higher level threading interface
http://docs.python.org/2/library/threading.html
Python Software Foundation (1990-2014); Python 2.7.6 Documentation - Spin Locks & Other Forms of Mutual Exclusion
http://www.cs.fsu.edu/~baker/devices/notes/spinlock.html
Theodore P. Baker (2010); Florida State University - Dining philosophers revisited
https://dl.acm.org/citation.cfm?id=101091
Armando R. Gingras (1990), ACM SIGCSE Bulletin
Pies de página:
1 Estas operaciones pueden incluir notificar al proceso padre, cerrar las conexiones de red que tenía activas, liberar memoria, etc.
2 Entendiendo burocrático como el tiempo que se pierde en asuntos administrativos. Recordar que el tiempo que consume el sistema operativo en administración es tiempo perdido para el uso real, productivo del equipo.
3 Se utiliza una versión ficticia del lenguaje C para el ejemplo, evitando entrar en los detalles de sintáxis de un lenguaje concurrente.
4 http://www-users.cs.york.ac.uk/burns/pf.html
5 ¿Qué significa inferior? Las llamadas de sincronización entre hilos deben implementarse por lo menos a nivel del proceso que los contiene; aquellas que se realizan entre procesos independientes, deben implementarse a nivel del sistema operativo. Debe haber un agente más abajo en niveles de abstracción, en control real del equipo de cómputo, ofreciendo estas operaciones.
6 El símil presentado por Dijkstra no es del semáforo vial, con una luz roja y una luz verde (dicho esquema se asemeja al del mutex). La referencia es a la del semáforo de tren, que permite el paso estando arriba, e indica espera estando abajo.
7 Mientras que otras implementaciones permiten que se declaren por separado, pero siempre que se invoca a una variable de condición, debe indicársele qué mutex estará empleando.
8 Esto es, no buscan obtener y conservar los recursos preventivamente, sino que los toman sólo cuando satisfacen por completo sus necesidades.
9 Implementación basada en el ejemplo de Ted Baker, sobre la solución propuesta por Tanenbaum
10 Incluso varios recursos distintos, o varias clases, pueden compartir prioridad, aunque esto dificultaría la programación. Podría verse a la solicitud de una vez como un caso extremo de asignación jerárquica, con una jerarquía plana.
11 En realidad, sólo sería posible tocar el márgen izquierdo o inferior de este bloque: al caer en bloqueo mutuo, avanzar hacia su área interior resultaría imposible.