Capitulo 11. Sicronizadores y Eventos.

En este capítulo.

Mas mecanismos de sicronización.

El material introducido en capítulos anteriores ha cubierto todos los mecanismos básicos de sincronización. En el conjunto, los semáforos y los mutexes permiten que el programador cree el resto de los mecanismos de sincronización, no obstante con un cierto esfuerzo. A pesar de esto, hay algunas situaciones que son muy comunes adentro la programación multihilo, pero no fáciles de ocuparse de usar los mecanismos demostrados hasta ahora. Dos nuevas primitivas
serán introducidos para solucionar estos problemas: La Multi Read Exclusive Write Synchronizer, y la Event. La primera viene en algunas versiones de Delphi como parte de la VCL, y la última es proporcionada por el Win32 API.

Cuando la eficiencia óptima es imprescindible.

Hasta ahora, todas las operaciones en un valor compartido han sido mutuamente exclusiva. Todas las operaciones de lectura y escritura han sido protegidas hasta el punto de solamente una lectura o una escritura suceda en cualquier momento. Sin embargo, en muchas situaciones del mundo real donde un recurso crítico se debe acceder con frecuencia por una gran cantidad de hilos, esto puede resultar ser ineficiente. El bloqueo exclusivo es, de hecho, más cuidadoso que absolutamente
necesario. Recordar el capítulo 6, observa que la sincronización mínima requerida es ésa:

Al permitr un mínimo control absoluto de la concurrencia, es posible producir un aumento significativo en el funcionamiento. Se observan los mejores aumentos de funcionamiento cuando muchas operaciones de lectura ocurren
de un número relativamente grande de hilos, operaciones de escritura son relativamente infrecuentes, y solamente un número pequeño de hilos las realizan.

Estas condiciones permanecen en numerosas situaciones del mundo real. Por ejemplo, la base de datos de stock para una compañía puede contener una gran cantidad de artículos, y numerosas lecturas pueden ocurrir para calcular la disponibilidad de ciertas mercancías. Sin embargo, la base de datos es solamente actualizada cuando los artículos se piden o se envían realmente. Tambien, los registros de miembros de un club se pueden comprobar muchas veces para encontrar direcciones, enviar correos y suscripciones, pero los miembros se unen al club, lo dejan o cambian sus direcciones relativamente muy poco. Lo mismo ocurre en situaciones de computación: las listas maestras de recursos globales en un programa se pueden leer a menudo, pero se escriben con poca frecuencia. El nivel requerido de control de concurrencia se proporciona con una primitiva conocida como MultipleReadExclusiveWriteSynchronizer, en adelante referenciado como MREWS.

La mayoría de los sincronizadores soportan cuatro operaciones principales: StartRead, StartWrite, EndRead y EndWrite. Un hilo llama a StartRead en un sincronizador particular cuando desea leer el recurso compartido. Entonces realizará unas o más operaciones de lectura, que se garantizan serán atómicas y consistentes. Una vez que haya acabado la lectura, llama a EndRead. Si dos operaciones de lectura se realizan entre un par dado de llamadas a StartRead y EndRead, los datos obtenidos en esos pares son siempre consistentes: ninguna operación de escritura habrá ocurrido entre las llamadas a StartRead y EndRead.

Asimismo, al realizar una serie de operaciones de escritura, un hilo llamará StartWrite. Puede entonces realizar una o más operaciones de escritura, y puede estar seguro que todas las operaciones de escritura son atomicas. Después de las operaciones de escritura, el hilo llama a EndWrite. Las operaciones de escritura no serán sobreescritas por otras operaciones, y ninguna lectura obtendrá resultados inconsistentes debido a estas operaciones cuando están en progreso.

Un MREWS Simple.

Hay varias maneras de implementar un MREWS. La VCL contiene una implementación bastante sofisticada. Para familiarizar al usuario con los principios basicos, aquí es una implementación más simple pero levemente menos funcional usando los semáforos. El MREWS simple contiene los puntossiguientes:

La lectura y la escritura se pueden resumir así:

Hay dos etapas en la lectura o la escritura. La primera es la etapa activa, donde un hilo indica su intención de leer o de escribir. Una vez que haya ocurrido esto, el hilo se puede bloquear, dependiendo de si hay otra operación de lectura o escritura en progreso. Cuando se desbloquea, ingresa a la segunda etapa, realiza las operaciones de lectura o escritura, y después libera el recurso, estableciendo las cuentas de lectores o de escritores activos a los valores apropiados. Si es el último lector o escritor activo, desbloquea todos los hilos que fueron bloqueados previamente como resultado de la operación que el hilo realizaba (leído o escriba). El diagrama siguiente ilustra esto más detalladamente.

En este punto, una implementación de esta clase particular de sincronización debe ser obvia. Aquí está. Si en este punto el lector todavía está confundido, ¡entonces no se asuste! ¡Este objeto de sincronización no se entiende fácilmente a primera vista! Observe atentamente por algunos minutos, y si comienzas a ver doble antes de que lo entiendas, entonces no te preocupes, y ¡continuemos!

Puntos sobre la implementación a resaltar.

Hay una asimetría en el esquema de la sincronización: los hilos que potencialmente quieren leer se bloquearán antes de la lectura si hay algunos escritores activos, mientras que los hilos que desean escribir se bloquean antes de la escritura si hay algunos lectores leyendo. Esto da prioridad a los hilos lectores; un acercamiento sensible, dado que las escrituras son menos frecuentes que las lecturas. Esta necesidad no es necesariamente el caso, dados todos los cálculos, si un hilo debe ser bloqueado o no ocurre en la sección crítica, es perfectamente permisible hacer el sincronizador simétrico. Lo malo de esto es que, si ocurren muchas operaciones de lectura concurrentes, pueden impedir que todas las escrituras ocurran. Por supuesto, la situación opuesta, con muchas escrituras deteniendo operaciones de lecturas también se puede dar.
También es digno de observar el uso de semáforos cuando se adquieren recursos de lectura o escritura: Operaciones de espera en semáforos se deben realizar siempre fuera de la sección crítica que guarda los datos compartidos. Así la señalización condicional de un semáforo dentro de la sección crítica está puramente para asegurarse de que la operación de espera resultante no bloquea.

Un uso de ejemplo de MREWS simple.

Para demostrar lo que hace el MREWS, es necesario separarlo levemente de los ejemplos presentados hasta ahora. Imagínese que es necesario que una gran cantidad de hilos no pierdan de vista el estado de un número de archivos en cierto directorio. Estos hilos desean saber si un archivo ha cambiado desde que el hilo tuvo acceso a ese archivo por última vez. Desafortunadamente, los archivos pueden ser modificados por un número diverso de programas en el sistema, así que no es posible que un solo programa no pierda de vista las operaciones que son realizadas en todos los archivos.
Este ejemplo tiene un hilo en ejecución que itera a través de todos los archivos en un directorio, calculando una suma de comprobación (checksum) simple para cada archivo. Hace esto repetidamente, con eficacia ad infinitum. Los datos se almacenan en una lista que contiene un sincronizador MREW, permitiendo así que a una gran cantidad de hilos lectores lean las sumas de comprobación en unos o más archivos.

Primero, vamos mirar la fuente para la lista de la suma de comprobación. Aquí está. Las operaciones básicas son:

Todas estas operaciones publicamente accesibles tienen llamadas de sincronización apropiadas al comienzo y al final de la operación.

Observe que hay un par de métodos los cuales comienzan con el nombre "NoLock". Estos métodos son los métodos que necesitan ser invocados desde más de un método visible publicamente. La clase se ha escrito de esta manera debido a una limitación de nuestro sincronizador actual: Las llamadas anidadas para comenzar a leer o a escribir no se permiten. Todas las operaciones que utilizan el sincronizador simple deben llamar solamente a StartRead o StartWrite si han terminado todas las operaciones de lectura o escritura anteriores. Esto será discutida más detalladamente más adelante. Aparte de esto, la mayoría del código para la lista de la suma de comprobación es bastante mundano, consistiendo sobre todo en el manejo de la lista, y no debe presentar ninguna sorpresa para la mayoría de los programadores de Delphi.

Ahora demos una mirada al código del hilo en ejecusión. Este hilo parece levemente diferente de la mayoría de los hilos de ejemplo que he presentado hasta ahora porque se pone en ejecución como una máquina de estado. El método Execute simplemente ejecuta una función para cada estado, y dependiendo del valor de retorno de la función, busca el siguiente estado requerido en una tabla de transición. Una función lee la lista de archivos desde el objeto lista de sumas de comprobación, el segundo quita sumas de comprobación innecesarias de la lista, y el tercero calcula la suma de comprobación para un archivo particular, y la actualiza en caso de ser necesario. La belleza de usar una máquina de estado es que hace mucho más limpia la terminación del hilo. El método Execute llama a las funciones, busca el siguiente estado y comprueba en un ciclo while si el hilo debe terminar. Puesto que a cada función le toma normalmente un par de segundos terminar, la terminación del hilo es normalmente bastante rápida. Además, una sola verificación de terminación del hilo es necesaria, haciendo al código más limpio. También me gusta el hecho de que la lógica entera de la máquina de estado está implementada en una línea de código. Hay cierta pulcritud en esto.

Finalmente, hecharemos una ojeada el código del form principal. Esto es relativamente simple: el hilo y la lista de sumas de comprobación se crean al iniciar, y se destruyen cuando el programa se cierra. La lista archivos y sus sumas de comprobación se muestra regularmente como resultado un contador de tiempo (timer). El directorio que está siendo observado es fijo en el código; los lectores que deseen ejecutar el programa pueden cambiar este directorio, o posiblemente modificar el programa para poder especificar lo al inicio del mismo.

Este programa no realiza operaciones en datos compartidos en una manera estrictamente atómica. Hay varios lugares en el hilo de la actualización en donde los datos locales se asume implicitamente que son correctos, cuando el archivo subyacente pudo haber sido modificado. Un buen ejemplo de esto está en la función "check file" del hilo. Una vez que se haya calculado la suma de comprobación del archivo, el hilo lee la suma de comprobación almacenada para ese archivo, y lo actualiza si no coincide con la actual suma de comprobación calculada. Estas dos operaciones no son atómicas, puesto que las llamadas múltiples al objeto lista de sumas de comprobación no son atómicas. Esto proviene principalmente del hecho que llamadas anidadas al sincrinizador no trabaja con nuestro sincronizador simple. Una solución posible es dar al objeto lista de sumas de comprobación, dos nuevos métodos: "bloquearse para la lectura" y "bloquearse para la escritura". Un bloqueo se podría adquirir en los datos compartidos, para la lectura o la escritura, y operaciones de lecturas y escrituras realizadas. Sin embargo, esto todavía no soluciona todos los posibles problemas de sincronización. Soluciones más avanzadas serán discutidas más adelante en este capítulo.

Puesto que el funcionamiento internos del sincronizador ocurre a nivel de Delphi, es posible obtener una estimación de cómo
ocurren a menudo los conflictos del hilo realmente. Poniendo un punto de parada (breakpoint) en los ciclos while de los procedimientos EndRead y EndWrite, el programa se detendrá si un hilo lector o escritor fue bloqueado mientras intentaba tener acceso al recurso. El punto de parada ocurre realmente cuando se desbloquea el hilo que espera, pero se puede hacer una cuenta exacta de conflictos. En el programa de ejemplo, estos conflictos son absolutamente raros, especialmente bajo poca carga, pero si el número de archivos y de sumas de comprobación llega a ser grande, los conflictos son cada vez más comunes, puesto que mas tiempo se pierde accediendo y copiando datos compartidos.

Una introducción a los Eventos.

Los eventos son quizás una de las primitivas de sincronización más simples de entender, pero una explicación de ellos se ha dejado para este punto, simplemente porque se utilizan mejor conjuntamente con otras primitivas de sincronización. Hay dos tipos de eventos: eventos manuales y eventos automáticos. Por el momento, consideraremos eventos manuales. Un evento trabaja exactamente como un semáforo (o luz de parada para los lectores de ESTADOS UNIDOS)[1].
Tiene dos estados posibles: señalado (análogo a un semáforo en verde) o no-señalado (análogo a un semáforo en rojo). Cuando el evento está señalado, los hilos que están en espera en el evento no se bloquean y continúan en ejecución. Cuando
el evento no está señalado, los hilos que estan en espera en el evento se bloquean hasta que se señala el evento. El Win32 API proporciona una gama de funciones para ocuparse de eventos.

Los eventos automáticos son un caso especial de los eventos manuales. En un evento automático, el estado de un evento señalado se fija de nuevo a no-señalado una vez que ha pasado exactamente un hilo en el evento sin bloqueo, o se ha lanzado un hilo que estaba bloqueado. En este sentido, trabajan de una manera casi idéntica a los semáforos, y si un programador está utilizando eventos automáticos, deben considerar usar semáforos en su lugar, para hacer el comportamiento del mecanismo de sincronización más obvio.

Simulación de eventos usando semáforos.

Una primitiva de evento de hecho puede ser creada usando semaforos: Es posible utilizar un semáforo para bloquear condicionalmente todos los hilos que esperan en la primitiva de evento y para desbloquear hilos cuando se señala la primitiva. Para hacer esto, se utiliza un acercamiento muy similar al algoritmo de sincronización. El evento guarda dos piezas de estado: un boleano indicando si el evento está señalado o no, y una cuenta del número de hilos bloqueados actualmente en el semáforo en el evento. Aquí está cómo se ponen en ejecución las operaciones:

Aquí está el código para un evento simulado usando semáforos. Si el lector ha entendido el sincronizador simple, entonces este código debe ser bastante auto explicativo. La implementación podría ser simplificada levemente substituyendo los ciclos while que desbloquean los hilos con una sola sentencia que incremente la cuenta en el semáforo por la cantidad requerida, no obstante el acercamiento implementado aquí es más consistente con la implementación del sincronizador presentado anteriormente.

El MREWS simple usando eventos.

Las estructuras del control requeridas para simular un evento que usa semáforos son notablemente similares a las estructuras usadas en el sincronizador simple. Así se parece posible tratar de crear un sincronizador usando eventos en vez de semáforos. Esto no es particularmente difícil: aquí está. Como es normal, la conversión atrae la atención sobre algunos puntos de la implementación dignos de mirar.

Primero que nada, el sincronizador simple calculaba si los hilos se deben bloquear en la sección crítica de los procedimientos StartRead y StartWrite, y después realizar las acciones de bloqueo requeridas fuera de la sección crítica. Lo mismo se necesita para nuestro nuevo sincronizador de eventos. Para hacer esto, asignamos un valor a una variable local llamada "Block" (recuerda, las variables locales son inmunes a los hilos). Esto se hace dentro de la sección crítica de DataLock, para garantizar resultados consistentes, y las acciones de bloqueo se realizan fuera de la sección crítica para evitar Deadlocks.

En segundo lugar, este sincronizador particular es simétrico, y permite operaciones de escritura o lectura con igual prioridad.
Desafortunadamente, puesto que hay solamente un sistema de contadores en este sincronizador, es algo más difícil hacerlo asimétrico.

El MREWS de Delphi.

El problema principal con los sincronizadores existentes es que no son reentrantes. Es totalmente imposible anidar llamadas a StartWrite, un Deadlock ocurrirá de inmediato. Es posible anidar llamadas a StartRead, a condición de que ningún hilo llame a StartWrite en el medio de una secuencia de llamadas anidadas a StartRead. Una vez más, si esto ocurre, un Deadlock será una consecuencia inevitable. Lo ideal sería que pudieramos anidar operaciones de lectura y de escritura. Si un hilo es un lector activo, entonces las llamadas repetidas a StartRead no deberían tener ningún efecto, con tal que sean emparejadas por un número igual de llamadas a EndRead. Semejantemente, llamadas anidadas a StartWrite deben ser posibles también, y todas pero el par externo de las llamadas a StartWrite y EndWrite no deberían tener ningún efecto.

El segundo problema es que los sincronizadores ilustrados hasta ahora no permiten operaciones atómicas de leer-modificar-escribir. Lo ideal sería que un simple hilo pudiese llamar a StartRead, StartWrite, EndWrite, EndRead; así permitiendo que un valor sea leído, modificado y escrito atomicamente. A los otros hilos no se les deben permitir escribir en cualquier parte de la secuencia, y no se les deben permitir leer durante la operación de escritura de la secuencia. Con los sincronizadores actuales, es perfectamente posible hacer esto simplemente realizando operaciones de lectura y escritura dentro de un par de llamadas a StartWrite y EndWrite. Sin embargo, si las llamadas de la sincronización se encajan en un objeto compartido de los datos (como en el ejemplo) puede ser muy difícil proporcionar un interfaz conveniente a ese objeto que permita operaciones de lectura-modificación-y-escritura sin también proveer llamadas separadas de sincronización para bloquear el objeto en la lectura o escritura.

Para hacer esto, se requiere una implementacion en conjunto más sofisticada, por el que cada operación de comienzo y fin se fije en cuál hilo esta realizando la operación de lectura o escritura actualmente. De hecho esto es lo que hace el sincronizador de Delphi. Desafortunadamente, debido a los acuerdos que licenciasno es posible exhibir el código de fuente de VCL aquí y discutir exactamente que lo hace. Sin embargo, sea suficiente decir que el Delphi MREWS:


Nota del traductor [1]: El autor hace notar la diferencia de nombres que tienen los semaforos de calle entre EEUU e Inglaterra, stopping ligth y traffic ligth respectivamente.

Contenido - Anterior - Siguiente