Capítulo 10. E/S y flujo de datos: del bloqueo a lo asincrónico, ida y vuelta.

En este capítulo:

Diferencias en los hilos VCL y diseño de interfaces de E/S.

Con hilos de procesamiento, tiene sentido el bloqueo de E/S, ya que en general, el bloqueo de E/S es el más sencillo. Desde el punto de vista de un hilo usando un recurso de E/S a través de llamadas con bloqueo, el éxito o fracaso es inmediatamente evidente después de hacer la llamada de E/S, y la lógica del programa nunca debe preocuparse acerca del período de tiempo entre las operaciones de E/S que están siendo invocadas ni cuando serán completadas.

Las operaciones que involucran al hilo de la VCL, no se les suele permitir bloqueos por largos períodos de tiempo: el hilo debe estar siempre disponible para procesar nuevos mensajes con una demora mínima. En general, la E/S a disco tiende a usar bloqueo, ya que las demoras involucradas son cortas desde el punto de vista del usuario, pero todas las operaciones de E/S tienden a ser asincrónicas, especialmente las operaciones que involucran comunicación entre hilos, procesos o maquinas, ya que el tiempo de demora involucrado en la operación no puede ser conocido de antemano. El beneficio de las operaciones asincrónicas, como fue discutido anteriormente, es que el hilo de la VCL siempre permanece con capacidad para responder a nuevos mensajes. La principal desventaja es que el código que se ejecuta en el hilo de la VCL tiene que desconocer el estado de evolución de todas las operaciones de E/S pendientes. Esto puede volverse un poco complicado, y significar el almacenamiento de grandes cantidades potenciales de estados. Algunas veces, esto involucra construir una máquina de estados; especialmente cuando se implementan protocolos bien definidos como HTTP, FTP o NNTP. Con mayor frecuencia, el problema es simple, y se puede resolver de igual manera. En estos casos, una solución bajo demanda será suficiente.

Cuando diseñamos un grupo de funciones de transferencia de datos, esta diferencia debe ser tenida en cuenta. Tomando las comunicaciones como un ejemplo, el más frecuente grupo de operaciones soportas en un canal de comunicación son: Open, Close, Read y Write. Las interfaces de bloqueo de E/S ofrecen estas facilidades como funciones simples. Las interfaces asincrónicas ofrecen cuatro funciones básicas, y además, proveen hasta cuatro notificaciones, ya sea por call-back o por evento. Estas notificaciones indican que  una operación previa que estaba pendiente se ha completado, o que es posible repetir la operación o un mezcla de ambas. Un ejemplo de interfaz podría ser:

Mapa de ruta.

Antes de seguir adelante en este capítulo, parece apropiado revisar los mecanismos existentes para transferencia de datos entre hilos y hacer un bosquejo de los métodos por los que se extenderán. Sin más, se podría persuadir a algunos lectores a completar este capítulo sin dejar de leer, mas allá del hecho de que hay un montón de código para estudiar. El punto más importante en esta coyuntura es que muchos de los detalles de implementación, al tiempo que son útiles para aquellos que quieran escribir programas funcionales que incluyan estas técnicas, no son de prima importancia para quienes desean tener un conocimiento general de los conceptos descriptos. Hasta ahora, el único mecanismo de transferencia que hemos visto es el buffer limitado, representado en el siguiente diagrama:


En este capítulo se mostrarán varias extensiones a este buffer. El primer puñado de modificaciones será bastante simple: colocar dos buffer vuelta y vuelta, y agregar una operación sin bloqueo en ambos lados del buffer bi-direccional resultante.


Hasta ahora vamos bien. Esto no debería ser ninguna sorpresa para cualquier lector en este punto, y todos los que han seguido este tutorial hasta aquí no deberían tener problemas en implementar este tipo de construcción. La siguiente modificación es un poco más ambiciosa: en lugar de hacer todas las lecturas y escrituras en el buffer mediante bloqueos de buffer, haremos una serie de operaciones asincrónicas.


Específicamente, crearemos un componente que convierta operaciones de bloqueo en asincrónicas y viceversa. En su personificación natural, simplemente encapsulará operaciones de lectura y escritura en el buffer bi-direccional, pero implementaciones futuras pueden sobrescribir esta funcionalidad para conertir diferentes operaciones de E/S entre semánticas de bloqueo y asincónicas.


La pregunta aquí es: ¿Porqué? La respuesta debería ser obvia: Si podemos hacer un buffer que provea comunicación bi-direccional entre dos hilos, donde un hilo usa operaciones de bloqueo, y el otro usa operaciones asincrónicas, entonces:

Implementando una conversión de bloqueo a asincrónico.

El Componente que crearemos asume que un solo hilo de la VCL está ejecutándose, y por consiguiente, una interfaz asincrónica será provista para solamente un hilo. Las operaciones de bloqueo provistas por este buffer funcionarán con exactamente las mismas limitaciones que las presentadas para el ejemplo del buffer limitado en el capítulo anterior, y por ende, cualquier número de hilos con bloqueo podrán acceder a la interfaz de bloque en forma concurrente. Del mismo modo que el buffer limitado permite simples operaciones de Tomar (Get) y poner (Put), involucrándose solamente un elemento, el bloqueo a un buffer asincrónico (también llamado el “BAB”) también permitirá simples operaciones que involucran un solo elemento. La semántica de la interfaz será:

Agregando operaciones de observación en el buffer limitado.

Aquí hay una mejora al buffer limitado para permitir operaciones de observación. Nótese que si bien es posible leer la cuenta que lleva los semáforos durante ciertas operaciones, preferí mantener estos conteos manualmente usando un par de variables extra FEntryCountFree y FEntryCountUsed. Un par de métodos extra fueron provistos para leer estas variables. Muchos programadores Delphi pensarán inmediatamente en exponer estos atributos del buffer limitado como propiedades. Desgraciadamente, necesitamos tener en mente que las operaciones de sincronización necesarias para acceder esas variables podrían fallar. En lugar de devolver conteos de -1 en una propiedad Integer, parece más apropiado dejar las operaciones de observación como funciones, informando al programador que requiere algún trabajo acceder a los datos requeridos, y que la función podría fallar. Algunos podrían argumentar que, siguiendo este razonamiento, también se debería programar la lectura del atributo Size (tamaño) del buffer como una función explícita de lectura. Esto es más que nada un tema de estilo, ya que el tamaño del buffer puede ser leído directamente sin que se necesite algún tipo de sincronización.

Creando un buffer limitado bi-direccional.

Esta operación es casi trivial y no requiere explicaciones complejas. Lo he implementado como una simple encapsulación de dos objetos buffer limitados. Todas las operaciones soportadas por el buffer limitado también son soportadas por el buffer limitado bi-direccional, con la pequeña modificación que el hilo usando este objeto debe especificar con que lado del buffer desea operar. Típicamente, un hilo opera con el lado A, y el otro con el lado B. Aquí está el código fuente. Esta clase implementa la funcionalidad descrita pictóricamente en el diagrama de abajo representando el buffer limitado bi-direccional.

El buffer de bloqueo a asincrónico en detalle.

Habiendo hecho todo el trabajo previo de preparación, ahora puede ser explicado el BAB con mayor detalle. El BAB posee un buffer bi-direccional y dos hilos, uno lector y otro escritor. Los hilos de lectura y escritura realizan operaciones de lectura y escritura en el buffer limitado en nombre del hilo de la VCL. La ejecución de todos estos hilos puede ser representada pictóricamente, con sólo un mínimo abuso de las convenciones existentes:


Este diagrama se ve un poco intimidatorio; quizá resulte más fácil de entender si presentamos un ejemplo de funcionamiento. Vamos a considerar el caso en el que el hilo de procesamiento realiza una escritura por bloqueo en el BAB.

  1. El hilo de procesamiento hace una escritura por bloqueo.
  2. El hilo de lectura del BAB está actualmente bloqueado, tratando de leer del buffer bi-direccional. Como resultado de la escritura, éste se desbloquea y puede leer el buffer.
  3. El hilo copia los datos leídos en un buffer intermedio y local para la clase hilo, y dispara un evento de flujo de datos, manejado por el BAB.
  4. El código de manejo del flujo de datos del BAB, ejecutándose en el contexto del hilo de lectura, envía un mensaje a su propio manejador de ventanas indicando que los datos fueron leídos por el hilo de lectura.
  5. El hilo de lectura espera entonces en un semáforo que indicará que los datos fueron leídos por el hilo principal de la VCL.
  6. En algún momento posterior, el hilo principal de la VCL procesa los mensajes pendientes para el componente, del mismo modo que lo hace para todos los componentes con un manejador de ventanas.
  7. Entre estos mensajes que esperan por el componente está el mensaje de notificación enviado por el hilo de la VCL. Este mensaje es manejado y genera un evento de OnRead para el componente.
  8. El evento OnRead es manejado por la lógica del resto de la aplicación (probablemente por el formulario principal) y esto resultará seguramente en que el hilo de la VCL intente leer datos.
  9. El hilo de la VCL llamará el método AsyncRead del BAB.
  10. AsyncRead copia los datos desde el buffer interno y se los devuelve al hilo de la VCL. Este entonces libera el semáforo en el que está bloqueado el hilo de lectura, permitiéndole intentar y realizar otra operación de lectura en el buffer bi-direccional.

El BAB funciona exactamente de la misma manera cuando escribe. La escritura es realizada asincrónicamente por el hilo de la VCL, el hilo de escritura interno del BAB es reactivado y realiza una escritura por bloqueo en el buffer bi-direccional, y una vez que esa escritura se completa, el hilo de la VCL es notificado por un evento que puede intentar más operaciones de escritura.

En esencia, la interfaz entre operaciones de bloqueo y asincrónicas a través del envío de mensajes es idéntico al introducido informalmente en ejemplos anteriores. La diferencia con este componente es que los detalles son encapsulados para el usuario final, y el problema es resuelve de un modo más formal y de una manera mejor definida.

Aquí está el código para este componente. Algunos puntos pueden ser destacados provechosamente. En suma, el descendiente de TThread hace poco uso de la herencia. Sin embargo, en este caso particular, el hilo lector y escritor tienen una gran cantidad de funcionalidad en común, lo que es implementado en la case base TBlockAsyncThread. Esta clase contiene:

La case base del hilo también implementa las imprescindibles funcionalidades comunes: creación del hilo, destrucción, y el disparador del evento OnDataFlow. La clase base tiene dos hijos: TBAWriterThread y TBAReaderThread. Estas implementan los métodos actuales de ejecución de los hilos y también proveen métodos de lectura y escritura que serán ejecutados en forma indirecta por el hilo de la VCL. El componente BAB en sí mismo almacena el buffer bi-direccional y los dos hilos. Además, almacena el manejador de ventana FHWND, que es usado para el procesamiento especializado de mensajes.

Construcción del BAB

Echémosle ahora un vistazo a la implementación. Desde la creación, el componente BAB asigna un manejador de ventanas usando AllocateHWnd. Esta es una función muy útil mencionada en el libro de Danny Thorpe, “Delphi Component Design”. El componente BAB es un poco inusual en el sentido de que necesita un manejador de ventanas para realizar el procesamiento de mensajes, aunque no es realmente un componente visual. Se puede dar la componente BAB un manejador de ventanas haciéndolo hijo de un TWinControl. Sin embargo, éste no es el padre apropiado para el componente, por no es una ventana de control. Usando AllocateHWnd, el componente puede ejecutar su propio procesamiento de mensajes sin cargar también con una gran cantidad de cosas extra que no necesita. También hay una pequeña mejora de eficiencia, ya que el procedimiento de manejado de mensajes en el componente realiza una mínima cantidad de procesamiento requerido, lidiando con un mensaje en particular e ignorando el resto.

Durante la creación, el componente BAB también inicializa una serie de manejadores de eventos desde los hilos del componente mismo. Estos manejadores de eventos se ejecutan en el contexto de los hilos lectores y escritores, y realizan la notificación publicando estas interfaces entre los hilos lectores y escritores y el hilo principal de la VCL.

Como resultado de la creación del componente, los hilos se inicializan. Todo el trabajo aquí es común a ambos hilos lectores y escritores y, de igual modo, en el constructor del TBlockAsyncThread. Esto simplemente inicializa una sección crítica necesaria para mantener un acceso atómico al buffer intermedio en cada hilo, y éste también crea el semáforo inactivo para cada hilo, que asegura que el hilo de procesamiento esperará al hilo de la VCL antes de leer o escribir algún dato.

Destrucción del BAB.

La destrucción del componente es ligeramente más complicada, pero usa principios que ya discutimos en capítulos anteriores. El buffer bi-direccional contenido en el BAB es similar al buffer limitado discutido en capítulos anteriores, en el sentido de que la destrucción es un proceso compuesto por tres etapas. La primera etapa es desbloquear todos los hilos que realizan operaciones de E/S en el buffer, mediante una llamada a ResetState. La segunda etapa es esperar a que todos los hilos terminen, o por lo menos estén en un estado en el que no puedan realizar ninguna otra operación en el buffer. Una vez que esta condición se haya alcanzado, la tercera etapa comienza, que es la destrucción de las estructuras físicas de datos.

El modo de destrucción del BAB en líneas generales:

Ya que los hilos son internos del BAB, estos procedimientos de limpieza se ejecutan de modo que el BAB puede desbloquear y liberar todos los hilos y objetos de sincronización internos del componente sin que el usuario del componente ni siquiera tenga que preocuparse por los problemas potenciales de ordenamiento inherentes a la operación de limpieza. Una simple llama al Free del BAB será suficiente. Esto es obviamente deseable.

Mas allá de esto, el componente todavía expone su método ResetState. La razón para esto es que el componente no tiene control sobre los hilos en funcionamiento que pueden realizar operaciones por bloqueo en el buffer. En situaciones como estas, la aplicación principal debe terminar los hilos de procesamiento, reiniciar el estado del BAB y esperar a que el hilo de procesamiento termine antes de destruir físicamente el BAB.

Un programa de ejemplo usando el BAB.

Aquí hay una nueva variante del tema de los números primos. El formulario principal le pide al usuario dos números –el número de comienzo y fin de un rango. Estos números son dados, colocados en una estructura de pedido, y un puntero hacia esta estructura es escrito asincrónicamente en el BAB. En algún momento posterior, el hilo de procesamiento realizará una lectura por bloqueo y tomará el pedido. Entonces tomará una cantidad variable de tiempo procesando el pedido, determinando cuáles números en el rango son primos. Una vez que ha terminado, realiza una escritura por bloqueo, pasando el puntero a una lista de strings con los resultados. El formulario principal es notificado que hay datos listos para leer, y entonces leer la lista de strings desde el BAB y copia los resultados en un memo.

Hay dos puntos principales para notar en el formulario principal. La primera es que la interfaz de usuario es actualizada en forma elegante alineada con el control de flujo del buffer. Una vez que un pedido es generado, el botón de pedido es deshabilitado. Solamente es re-habilitado cuando recibe un evento OnWrite del BAB indicando que se pueden escribir más datos en forma segura. La implementación actual establece el tamaño del buffer bi-direccional a 4. Esto es suficientemente pequeño como para que el usuario pueda verificar que luego de enviar cuatro pedidos que tomen mucho tiempo en procesar, el botón permanece deshabilitado permanentemente hasta que uno de los pedidos sea procesado. Del mismo modo, si el formulario principal no puede procesar notificaciones de lectura lo suficientemente rápido desde el BAB, el hilo de procesamiento permanecerá bloqueado.

El segundo punto para notar es que cuando el formulario es destruido, el destructor usa el método ResetState del BAB como fue descripto anteriormente para asegurarse que se limpia el hilo y la liberación del buffer se produce de manera ordenada. Una falla en esto podría resultar en una violación de acceso.

El código del hilo de procesamiento es bastante simple. No es muy interesante, ya que usa operaciones de lectura y escritura por bloqueo, sólo usa la CPU cuando está procesando un pedido: si no puede recibir un pedido o enviar una respuesta, debido a una congestión en el buffer, entonces está bloqueado.

¡Hemos alcanzado nuestro objetivo!

Un pequeño resumen de las cosas que hemos conseguido con este componente:

El lector podría haber olvidado que estos problemas existían…

¿Has notado el agujero en la memoria?

Durante los dos capítulos anteriores y este capítulo, un aspecto importante fue dejado de lado; los ítems en los varios buffer que hemos diseñado no se destruyen de forma apropiada cuando el buffer es destruido. Cuando diseñamos inicialmente estas estructuras de buffer, adoptamos una implementación similar a TList: la lista o buffer simplemente provee almacenamiento y sincronización. La correcta asignación y liberación del objeto es responsabilidad del hilo usando el buffer.

Esta implementación simplista tiene dificultades mayores. En el más común de los casos, es excesivamente difícil asegurarse que el buffer está vacío en ambas direcciones antes de que sea destruido. En el ejemplo de arriba, que es el uso más simple posible del buffer, hay cuatro hilos, cuatro mutex o secciones críticas, y seis semáforos en el sistema completo. Determinar el estado de todos los hilos y orquestar una salida con una limpieza perfecta, en estas situaciones es obviamente imposible.

En el programa de ejemplo, esto fue resuelto conservando el conteo de cuántos pedidos hay sin responder en cualquier momento. Si hemos recibido tantas respuestas como pedidos hemos hecho, podemos estar seguros de que los varios buffer están vacíos.

Evitando agujeros en la memoria.

Un enfoque sería permitir que los buffer implementen call-backs que destruyan los objetos que contienen al momento de la limpieza. Esto funcionaría en el caso general, pero abre una puerta al abuso, y el uso de implementaciones de este tipo suelen terminar siendo difícil en la práctica.

Otra posibilidad es tener un esquema de administración general del buffer que lleva cuenta de los tipos específicos de objetos, tomando cuenta cuando entran y salen de los varios buffer en la aplicación. Una vez más, una implementación de este tipo se volvería difícil y requeriría un mecanismo de seguimiento potencialmente complicado para hacer un trabajo que realmente debería ser simple.

La mejor solución es hacer la estructura de los buffers análogas a TObjectList; es decir, todos los itemas colocados en el buffer son clases. Esto permitirá a los hilos realizar la operación de limpieza llamando al destructor apropiado en todos los ítems del buffer. Aún mejor, usando tipos de referencia de clases, podemos realizar verificaciones automáticas en tiempo de ejecución en los objetos pasados a través del buffer, y producir un paquete de buffers de tipos seguros.

La implementación de un esquema semejante se deja como ejercicio para el lector. Ningún cambio es necesario para el mecanismo básico de sincronización, pero la armonía en los procedimientos de lectura y escritura necesitarán modificaciones, como también lo necesitarán el destructor del buffer limitado, y el de las clases hilo.

Problemas al echar un vistazo en el buffer.

Cuando implementamos el buffer bi-direccional, todavía era posible proveer un mecanismo razonablemente constante para echar un vistazo a los buffer y ver cuántos ítems hay en ellos. Es posible que cuando le echemos un vistazo al buffer bi-direccional, la liberación y el conteo de uso no se agregue a la misma figura, ya que ambas operaciones no pueden realizarse atómicamente. Sin embargo, se asegura que con sólo un hilo lector y uno escritor en cada dirección, las ojeadas pueden ser utilizadas como indicación razonable de que una operación tendría éxito sin el bloqueo.

Con el buffer asincrónico, el problema es peor en el sentido de que no es posible asegurarse una buena mirada en el estado del buffer con la implementación actual. Esto es así porque hay esencialmente dos buffer en cada dirección, el buffer limitado y el interno, que almacena un solo ítem. Ningún mecanismo es provisto para bloquear globalmente ambos buffer y, en una operación atómica, determinar el estado de los dos.

El componente un tajo al proveer alguna posibilidad de mirar al llevar una cuenta rigurosa de los ítems en transito en el buffer. ¡Esto es tan deliberadamente vago que no se puede ni engañar al programador haciéndolo pensar que los resultados podrían ser exactos! ¿Es posible hacerlo de otra manera?

Haciendo a un lado el buffer intermedio.

La mejor manera de superar la situación es quitando completamente el buffer intermedio. Si nos detenemos a pensar un momento, esto es posible, pero requiere la reescritura de todo el código para el buffer. Necesitaremos implementar un nuevo buffer limitado con una semántica ligeramente diferente. El nuevo buffer deberá:

De esta manera, los hilos lector y escritor pueden ser usados para enviar notificaciones por bloqueo hasta que una operación sea posible, y el hilo de la VCL pueda realizar la operación de lectura y escritura sobre el buffer limitado, sin bloqueo.

Con esta semántica, sólo tenemos un grupo de buffer que deben ser administrados, y es comparativamente más fácil proveer una operación para ver el estado del buffer que provea resultados exactos. Un vez más, esto se deja como ejercicio para el lector…

Miscelánea de limitaciones.

Todas las estructuras del buffer introducidas en los últimos capítulos han asumo que el programador envía punteros a direcciones de memoria válidas, y no NIL. Algunos lectores puede que hayan notado que parte del código en los hilos lector y escritor asumen implícitamente que NIL es un valor null válido que no será enviado a través del buffer. Esto podría naturalmente ser solucionado con algunas marcas de validación en el buffer, pero a costa de que el código quede un poco desprolijo.

Una limitación más teórica es que el usuario final de este componente podría crear una gran cantidad de buffer. La guía de programación de Win32 para la programación con hilos establece que generalmente es una buena idea limitar el número de hilos de procesamiento a alrededor de dieciséis por aplicación, lo que podría permitir ocho componentes BAB. Ya que no hay limitación en el número de hilos de procesamiento que pueden realizar operaciones de bloqueo en el BAB, parece apropiado tener sólo un BAB por aplicación y usarlo para comunicarse entre un hilo VCL y todos los hilos de procesamiento. Esto, por supuesto, asume que todos los hilos de procesamiento están realizando el mismo trabajo. En suma, esto debe ser aceptable, porque la mayoría de las aplicaciones Delphi deberían compartir su tiempo de ejecución con un puñado de hilos para consumir el tiempo de las operaciones en segundo plano.

La otra cara de la moneda: buffer de flujos de datos.

Hasta ahora, todas las estructuras de buffer discutidas han implementado buffer de punteros para transferencia de datos. Mientras esto es muy útil para operaciones discretas, la mayoría de las operaciones de E/S involucran flujo de datos. Todas las estructuras de buffer tienen una parte de conteo muy parecida que involucra el flujo, que, por sus características, pueden ser tratados de manera similar. Hay un par de diferencias muy significativas que vale la pena mencionar:

Hay mucho más que debería ser mencionado al respecto. Si el lector quiere ver un ejemplo funcional de buffer de flujo, puede consultar el código en el capítulo final.


Contenido - Anterior - Siguiente