Hilos

Hilos

Esto es una primera aproximación al uso de hilos en Windows. El tema es mucho más extenso y complejo de lo que se explica aquí, pero daremos algunas nociones para poder usarlos en nuestros programas y más concretamente, en juegos para la consola de Windows.

Para empezar, supongo que sabemos que Windows es un sistema operativo multitarea. Esto quiere decir que el sistema es capaz de gestionar los recursos de modo que varios procesos se puedan ejecutar al mismo tiempo.

Nota:

Otros sistemas operativos actuales también son multitarea, como Linux. Sin embargo, las funciones que explicaremos aquí son exclusivas del API de Windows, aunque no así los conceptos como thread o hilo y mutex, etc. En otros artículos seguramente expliquemos cómo usar estas técnicas aplicadas a otros sistemas operativos.

En realidad, el número de procesos que se pueden ejecutar simultáneamente es limitado, y mucho. Concretamente, el límite lo pone el número de procesadores. Con un procesador de dos núcleos se pueden ejecutar dos tareas simultáneas, con uno de cuatro núcleos, cuatro, etc.

Sin embargo, el sistema puede simular la ejecución simultánea de muchas más tareas, dividiéndolas en pequeñas partes, y ejecutando secuencialmente partes de distintas tareas. Como el procesador es muy rápido, para el usuario los procesos parece que se ejecutan a la vez.

Generalmente tendemos a pensar que la multitarea se limita a poder tener abiertas varias ventanas con distintas aplicaciones a la vez, pero hay mucho más. Por ejemplo, un navegador como Firefox, puede tener abiertas varias páginas a la vez. A su vez, cada página, mientras se carga, puede estar descargando varios recursos: texto, imágenes, etc, y actualizando la pantalla a medida que los descarga.

En nuestros programas podemos hacer algo similar. Podemos crear hilos para realizar diferentes tareas, que no interfieran con el programa principal: comprobar el estado del teclado, mostrar la hora en pantalla, mover personajes distintos del jugador, etc.

Cada hilo es en realidad una función de nuestro programa, que se ejecuta en paralelo con él, y con la que podemos intercambiar información. Esto libera al programa principal de tareas de consulta y de esperas de eventos concretos.

La función CreateThread

Lo primero que tenemos que tener en cuenta es que un hilo es un recurso. Cada vez que creemos un hilo, el sistema creará un manipulador al que irá asociado el hilo, pero también reservará otros recursos, como una pila, memoria, etc. Es responsabilidad nuestra liberar esos recursos cuando ya no los necesitemos.

Para crear un hilo se usa la función CreateThread. Esta función devuelve un manipulador, un objeto de tipo HANDLE, que nos sirve para referirnos a ese hilo.

La función requiere seis parámetros, pero de momento sólo usaremos dos, el tercero y el cuarto. El tercero es la función que define el hilo, y el cuarto el parámetro que recibirá esa función al ser invocada. El resto de los parámetros pueden ser cero.

Otra cosa que necesitamos es, evidentemente, la función que debe ejecutar el hilo. Esa función debe tener el siguiente prototipo:

DWORD WINAPI ThreadFunction( void* );

El parámetro es un puntero genérico que podemos usar para pasar cualquier tipo de dato, ya que después podemos hacer un casting al tipo adecuado.

Bien, lo siguiente que necesitamos es el dato que pasaremos al hilo. Puede ser un puntero nulo, si el hilo no necesita parámetros. Puede ser un puntero a un tipo fundamental, o un puntero a una estructura o incluso a un objeto de una clase.

Para este ejemplo usaremos una entero.

int parametro;
HANDLE hHilo = CreateThread( NULL, 0, ThreadFunction, (void*)&parametro, 0, NULL);

En ese momento, el hilo empieza a ejecutarse.

El hilo debería disponer de algún mecanismo para terminar cuando queramos cerrar la aplicación. Esto es necesario porque no podremos destruir el manipulador del hilo mientras se siga ejecutando, y necesitamos destruir ese manipulador antes de salir de la aplicación.

En rigor, el sistema operativo libera esos recursos cuando el proceso que ha lanzado los hilos temina, pero según la documentación de Windows, se pueden producir pequeñas fugas de memoria.

Una vez termine el hilo, para liberar el manipulador usaremos la función CloseHandle.

CloseHandle(hHilo);

Primer ejemplo

Veamos un primer ejemplo sencillo. Crearemos un hilo que mostrará la hora en una esquina de la pantalla. Nuestra función para eso es esta:

DWORD WINAPI MostrarHora( void* par) {
    bool *salir = (bool*)par;  // Recuperar el parámetro, un puntero a bool
    SYSTEMTIME st;             // Estructura para obtener la hora
    COORD dwPos = {67,1};      // Coordenadas de pantalla para mostrar hora
    int seg = 0;               // Variable auxiliar para detectar cambio de segundo

    do {
        // Esperamos a que cambie el segundo:
        while(seg == st.wSecond) {
            GetLocalTime(&st);
            Sleep(100);
        }
        // Colocar el cursor:
        SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), dwPos);
        // Mostrar hora:
        printf("%02d:%02d:%02d", st.wHour, st.wMinute, st.wSecond);
        // Almacenar el segundo actual:
        seg = st.wSecond;
    } while(!*salir); // Repetir hasta que salir sea true

    printf("\nAbandonando el hilo\n");
    return 0;
}

En la función main creamos el hilo, que se seguirá ejecutando indefinidamente, hasta que pulsemos una tecla, momento en el que se modificará el valor de salir y el programa termina:

int main() {
    HANDLE hHora;
    bool salir = false;

    // Crear el hilo
    hHora = CreateThread( 0, 0, MostrarHora, (void*)&salir, 0, 0);

    // Esperar a que el usuario pulse una tecla, el hilo se sigue ejecutando:
    printf("Pulsa una tecla para salir");
    getchar();
    salir = true;

    // Dejar tiempo a que termine el hilo:
    Sleep(2000);

    // Liberar el manipulador:
    CloseHandle(hHora);
    return 0;
}
Nombre Fichero Fecha Tamaño Contador Descarga
Ejemplo 1 de hilos thread1.zip 2012-07-31 1300 bytes 524

Varios hilos con la misma función

La misma función que usamos para ejecutar un hilo puede usarse para crear varios hilos. El sistema es lo bastante potente como para usar el mismo código con un juego diferente de variables.

Vamos a modificar el ejemplo anterior para que podamos mostrar la hora en diferentes partes de la pantalla. Para ello crearemos una estructura con las coordenadas y la variable 'salir':

struct stHora {
    int x;
    int y;
    bool salir;
};

Tendremos que modificar la función del hilo para que tenga en cuenta esta estructura:

DWORD WINAPI MostrarHora( void* par) {
    stHora *hora = (stHora*)par;      // Recuperar el parámetro, un puntero a stHora
    SYSTEMTIME st;                    // Estructura para obtener la hora
    COORD dwPos = {hora->x, hora->y}; // Coordenadas de pantalla para mostrar hora
    int seg = 0;                      // Variable auxiliar para detectar cambio de segundo

    do {
        // Esperamos a que cambie el segundo:
        while(seg == st.wSecond) {
            GetLocalTime(&st);
            Sleep(100);
        }
        // Colocar el cursor:
        SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), dwPos);
        // Mostrar hora:
        printf("%02d:%02d:%02d", st.wHour, st.wMinute, st.wSecond);
        // Almacenar el segundo actual:
        seg = st.wSecond;
    } while(!hora->salir); // Repetir hasta que salir sea true

    printf("\nAbandonando el hilo\n");
    return 0;
}

Finalmente, en la función 'main' modificamos algunas sentencias:

int main() {
    HANDLE hHora;    stHora hora={70, 1, false};

    // Crear el hilo
    hHora = CreateThread( 0, 0, MostrarHora, (void*)&hora, 0, 0);

    // Esperar a que el usuario pulse una tecla, el hilo se sigue ejecutando:
    printf("Pulsa una tecla para salir");
    getchar();
    hora.salir = true;

    // Dejar tiempo a que termine el hilo:
    Sleep(2000);

    // Liberar el manipulador:
    CloseHandle(hHora);
    return 0;
}

Ahora es posible añadir un segundo hilo:

int main() {
    HANDLE hHora;
    HANDLE hHora2;
    stHora hora={70, 1, false};
    stHora hora2={1, 1, false};

    // Crear el hilo
    hHora = CreateThread( 0, 0, MostrarHora, (void*)&hora, 0, 0);
    hHora2 = CreateThread( 0, 0, MostrarHora, (void*)&hora2, 0, 0);

    // Esperar a que el usuario pulse una tecla, el hilo se sigue ejecutando:
    printf("Pulsa una tecla para salir");
    getchar();
    hora.salir = true;
    hora2.salir = true;

    // Dejar tiempo a que termine el hilo:
    Sleep(2000);

    // Liberar el manipulador:
    CloseHandle(hHora);
    CloseHandle(hHora2);
    return 0;
}

Ejemplo 2

Nombre Fichero Fecha Tamaño Contador Descarga
Ejemplo 2 de hilos thread2.zip 2012-07-31 1373 bytes 537

Sincronización

Si has compilado el ejemplo anterior, probablemente has notado que algo no va del todo bien.

El problema es que los dos hilos están accediendo a la pantalla. Primero establecen las coordenadas donde se debe mostrar la hora, y luego la muestran. Pero estas dos acciones no son atómicas, es decir el hilo puede ser interrumpido entre las dos sentencias. El resultado es que una instancia del hilo establece las coordenadas, en ese momento es interrumpido por la otra instancia, que establece unas coordenadas diferentes, y muestran la hora. La ejecución retorna al hilo interrumpido, que de nuevo muestra la hora, pero como las coordenadas que había establecido ya no son las actuales, lo hace en el lugar equivocado.

Se trata de un problema frecuente cuando se trabaja con recursos limitados. En este caso, el cursor de pantalla es un recurso único, y sólo puede apuntar a una coordenada concreta.

Lo que tenemos que hacer es establecer algún tipo de sincronización, de modo los hilos no puedan ser interrumpidos durante acciones críticas, que deben ser atómicas.

Esto no es tan complicado como pudiera parecer. Afortunadamente, el sistema operativo lo tiene previsto, y nos proporciona herramientas para solucionar el problema.

En este caso usaremos un mutex, o exclusión mutua (en inglés 'mutual exclusion' se abrevia como mutex). Se trata un objeto que sólo puede pernecer a un proceso. La idea es que cuando necesitemos acceso a la pantalla pidamos y esperemos a que el mutex esté libre. Cuando terminemos de usar la pantalla liberamos el mutex para dejar que otros procesos lo usen. Es el equivalente al 'testigo' de las carreras de relevos, sólo corre el atleta que tiene el testigo, o en nuestro caso, sólo puede pintar aquel proceso que posee el mutex.

Para crear un mutex usaremos la función CreateMutex (curioso nombre, ¿no?).

Esta función nos devuelve un manipulador de un objeto mutex, que podremos usar para invocar funciones que lo pidan y esperen hasta que esté libre si actualmente lo posee otro hilo.

Si hemos creado un mutex con nombre, cada hilo puede obtener un manipulador del mutex intentando crear el mismo mutex, o bien usando la función OpenMutex.

Cuando queramos trazar un texto en pantalla, pediremos el mutex antes de establecer las coordenadas, trazaremos el texto, y liberaremos el mutex.

Para pedir el mutex hay varias posibles funciones. En nuestro caso, al tratarse de una aplicación de consola, y como sólo necesitamos que un mutex esté disponible, la función a usar es WaitForSingleObject.

Bien, modifiquemos el ejemplo anterior para añadir un mutex. Lo crearemos en la función main:

    HANDLE hMutex = CreateMutex(NULL, FALSE, "MutexAccesoPantalla");
	...

Cuando el programa haya terminado, y antes de abandonar la ejecución, cerraremos el manipulador:

   ...
   CloseHandle(hMutex);

Exploraremos varias maneras de usar el mutex en nuestro programa multihilo. La primera forma que se me ocurre es sustituir las llamadas a SetConsoleCursorPosition y fprintf por una función nueva que tenga en cuenta las probables llamadas concurrentes a esa pareja de funciones, y las haga de forma atómica, haciendo uso del mutex. Por ejemplo:

void TrazarTexto(int x, int y, char *texto) {
    HANDLE hMutex;
    COORD dwPos = {x, y};

    hMutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, "MutexAccesoPantalla");

    if(WaitForSingleObject(hMutex, INFINITE) == WAIT_OBJECT_0){
        SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), dwPos);
        printf("%s", texto);
        ReleaseMutex(hMutex);
    }
    CloseHandle(hMutex);
}

Esta función obtiene una copia del mutex mediante OpenMutex. Después espera de forma indefinida a que esté libre, con la función WaitForSingleObject. Si la función tiene éxito, muestra el texto en las coordenadas indicadas, y libera el mutex. Finalmente, cierra la copia del manipulador del mutex.

Finalmente, modificamos el código del hilo para que use esta nueva función:

DWORD WINAPI MostrarHora( void* par) {
    stHora *hora = (stHora*)par;      // Recuperar el parámetro, un puntero a stHora
    SYSTEMTIME st;                    // Estructura para obtener la hora
    int seg = 0;                      // Variable auxiliar para detectar cambio de segundo
    char texto[9];                    // Variable con el texto de la hora

    do {
        // Esperamos a que cambie el segundo:
        while(seg == st.wSecond) {
            GetLocalTime(&st);
            Sleep(100);
        }
        // Mostrar la hora:
        sprintf(texto, "%02d:%02d:%02d", st.wHour, st.wMinute, st.wSecond);
        TrazarTexto(hora->x, hora->y, texto);
        // Almacenar el segundo actual:
        seg = st.wSecond;
    } while(!hora->salir); // Repetir hasta que salir sea true

    printf("\nAbandonando el hilo\n");
    return 0;
}

Una ventaja adicional es que disponemos de una función para mostrar texto en pantalla, en las coordenadas que queramos, que no interferirá con otros hilos. Ahora usaremos esa función para mostrar el texto "Pulsa una tecla para salir":

    TrazarTexto(4,10,"Pulsa una tecla para salir");