Capítulo 12. Más dispositivos Win32 para la sincronización.

En este capítulo:

Mayor eficacia vía operaciones de interbloqueo.

Las primitivas convencionales para la sincronización pueden ser un gasto considerable en simples sistemas multihilo, particularmente para los hilos que se sincronizan firmemente el uno al otro. Un alternativa posible es utilizar operaciones de interbloqueo.

Las operaciones de interbloqueo fueron concebidas originalmente como mecanismo para la sincronización de bajo nivel en los sistemas con multiples procesadores simétricos y memoria compartida. En sistemas con multiples procesadores, la memoria compartida es una manera extremadamente eficiente de transferir datos entre los procesos y los hilos. Una manera tuvo que ser encontrada para prevenir problemas de atomidad cuando dos o más procesadores intentan utilizar el mismo pedazo de la memoria. Casi todos los procesadores introdujeron recientemente soporte para operaciones de interbloqueo para permitir esto. Éstas son operaciones por el que un procesador puede leer un valor de la memoria, modificarla y después escribirla atómicamente, mientras que se asegura de que ningún otro procesador tenga acceso a la misma memoria, y el procesador que realiza la operación no se interrumpe. Win32 proporciona las siguientes operaciones de interbloqueo:

¿Por qué uno debería usar operaciones de interbloqueo después de todo? Un buen ejemplo es el de una cerradura de vueltas. De vez en cuando uno desea crear algo similar a una sección crítica. Sin embargo, puede haber código muy pequeño en la sección crítica, y el código en la sección crítica puede ser accedido muy a menudo. En casos tales como este, un objeto totalmente basado en la sincronización puede probar ser ¿overkill?. La cerradura de vueltas permite que hagamos una cosa similar, y trabaja así. Un hilo adquiere la cerradura si, al realizar un incremento interbloqueado, encuentra que, después del incremento, el valor de la cerradura es 0. Si encuentra que el valor es mayor de 0, entonces otro hilo tiene la cerradura, y realiza otro intento. La llamada a dormir es incluida de modo que un hilo no de vueltas por períodos largos en la cerradura mientras que un hilo de más baja prioridad tiene la cerradura. En planificadores simples, si las prioridades del hilo son iguales, después la llamada a dormir no será necesaria. La operación de interbloqueo es necesaria, porque si un hilo realizó una lectura de memoria, incremento, comparación y posterior escritura, entonces dos hilos podrían adquirir la cerradura simultáneamente.

El gasto se reduce porque apenas un par de las instrucciones de la CPU se requieren para entrar y para salir de la cerradura, con tal que un hilo no tenga que esperar. Si los hilos tienen que esperar algún tiempo apreciable, entonces la CPU de desperdicia, así que son solamente útiles para poner secciones críticas pequeñas. Las cerraduras de vuelta son útiles al hacer cumplir las secciones críticas que son ellos mismos parte de las estructuras de la sincronización. Los datos compartidos dentro de primitivas o de planificadores de sincronización son protegidos a menudo por las cerraduras de esta clase: las cerraduras son a veces necesarias porque las primitivas de sincronización a nivel del OS no pueden ser usadasr para implementar primitivas de sincronización a nivel del OS. Las cerraduras de vuelta tienen todos los mismos problemas de concurrencia que los mutexes, con salvedad de que la adquisición cíclica da lugar ya no a deadlocks, si no a livelocks. Esta es una situación levemente peor que un deadlock porque aunque hilos "bloqueados" no están ejecutando ningún código útil, están funcionando como un bucle infinito, están utilizando la CPU y están degradando el funcionamiento del sistema entero. Las cerraduras de vuelta no deben ser utilizadas como semáforos para "suspender" un hilo.

Atomicidad desde la nada.

Con cuidado, Es de hecho posible crear una cerradura de vueltas que sea atómica sin asumir ningún interbloqueo en absoluto, a condición de que las interrupciones ocurren solamente entre instrucciones de la CPU. Considere esto. Veamos con pascal primero para tener una idea general. Tenemos una cerradura entera en memoria. Al intentar entrar en la cerradura, primero incrementamos la cerradura en memoria. Entonces leemos el valor de la memoria en una variable local, y verificamos, como antes, para ver si es mayor de cero. Si es, entonces algún otro tiene la cerradura, y vamos otra vez, si no, tenemos la cerradura.

Lo importante sobre este sistema de operaciones es que, dado ciertas claúsulas, un cambio de hilo puede ocurrir en cualquier momento, ésto todavía sigue siendo seguro contra hilos. El primer incremento de la cerradura es un incremento indirecto del registro. El valor está siempre en memoria, y el incremento es atómico. Entonces leemos el valor de la cerradura en un vairable local. Esto no es atómico. El valor leído dentro de la variable local puede ser diferente del resultado del incremento. Sin embargo, la cosa realmente astuta sobre esto es que porque el incremento se realiza antes de la operación de lectura, los conflictos de hilo que ocurren significarán siempre que el valor leído es demasiado alto en vez de demasiado bajo: los conflictos de hilo resultan en una estimación conservadora de si la cerradura está libre.

A veces es útil escribir operaciones como esto en ensamblador, para estar totalmente seguro que los valores correctos se están dejando en memoria, y no se están depositando en registros. Mientras que resulta, en Delphi 4 al lo menos, pasando la cerradura como parámetro var, e incluyendo la variable local, el compilador Delphi genera el código correcto que trabajará en máquinas de processor único. En las máquinas con multiples procesadores, los incrementos y los decrementos indirectos no son atómicos. Esto ha sido solucionada en la versión ensamblador codificada a mano agregando el prefijo de la cerradura delante de las instrucciones que manipulan la cerradura. Este prefijo manda a un procesador bloquear el bús de memoria exclusivamente mientras dura la instrucción, haciendo atómicas estas operaciones así.
Las malas noticias son que aunque en teoría ésto es correcto, la máquina virtual Win32 no permite que los procesos a nivel de usuario ejecuten instrucciones con prefijo de cerradura. Los programadores que se proponen utilizar este mecanismo deben utilizarlo solamente en código con privilegios de Ring 0. Otro problema es que desde esta versión de la cerradura de vueltas no llama a dormir, es posible que los hilos monopolicen el procesador mientras esperan la cerradura, algo que está garantizado para traer la máquina a un cuelgue total.

Eventcounts y secuenciadores.

Una propuesta alternativa a los semáforos es usar dos nuevos tipos de primitivas: eventcounts y secuenciadores. Ambas contienen contadores, pero a diferencia de los semáforos, los contadores aumentan indefinidamente a partir del tiempo de su creación. Alguna gente es más feliz con la idea que es posible distinguir individualmente entre las 32da y 33ra ocurrencias de un acontecimiento en el sistema. Los valores de estos contadores se ponen a disposición los hilos para que los usen, y los valores se pueden utilizar por procesos para pedir sus acciones. Los Eventcounts soportan tres operaciones:

Los secuenciadores tienen solo una operación:

  • Sequencer.Ticket(): Vuelve el contador interno actual en el secuenciador, y lo incrementa.
  • Una definición de las clases implicadas se debería ver a algo como esto. Es entonces relativamente fácil utilizar eventcounts y secuenciadores para realizar todas las operaciones que se pueden realizar usando semáforos:

  • Hacer cumplir una exclusión mutua.
  • Buffer limitado con un productor y un consumidor.
  • Buffer limitado con un número arbitrario de productores y de consumidores.
  • Una ventaja particular de este tipo de primitiva de sincronización es que las operaciones de avanzar y pedir turnos se pueden implementar de forma muy sensilla, usando la instrucción de comparación de bloqueos mutuos. Esto se deja como ejercicio levemente más difícil para el lector.

    Otros dispositivos Win32 para la sincronización.

    Waitable Timers (Temporizadores con Tiempo de espera). Windows NT y Win2K proporcionan objetos Waitable Timers. Éstos permiten a un hilo o a un número de hilos esperar por una cantidad de tiempo particular dentro de un objeto temporizador. Los temporizadores se pueden utilizar para lanzar un solo hilo o cierto número de hilos sobre una base sincronizada; una manera de controlar el flujo de los hilos. Además, el retardo que los temporizadores con tiempo de espera (Waittables Timers) proporcionan se pueden fijar a valores muy exactos: el valor más pequeño disponible es alrededor 100 nanosegundos, haciendo a los temporizadores más deseables que usar Sleep() si un hilo tiene que ser suspendido por cierta cantidad de tiempo.

    MessageWaits (Espera de mensajes). Cuando las aplicaciones Delphi están esperando que los hilos terminen, el hilo principal de VCL se bloquea permanentemente. Esto es una situación potencialmente problemática, porque el hilo de la VCL no puede procesar mensajes. Win32 proporciona la función MsgWaitForMultipleObjects para solucionar esto. Un hilo espera un mensaje se bloquea también hasta que los objetos de sincronización se señalan, o un mensaje se pone en la cola de mensaje de los hilos. Esto significa que usted puede conseguir que el hilo principal de la VCL espere por los hilos actualmente en ejecusión mientras que también permite que responda a los mensajes de las ventanas. Un buen artículo sobre el tema se puede encontrar en: http://www.midnightbeach.com/jon/pubs/MsgWaits/MsgWaits.html (en inglés).


    Contenido - Anterior - Siguiente