Main logo La Página de DriverOp

Capítulo 8. Clases Delphi seguras para entornos multihilo y prioridades.

En este capitulo:

  • ¿Porqué escribir clases seguras para la entornos multihilo?
  • Tipos de clases seguras para entornos multihilo.
  • Encapsulado de clases seguras en entornos multihilo o derivaciones de clases existentes.
  • Clases para la administración del flujo de los datos.
  • Monitores.
  • Clases Interlock.
  • Soporte multihilo en la VCL.
  • TThreadList
  • TSynchroObject
  • TCriticalSection
  • TEvent y TSimpleEvent
  • TMultiReadExclusiveWriteSincronizer
  • Guía para programadores de clases seguras en entornos multihilo.
  • Administración de prioridades.
  • ¿Qué hay en una prioridad? El modo de hacerlo de Win32.
  • ¿De qué prioridad debo hacer mi hilo?

¿Porqué escribir clases seguras para la entornos multihilo?

Las aplicaciones simples en Delphi, escritas por iniciados en la programación multihilo, tienden a incluir su sincronización como parte de la lógica de la aplicación. Como demostró el capitulo anterior, es increíblemente fácil que se generen errores en la lógica de sincronización, y diseñar un esquema de sintonización separado para cada aplicación es mucho trabajo. Un número relativamente pequeño de mecanismos de sincronización son usados una y otra vez: casi todos los hilos destinados a E/S, comunican los datos a través de buffers compartidos, y el uso de listas y colas con sincronización incorporada en situaciones de E/S es muy común. Estos factores indican que hay muchas ventajas si creamos librerías de objetos y estructuras de datos que son seguras en entornos multihilos: los problemas involucrados en la comunicación entre hilos son difíciles, pero un pequeño número de soluciones “en stock” cubren casi todos los casos.

Algunas veces es necesario escribir una clase que sea segura en entornos multihilo porque no es aceptable otro enfoque. Códigos en DLL’s que accede a variables únicas del sistema deben poseer sincronización de hilos, aún si la DLL no posee ningún objeto hilo. Dado que los programadores Delphi usarán las facilidades del lenguaje (clases) para permitir un desarrollo modular y re-utilización de código, estas DLL’s tendrán clases, y estas clases deben ser seguras para entornos multihilo. Algunas pueden ser bastante simples, quizás clases que sean instancias de buffers comunes, como las descriptas antes. De todos modos, es muy deseable que algunas de estas clases hilo puedan implementar el bloqueo de recursos u otro mecanismo de sincronización en un modo totalmente único de modo de resolver un problema en particular.

Tipos de clases seguras para entornos multihilo.

Las clases vienen en muchas formas y tamaños diferentes, programadores con una razonable experiencia en Delphi estarán al tanto que el concepto de clase se usa en muchas formas diferentes. Algunas clases son usadas principalmente como estructuras de datos, otras como abstracciones para simplificar un compleja estructura interior. Algunas veces, familias de clases cooperando son usadas para proveer flexibilidad cuando son usadas para alcanzar un logro importante, como está bien demostrado en el mecanismo de streaming de Delphi. Cuando nos referimos a las clases seguras para los entornos multihilo, se presenta una diversidad similar. En algunos casos, la clasificación puede resultar un poco confusa, pero de todos modos, cuatro tipos distintivos de clases seguras para entornos multihilo pueden ser distinguidas.

Encapsulado de clases seguras en entornos multihilo o derivaciones de clases existentes.

Estas son el tipo más simple de clases para entornos multihilo. Típicamente, la clase que es ampliada, tiene una funcionalidad bastante limitada y está contenida en sí misma. En el caso más simple, hacer que la clase sea segura para entornos multihilo puede consistir simplemente en agregar un mutex, y dos funciones extra, Lock y Unlock. Como alternativa, las funciones que manipulan los datos en la clase pueden realizar las operaciones de bloqueo y desbloqueo automáticamente. Cuál enfoque es usado, depende mucho del tipo de operaciones posibles en el objeto, y la probabilidad de que el programador vaya a usar funciones de bloqueo manual para forzar la atomicidad de operaciones compuestas.

Clases para la administración del flujo de los datos.

Estas son una pequeña extensión de las de arriba y tienden a ser una clase buffer: listas, pilas y colas. Además, para mantener la atomicidad, estas clases pueden realizar un control automático del flujo de datos en los hilos que operan en el buffer. Esto consiste frecuentemente en suspender los hilos que intentan leer de un buffer vacío o escribir en uno que está lleno. La implementación de estas clases se trata con más detalle en el capitulo 10. Un rango de operaciones puede ser soportado por esta clase: en un extremo del buffer se proveerán operaciones que no realizarán ningún bloqueo, y en el otro extremo, todas las operaciones pueden bloquear hilos si no fuera posible completarlas con éxito. Un punto intermedio se da cuando las operaciones son asincrónicas, pero provistas de notificaciones por call-back o mensajería cuando una operación anterior no pueda llegar a completarse con éxito. El API de sockets de Win32 es un buen ejemplo de interfase de flujo de datos, que implementa todas las opciones de arriba en lo que a flujo de datos concierne.

Monitores.

Monitores son un paso lógico en el camino hacia las clases administradoras del flujo de datos. Estos típicamente permiten acceso concurrente a los datos, lo que requiere una sincronización y bloqueo más complejo que un simple encapsulado de clases Delphi para que sean seguras en entornos multihilo. Los motores de bases de datos caen en el fin último de esta categoría: típicamente, un complicado bloqueo y administración de transacciones es provisto para permitir un alto grado de concurrencia cuando se acceden a datos compartidos, con una mínima pérdida de performance por los conflictos entre hilos. Los motores de bases de datos son un caso especial en el sentido de que usan administradores de transacciones para permitir un control fino sobre las operaciones de composición, y también proveen garantías acerca de la persistencia de las operaciones para funcionar hasta completarse. Otro buen ejemplo de monitores es el del sistema de archivos.  El sistema de archivos de Win32 permite que múltiples hilos accedan a múltiples archivos que pueden estar abiertos por varios procesos diferentes en modos muy diferentes al mismo tiempo. Una gran parte de un buen sistema de archivos consiste en la administración de manejadores y esquemas de bloqueo que proveen una óptima performance, mientras aseguran que la atomicidad y la persistencia de las operaciones sea preservada. Como dice Layman: “Todo el mundo puede tener sus dedos en el sistema de archivos, pero éste se asegura de que ninguna operación entrará en conflicto y, una vez que la operación se haya completado, es garantizado que será conservada permanentemente en el disco”. En particular, el sistema de archivos NTFS está “basado en log”, de modo que es garantizado que será consistente, aún cuando haya fallas de energía o en el sistema operativo.

Clases Interlock.

Las clases Interlock son únicas en esta clasificación, porque éstas no contienen ningún dato. Algunos mecanismos de bloqueo son muy útiles en el sentido de que el código que forma parte del sistema de bloqueo puede ser fácilmente separado del código que maneja los datos compartidos. El mejor ejemplo de esto es la clase “Interlock de Muchos lectores y un único escritor”, que permite una lectura compartida y operaciones de escritura atómicas en un recurso. El modo de operación de esto será examinado más abajo, y el funcionamiento interno de la clase será visto en capítulos posteriores.

Soporte multihilo en la VCL.

En Delphi 2, ninguna clase fue provista para asistir al programador multihilo, todas las sincronizaciones fueron hechas en un estricto “hágalo usted mismo”. Desde entonces, el estado de la VCL fue mejorado en este aspecto. Discutiré las clases que se encuentran en Delphi 5, ya que esta es la versión disponible para mí. Los usuarios de Delphi 2 y 3 puede que no tengan algunas de estas clase, y los usuarios de Delphi 5(+) puede que encuentren extensiones a estas clases. En este capitulo, presentaré una breve descripción a estas clases y sus usos. Tenga en cuenta que en sí, muchas de estas clases prefabricadas de Delphi no son terriblemente útiles, en cambio, ofrecen un pequeño valor agregado sobre el mecanismo disponible en el API Win32.

TThreadList

Como se mencionó antes, listas, pilas y colas son muy comunes cuando se implementa la comunicación entre hilos. La clase TThreadList realiza sincronizaciones de las mas básicas requeridas por hilos de ejecución. En adición a los métodos presentes en TList, se agregaron dos métodos extra: Lock y Unlock. El uso de estos debe ser bastante obvio para los lectores que han visto como se trabaja a través de los capítulos anteriores: La lista es bloqueada antes de ser manipulada, y desbloqueada luego. Si un hilo realiza múltiples operaciones en a lista que necesitan ser atómicas, entonces la lista permanece bloqueada. La lista no realiza ninguna sincronización implícita en los objetos que son propiedad de una lista en particular. El programador puede idear mecanismos extra de bloqueo para proveer esta habilidad, o alternativamente, usar el bloqueo en la lista para cubrir todas las operaciones en estructuras de datos que sean propiedad de la lista.

TSynchroObject

Esta clase provee un puñado de métodos virtuales, Adquire y Release que son usados en todas las clases básicas de sincronización en Delphi, dado que la realidad última de los objetos simples de sincronización tienen el concepto de posesión como fue discutido previamente. Las secciones críticas y las clases evento son derivadas de esta clase.

TCriticalSection

 Esta clase no necesita ninguna explicación detallada. Sospecho su inclusión en Delphi como simplemente destinada a aquellos programadores Delphi con fobia al API Win32. No es nada valiosa, ya que provee cuatro métodos: Adquire, Release, Enter y Leave. Los dos últimos no hacen más que llamar a los dos primeros, sólo en caso de que un programador prefiera un tipo de nomenclatura en lugar del otro.

TEvent y TSimpleEvent

Los eventos son un modo ligeramente diferente de bloqueo en la sincronización. En lugar de forzar la exclusión mutua, se usan para hacer que un número variable de hilos esperen hasta que algo suceda, y entonces liberar uno o todos esos hilos cuando ese algo sucede. TSimpleEvent es un caso particular de evento, que especifica varios valores por defecto deseables para ser usados en aplicaciones Delphi. Los eventos están muy relacionados con los semáforos, y son discutidos en capítulos posteriores.

TMultiReadExclusiveWriteSincronizer

Este objeto de sincronización es muy útil en situaciones conde un gran número de hilos pueden necesitar leer un recurso compartido, pero ese recurso es escrito con relativa poca frecuencia. En estas situaciones, no suele ser necesario bloquear completamente el recurso. En capítulos anteriores dije que cualquier uso de recursos compartidos sin sincronizar era un potencial generador de conflictos entre hilos. Si bien esto es cierto, no es necesario seguir con la idea de que una exclusión mutua se necesita siempre. Una exclusión mutua completa insiste en que sólo un hilo puede realizar alguna operación en algún momento. Podemos relajarnos con esto, si nos vemos que hay dos tipos principales de conflictos entre hilos:

  • Escribir después de que se haya hecho una lectura.
  • Escribir después de que se haya hecho otra escritura.

El conflicto de escribir después de que se haya hecho una lectura ocurre cuando un hilo escribe en una parte de un recurso después de que otro hilo ha leído ese valor, y asume que es válido. Este es el tipo de conflictos ilustrado en el capítulo tres. El otro tipo de conflicto ocurre cuando dos hilos escriben en un recurso compartido, uno después del otro, sin que el segundo hilo haya percibido la escritura anterior. Esto resulta en que la primera escritura es eliminada. Por supuesto, algunas operaciones son perfectamente legales, como leer después de leer o leer después de escribir. ¡Estas dos operaciones ocurren todo el tiempo en programas con un único hilo! Esto parece indicarnos que podemos relajar un poco el criterio para la consistencia de datos. Los criterios mínimos son:

  • Varios hilos pueden leer al mismo tiempo.
  • Sólo un hilo puede escribir por vez.
  • Si un hilo está escribiendo, entonces ningún hilo puede estar leyendo.

El sincronizador TMultiReadExclusiveWriteSincronizer fuerza este criterio al proveer cuatro funciones: BeginRead, BeginWrite, EndRead, EndWrite. Al llamar estas funciones antes y después de escribir, se consigue la sincronización apropiada. En lo que se refiere al programador de aplicaciones, puede verlo más bien como una sección crítica, con la excepción de que los hilos la adquieren para leer o para escribir.

Guía para programadores de clases seguras en entornos multihilo.

Si bien los capítulos posteriores cubren los detalles de la escritura de clases seguras en entornos multihilo, y los muchos beneficios y peligros en los que se puede incurrir cuando se diseñan clases seguras para entornos multihilo, me parece valioso incluir una serie de simples consejos que le ayudarán mucho.

  • ¿Quién hace el bloqueo?
  • Sé económico cuando bloquees recursos.
  • Sé tolerante con las fallas.

La responsabilidad del bloqueo de clases seguras en entornos multihilo puede ser del programador de la clase o del usuario de la clase. Si una clase provee sólo una funcionalidad simple, es normalmente lo mejor entregar esta responsabilidad al usuario de la clase. Seguramente usarán varias instancias de esta clase, y al darle la responsabilidad del bloqueo, un se asegura que los Deadlocks inesperados no ocurrirán, y uno también le da la posibilidad de elegir cuánto bloquea, de modo de maximizar la simplicidad o la eficiencia. Para clases más complicadas, como monitores, es normal que la clase (o grupo de clases) tome la responsabilidad, al ocultar las complejidades del objeto bloqueado del usuario final de la clase.

En todos los casos, los recursos deben ser bloqueados tan poco como sea razonablemente posible, y el bloqueo de recursos debe ser una tarea fina. Si bien los esquemas de bloqueo simplistas reducen las chances de un bug sea sutilmente insertado en el código, pueden en principio limitar sensiblemente los beneficios de usar hilos de ejecución. Por supuesto, no hay nada de malo con empezar haciéndolo simple, pero si hay problemas de performance, el esquema de bloqueo deberá ser examinado con mayor detalle.

Nada funciona perfectamente todo el tiempo. Si se usan las llamadas al API de Win32, tolera las fallas. Si sos del tipo de programadores que es feliz verificando millones de códigos de error, entones este es un enfoque posible. Alternativamente, podrás desear escribir una clase de abstracción que encapsule los objetos de sincronización Win32 que puedan llegar a emitir un mensaje de error cuando esto ocurra. En cualquier caso, siempre ten en cuenta usar el bloque try … finally para asegurarte que en el caso de una falla, los objetos de sincronización son dejados en un estado conocido.

Administración de prioridades.

Todos los hilos son creados igual, pero algunos son más iguales que otros. El administrador de tareas debe dividir el tiempo del microprocesador entre todos los hilos en funcionamiento en la máquina en todo momento. Para hacer esto, necesita tener alguna idea de cuánto tiempo del microprocesador desearía usar cada hilo, y cuán importante es que un hilo en particular sea ejecutado cuando está disponible para correr. La mayoría de los hilos se comportan de dos maneras posibles: su tiempo de ejecución está atado al microprocesador o a la E/S.

Los hilos atados al microprocesador tienden a realizar un gran número de operaciones en segundo plano. Absorberán todos los recursos del microprocesador disponibles para ellos, y raramente se suspenderán para esperar por comunicaciones de E/S con otros hilos. Con bastante frecuencia, su tiempo de ejecución no es crítico. Por ejemplo, un hilo en un programa de gráficos por computadoras puede realizar una operación de manipulación de una imagen muy grande (difuminando o rotando la imagen), lo que puede tomar unos segundos o hasta minutos. En la escala de tiempos de los ciclos del procesador, este hilo no necesita nunca ser corrido con urgencia, ya que el usuario no se molesta si la operación toma doce o treinta segundos para ejecutarse, y ningún otro hilo en el sistema está esperando urgentemente un resultado de este hilo.

En el otro extremo de la escala de tiempo tenemos a los hilos atados a E/S. Estos normalmente no usan mucho el microprocesador, y pueden consistir en relativamente pequeñas cantidades de procesamiento. Con mucha frecuencia están suspendidos (bloqueados) en E/S, y cuando reciben una entrada, típicamente corren por un corto período de tiempo, para procesar esa entrada en particular, y en forma prácticamente inmediata se vuelven a suspender cuando no hay más entradas disponibles. Un ejemplo de esto es el hilo que procesa las operaciones de movimiento del ratón y actualiza la posición del cursor. Cada vez que el ratón es movido, el hilo se toma una pequeña fracción de segundos en actualizar el cursor y vuelve a ser suspendido. Hilos de este tipo tienden a ser más críticos con respecto al tiempo: no corren por largos períodos de tiempo, pero cuando corren, es bastante crítico que respondan de inmediato. En la mayoría de los sistemas GUI, es inaceptable que el cursor permanezca son responder, aún por cortos períodos de tiempo, y de hecho el hilo de actualización del cursor del ratón es crítico con respecto tiempo. Los usuarios de WinNT notarán que aún cuando la computadora esté trabajando muy duro en operaciones intensas en el microprocesador, el cursor del ratón sigue respondiendo inmediatamente. Todos los sistemas operativos multihilo que utilizan un mecanismo de preferencia, Win32 incluido, proveen soporte para estos conceptos, permitiéndole al programador asignar “prioridades” a los hilos. Típicamente, los hilos con mayor prioridad tienen a ser los atados a E/S y los hilos con menor prioridad, los que están atados al microprocesador. La implementación de las prioridades de los hilos de ejecución en Win32 es ligeramente diferente de las implementaciones de (por ejemplo) UNIX, de modo que los detalles discutidos aquí son específicos para Win32.

¿Qué hay en una prioridad? El modo de hacerlo de Win32.

La mayoría de los sistemas operativos asignan una prioridad a los hilos de ejecución, para saber cuánta atención del microprocesador debe recibir cada hilo. En Win32, la prioridad de cada hilo de ejecución es calculada en el momento, a partir de un número de factores, algunos de los cuales pueden ser establecidos directamente por el programador. Estos factores son el “Priority Class” (clase de prioridad) del proceso, el “Priority Level” (nivel de prioridad) del hilo, y estos juntos son usados para calcular la “Base Priority” (prioridad base) del hilo, y la “Priority Boost” (prioridad de estímulo) en efecto para ese hilo. La prioridad del proceso es establecida en base al proceso en funcionamiento. Para casi todas las aplicaciones Delphi, esto será la clase de prioridad normal, con la excepción de los salvapantallas, que pueden ser establecidos a la clase de prioridad inactiva. En suma, el programador Delphi no necesitará cambiar la clase de prioridad de un proceso en funcionamiento. El nivel de prioridad de cada hilo puede ser establecido desde adentro de la clase asignada para el proceso. Esto suele ser mucho más útil, y el programador Delphi puede usar la llamada al API SetThreadPriority para cambiar el nivel de prioridad de un hilo. Los valores permitidos para esta llamada son: THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_ABOVE_NORMAL, THREAD_PRIORITY_NORMAL, THREAD_PRIORITY_BELOW_NORMAL, THREAD_PRIORITY_LOWEST y THREAD_PRIORITY_IDLE. Como la prioridad base del hilo es calculada como resultado de ambos, el nivel de prioridad del hilo y la clase de prioridad del proceso. Hilos con niveles de prioridad por encima del normal en un proceso con una clase de prioridad normal tendrán una prioridad base mayor a los compuestos por un hilo con nivel de prioridad encima del normal pero en un proceso con una clase de prioridad por debajo de lo normal. Una vez que la prioridad base de un hilo fue calculada, este nivel permanece fijo mientras se ejecuta el hilo, o hasta que el nivel de prioridad (o la clase del proceso propietario) sea cambiado. Sin embargo, la prioridad actual usada de un momento a otro en el administrador de tareas cambia ligeramente como resultado de la prioridad de estímulo.

La prioridad de estímulo es un mecanismo que el administrador de tareas usa para probar y tomar cuenta del comportamiento de los hilos en tiempo de ejecución. Pocos hilos serán totalmente atados al microprocesador o a la E/S durante todo su funcionamiento, y el administrador de tareas fomentará la prioridad de los hilos que se bloquean sin llegar a usar por completo un bloque de tiempo asignado. Además, a los hilos que poseen manejadores de ventanas que están como ventanas en segundo plano también se les da un ligero fomento para probar y mejorar la respuesta al usuario.

¿De qué prioridad debo hacer mi hilo?

Con una básica comprensión de las prioridades, podemos intentar asignar prioridades realmente útiles a los hilos en nuestra aplicación. Ten en cuenta que, por defecto, el hilo de la VCL se ejecuta en un nivel de prioridad normal. Generalmente, la mayoría de las aplicaciones Delphi están enfocadas en proveer tanta capacidad de respuesta al usuario como sea posible, de modo que uno raramente necesita incrementar la prioridad del hilo por encima de lo normal – al hacerlo, demorará operaciones como el repintado de ventadas mientras el hilo esté en ejecución. La mayoría de los hilos que lidian con E/S o la transferencia de datos en las aplicaciones Delphi, pueden ser dejadas en una prioridad normal, ya que el administrador de tareas fomentará la prioridad del hilo cuando lo necesite, y si el hilo cambia a un estado en que acapara todo el microprocesador, perderá el fomento, resultando en una razonable velocidad de operación del hilo principal de la VCL. A la inversa, prioridades por debajo de lo normal pueden ser muy útiles. Si bajas la prioridad de un hilo que realiza operaciones intensas en el microprocesador en segundo plano, la máquina resultará para el usuario con mucha más capacidad de respuesta que si el hilo fuera dejado a un nivel de prioridad normal. Típicamente, un usuario es mucho más tolerante a sensibles demoras para que se completen las operaciones en hilos de ejecución de baja prioridad: podrá hacer otras cosas mientras se completan estas tareas, y la máquina lo mismo que la aplicación se mantendrán con una capacidad de respuesta normal.

Martin Harvey -