Main logo La Página de DriverOp

Capítulo 6. Más sincronización: Secciones críticas y mutexes.

En este capítulo:

  • Limitaciones de la sincronización.
  • Secciones críticas.
  • ¿Qué significa todo esto para el programador Delphi?
  • Puntos de interés.
  • ¿Pueden perderse los datos o quedar congelados en el buffer?
  • ¿Qué hay de los mensajes “desactualizados”?
  • Control de Flujo: consideraciones y lista de ineficiencias.
  • Mutexes.

Limitaciones de la sincronización.

Synchronize tiene algunas desventajas que lo hacen inadecuado para cualquier cosa, salvo aplicaciones multihilo muy sencillas.

  • Synchronize es útil solamente cuando se intenta comunicar un hilo en funcionamiento con el hilo principal de VCL.
  • Synchronize insiste en que el hilo en funcionamiento espere hasta que el hilo principal de VCL esté completamente inactivo aún cuando esto no es estrictamente necesario.
  • Si las aplicaciones hacen un uso frecuente de Synchronize, el hilo principal de VCL se vuelve un cuello de botella y no una verdadera ganancia de performance.
  • Si Synchronize es usado para comunicar indirectamente dos hilos en ejecución, ambos hilos pueden quedar suspendidos esperando por el hilo principal de VCL.
  • Synchronize puede causar Deadlock si el hilo principal de VCL espera por algún otro hilo.

En la parte de las ventajas, Synchronize tiene una por sobre la mayoría de los demás mecanismos de sincronización:

  • Casi cualquier código puede ser pasado a Synchronize, incluso código VCL inseguro entre hilos.

Es importante recordar porque los hilos son usados en la aplicación. La principal razón para la mayoría de los programadores Delphi es que quieren que sus aplicaciones permanezcan siempre con capacidad de respuesta, mientras se estén realizando otras operaciones que pueden llevar más tiempo o usan transferencias de datos con bloqueo o E/S. Esto generalmente significa que el hilo principal de la aplicación debe realizar rutinas cortas, basadas en eventos y el manejo de las actualizaciones de la interfaz. Es bueno al responder a las entradas de usuario y mostrar las salidas al usuario. Los otros hilos no usan partes de la VCL que no son seguros para trabajar con múltiples hilos. Los hilos que realizan el trabajo pueden realizar operaciones con archivos, bases de datos, pero rara vez usarán descendentes de TControl. A la vista de esto, Synchronize es un caso perdido.

Muchos hilos necesitan comunicarse con la VCL de una manera sencilla, como realizar transferencias de cadenas de datos, o ejecutar querys de bases de datos y devolver una estructura de datos como resultado del query. Volviendo atrás, al capitulo 3, notamos que sólo necesitamos mantener la atomicidad cuando modificamos datos compartidos. Para tomar un ejemplo sencillo, nosotros podemos tener una cadena que puede ser escrita por un hilo de procesamiento y ser leída periódicamente por el hilo principal de VCL. ¿Necesitamos asegurarnos que el hilo principal de VCL no se está ejecutando nunca en el mismo momento que el hilo en funcionamiento? ¡Por supuesto que no! Todo lo que necesitamos asegurarnos es que sólo un hilo por vez modifica este recurso compartido, de modo de eliminar las condiciones de carrera y hacer las operaciones en los recursos compartidos atómicas. Esta propiedad es conocida como exclusión mutua. Hay muchas primitivas de sincronización que pueden ser usadas para forzar esta propiedad. La más simple de esta es conocida como Mutex. Win32 provee la primitiva mutex, y una pariente cercana de esta, la Sección Crítica (Critical Section). Algunas versiones de Delphi poseen una clase que encapsula las llamadas a secciones críticas Win32. Esta clase no será discutida aquí, ya que su funcionalidad no es común a todas las versiones de 32 bits de Delphi. Los usuarios de esa clase han de tener algunas dificultades usando los métodos correspondientes en la clase para lograr los mismos efectos que los discutidos aquí.

Secciones Críticas.

La sección crítica es una primitiva que nos permite forzar la exclusión mutua. El API Win32 soporta varias operaciones sobre esta:

  • InitializeCriticalSection.
  • DeleteCriticalSection.
  • EnterCriticalSection.
  • LeaveCriticalSection.
  • TryEnterCriticalSection (Windows NT unicamente).

Las operaciones InitializeCriticalSection y DeleteCriticalSection pueden considerarse como algo muy parecido a la creación y destrucción de objetos en memoria. Por ende, es sensato dejar la creación y destrucción de secciones críticas a un hilo en particular, normalmente el que exista más tiempo en memoria. Obviamente, todos los hilos que quieran tener un acceso sincronizado usando esta primitiva deberán tener un manejador o puntero a esta primitiva. Esto puede ser directo, a través de una variable compartida, o indirecto, quizá porque la sección crítica está embebida en un clase hilo segura, a la que ambos hilos puedan acceder.

Una vez que el objeto sección crítica es creado, puede ser usado para controlar el acceso a recursos compartidos. Las dos operaciones principales son EnterCriticalSection y LeaveCriticalSection. En una gran lucha de la literatura estándar en el tema de las sincronizaciones, estas operaciones son también conocidas como WAIT y SIGNAL, o LOCK y UNLOCK respectivamente. Estos términos alternativos son también usados para otras primitivas de sincronización, y tienen significados equivalentes. Por defecto, cuando se crea la sección crítica, , ninguno de los hilos de la aplicación tiene posesión de ella. Para obtener posesión, un hilo debe llamar a EnterCriticalSection, y si la sección crítica no pertenece a nadie, entonces el hilo obtiene su posesión. Es entonces cuando, típicamente, el hilo realiza operaciones sobre recursos compartidos (la parte crítica del codigo, ilustrada por una doble línea), y una vez que ha terminado, libera su posesión mediante un llamado a LeaveCriticalSection.

La propiedad que tienen las secciones críticas es que sólo un hilo por vez puede ser propietario de alguna de ellas. Si un hilo intenta entrar a una sección crítica cuando otro hilo está aún en la sección crítica, el que intenta entrar quedará suspendido, y solamente se reactivará cuando el otro hilo abandone la sección crítica. Esto nos provee la exclusión mutua necesaria con los recursos compartidos. Más de un hilo puede ser suspendido, esperando ser propietario en algún momento, de modo que las secciones críticas pueden ser útiles para sincronizaciones entre más de dos hilos. A modo de ejemplo, aquí está lo que sucedería si cuatros hilos intentaran tener acceso a la misma sección crítica en momentos muy cercanos.

Como deja en claro el gráfico, sólo un hilo esta ejecutando código crítico por vez, de modo que no hay problemas de carreras ni de atomicidad.

¿Qué significa todo esto para el programador Delphi?

Esto significa que, más allá de que uno no esté realizando operaciones con la VCL, sino sólo haciendo sencillas transferencias de datos, el programador de hilos en Delphi es libre de la carga que significa trabajar con TThread.Synchronize.

  • El hilo principal de la VCL no necesita estar inactivo antes de que el hilo en proceso pueda modificar recursos compartidos, sólo necesita estar fuera de la sección crítica.
  • Las secciones críticas no saben ni les preocupa saber si un hilo es el hilo principal de la VCL o una instancia de un objeto TThread, de modo que uno puede usar las secciones críticas entre cualquier par de hilos.
  • El programador de hilos puede ahora (prácticamente) usar WaitFor en forma segura, evitando problemas de Deadlock.

El último punto no es absoluto, ya que aún es posible producir Deadlocks de la misma manera que antes. Todo lo que uno tiene que hacer es llamar a WaitFor en el hilo principal cuando está actualmente en una sección crítica. Como veremos luego, suspender hilos por largos períodos de tiempo mientras está en una sección crítica es normalmente una mala idea. Ahora que la teoría fue explicada adecuadamente, presentaré un nuevo ejemplo. Este es un poco más elegante e interesante que el programa de números primos. Cuando empieza, intenta buscar números primos empezando por el 2, y sigue hacia arriba. Cada vez que encuentra un número primo, actualiza una estructura de datos compartida (una lista de strings) e informa al hilo principal que ha agregado datos a la lista de strings. Aquí está el código del formulario principal.

Es bastante similar a los ejemplos anteriores con respecto a la creación del hilo, pero hay algunos miembros extra en el formulario principal que deben ser inicializadas. StringSection es la sección crítica que controla el acceso al recurso compartido entre hilos. FStringBuf es una lista de strings que actúa como buffer entre el formulario principal y el hilo en proceso. El hilo en proceso envía los resultados al formulario principal agregándolos a esta lista de strings, que es el único recurso compartido en este programa. Finalmente tenemos una variable boleana, FStringSectInit. Esta variable actúa como un verificador, asegurándose que los objetos necesarios en la sincronización están realmente creados antes de ser usados. Los recursos compartidos son creados cuando comenzamos un hilo de procesamiento y se destruyen poco tiempo después de que estemos seguros que el hilo de procesamiento ha salido. Nótese que pese a que las listas de strings actúan como buffer que son asignados dinámicamente, debemos usar WaitFor al momento de destruir el hilo, para asegurarnos que el hilo de procesamiento no usa más el buffer antes de liberarlo.

Podemos usar WaitFor en este programa sin tener que preocuparnos por posibles Deadlocks, porque podemos probar que no hay nunca una situación donde dos hilos se estén esperando uno al otro. La línea de razonamiento para probar esto es bien simple:

  1. El hilo de procesamiento sólo espera cuando intenta ganar acceso a la sección crítica.
  2. El hilo del programa principal sólo espera cuando está esperando que el hilo de procesamiento termine.
  3. El programa principal no espera cuando tiene posesión de la sección crítica.
  4. Si el hilo de procesamiento está esperando por la sección crítica, el programa principal abandonará la sección crítica antes de esperar por algún motivo al hilo de procesamiento.

Aquí está el código del hilo de procesamiento. El hilo de procesamiento busca a través de sucesivos enteros positivos, tratando de encontrar alguno que sea primo. Cuando lo encuentra, toma posesión de la sección crítica, modifica el buffer, abandona la sección crítica y luego envía un mensaje al formulario principal indicando que hay datos en el buffer.

Puntos de interés.

Este ejemplo es más complicado que los ejemplos anteriores, porque tenemos un largo de buffer arbitrario entre dos hilos, y como resultado, hay varios problemas que deben ser considerados y evitados, como así también algunas características del código que lidian con situaciones inesperadas. Estos puntos se pueden resumir en:

  • ¿Pueden perderse los datos o quedar congelados en el buffer?
  • ¿Qué hay acerca de mensajes “desactualizados”?
  • Aspectos de control de flujo.
  • Ineficiencias en la lista de strings, dimensionado estático vs. dinámico.

¿Pueden perderse los datos o quedar congelados en el buffer?

El hilo de procesamiento le indica al hilo principal del programa que hay datos para procesar en el buffer mediante el envío de un mensaje. Vale la pena hacer notar que, cuando se usan mensajes de Windows de esta manera, no hay nada inherente al objeto de sincronización del hilo que enlace a un mensaje de windows con una actualización en particular del buffer. Por suerte en este caso, las reglas de causa y efecto funcionan a nuestro favor: cuando el buffer es actualizado, un mensaje es enviado después de la actualización. Esto significa que el hilo principal del programa siempre recibe mensajes de actualización del buffer después de una actualización del buffer. Por este motivo, es imposible que los datos permanezcan en el buffer por una indeterminada cantidad de tiempo. Si los datos están actualmente en el buffer, el hilo de procesamiento y el hilo principal están en algún punto en el proceso desde el envío a la recepción de mensajes de actualización del buffer. Nótese que si el hilo de procesamiento enviara un mensaje antes de actualizar el buffer, puede ser posible que el hilo principal procese el mensaje y lea el buffer antes de que el hilo de procesamiento actualice el buffer con los resultados más recientes, provocando que los resultados más recientes queden atascados en el buffer por algún tiempo.

¿Qué hay de los mensajes “desactualizados”?

Las leyes de causa y efecto funcionaron bien en el caso anterior, pero por desgracia, los problemas de comunicación también cuentan. Si el hilo principal está ocupado actualizando por un largo período de tiempo, es posible que los mensajes se apilen en el la cola, de modo que recibimos los mensajes de actualizaciones mucho tiempo después de que el hilo de procesamiento enviara esos mensajes. En la mayoría de las situaciones, esto no presenta un problema. Sin embargo, un caso particular que necesita ser considerado es el caso de que el usuario detenga al hilo de procesamiento, ya sea directamente, presionando el botón “stop”, o indirectamente, mediante el cierre del programa. En este caso, es completamente posible para el hilo principal de VCL terminar el hilo de procesamiento, quitar todos los objetos de sincronización y el buffer, y luego, subsecuentemente, recibir mensajes que se han apilado durante algún tiempo. En el ejemplo mostrado, verifiqué este problema, asegurándome que la sección crítica y el objeto buffer existen antes de procesar los mensajes (La línea de código comentada Not necessarily the case!). Esta consideración tiende a ser suficiente para la mayoría de las aplicaciones.

Consideraciones de control de flujo y lista de ineficiencias.

Atrás, en el capitulo 2, dije que una vez que se crean hilos, no existe ninguna sincronización implícita entre ellos. Esto era evidente en ejemplos anteriores, como fue demostrado con el problema que puede causar el intercambio de datos entre hilos, como una manifestación del nivel del problema de sincronización en un programa. El mismo problema existe al querer sincronizar la transferencia de datos. No hay nada en el ejemplo de arriba que garantice que el hilo de procesamiento producirá resultados lo suficientemente rápido para que el hilo principal de VCL los pueda tomar cuando los muestra. De hecho, si el programa se ejecuta de modo que el hilo de procesamiento comienza buscando números primos pequeños, es bastante probable que, compartiendo igual cantidad de tiempo de CPU, el hilo de procesamiento desplace el hilo VCL por un margen bastante grande. Este problema es solucionado mediante algo que se llama control de flujo.

Control de flujo es el nombre dado al proceso por el que la velocidad de ejecución de algunos hilos es balanceada de modo que la tasa de entradas en el buffer y la tasa de salidas estén medianamente balanceadas. El ejemplo de arriba es particularmente simple, pero ocurre en muchos otros casos. Casi cualquier E/S o mecanismo de transferencia de datos entre hilos o procesos incorpora algún tipo de control de flujo. En casos simples, esto simplemente puede involucrar alguna pieza excepcional de dato en tránsito, suspendiendo ya sea al productor (el hilo que coloca los datos en el buffer) o al consumidor (el hilo que toma los datos). En casos más complejos, el hilo puede ejecutarse en diferentes máquinas y el “buffer” puede estar compuesto de buffers internos en esas máquinas, y las capacidades de almacenamiento de la red entre ellas. Una gran parte del protocolo TCP es la que administra el control de flujo. Cada vez que descargas una página web, el protocolo TCP arbitra entre las dos máquinas, asegurándose que más allá del microprocesador o la velocidad de disco, toda la transferencia de datos ocurre a una tasa que puedan manejar las dos máquinas [1] . En el caso de nuestro ejemplo de arriba, se hizo un intento tosco de controlar el flujo. La prioridad del hilo de procesamiento ha sido establecida de modo que el administrador de tareas seleccione preferentemente al hilo principal de la VLC y no al hilo de procesamiento, mas allá de que ambos tengan trabajo que hacer. En el administrador de tareas de Win32, esto soluciona el problema, pero no es realmente una garantía de hierro.

Otro aspecto relacionado con el control de flujo es que, en el caso del ejemplo de arriba, el tamaño del buffer es ilimitado. Primero, esto crea un problema de eficiencia, en el que el hilo principal de la VCL tiene que hacer un gran número de movimientos de memoria cuando quita el primer elemento de una larga lista de strings, y segundo, esto significa que con el control de flujo mencionado arriba, el buffer puede crecer sin límite. Intenta quitar la sentencia que establece la prioridad del hilo. Notarás que el hilo de procesamiento genera resultados mas rápido de lo que el hilo principal de VCL pueda procesar, lo que hace a la lista de strings muy larga. Esto, además, lentifica más el hilo principal de la VCL (ya que las operaciones para quitar strings en una lista larga toman mas tiempo), y el problema se vuelve peor. Eventualmente, notará que la lista se vuelve tan larga como para llenar la memoria principal, la máquina comenzará a retorcerse y todo se detendrá ruidosamente. ¡Tan caótico es, que cuando probé el ejemplo, no pude conseguir que Delphi respondiera a mis solicitudes para salir de la aplicación, y tuve que recurrir al administrador de tareas de Windows NT para terminar el proceso!

Simplemente piensa en lo que este programa parece a primera vista. Ha disparado un gran número de potenciales gremlins. Soluciones más robustas a este problema son discutidas en la segunda parte de esta guía.

Mutexes.

Un mutex funciona exactamente del mismo modo que las secciones críticas. La única diferencia en las implementaciones Win32 es que la sección crítica esta limitada para ser usada con solamente un proceso. Si tienes un programa que usa varios hilos, entonces la sección crítica es liviana y adecuada para tus necesidades. Sin embargo, cuando escribes una DLL, es muy posible que diferentes procesos usen la DLL en el mismo momento. En este caso, debes usar mutexes, en lugar de secciones críticas. Pese a que el API Win32 provee un rango más variado de funciones para trabajar con mutexes y otros objetos de sincronización que serán explicados aquí, las siguientes funciones son análogas a las descriptas para secciones críticas más arriba:

  • CreateMutex / OpenMutex
  • CloseHandle
  • WaitForSingleObject(Ex)
  • ReleaseMutex

Estas funciones están bien documentadas en los archivos de ayuda del API Win32, y serán discutidas en más detalle luego.

[1] El protocolo TCP también realiza muchas otras funciones raras y maravillosas, como copiar con datos perdidos y el optimizado del tamaño de las ventanas de modo que el flujo de la información no sólo se ajusta a las dos máquinas en los extremos de la conexión, sino también a la red que las une, mientras mantiene una mínima latencia y maximizando la conexión. También posee algoritmos de back-off para asegurarse que varias conexiones TCP puedan compartir una conexión física, sin que ninguna de ellas monopolice el recurso físico.

Martin Harvey -