Main logo La Página de DriverOp

Capítulo 13. Usar hilos conjuntamente con el BDE, las excepciones y las DLLs.

En este capítulo:

Programación de DLL y Multiprocesos.

Las bibliotecas de enlace dinámicas, o los DLL permiten que un programador comparta código ejecutable entre varios procesos. Se utilizan comúnmente para proporcionar el código de la librería compartida para varios programas. El código de la escritura para los DLL está en la mayoría es similar al código de escritura para los executables. A pesar de esto, la naturaleza compartida de las DLL significa que los programadores familiarizados con programación multihilos los utilizan a menudo para proporcionar servicios a nivel del sistema: ése es el código que afecta varios procesos que tengan el DLL cargado. En este capítulo, miraremos cómo escribir código para una DLL que funciona a través de más de un proceso.

Alcance del hilo y del proceso. Una sola DLL dentro de un hilo.

Las variables globales en las DLL tienen ambito en todo el proceso. Esto significa que si dos procesos separados tienen una DLL cargada, todas las variables globales en el DLL son locales a ese proceso. Esto no se limita a las variables en el código de los usuarios: también incluye todas las variables globales en las bibliotecas runtime de Borland, y cualquier unidad usada por código en la DLL. Esto tiene la ventaja que los programadores principiantes de DLLs pueden tratar la programación de DLLs de la misma manera que la programación de ejecutables: si una DLL contiene una variable global, entonces cada proceso tiene su propia copia. Además, esto también significa que si una DLL es invocada por los procesos que contienen solamente un hilo, entonces no se requieren ninguna técnica en especial: la DLL no necesita ser segura frente a hilos, puesto que todos los procesos tienen instancias totalmente aisladas de la DLL.

Podemos demostrar esto con una DLL simple que no haga nada mas que almacenar un número entero. Exporta un par de funciones que permiten a una aplicacion leer y escribir el valor de ese número entero. Podemos entonces escribir; una simple aplicación de prueba que utilice esta DLL. Si varias copias de la aplicación se ejecutan, uno observa que cada aplicación utiliza su propio número entero, y ninguna interferencia existe entre ellas.

Escribir una DLL multihilo.

Escribir una DLL multihilo es sobre todo igual que la escritura código multihilo en una aplicación. El comportamiento de hilos múltiples dentro de la DLL es igual que el comportamiento de hilos múltiples en una aplicación cualquiera. Como siempre, hay un par de trampas para el distraido:

La trampa principal en que uno puede caer en es el comportamiento del administrador de memoria de Delphi. Por omisión, el administrador de memoria de Delphi no es seguro frente a hilos. Esto está por razones de eficacia: si un programa contiene solamente siempre un hilo, entonces es un gasto de recursos incluira sincronización en el administrador de memoria. El administrador de memoria de Delphi puede ser seguro frente a hilos fijando la variable IsMultiThread a true. Esto se hace automáticamente si se crea una clase TThread para un módulo dado.

El problema es que un ejecutable y la DLL consisten de dos módulos separados, cada uno con su propia copia del administrador de memoria de Delphi. Así, si un ejecutable crea varios hilos, su administrador de memoria es multihilo. Sin embargo, si esos dos hilos llaman una DLL cargada por el ejecutable, el administrador de memoria de la DLL no está enterado del hecho de que está siendo llamado por los hilos múltiples. Esto puede ser solucionado estableciendo la variable IsMultiThread a true. Es mejor establecer esto usando la función Entry Point de la DLL, discutido más adelante.

La segunda trampa ocurre como resultado del mismo problema; el de tener dos administradores de memoria separados. La memoria asignada por el administrador de memoria de Delphi que se pasa desde la DLL al ejecutable no se puede asignar en uno y liberar en el otro. Esto ocurre más a menudo con los strings largos, pero puede ocurrir al usar asignación de memoria con New o GetMem, y liberarla usando Dispose o FreeMem. La solución en este caso es incluir ShareMem, una unidad que mantiene dos administradores de memoria en conjunto usando las técnicas discutidas más adelante.

Puesta a punto e implementación de la DLL.

Atento al hecho de que los programadores de DLL necesitan a menudo estar enterados de cuántos hilos y procesos están activos en una DLL en cualquier momento, los arquitectos del sistema Win32 proporcionan un método para los programadores de DLL para no perder de la cuenta de los hilos y procesos en una DLL. Este método se conoce como la función de punto de entrada (Entry Point) de la DLL.

En un ejecutable, el punto de entrada (según lo especificado en el encabezado del módulo) indica donde la ejecución del programa debe comenzar. En una DLL, señala a una función que se ejecuta siempre que un ejecutable cargue o descargue la DLL, o siempre que un ejecutable que está utilizando actualmente la DLL cree o destruya un hilo. La función toma un solo parmámetro integer que puede ser uno de los siguientes valores:

  • DLL_PROCESS_ATTACH: Un proceso se ha unido a la DLL. Si éste es el primer proceso, entonces acaba de cargarse la DLL
  • DLL_PROCESS_DETACH: Un proceso se ha separado de la DLL. Si éste es el único proceso usando la DLL, entonces la DLL será descargada.
  • DLL_THREAD_ATTACH: Un hilo se ha unido a la DLL. Esto sucederá una vez cuando el proceso carga la DLL, y posteriormente siempre que el proceso cree un hilo nuevo.
  • DLL_THREAD_DETACH: Un hilo se ha separado de la DLL. Esto sucederá siempre que el proceso destruya un hilo, y finalmente cuando el proceso descarga la DLL.

A su turno, los puntos de entrada de la DLL tienen dos características que pueden conducir a malentendidos y problemas al escribir códigos de punto de entrada. La primera característica ocurre como resultado de la encapsulación de Delphi de la función Entry Point, y es relativamente simple de solucionar. La segunda ocurre como resultado de contexto del hilo, y será discutido más adelante.

Trampa 1: La encapsulación de Delphi de la función de punto de entrada.

Delphi utiliza la función del punto de entrada de la DLL para manejar inicialización y finalización de unidades dentro de una DLL así como la ejecución del cuerpo principal del código de la DLL. El escritor de la DLL puede poner un gancho en el manegador de Delphi asignando una función apropiada a la variable DLLProc. El manejador por omisión de Delphi funciona así:

  • Se carga la DLL, la función del punto de entrada se llama con DLL_PROCESS_ATTACH.
  • Delphi utiliza esto para llamar la inicialización de todas las unidades en la DLL, seguido por el cuerpo principal del código de la DLL.
  • La DLL se descarga, dando por resultado dos llamadas a la función del punto de entrada, con los argumentos DLL_PROCESS_DETACH.

Ahora, el escritor de la aplicación solamente consigue código para ejecutarse en respuesta a la función del punto de entrada cuando la variable DLLProc apunta a una función. El punto correcto para establecer esto está en el cuerpo principal de la DLL. Sin embargo, esta está en respuesta a la segunda llamada a la función del punto de entrada. Resumiendo, lo que esto significa es que al usar la función del punto de entrada en la DLL, el programador de Delphi nunca verá la primera unión del proceso a la DLL. A la postre, éste no es un problema serio: uno puede asumir simplemente que el cuerpo principal de la DLL se llama en respuesta a un proceso de carga de la DLL, y por lo tanto el proceso y la cuenta del hilo es 1 en ese punto. Puesto que la variable DLLProc se copia proceso a proceso, incluso si más procesos se unen más adelante, el mismo argumento se aplica, puesto que cada instancia de la DLL tiene variables globales separadas.

En caso de que todavía confundan al lector, presentaré un ejemplo. Aquí está una DLL modificada que contiene una unidad con una función que muestra un mensaje. Como usted puede ver, el cuerpo principal, la inicialización de la unidad y la función de punto de entrada de la DLL contienen las llamadas a "ShowMessage" que permiten a uno seguir la pinsta a lo que está ocurriendo. Para probar esta DLL, aquí hay una aplicación de prueba. Consiste de una ventana con un botón encendido. Cuando se hace click en el botón, se crea un hilo, el cual llama al procedimiento en la DLL, y después se destruye. ¿Así pues, qué sucede cuando ejecutamos el programa?

  • La DLL avisa de la inicialización de las unidades.
  • La DLL avisa de la ejecución del cuerpo principal de la DLL.
  • Cada vez que se hace click en el botón la DLL informa:
    • Punto de entrada: unión de un hilo.
    • Procedimiento de la Unidad.
    • Punto de entrada: separación del Hilo
  • Note que si disparamos más de un hilo desde la aplicación, mientras que dejamos los hilos existentes bloqueados con MessageBox del procedimiento de la unidad, la cuenta total de hilos unidos a la DLL puede aumentar más allá de una.
  • Cuando el programa se cerra, la DLL informa el punto de entrada: separación del proceso, seguido por la finalización de la unidad.

Escribiendo una DLL multiproceseso.

Armado con el conocimiento de cómo utilizar la función de punto de entrada, ahora escribiremos una DLL multiprocesos. Esta DLL almacenará cierta información a nivel de sistema usando memoria compartida entre los procesos. Vale recordar que cuando el código tiene acceso a los datos compartidos entre los procesos, el programador debe proporcionar la sincronización apropiada. Pues los hilos múltiples en un solo proceso intrínsecamente no se sincronizan, así que los hilos principales en diversos procesos tampoco se sincronizan. También miraremos algunas delicadezas que ocurren al intentar utilizar la función de punto de entrada para poder seguirles la pista a los hilos globales.

Esta DLL compartirá un solo número entero entre los procesos, así como mantener un contador del número de procesos e hilos en la DLL en cualquier momento. Consiste en un archivo de cabecera compartido entre la DLL y las aplicaciones que utilizan la DLL, y el archivo de proyecto de la DLL. Antes de que miremos más de cerca al código, vale repasar cómo se comporta la Win32.

Objetos globales con nombre.

El API Win32 permite que el programador cree varios objetos. Para algunos de estos objetos, pueden ser creados anónimos o con cierto nombre. Los objetos creados anónimos son, en el todo, limitado para utilizar por un solo proceso, la excepción es que pueden ser heredados por procesos hijos. Los objetos creados con un nombre se pueden compartir entre los procesos. Típicamente, un proceso creará el objeto, especificando un nombre para ese objeto, y otros procesos abrirán un manejador (handle) a ese objeto especificando su nombre.

La cosa encantadora sobre objetos con nombre es que los manejadores a estos objetos tienen un contador de referencias a nivel de sistema. Es decir, varios procesos pueden adquirir manejadores de un objeto, y cuando todos los manejadores de ese objeto se cierran, el objeto sí mismo se destruye, y no antes. Esto incluye cuando la aplicación se cae: muchas veces Windows hace un buen trabajo de limpieza de los manejadores después de un desplome.

La DLL en detalle.

Nuestro DLL utiliza esta propiedad para mantener un archivo mapeado en memoria. Normalmente, los archivos mapeados en memoria se utilizan para crear un área de memoria que es una imagen espejo de un archivo en disco. Esto tiene muchos usos útiles, no solo para paginación "a pedido" de imágenes de ejecutables en disco. Sin embargo para esta DLL, se utiliza un caso especial por el que un archivo mapeado en memoria se crea sin imagen correspondiente en el disco. Esto permite que el programador asigne una porción de la memoria que se compartirá entre varios procesos. Esto es asombrosamente eficiente: una vez que se instale el archivo mapeado, no se hace ningún copiado de memoria entre los procesos. Una vez que se haya instalado el archivo mapeado en memoria, un mutex con nombre global se utiliza para sincronizar el acceso a esa porción de la memoria.

Inicialización de la DLL.

La inicialización consiste en cuatro etapas principales:

  • Creación de los objetos de sincronización (globales y otros).
  • Creación de datos compartidos.
  • Incremento inicial de los contadores de hilo y de proceso.
  • Enganchar la función de punto de entrada de la DLL.

En la primera etapa, se crean dos objetos de sincronización, un mutex global, y una sección crítica. Poco necesita ser dicho acerca de la sección crítica. El mutex global se crea vía la llamada a la API CreateMutex. Esta llamada tiene la característica beneficiosa que si se nombra el mutex, y ya existe el objeto con nombre, entonces se devuelve un manejador de objeto con nombre existente. Esto ocurre atómicamente. Si esto no es el caso, entonces podrían ocurrir toda una serie de condiciones de carrera (race conditions). Determinar de forma precisa toda la serie de problemas y sus posibles soluciones (involucrando principalmente control de concurrencia optimista) se deja como ejercicio al lector. Sea suficiente decir que si las operaciones en los manejadores de los objetos compartidos globales no fueran atómicas, el programador de aplicaciones Win32 estaría mirando fijamente en un abismo...

En la segunda etapa se instala el área de la memoria compartida. Puesto que hemos instalado ya el mutex global, se utiliza al instalar el archivo mapeado. Una vista del "archivo" mapeado, que mapea el archivo (virtual) en el espacio de dirección del proceso que llama. También comprobamos si es el proceso que creó originalmente el archivo mapeado, si éste es el caso, entonces ponemos a cero los datos en nuestra vista mapeada. Esta es la razón por la cual el procedimiento se envuelve en un mutex: CreateFileMapping tiene las mismas características de atomicidad que CreateMutex, asegurándose de que nunca ocurrirán las condiciones de carrera en los manejadores. En el caso general, sin embargo, igual no es necesariamente cierto para los datos en el mapeado. Si el mapeado tenía un archivo físico, entonces podemos asumir la validez de los datos compartidos desde el inicio. Para los mapeos virtuales esto no está asegurado. En este caso necesitamos inicializar los datos en el mapeado atomicamente estableciendo un manejador al archivo mapeado, por lo tanto al mutex.

En la tercera etapa, realizamos nuestra primera manipulación en los datos global compartidos, incrementando los contadores de proceso s y de hilos, puesto que la ejecución del cuerpo principal de la DLL es consistente con la adición de otro hilo y proceso a aquellos que usan la DLL. Observe que el procedimiento AtomicIncThreadCount incrementa ambos contadores locales y globales de los hilos mientras se han adquirido el mutex global y la sección crítica del proceso local. Esto asegura que los hilos múltiples del mismo proceso vean una vista completamente consistentes de ambas cuentas.

En la etapa final, se engancha el DLLProc, así se asegura que la creación y la destrucción de otros hilos en el proceso es monitoreada, y la salida final del proceso también es registrada.

Una aplicación usando la DLL.

Una aplicación simple que utiliza el DLL se presenta aquí. Consiste en la unidad compartida global, una unidad que contiene la ventana principal, y una unidad subsidiaria que contiene un hilo simple. Existen cinco botones en la ventana, permitiendo que el usuario lea los datos contenidos en la DLL, incrementar, decrementar y establecer el valor del número entero compartido, y crean unos o más hilos dentro de la aplicación, solo para verificar que los contadores locales del hilo funcionan. Según lo esperado, los contadores de hilo se incrementan siempre que una nueva copia de la aplicación se ejecute, o uno de las aplicaciones crea un hilo. Observe que el hilo no necesita utilizar directamente la DLL para que la DLL esté al tanto de su presencia.

Trampa 2: Contexto del hilo en las funciones de punto de entrada.

En vez de usar una aplicación simple, intentemos uno que haga algo avanzado. En esta situación, el DLL se carga manualmente por el programador de la aplicación, en vez de ser cargado automáticamente. Esto es posible substituyendo la unidad con la ventana anterior por ésta. Se agrega un botón adicional que carga la DLL, e instala el procedimiento manualmente. Intente ejecutar el programa, crear varios hilos de rosca y después cargar la DLL. Debe apreciar que la DLL ya no le sigue la pista correctamente al número de hilos en los variados procesos que lo utilizan. ¿Por qué es esto?. El archivo de la ayuda Win32 indica eso al usar la función punto de entrada con los parámetros DLL_THREAD_ATTACH y DLL_THREAD_DETACH:

DLL_THREAD_ATTACH indica que el proceso actual está creando un hilo nuevo. Cuando ocurre esto, el sistema llama a la función entry-point de todas las DLLs unidas actualmente al proceso. La llamada se hace en el contexto del nuevo hilo. Las DLLs pueden utilizar esta oportunidad de inicializar una ranura de TLS para el hilo. Un hilo que llama a la función entry-point de la DLL con el valor DLL_PROCESS_ATTACH no llama a la función entry-point de la DLL con el valor DLL_THREAD_ATTACH.

Observe que la función entry-point de una DLL es llamada con este valor solamente por los hilos creados después de que la DLL se una al proceso. Cuando una DLL es cargada con LoadLibrary, los hilos existentes no llaman a la función entry-point de la DLL recientemente cargada.

Lo que conduce a:

DLL_THREAD_DETACH indica que un hilo ha terminado limpiamente. Si la DLL ha almacenado un puntero a la memoria asignada en una ranura de TLS, utiliza esta oportunidad para liberar la memoria. El sistema operativo llama a la función entry-point de todas las DLLs que estan cargadas actualmente con este valor. La llamada se hace en el contexto del hilo que termina. Hay casos en los cuales la función entry-point es llamada por un hilo que termina incluso si el DLL nunca se ha unido al hilo en cuestión.

  • El hilo era el hilo inicial en el proceso, así que el sistema llamó a la función entry-point con el valor DLL_PROCESS_ATTACH.
  • El hilo ya funcionaba cuando fue hecha una llamada a la función, así que el sistema nunca llamó a la función entry-point para ella"

Este comportamiento tiene dos efectos secundarios potencialmente desagradables.

  • No es posible, por lo general no perder de vista cuántos hilos están en la DLL sobre una base global, a menos que uno pueda garantizar que una aplicación carga la DLL antes de crear cualquier hilo hijo. Uno podría asumir equivocadamente que una aplicación que carga una DLL tendría el punto de entrada de DLL_THREAD_ATTACH llamado para los hilos ya existentes. éste no es el caso porque, garantizando que las uniones y las separaciones del hilo están notificadas a la DLL en el contexto del hilo que se une o que se separa, es imposible llamar al punto de entrada de la DLL en el contexto correcto de los hilos que están funcionando ya.
  • Puesto que el punto de entrada de la DLL puede ser llamado por varios hilos, las condiciones de carrera pueden ocurrir entre la función del punto de entrada y la inicialización de la DLL. Si un hilo se crea casi al mismo tiempo que la DLL es cargada por una aplicación, entonces es posible que el punto de entrada de la DLL se pudo llamar para el accesorio del hilo mientras que el cuerpo principal del hilo todavía se está ejecutando. Esta es la razón por la cual es siempre una buena idea instalar la función del punto de entrada como la última acción en la inicialización del DLL.

Los lectores se beneficiarían al observar que ambos efectos secundarios tienen repercusiones al decidir cuando fijar la variable IsMultiThread.

Control de Excepciones.

Al escribir aplicaciones robustas, el programador debe prepararse siempre para las cosas que van a ir mal. Lo mismo es cierto para la programación multihilo. La mayoría de los ejemplos presentados en esta tutorial en particular han sido relativamente simples, y el control de excepciones se ha omitido sobre todo para mantener claridad. En aplicaciones del mundo real, esto es probablemente inaceptable.

Recuerde que los hilos tienen su propia pila de llamadas. Esto significa que una excepción en un hilo no cae dentro de los mecanismos de control de excepción estándares de la VCL. En vez de levantar una caja de diálogo, una excepción no controlada abortará la aplicación. Como resultado de esto, el método Execute de un hilo es uno de los pocos lugares en donde puede ser útil crear a un contolador de excepciones que capture todas las excepciones. Una vez que una excepción se haya capturado en un hilo, trabajar con ella es también un poco diferente al manejo ordinario que hace la VCL. Puede no ser apropiado demostrar una caja de diálogo siempre. Muy frecuentemente, una táctica válida es dejar que el hilo comunique al hilo principal de la VCL el hecho de que una falla ha ocurrido, usando cualquiera de los mecanismos de comunicación usuales, y después dejar que el hilo de la VCL decida qué hacer. Esto es particularmente útil si el hilo de la VCL ha creado el hilo hijo para realizar una operación en particular.

A pesar de esto, hay algunas situaciones con los hilos donde el tratar casos de error puede ser particularmente difícil. La mayoría de estas situaciones ocurren cuando se usan hilos para realizar operaciones de fondo continuas. Recordando el capítulo 10, el BAB tiene un par de hilos con operaciones de lectura y escritura en el hilo de la VCL a un buffer bloqueante. Si un error ocurre en cualquiera de estos hilos, puede mostrar una relación no muy clara con ninguna operación dentro del hilo de la VCL, y puede ser difícil comunicar la falla inmediatamente de regreso al hilo de la VCL. No solamente esto, una excepción cualquiera en éstos hilos probablemente romperan con el bucle de lectura y escritura en el que están, planteando la difícil pregunta de si estos hilos pueden ser recomenzados provechosamente. Lo mejor que puede hacerse es fijar un cierto estado que indique que todas las operaciones futuras fallarán, forzando al hilo principal que destruya y para volva a iniciar el buffer.

La mejor solución es incluir la posibilidad de tales problemas en el diseño original de la aplicación, y determinar las mejores tentativas de recuperación que se puedan hacer.

La BDE.

En el capítulo 7, indiqué que una solución potencial a los problemas de bloqueo es poner datos compartidos en una base de datos, y utilizar La BDE para realizar control de concurrencia. El programador debe observar que cada hilo debe mantener una conexión separada de la base de datos para que esto trabaje correctamente. Por lo tanto, cada hilo debe utilizar un objeto TSession separado para manejar su conexión a la base de datos. Cada aplicación tiene un componente TSessionList llamado Sessions para permitir que esto se haga fácilmente. La explicación detallada de sesiones múltiples está más allá del alcance de este documento.

Martin Harvey -