Logo Studenta

Silberschatz 10a castellano cap7

¡Este material tiene más páginas!

Vista previa del material en texto

Cap 7
Ejemplos de Sincronización
En el Capítulo 6, presentamos el problema de la sección crítica y nos enfocamos en cómo las condiciones de carrera pueden ocurrir cuando múltiples procesos concurrentes comparten datos. Vamos a examinar varias herramientas que abordan el problema de la sección crítica para evitar que ocurran condiciones de carrera. Estas herramientas van desde (el bajo nivel) soluciones de hardware (como barreras de memoria y la operación de comparar e intercambiar , CAS) a herramientas de nivel cada vez más alto (desde cerraduras Lock Mutex hasta semáforos o monitores). También discutimos varios desafíos en el diseño de aplicaciones que están libres de condiciones de carrera, incluidos los riesgos de vida (liveness) como los deadlocks. En este capítulo, aplicamos las herramientas presentadas en el Capítulo 6 a varios problemas clásicos de sincronización. También exploramos los mecanismos de sincronización utilizados por los sistemas operativos Linux, UNIX y Windows, y describimos detalles de la API para sistemas Java y POSIX.
OBJETIVOS DEL CAPÍTULO
• Explicar la sincronización del problema del búfer acotado, y de los problemas de los lectores-escritores y los filósofos cenando. 
• Describir herramientas específicas utilizadas por Linux y Windows para resolver Problemas de sincronización de los procesos.
• Ilustrar cómo POSIX y Java pueden usarse para resolver los problemas de sincronización de procesos.
• Diseñar y desarrollar soluciones para procesar problemas de sincronización utilizando POSIX y API de Java.
7.1 Problemas clásicos de sincronización
En esta sección, presentamos una serie de problemas de sincronización como ejemplos de una gran clase de problemas de control de concurrencia. Estos problemas se usan para probar casi todos los esquemas de sincronización recientemente propuestos. En nuestras soluciones para los problemas, utilizamos semáforos para la sincronización, ya que esa es la forma tradicional de presentar tales soluciones. Sin embargo, implementaciones reales de estas soluciones podrían usar bloqueos mutex en lugar de semáforos binarios.
7.1.1 El problema del búfer acotado
El problema del búfer acotado se introdujo en la Sección 6.1; se usa comúnmente para ilustrar el poder de las primitivas de sincronización. Aquí presentamos una estructura general de este esquema sin comprometernos con ninguna implementación particular. Proporcionamos un proyecto de programación relacionado con los ejercicios del final del capítulo. En nuestro problema, los procesos productor y consumidor comparten las siguientes estructuras de datos:
Suponemos que el grupo consta de n buffers, cada uno capaz de contener un elemento. El semáforo binario mutex proporciona exclusión mutua para los accesos a los ítems de datos del buffer y se inicializa al valor 1. Los semáforos contadores vacío (empty) y lleno (full) muestran la cantidad de vacíos y llenos que tiene el buffer. El semáforo empty se inicializa al valor n; el semáforo full lleno se inicializa al valor 0.
int n;
semáforo mutex = 1;
semáforo empty = n;
semáforo full = 0
while (true) {
     . . .
  /* produce an item in next_produced */
     . . .
  wait(empty);
  wait(mutex);
     . . .
  /* add next_produced to the buffer */
     . . .
  signal(mutex);
  signal(full);
}
Figura 7.1 La estructura del proceso productor.
while (true) {
  wait(full);
  wait(mutex);
    . . .
  /* remove an item from buffer to next_consumed */
    . . .
  signal(mutex);
  signal(empty);
    . . .
  /* consume the item in next_consumed */
    . . .
}
Figura 7.2 La estructura del proceso consumidor.
El código para el proceso del productor se muestra en la Figura 7.1, y el código para el proceso consumidor se muestra en la Figura 7.2. Tenga en cuenta la simetría entre el productor y el consumidor. Podemos interpretar estos códigos como: el productor incrementa los “llenos” para el consumidor y el consumidor incrementa los “vacíos” para el productor cuando extrae del buffer.
7.1.2 El problema de los lectores y escritores
Suponga que una base de datos se compartirá entre varios procesos concurrentes. Es posible que algunos de estos procesos solo quieran leer la base de datos, mientras que otros, capaz que deseen actualizar (es decir, leer y escribir) la base de datos. Distinguimos entre estos dos tipos de procesos al referirse a los primeros como lectores y a estos últimos como escritores. Obviamente, si dos lectores acceden a los datos compartidos simultáneamente, no se producirán efectos adversos. Sin embargo, si un escritor y algún otro proceso (ya sea un lector o un escritor) accede a la base de datos simultáneamente puede llevar al caos.
Para garantizar que no surjan estas dificultades, requerimos que los escritores tengan acceso exclusivo a la base de datos compartida mientras escribe en la base de datos. Este problema de sincronización se conoce como el problema de lectores y escritores. Desde que se declaró originalmente, se ha utilizado para probar casi todas las primitivas de sincronización nuevas. El problema de los lectores y escritores tiene algunas variaciones, todas involucrando prioridades. El más simple, conocido como el primer problema de lectores y escritores, requiere que ningún lector tenga que esperar a menos que un escritor ya haya obtenido permiso para usar el objeto compartido. En otras palabras, ningún lector debe esperar a que otros lectores terminen simplemente porque un escritor está esperando. La segunda variante del problema de lectores-escritores requiere que, una vez que un escritor esté listo, ese escritor realice su escritura tan pronto como sea posible. En otras palabras, si un escritor está esperando acceder al objeto, no se permite que nuevos lectores puedan comenzar a leer.
Una solución a cualquiera de los problemas puede resultar en inanición. En el primer caso, los escritores pueden morir de inanición; en el segundo caso, los lectores pueden morir de inanición. Por esta razón, se han propuesto otras variantes del problema. A continuación, presentamos una solución al primer problema de lectores y escritores.
En la solución al primer problema de lectores-escritores, los procesos lectores comparten las siguientes estructuras de datos:
semáforo rw_mutex = 1;
semáforo mutex = 1;
int read_count = 0;
while (true) {
  wait(rw_mutex);
    . . .
  /* writing is performed */
    . . .
  signal(rw_mutex);
}
Figura 7.3 La estructura de un proceso escritor.
Los semáforos binarios mutex y rw_mutex se inicializan a 1; read_count es un semáforo contador inicializado a 0. El semáforo rw_mutex es común a los procesos lector y escritor. El semáforo mutex se utiliza para garantizar la exclusión mutua cuando se actualiza la variable read_count. La variable read_count realiza un seguimiento de cuántos procesos hay actualmente leyendo el objeto. El semáforo rw_mutex funciona como semáforo de exclusión mutua para los escritores. También lo usa el primer o último lector que ingresa o sale de la sección crítica. No es utilizado por lectores que entran o salen mientras otros lectores están en sus secciones críticas.
El código para un proceso escritor se muestra en la Figura 7.3; el código para un proceso lector se muestra en la Figura 7.4. Tenga en cuenta que, si un escritor está en la sección crítica y n lectores están esperando, entonces un lector está en la cola de rw_mutex, y n – 1 lectores están en cola de mutex. Observe también que, cuando un escritor ejecuta signal(rw_mutex), podemos reanudar la ejecución de los lectores en espera o de un solo escritor en espera. La selección la realiza el planificador.
while (true) {
  wait(mutex);
  read_count++;
  if (read_count == 1)
    wait(rw_mutex);
  signal(mutex);
    . . .
  /* reading is performed */
    . . .
  wait(mutex);
  read_count--;
  if (read_count == 0)
    signal(rw_mutex);
  signal(mutex);
}
Figura 7.4 La estructura de un proceso lector.
El problema de lectores y escritores y sus soluciones se han generalizado para proporcionar bloqueos de lector-escritor en algunos sistemas.La adquisición de un bloqueo lector-escritor requiere especificar el modo del bloqueo que desee: acceso de lectura o escritura. Cuando un proceso solo desea leer datos compartidos, solicita el bloqueo lector-escritor en modo de lectura. Un proceso que desee modificar los datos compartidos debe solicitar el bloqueo en modo de escritura. Se permiten múltiples procesos para adquirir simultáneamente un bloqueo de lector-escritor en modo de lectura, pero solo un proceso puede adquirir el bloqueo para escribir, ya que se requiere acceso exclusivo para los escritores.
Los bloqueos de lector-escritor son más útiles en las siguientes situaciones:
• En aplicaciones donde es fácil identificar cuáles procesos sólo leen datos compartidos y cuáles procesos solo escriben datos compartidos.
• En aplicaciones que tienen más lectores que escritores. Esto es porque los bloqueos de lector-escritor generalmente requieren más sobrecarga que los semáforos o locks (cerraduras) Mutex (exclusión mutua). La mayor concurrencia permite que múltiples lectores compensen la sobrecarga involucrada en configurar el bloqueo lector-escritor.
7.1.3 El problema de los filósofos cenando
Considere cinco filósofos que pasan sus vidas pensando y comiendo. Los filósofos comparten una mesa circular rodeada de cinco sillas, cada una de las cuales pertenece a un filósofo. En el centro de la mesa hay un plato de arroz, y la mesa está puesta con cinco palillos individuales (Figura 7.5). Cuando un filósofo piensa, no hay interacción con sus colegas. De vez en cuando, un filósofo tiene hambre e intenta recoger los dos palillos que están más cerca de él (los palillos que están entre él y sus vecinos izquierdo y derecho). El filósofo puede elegir sólo un palillo a la vez. Obviamente, él no puede recoger un palillo que ya está en manos de un vecino. Cuando un filósofo hambriento tiene a sus dos palillos chinos al mismo tiempo, ella come sin soltar los palillos chinos. Cuando termina de comer, deja los dos palillos y comienza a pensar de nuevo.
El problema de los filósofos cenando se considera un problema de sincronización clásica no por su importancia práctica ni porque a los científicos de la computación no les gustan los filósofos, sino porque es un ejemplo de una gran clase de problemas de control de concurrencia. Es una simple representación de la necesidad para asignar varios recursos entre varios procesos libre de deadlock y de inanición.
Figura 7.5 La situación de los filósofos cenando.
while (true) {
  wait(chopstick[i]);
  wait(chopstick[(i+1) % 5]);
    . . .
  /* eat for a while */
    . . .
  signal(chopstick[i]);
  signal(chopstick[(i+1) % 5]);
    . . .
  /* think for awhile */
    . . .
}
Figura 7.6 La estructura del filósofo i.
7.1.3.1 Solución de semáforo
Una solución simple es representar cada palillo con un semáforo. Un filósofo intenta tomar un palillo ejecutando una operación wait () en ese semáforo. Luego suelta sus palillos ejecutando la operación signal () en los semáforos apropiados. Por lo tanto, los datos compartidos son los 
semaphore chopstick[5];
donde todos los elementos de chopstick se inicializan a 1. La estructura del filósofo i se muestra en la figura 7.6. Aunque esta solución garantiza que no haya dos vecinos comiendo simultáneamente, sin embargo, debe ser rechazada porque podría crear un deadlock. Supongamos que los cinco filósofos tienen hambre al mismo tiempo y cada uno agarra su palillo izquierdo. Todos los elementos del palillo ahora serán iguales a 0. Cuando cada filósofo intente agarrar su palillo derecho, se retrasará por Siempre.
Algunos posibles remedios para el problema del punto muerto son los siguientes:
• Permita que a lo sumo cuatro filósofos estén sentados simultáneamente en la mesa.
• Permita que un filósofo recoja sus palillos solo si ambos están disponible (para hacer esto, debe recogerlos en una sección crítica).
• Use una solución asimétrica, es decir, un filósofo de números impares elige primero su palillo izquierdo y luego su palillo derecho, mientras que un número par de filósofo toma su palillo derecho y luego el palillo izquierdo.
En la Sección 6.7, presentamos una solución al problema de los filósofos cenando, eso asegura que es libre de deadlock. Tenga en cuenta, sin embargo, que cualquier solución satisfactoria al problema de los filósofos cenando debe evitar la posibilidad que uno de los filósofos muera de inanición. Una Solución libre de deadlock no necesariamente elimina la posibilidad de morir de inanición.
7.1.3.2 Solución de monitor
A continuación, ilustramos los conceptos de monitor presentando una solución sin deadlock para el problema de los filósofos cenando Esta solución impone la restricción de que un filósofo puede recoger sus palillos solo si ambos están disponibles. Al codificar esta solución, tenemos que distinguir entre tres estados en los que podemos encontrar un filósofo Para este propósito, presentamos la siguiente estructura de datos:
enum {THINKING, HUNGRY, EATING} estado [5];
El Filósofo i, puedo establecer la variable state [i] = EATING sólo si sus dos vecinos no están comiendo: (state [(i + 4) % 5]! = EATING) y (state [(i + 1) % 5]! = EATING).
También necesitamos declarar
condición SELF [5];
Esto le permite al filósofo i retrasarse cuando tiene hambre pero no puede obtener los palillos que necesita.
Ahora estamos en condiciones de describir nuestra solución al problema de los filósofos cenando. La distribución de los palillos está controlada por el monitor DiningPhilosophers, cuya definición se muestra en la Figura 7.7. Cada filósofo, antes de comenzar a EATING, debe invocar la operación pickup (). Este acto puede resultar en la suspensión del proceso filósofo. Después de la finalización exitosa de la operación, el filósofo puede EATING. Siguiendo esto, el filósofo invoca la operación putdown (). Así, el filósofo i debe invocar las operaciones pickup () y putdown () en la siguiente secuencia:
DiningPhilosophers.pickup (i);
...
EAT
...
DiningPhilosophers.putdown (i);
Es fácil demostrar que esta solución garantiza que no haya dos vecinos EATING simultáneamente y que no se producirán deadlock. Como ya notamos, sin embargo, es posible que un filósofo muera de inanición. No presentamos una solución a este problema, pero dejamos como un ejercicio para ti.
7.2 Sincronización dentro del kernel
A continuación describimos los mecanismos de sincronización proporcionados por Windows y sistemas operativos Linux. Estos dos sistemas operativos proporcionan buenos ejemplos de diferentes enfoques para sincronizar el kernel, y como ud. podrá ver, los mecanismos de sincronización disponibles en estos sistemas difieren en pequeñas diferencias.
Figura 7.7 Una solución de monitor para el problema de los filósofos cenando.
7.2.1 Sincronización en Windows
El sistema operativo Windows es un kernel multihilo que brinda soporte para aplicaciones en tiempo real y múltiples procesadores. Cuando el kernel de Windows accede a un recurso global en un sistema de procesador único, temporalmente enmascara las interrupciones para todos los controladores de interrupciones que también pueden acceder al recurso global. En un sistema multiprocesador, Windows protege el acceso a los recursos usando spinlocks, aunque el kernel usa spinlocks solo para proteger segmentos de código cortos. Además, por razones de eficiencia, el kernel asegura que un hilo nunca será expulsado mientras mantenga un spinlock.
Para la sincronización de hilos fuera del kernel, Windows proporciona el despachador de objetos. Usando un objeto despachador, los hilos se sincronizan de acuerdo con varios mecanismos diferentes, incluidos bloqueos de mutex, semáforos, eventos y temporizadores El sistema protege los datos compartidos al requerir un hilo para obtener la propiedad de un mutex para acceder a los datos y liberar la propiedad cuando haya terminado. Los semáforos se comportan como se describe en la Sección 6.6. Los eventos son similares a las variables condición es decir, pueden notificar un hilo en espera cuando una condicióndeseada ocurre. Finalmente, los temporizadores se utilizan para notificar a un hilo (o más) que una cantidad de tiempo especificada ha expirado.
Los objetos del despachador pueden estar en un estado señalizado o no señalizado. Un objeto en un estado señalizado está disponible, y un hilo no se bloqueará cuando adquiera el objeto. Un objeto en un estado no señalado no está disponible, y un hilo se bloqueará cuando intente adquirir el objeto. Ilustramos las transiciones de estado de un objeto despachador de bloqueo mutex en la Figura 7.8.
Existe una relación entre el estado de un objeto despachador y el estado de un hilo Cuando un hilo se bloquea en un objeto despachador no señalado, su estado cambia de listo a espera, y el hilo se coloca en una cola de espera para ese objeto Cuando el estado del objeto despachador se mueve a señalado, el kernel comprueba si hay hilos esperando en el objeto. Si es así, el kernel mueve un hilo, o posiblemente más, desde el estado de espera al estado de listo, donde pueden reanudar la ejecución. El número de hilos del kernel seleccionados de la cola de espera depende del tipo de objeto despachador por el que cada hilo está esperando. El kernel seleccionará solo un hilo de la cola de espera para un mutex, ya que un objeto mutex puede ser "propiedad" de sólo un único hilo Para un objeto de evento, el kernel seleccionará todos los hilos que están esperando el evento
Podemos usar un bloqueo mutex como ilustración de los objetos del despachador y estados de un hilo. Si un hilo intenta adquirir un objeto despachador de mutex que está en un estado no señalado, ese hilo será suspendido y puesto en espera para el objeto mutex. Cuando el mutex se mueve al estado señalado (porque otro hilo ha liberado el bloqueo en el mutex), el hilo esperando al frente de la cola se moverá del estado de espera al estado listo y adquirirá el bloqueo mutex.
Un objeto de sección crítica es un mutex de modo de usuario que a menudo se puede adquirir y lanzar sin intervención del kernel. En un sistema multiprocesador, un objeto de sección crítica primero usa un spinlock mientras espera que el otro hilo suelte el objeto. Si el tiempo del spin es demasiado largo, el hilo que adquiere se le asignará un mutex del kernel y cederá la CPU. Los objetos de sección crítica son particularmente eficientes porque el mutex del kernel se asigna solo cuando hay contención para el objeto. En la práctica, hay muy poca discusión, por lo que los ahorros son significativos.
Figura 7.8 Objeto Mutex del despachador.
Proporcionamos un proyecto de programación al final de este capítulo que utiliza bloqueos de mutex y semáforos en la API de Windows.
7.2.2 Sincronización en Linux
Antes de la Versión 2.6, Linux era un kernel no apropiativo, lo que significa que un proceso no se puede expulsar de la ejecución en modo kernel, incluso si se trata de un proceso de prioridad más alta que estuvo disponible para ejecutarse. Ahora, sin embargo, el kernel de Linux es completamente apropiativo, por lo que una tarea puede ser expulsada cuando se ejecuta en el kernel.
Linux proporciona varios mecanismos diferentes para la sincronización en el kernel. Como la mayoría de las arquitecturas informáticas proporcionan versiones de instrucciones atómicas para las operaciones matemáticas simples, la técnica más simple de sincronización dentro del kernel de Linux es un número entero atómico, que se representa usando el tipo de datos opaco atomic_t. Como su nombre lo indica, todas las operaciones matemáticas usan los enteros atómicos y se realizan sin interrupción. Para ilustrar, considere un programa que consiste en un counter entero atómico y un entero value.
atomic_t counter;
int value;
El siguiente código ilustra el efecto de realizar algunas operaciones atómicas.
Los enteros atómicos son particularmente eficientes en situaciones donde una variable entera, tal como un contador, debe actualizarse, ya que las operaciones atómicas no requieren sobrecarga de los mecanismos de bloqueo. Sin embargo, su uso está limitado a este tipo de escenarios. En situaciones donde hay varias variables contribuyendo a una posible condición de carrera, las herramientas de bloqueo más sofisticadas deben ser usadas.
Los bloqueos Mutex están disponibles en Linux para proteger las secciones críticas dentro del kernel. Aquí, una tarea debe invocar la función mutex_lock () antes de ingresar a una sección crítica y la función de mutex_unlock () después de salir de la sección crítica. Si el bloqueo de mutex no está disponible, una tarea que llama a mutex_lock () es puesta en un estado de “dormir” y se despierta cuando el propietario de la cerradura (lock) invoca el mutex_unlock ().
Linux también proporciona spinlocks y semáforos (así como versiones lector-escritor de estos dos bloqueos) para el bloqueo en el kernel. En máquinas SMP, lo fundamental es el mecanismo de bloqueo spinlock, y el kernel está diseñado para que el spinlock se mantenga solo por períodos cortos. En máquinas de un solo procesador, como los sistemas embebidos con un solo núcleo de procesamiento, los spinlocks son inapropiados para su uso y se reemplazan por habilitar y deshabilitar la apropiatividad del kernel. Es decir, en sistemas con un solo núcleo de procesamiento, en lugar de mantener un spinlock, el kernel desactiva la característica de apropiatividad; y en lugar de liberar el spinlock, habilita la apropiatividad de kernel. Esto se resume a continuación:
En el kernel de Linux, tanto los spinlocks como los bloqueos mutex no son recursivos, lo que significa que si un hilo ha adquirido uno de estos bloqueos, no puede adquirir la misma cerradura por segunda vez sin liberar primero la cerradura. De lo contrario, el segundo intento de adquirir el bloqueo se bloqueará.
Linux utiliza un enfoque interesante para deshabilitar y habilitar la apropiatividad del kernel. Proporciona dos simples llamadas al sistema: preempt_disable() y prempt_enable () - para deshabilitar y habilitar la apropiatividad del kernel. El kernel es sin embargo, no apropiativo si una tarea que se ejecuta en el kernel mantiene un bloqueo. Al aplicar esta regla, cada tarea en el sistema tiene una estructura de información de hilo que contiene un contador, cuenta de apropiación, para indicar el número de bloqueos retenidos por la tarea. Cuando se adquiere un bloqueo, la cuenta de apropiación se incrementa. Este es decrementado cuando se libera un bloqueo. Si el valor de la cuenta de apropiación para la tarea actualmente ejecutándose en el núcleo es mayor que 0, no es seguro apropiar el kernel, ya que esta tarea actualmente tiene un bloqueo. Si la cuenta de apropiación es 0, el kernel puede ser interrumpido en forma segura (suponiendo que no hay llamadas pendientes de preempt_disable()).
Los Spinlocks, junto con habilitar y deshabilitar la apropiatividad del kernel, se usan en el kernel solo cuando se mantiene un bloqueo (o deshabilita la apropiatividad del kernel) por una corta duración. Cuando un bloqueo debe mantenerse durante un período más largo, los semáforos o las cerraduras mutex son apropiadas para este uso.
7.3 Sincronización POSIX
Los métodos de sincronización discutidos en la sección anterior pertenecen a la sincronización dentro del kernel y, por lo tanto, solo están disponibles para los desarrolladores del kernel. Por el contrario, la API POSIX está disponible para los programadores a nivel de usuario y no es parte de ningún kernel particular del sistema operativo. (En última instancia, debe implementarse utilizando herramientas proporcionadas por el sistema operativo anfitrión.)
En esta sección, cubrimos bloqueos de mutex, semáforos y variables de condición que están disponibles en las API de Pthreads y POSIX. Estas API son ampliamente utilizadas para la creación y sincronización de hilos por desarrolladores en UNIX, Linux y sistemas macOS.
7.3.1 Locks Mutex en POSIX 
Los bloqueos (locks) Mutex representan la técnica fundamental de sincronización utilizada con Pthreads. Se utiliza un bloqueo Mutex para proteger seccionescríticas de código, es decir, un hilo adquiere el bloqueo antes de entrar en una sección crítica y lo libera al salir de la sección crítica. Pthreads utiliza el tipo de datos pthread_mutex_t para los locks Mutex. Se crea un Mutex con la función pthread_mutex_init (). El primer parámetro es un puntero al mutex. Al pasar NULL como segundo parámetro, inicializamos el mutex a sus atributos predeterminados. Esto se ilustra abajo:
El mutex se adquiere y libera con el pthread mutex lock () y Funciones pthread mutex unlock (). Si el bloqueo de mutex no está disponible cuando se invoca pthread_mutex_lock (), el hilo que llama se bloquea hasta que el propietario del lock invoca pthread_mutex_unlock (). El siguiente código ilustra la protección de una sección crítica con bloqueos Mutex:
/ * adquirir el bloqueo de mutex * /
pthread_mutex_lock (& ​​mutex);
/* sección crítica */
/ * liberar el bloqueo de mutex * /
pthread_mutex_unlock (& ​​mutex);
Todas las funciones mutex devuelven un valor de 0 cuando la operación es correcta; si ocurre un error, estas funciones devuelven un código de error distinto de cero.
7.3.2 Semáforos POSIX
Muchos sistemas que implementan Pthreads también proporcionan semáforos, aunque los semáforos no son parte del estándar POSIX y, en cambio, pertenecen a la extensión POSIX SEM. POSIX especifica dos tipos de semáforos: con nombre y sin nombre. Básicamente, los dos son bastante similares, pero difieren en términos de cómo se crean y comparten entre procesos. Porque ambas técnicas son comunes, discutimos ambos aquí. A partir de la versión 2.6 del kernel, los sistemas Linux brindan soporte para semáforos con y sin nombre.
7.3.2.1 Semáforos con nombre en POSIX
La función sem_open () se usa para crear y abrir un semáforo con nombre en POSIX:
#include <semáforo.h>
sem_t * sem;
/ * Crear el semáforo e inicializarlo a 1 * /
sem = sem_open("SEM", O_CREAT, 0666, 1);
En este caso, estamos nombrando el semáforo SEM. La bandera O_CREAT indica que el semáforo se creará si aún no existe. Además, el semáforo da acceso de lectura y escritura para otros procesos (a través del parámetro 0666) y se inicializa a 1.
La ventaja de los semáforos con nombre es que múltiples procesos no relacionados puede usar fácilmente un semáforo común como mecanismo de sincronización al simplemente refiriéndose al nombre del semáforo. En el ejemplo anterior, una vez que se ha creado el semáforo SEM, las llamadas posteriores a sem_open () (con los mismos parámetros) por otros procesos devuelven un descriptor del semáforo existente.
En la Sección 6.6, describimos las operaciones de semáforo clásicas wait () y signal (). POSIX declara estas operaciones sem_wait () y sem_post (), respectivamente. El siguiente ejemplo de código ilustra la protección de una sección crítica usando el semáforo con nombre creado anteriormente:
/ * adquirir el semáforo * /
sem_wait (sem);
/* sección crítica */
/ * liberar el semáforo * /
sem_post (sem);
Tanto los sistemas Linux como macOS proporcionan semáforos con nombre POSIX.
7.3.2.2 POSIX Semáforos sin nombre
Un semáforo sin nombre se crea e inicializa utilizando la función sem_init (), que se pasa tres parámetros:
1. Un puntero al semáforo
2. Una bandera que indica el nivel de compartición
3. El valor inicial del semáforo
y se ilustra en el siguiente ejemplo de programación:
#include <semáforo.h>
sem_t sem;
/ * Crear el semáforo e inicializarlo a 1 * /
sem_init (& sem, 0, 1);
En este ejemplo, al pasar la bandera 0, estamos indicando que este semáforo puede ser compartido solo por hilos pertenecientes al proceso que creó el semáforo. (Si proporcionamos un valor distinto de cero, podríamos permitir que se comparta el semáforo con otros procesos pero en una región de memoria compartida.) Además, inicializamos el semáforo al valor 1.
Los semáforos POSIX sin nombre utilizan las mismas operaciones sem_wait () y sem_post () como los semáforos con nombre. El siguiente ejemplo de código ilustra la protección de una sección crítica que utiliza el semáforo sin nombre creado anteriormente:
/ * adquirir el semáforo * /
sem_wait(& sem);
/* sección crítica */
/ * liberar el semáforo * /
sem_post (& sem);
Al igual que los bloqueos mutex, todas las funciones de semáforo devuelven 0 cuando tienen éxito y distinto de cero cuando se produce una condición de error.
7.3.3 Variables condición POSIX
Las variables condición en Pthreads se comportan de manera similar a las descritas en la Sección 6.7. Sin embargo, en aquella sección, las variables de condición se usan dentro del contexto de un monitor, que proporciona un mecanismo de bloqueo para garantizar la integridad de los datos. Dado que Pthreads generalmente se usa en programas en C, y ya que C no tiene estructuras monitor: logramos el bloqueo asociando una variable de condición con un bloqueo mutex.
Las variables de condición en Pthreads usan el tipo de datos pthread_cond_t y se inicializan utilizando la función pthread_cond_init (). El siguiente código crea e inicializa una variable de condición, así como su bloqueo de exclusión mutua asociado:
pthread_mutex_t mutex;
pthread_cond_t cond_var;
pthread_mutex_init (& mutex, NULL);
pthread_cond_init (& cond var, NULL);
La función pthread_cond_wait () se usa para esperar a una variable condición. El siguiente código ilustra cómo un hilo puede esperar la condición a == b usando una variable de condición Pthread:
pthread_mutex_lock (& ​​mutex);
while (a! = b)
pthread_cond_wait (& cond var, & mutex);
pthread_mutex_unlock (& ​​mutex);
El Mutex asociado con la variable condición debe estar bloqueado antes de que se llame a la función pthread_cond_wait (), ya que se usa para proteger los datos con la variable condición de una posible condición de carrera. Una vez que esta cerradura se adquiere, el hilo puede verificar la condición. Si la condición no es verdadera, el hilo luego invoca pthread_cond_wait (), pasando el Mutex y la variable condición como parámetros. Llamando al pthread_cond_wait () libera el bloqueo de Mutex, permitiendo así que otro hilo acceda a los datos compartidos y posiblemente actualice su valor para que la variable condición se evalúe como verdadera. (Para proteger contra errores del programa, es importante colocar la cláusula condicional dentro de un bucle para que la condición se vuelva a verificar después de ser señalada).
Un hilo que modifica los datos compartidos puede invocar la función pthread_cond_signal(), señalando así a un hilo que espera en la variable condición. Esta se ilustra a continuación:
pthread_mutex_lock (& ​​mutex);
a = b;
pthread_cond_signal (& cond var);
pthread_mutex_unlock (& ​​mutex);
Es importante tener en cuenta que la llamada a pthread_cond_signal () no libera el bloqueo de mutex. Es la llamada posterior a pthread_mutex_unlock () la que libera el mutex. Una vez que se libera el bloqueo de mutex, el hilo señalado se convierte en el propietario del Mutex y devuelve el control de la llamada a pthread_cond_wait ().
Proporcionamos varios problemas de programación y proyectos al final de este capítulo que utiliza Mutex de Pthreads y variables condición, así como semáforos POSIX 
7.4 Sincronización en Java
El lenguaje Java y su API han proporcionado una gran compatibilidad para la sincronización de hilos desde los orígenes del lenguaje. En esta sección, primero cubrimos los Monitores Java, el mecanismo de sincronización original de Java. Luego cubrimos tres mecanismos adicionales que se introdujeron en la Versión 1.5: bloqueos reentrantes, semáforos y variables condición. Los incluimos porque representan los mecanismos de bloqueo y sincronización más comunes. sin embargo, API de Java proporciona muchas características que no cubrimos en este texto, por ejemplo, soporte para variables atómicas y la instrucción CAS, y alentamos a los lectores interesados ​​en consultar la bibliografía para más información.
7.4.1 Monitores Java
Java proporciona un mecanismo de simultaneidad similar a un monitor para la sincronización de hilos. Ilustramos este mecanismo con la clase BoundedBuffer(Figura 7.9), que implementa una solución al problema del búfer acotado en el que el productor y el consumidor invocan los métodos insert () y remove (), respectivamente.
Figura 7.9 Búfer limitado usando la sincronización Java.
Cada objeto en Java tiene asociado un único bloqueo. Cuando un método es declarado para ser sincronizado, el llamar al método requiere poseer el bloqueo para el objeto. Declaramos un método sincronizado colocando la palabra clave synchronized en la definición del método, como los métodos insert () y remove () en la clase BoundedBuffer.
Invocar un método synchronized requiere poseer el bloqueo en un objeto instancia de BoundedBuffer. Si el bloqueo ya es propiedad de otro hilo, el hilo que llama al método synchronized se bloquea y se coloca en la entrada establecida para el bloqueo del objeto. El conjunto de entrada representa el conjunto de hilos que esperan a que La cerradura esté disponible. Si el bloqueo está disponible cuando se llama al método synchronized, el hilo de llamada se convierte en el propietario del bloqueo del objeto y puede ingresar al método. El bloqueo se libera cuando el hilo sale del método. Si la entrada establecida para el bloqueo no está vacía cuando se libera el bloqueo, la JVM selecciona arbitrariamente un hilo de este conjunto para que sea el propietario del bloqueo. (Cuando decimos "arbitrariamente", queremos decir que la especificación no requiere que los hilos de este conjunto se organicen en un orden particular. Sin embargo, en la práctica, la mayoría de las máquinas virtuales ordenan hilos de acuerdo con una política FIFO.) La Figura 7.10 ilustra cómo funciona el conjunto de entrada.
Además de tener un bloqueo, cada objeto también tiene asociado un grupo de espera compuesto por un conjunto de hilos. Este conjunto de espera está inicialmente vacío. Cuando un hilo ingresa en un método sincronizado, posee el bloqueo para el objeto. Sin embargo, este hilo puede determinar que no puede continuar debido a que una determinada condición no se ha cumplido. Eso sucederá, por ejemplo, si el productor llama al método insert () y el búfer está lleno. El hilo luego liberará el bloqueo y esperará hasta que se cumpla la condición que le permitirá continuar.SINCRONIZACIÓN DE BLOQUES
La cantidad de tiempo entre el momento en que se adquiere un lock y cuando se lo libera se define como el alcance del lock. Un método synchronized que solo tiene un pequeño porcentaje de su código que manipula datos compartidos puede generar un alcance demasiado grande. En tal caso, puede ser mejor sincronizar solo el bloque de código que manipula datos compartidos que sincronizar todo el método. Tal diseño da como resultado un alcance de bloqueo más pequeño. Por lo tanto, además de declarar métodos sincronizados, Java también permite la sincronización de bloques, como se ilustra a continuación. Solo el acceso al código de la sección crítica requiere propiedad del bloqueo de objeto para este objeto.
public void someMethod () {
/ * sección no crítica * /
synchronized (this) {
/* sección crítica */
}
/ * sección restante * /
}
Figura 7.10 Conjunto de entrada para un lock.
Cuando un hilo llama al método wait (), sucede lo siguiente:
1. El hilo libera el bloqueo para el objeto.
2. El estado del hilo se establece en bloqueado.
3. El hilo se coloca en el conjunto de espera para el objeto.
Considere el ejemplo de la figura 7.11. Si el productor llama al método insert () y ve que el búfer está lleno, éste llama al método wait (). Esta llamada libera el bloqueo, bloquea al productor y lo pone en el grupo de espera para el objeto. Debido a que el productor ha liberado el bloqueo, el consumidor finalmente ingresa al método remove (), donde libera espacio en el búfer para el productor. La Figura 7.12 ilustra los conjuntos de entrada y espera para un bloqueo. (Tenga en cuenta que aunque wait () puede lanzar (throw) una InterruptedException, elegimos ignorar el código por claridad y simplicidad.)
¿Cómo indica el hilo del consumidor que el productor puede proceder ahora? Ordinariamente, cuando un hilo sale de un método synchronized, el hilo que sale, libera solo el bloqueo asociado con el objeto, posiblemente liberando un hilo desde el conjunto de entrada y dándole la propiedad de la cerradura. Sin embargo, al final de los métodos insert () y remove (), tenemos una llamada al método notify (), que hace lo siguiente:
1. Selecciona un hilo arbitrario T de la lista de hilos del conjunto de espera 
2. Mueve T del conjunto de espera al conjunto de entrada
3. Establece el estado de T (de bloqueado a ejecutable)
T ahora es elegible para competir por el bloqueo con los otros hilos. Una vez que T recupera el control de la cerradura (lock), retorna llamando a wait (), donde puede verificar nuevamente el valor de count. (Nuevamente, la selección de un hilo arbitrario está de acuerdo con la especificación de Java; en la práctica, la mayoría de las máquinas virtuales Java ordena los hilos en el conjunto de espera de acuerdo con una política FIFO.)
Figura 7.11 métodos insert () y remove () usando wait () y notify ().
Figura 7.12 Conjuntos de entrada y espera.
A continuación, describimos los métodos wait () y notify () en términos de los métodos mostrados en la Figura 7.11. Suponemos que el búfer está lleno y el bloqueo para el objeto está disponible.
• El productor llama al método insert (), ve que el bloqueo está disponible, y entra en el método. Una vez en el método, el productor determina que el búfer está lleno y llama a wait(). La llamada a wait () libera el bloqueo para el objeto, establece el estado del productor en bloqueado y coloca al productor en la espera establecida para el objeto.
• El consumidor finalmente llama e ingresa al método remove (), ya que El bloqueo para el objeto ya está disponible. El consumidor extrae un artículo del búfer y las llamadas a notify () Tenga en cuenta que el consumidor aún posee la cerradura para el objeto
• La llamada a notify() extrae al productor del conjunto de espera para el objeto, mueve al productor al conjunto de entrada y establece el estado del productor listo para ejecutar
• El consumidor sale del método remove (). Al Salir de este método libera el bloqueo para el objeto.
• El productor intenta recuperar el bloqueo y tiene éxito. Reanuda la ejecución de la llamada a wait(). El productor prueba el ciclo while, determina que esa pieza está disponible en el búfer y continúa con el resto del método insert (). Si no hay hilo en el conjunto de espera para el objeto, la llamada a notify() se ignora. Cuando el productor sale del método, libera el bloqueo para el objeto.
Los mecanismos wait () y notify () de synchronized han sido parte de Java desde sus orígenes. Sin embargo, las revisiones posteriores de la API de Java introdujeron muchos mecanismos de bloqueo más flexibles y robustos, algunos de los cuales examinamos en las siguientes secciones.
7.4.2 Locks reentrantes
Quizás el mecanismo de bloqueo más simple disponible en la API es ReentrantLock. En muchos sentidos, un ReentrantLock actúa como la declaración synchronized descrita en la Sección 7.4.1: un ReentrantLock es propiedad de un solo hilo y se usa para proporcionar acceso mutuamente exclusivo a un recurso compartido. Sin embargo, el ReentrantLock proporciona varias características adicionales, como establecer un parámetro de equidad, que favorece la cesión del bloqueo al hilo que más espera. (La especificación para la JVM no indica que los hilos en el conjunto de espera para un objeto de bloqueo se deben solicitar de una manera específica).
Un hilo adquiere un lock ReentrantLock invocando su método lock (). Si el lock está disponible, -- o si el hilo que invoca lock() ya lo posee, es por eso que se denomina reentrante—lock() asigna como propietario al hilo que invoca y se le devuelve el control. Si el bloqueo no está disponible, el hilo que invoca se bloquea hasta que finalmente se le asigna el lock cuando su propietario invoca unlock(). ReentrantLock implementa la interfaz del Lock; y se usa dela siguiente manera:
Lock key= new ReentrantLock ();
key.lock ();
try {
/* sección crítica */
}
finally {
key.unlock ();
}
La manera de usar try y finally requiere un poco de explicación. Si el lock se adquiere mediante el método lock (), es importante que el lock se libere de forma similar. Al encerrar unlock () en una cláusula finally, nos aseguramos que el bloqueo se libere una vez que se complete la sección crítica o si una excepción ocurre dentro del bloque try. Tenga en cuenta que no hacemos la llamada a lock () dentro de la cláusula try, ya que lock () no arroja excepción prevista. Considera que ocurre si colocamos lock () dentro de la cláusula try y se produce una excepción no prevista cuando se invoca lock () (como OutofMemoryError): La cláusula finally llama a unlock(), que luego arroja IllegalMonitorStateException, ya que el lock nunca se adquirió. Esta IllegalMonitorStateException reemplaza la excepción no verificada que ocurrió cuando se invocó lock (), ocultando así la razón por la cual el programa Inicialmente falló.
Mientras que un ReentrantLock proporciona exclusión mutua, puede ser una estrategia demasiado conservadora si varios hilos que comparten datos solo leen, pero no escriben, (Describimos este escenario en la Sección 7.1.2.). Para abordar esta necesidad, la API de Java también proporciona un ReentrantReadWriteLock, que es un bloqueo que permite múltiples lectores concurrentes pero solo un escritor.
7.4.3 Semáforos
La API de Java también proporciona un semáforo de conteo, como se describe en la Sección 6.6. El constructor para el semáforo aparece como
Semaphore (int value);
donde value especifica el valor inicial del semáforo (un valor negativo está permitido). El método acquire() arroja una InterruptedException si el hilo adquiere es interrumpido. El siguiente ejemplo ilustra el uso de un semáforo para la exclusión mutua:
Semaphore sem = new Semaphore (1);
try {
sem.acquire ();
/* sección crítica */
}
catch (InterruptedException ie) {}
finally {
sem.release ();
}
Tenga en cuenta que colocamos la llamada a release () en la cláusula finally para garantizar que se libera el semáforo.
7.4.4 Variables condición
La última utilidad que cubrimos en la API de Java es la variable condición. Tal como ReentrantLock es similar a la declaración syncronized de Java, las variables condición proporcionan una funcionalidad similar a los métodos wait () y notify (). Por lo tanto, para proporcionar exclusión mutua, se debe asociar una variable condición con un lock reentrante.
Creamos una variable condición creando primero un ReentrantLock e invocando su método newCondition (), que devuelve un objeto Condition que representa la variable condición para el ReentrantLock asociado. Esto es ilustrado en las siguientes declaraciones:
Lock key = new ReentrantLock ();
Condition condVar = key.newCondition ();
Una vez que se ha obtenido la variable condición, podemos invocar sus métodos await () y signal (), que funcionan de la misma manera que los comandos wait() y signal() descritos en la Sección 6.7.
Recuerde que con los monitores como se describe en la Sección 6.7, las operaciones wait () y signal () se pueden aplicar a variables condición con nombre, lo que permite que un hilo espere una condición específica o para ser notificado cuando una condición específica se ha cumplido A nivel de lenguaje, Java no proporciona soporte para Variables condición con nombres. Cada monitor Java está asociado con solo una variable condición sin nombre y las operaciones wait () y notify () descritas en la Sección 7.4.1 se aplican sólo a esta variable de condición única. Cuando un hilo Java es despertado a través de notify (), no recibe información de por qué fue despertado; depende del hilo reactivado comprobar por sí mismo si la condición por lo que estaba esperando se ha cumplido. Las variables condición resuelven notificando a un hilo específico.
Ilustramos con el siguiente ejemplo: supongamos que tenemos cinco hilos, numerados del 0 al 4, y una variable turno compartida que indica cuál hilo tiene turno. Cuando un hilo desea trabajar, llama al método doWork () en la figura 7.13, pasando su número de hilo. Solo el hilo cuyo valor de threadNumber coincide con el valor del turno puede continuar; otros hilos deberán esperar su turno.
Figura 7.13 Ejemplo usando variables de condición en Java.
También debemos crear un ReentrantLock y cinco variables de condición (que representan las condiciones que los hilos están esperando) para señalar el hilo cuyo turno es el siguiente. Esto se muestra a continuación:
Lock lock = new ReentrantLock ();
Condition [] condVars = new Condition [5];
For (int i = 0; i <5; i ++)
condVars [i] = lock.newCondition ();
Cuando un hilo ingresa a doWork (), invoca el método await () en su variable condición asociada y si su threadNumber no es igual a turn, tendrá que esperar para reanudar cuando sea señalizado por otro hilo. Después de que un hilo haya completado su trabajo, señala la variable condición asociada con el hilo que sigue.
Es importante tener en cuenta que doWork () no necesita ser declarado synchronized, ya que ReentrantLock proporciona exclusión mutua. Cuando un hilo invoca await () en la variable condición, libera el ReentrantLock asociado, permitiendo que otro hilo adquiera el bloqueo de exclusión mutua.
De manera similar, cuando se invoca signal (), solo se señala la variable de condición; el bloqueo (lock) se libera invocando unlock().
7.5 Enfoques alternativos
Con la aparición de sistemas multinúcleo, ha aumentado la presión para desarrollar aplicaciones concurrentes que aprovechen el procesamiento de múltiple núcleos Sin embargo, las aplicaciones concurrentes presentan un mayor riesgo de condiciones de carrera y riesgos de vida (liveness) como el deadlock. Tradicionalmente, técnicas como bloqueos (lock) de mutex, semáforos y monitores se han utilizado para abordar estos problemas, pero a medida que aumenta el número de núcleos de procesamiento, se vuelve cada vez más difícil diseñar aplicaciones multiproceso que estén libres de condiciones de carrera y deadlock. En esta sección, exploramos varias características proporcionadas tanto en lenguajes de programación como en hardware que admiten el diseño de hilos en aplicaciones concurrentes
7.5.1 Memoria transaccional
Muy a menudo en informática, se pueden usar ideas de un área de estudio para resolver problemas en otras áreas. El concepto de memoria transaccional se originó en teoría de bases de datos, por ejemplo, pero proporciona una estrategia para la sincronización de procesos. Una transacción de memoria es una secuencia de operaciones de memoria de lectura-escritura que son atómicas. Si se completan todas las operaciones en una transacción, la transacción de memoria está confirmada. De lo contrario, las operaciones deben ser abortadas y volver atrás. Los beneficios de la memoria transaccional se pueden obtener a través de características agregadas a un lenguaje de programación.
Considere un ejemplo. Supongamos que tenemos una función update () que modifica datos compartidos. Tradicionalmente, esta función se escribiría utilizando Locks mutex (o semáforos) como los siguientes:
void update()
{
acquire();
/ * modificar datos compartidos * /
release();
}
Sin embargo, el uso de mecanismos de sincronización como bloqueos mutex y semáforos implican muchos problemas potenciales, incluido el deadlock. Además, a medida que aumenta el número de hilos, el bloqueo tradicional no escala también, porque el nivel de contención entre hilos para la propiedad de bloqueo se vuelve muy alto.
Como alternativa a los métodos de bloqueo tradicionales, las nuevas características que toman la ventaja de la memoria transaccional se pueden agregar a un lenguaje de programación.
En nuestro ejemplo, supongamos que agregamos la construcción atómica {S}, que asegura que las operaciones en S se ejecutan como una transacción. Esto nos permite reescribir la función update () funcione de la siguiente manera:
void update()
{
atomic {
/ * modificar datoscompartidos * /
}
}
La ventaja de utilizar dicho mecanismo en lugar de locks es que el sistema de memoria transaccional, no el desarrollador, es responsable de garantizar la atomicidad. Además, debido a que no hay Locks involucrados, el deadlock es imposible. Además, un sistema de memoria transaccional puede identificar cuáles declaraciones en bloques atómicos se pueden ejecutar simultáneamente, por ejemplo, acceso de lectura a una variable compartida. Es, por supuesto, posible para un programador identificar estas situaciones y usar bloqueos de lector-escritor, pero la tarea se convierte cada vez más difícil a medida que crece el número de hilos dentro de una aplicación.
La memoria transaccional se puede implementar en software o hardware. 
La memoria transaccional de software (STM), como su nombre indica, implementa memoria transaccional exclusivamente en software: no se necesita hardware especial. STM funciona insertando código de instrumentación dentro de los bloques de transacciones. El compilador inserta el código y gestiona cada transacción examinando dónde las declaraciones pueden ejecutarse simultáneamente y dónde el bloqueo de bajo nivel específico es necesario. 
La memoria transaccional de hardware (HTM) utiliza jerarquías de caché de hardware y protocolos de coherencia de caché para gestionar y resolver conflictos relacionados con datos compartidos que residen en cachés de procesadores separados. HTM no requiere instrumentación especial de código y, por lo tanto, tiene menos sobrecarga que STM. Sin embargo, HTM requiere que las jerarquías de caché existentes y los protocolos de coherencia de caché sean modificados para soportar la memoria transaccional.
La memoria transaccional ha existido durante varios años sin una amplia difusión e implementación. Sin embargo, el crecimiento de los sistemas multinúcleo y el énfasis en la programación concurrente y paralela ha provocado una cantidad significativa de investigación en esta área por parte de académicos y vendedores comerciales de software y hardware.
7.5.2 OpenMP
En la Sección 4.5.2, proporcionamos una descripción general de OpenMP y su soporte de programación paralela en un entorno de memoria compartida. Recordemos que OpenMP incluye un conjunto de directivas de compilación y una API. Cualquier código que siga la directiva del compilador #pragma omp parallel se identifica como una región paralela y se gestiona con un número de hilos igual al número de núcleos de procesamiento en el sistema. La ventaja de OpenMP (y herramientas similares) es que la creación y gestión de hilos son manejados por la biblioteca OpenMP y no son responsabilidad de los desarrolladores de aplicaciones.
Junto con su directiva de compilador #pragma omp parallel, OpenMP proporciona la directiva del compilador #pragma omp critical, que especifica la región de código que sigue la directiva como una sección crítica en la que solo un hilo puede estar activo a la vez. De esta manera, OpenMP proporciona soporte para garantizar que los hilos no generan condiciones de carrera.
Como ejemplo del uso de la directiva del compilador de la sección crítica, primero supongamos que la variable compartida counter se puede modificar en la update() y funciona de la siguiente manera:
void update (int value)
{
counter + = value;
}
Si la función update () puede formar parte de - o invocarse desde ella - una región paralela, una condición de carrera es posible en la variable counter.
La directiva del compilador de la sección crítica se puede utilizar para remediar esta carrera condición y se codifica de la siguiente manera:
void update (int value)
{
#pragma omp critical
{
Counter + = value;
}
}
La directiva del compilador de la sección crítica se comporta como un semáforo binario o bloqueo de mutex, asegurando que solo un hilo a la vez esté activo en la sección crítica. Si un hilo intenta entrar en una sección crítica cuando otro hilo está actualmente activo en esa sección (es decir, posee la sección), el hilo de llamada es bloqueado hasta que el hilo propietario salga. Si se deben usar varias secciones críticas, A cada sección crítica se le puede asignar un nombre separado, y una regla puede especificar que no más de un hilo puede estar activo en una sección crítica del mismo nombre al mismo tiempo.
Una ventaja de usar la directiva del compilador de sección crítica en OpenMP es que generalmente se considera más fácil de usar que los bloqueos mutex estándar. Sin embargo, una desventaja es que los desarrolladores de aplicaciones aún deben identificar posibles condiciones de carrera y proteger adecuadamente los datos compartidos utilizando las directivas del compilador. Además, porque la directiva del compilador de la sección crítica se comporta como un Lock Mutex, el deadlock todavía es posible cuando se identifican dos o más secciones críticas.
7.5.3 Lenguajes de programación funcional
Los lenguajes de programación más conocidos, como C, C ++, Java y C #, son conocidos como lenguajes imperativos (o procedurales). Los lenguajes imperativos son utilizados para implementar algoritmos basados ​​en estado. En estos lenguajes, el flujo del algoritmo es crucial para su correcto funcionamiento, y el estado está representado con variables y otras estructuras de datos. Por supuesto, el estado del programa es mutable (cambiante), ya que a las variables se les pueden asignar diferentes valores a lo largo del tiempo.
Con el énfasis actual en la programación concurrente y paralela en sistemas multinúcleos, ha habido un mayor enfoque en los lenguajes de programación funcionales, que siguen un paradigma de programación muy diferente del ofrecido por lenguajes imperativos. La diferencia fundamental entre lenguajes imperativos y funcionales es que los lenguajes funcionales no mantienen el estado. Es decir, una vez que una variable ha sido definida y asignada un valor, su valor es inmutable, no puede cambiar. Porque los lenguajes funcionales no permiten estados mutables, no necesitan preocuparse por cuestiones como las condiciones de carrera y deadlock. Esencialmente, la mayoría de los problemas abordados en este capítulo son inexistentes en lenguajes funcionales.
Varios lenguajes funcionales están actualmente en uso, y mencionamos brevemente dos de ellos aquí: Erlang y Scala. El lenguaje Erlang ha ganado una significativa atención debido a su soporte para la concurrencia y la facilidad con la que puede ser utilizado para desarrollar aplicaciones que se ejecutan en sistemas paralelos. Scala es un lenguaje funcional que también está orientado a objetos. De hecho, gran parte de la sintaxis de Scala es similar a los populares lenguajes orientados a objetos Java y C #. Lectores interesados en Erlang y Scala, y en más detalles sobre lenguajes funcionales en general, se recomienda consultar la bibliografía al final de este capítulo para referencias adicionales
Resumen
• Los problemas clásicos de sincronización de procesos incluyen el búfer acotado, Problemas de lectores, escritores y filósofos. Soluciones a estos problemas se pueden desarrollar utilizando las herramientas presentadas en el Capítulo 6, que incluyen bloqueos (lock) de mutex, semáforos, monitores y variables de condición.
• Windows usa objetos despachadores tal como eventos para implementar herramientas de sincronización de procesos.
• Linux utiliza una variedad de enfoques para protegerse contra las condiciones de carrera, incluyendo variables atómicas, spinlocks y bloqueos mutex.
• La API POSIX proporciona bloqueos de mutex, semáforos y variables de condición. POSIX proporciona dos formas de semáforos: con nombre y sin nombre. Varios procesos no relacionados pueden acceder fácilmente al mismo semáforo con nombre simplemente refiriéndose a su nombre. Los semáforos sin nombre no se pueden compartir tan fácilmente, y requieren colocar el semáforo en una región de memoria compartida.
• Java tiene una API y amplia biblioteca para la sincronización. Las herramientas disponibles incluyen monitores (que se proporcionan a nivel del lenguaje), así comobloqueos reentrantes, semáforos y variables de condición (que son compatibles con las API).
• Los enfoques alternativos para resolver el problema de la sección crítica incluyen memoria transaccional, OpenMP y lenguajes funcionales. Los Lenguajes funcionales son particularmente interesantes, ya que ofrecen un paradigma de programación diferente de los lenguajes de procedimiento. A diferencia de los lenguajes de procedimiento, los lenguajes funcionales no mantienen el estado y, por lo tanto, generalmente son inmunes de las condiciones de carrera y secciones críticas.

Continuar navegando