Introducción al multiprocesamiento en C++ II

En el post anterior hemos hablado sobre los conceptos teóricos del multiprocesamiento. En esta introducción al multiprocesamiento en C++ II vamos a empezar a meterle mano al código desarrollando una serie de clases que sirva como wrapper sobre las librerías nativas de subprocesos en C de UNIX y Windows

Como ya vimos anteriormente, la API y la implementación del sistema de subprocesos difieren en UNIX y en Windows. Si queremos desarrollar aplicaciones portables entre ambas plataformas, hemos de desarrollar algún tipo de wrapper alrededor de las librerías nativas escritas en C en ambas implementaciones para que la lógica adyacente quede oculta y encapsulada detrás de nuestras interfaces.

Como te puedes imaginar, ya existen librerías en C++ que hacen exactamente eso, por ejemplo tenemos las libZthreads que llevan sin actualización desde el 2005 por lo tanto podemos asegurar que el proyecto está bastante muerto.

Las librerías boost incluyen un wrapper sobre los Threads nativos. Por otro lado, las librerías de Intel Threading Building Blocks parece que están adquiriendo mucha popularidad por su facilidad y eficacia (seguramente les dedique algún post en el futuro a éstas últimas).

Aunque todas esas librerías están ya implementadas y listas para usar, siempre es deseable cuando no conveniente, entender como funciona la librería nativa en C de nuestro sistema operativo puesto que estamos tratando con sistemas críticos que han de ser comprendidos a la perfección.

GThreads

Como las librerías nativas en C para el manejo de multiprocesamiento difieren bastante entre UNIX y Windows nos vemos obligados a desarrollar una librería en C++ encima de ambas implementaciones que nos permita utilizar el mismo código en ambas plataformas. Vamos a llamar a esa librería GThreads (Genbeta Threads).

Nuestra librería debe ser capaz de crear threads, matar threads, esperar a que los threads finalicen y adquirir el ID de un thread entre otras. Vamos a utilizar el espacio de nombres GThreads para nuestra librería y así evitar cualquier posible conflicto de nombres con librerías de terceros.

Compilación condicional

Todos los que hayáis programado en C o C++ conocéis la compilación condicional por medio de las directivas #ifdef #ifndef #else #endif del preprocesador. Vamos a utilizar esas directivas en nuestra librería para compilar partes del código para la plataforma correcta.

Lo primero que debemos saber acerca de los threads en Windows es que están soportados de forma nativa por lo que si queremos hacer uso de ellos solo tenemos que incluir el archivo de encabezado windows.h en nuestro código:

#ifdef WIN32
    #include <windows.h>
    #include <map>
#else
    #include <pthread.h>
    #include <unistd.h>
#endif
Para mejorar la lectura de la librería y facilitar su uso, vamos a definir algunos tipos para universalizar un poco los nombres utilizados por la misma y facilitar el uso de los threads en Windows que tienen algunas particularidades un poco... pues eso, particulares.

Cuando creamos un nuevo thread tenemos que tener alguna forma de poder referenciarlo más tarde. UNIX usa un tipo de datos sencillo para referirse a un thread pthread_t mientras que Windows tiene dos valores asociados a él, un HANDLE hacia el objeto y una variable de tipo DWORD. Para simplificar todo esto haremos uso de la compilación condicional:

#ifdef WIN32
    typedef DWORD ThreadID;
    std::map<dword , HANDLE> g_handlemap;
#else
    typedef pthread_t ThreadID;
#endif
En la versión para Windows utilizamos un map de la librería estándar de templates para almacenar el HANDLE y relacionarlo con su ThreadID. En UNIX la cosa es bastante más sencilla.

Creando Threads

Cuando creamos un thread éste necesita un puntero a una función que ejecutar ademas de un puntero a un dato de tipo void. Las dos API's de threads que estamos implementando utilizan firmas diferentes para esa función que es pasada al nuevo thread. En la API de Windows la firma es la siguiente:

DWORD WINAPI Function( void* );
Mientras que la de UNIX es:
void *Function( void* );
Vamos a utilizar un tipo custom que concuerda con esta última firma y después utilizaremos un objeto de tipo proxy para invocar al método adecuado según la plataforma en la que nos encontremos en tiempo de compilación.
typedef void (ThreadFunc)(void);
Cuando queramos crear un thread siempre pasaremos tanto la función que queremos invocar como los parámetros a través de la estructura de datos del proxy a una función especial que traducirá de forma transparente los punteros a la función al tipo correcto.

La estructura de datos del proxy

La función especial proxy que utilizaremos necesita saber solo dos cosas, la función a invocar y los parámetros que pasarle y solo acepta un parámetro de tipo void* así que debemos empaquetar esos dos parámetros en un objeto.

class ProxyData
{
public:
    ThreadFunc m_func;
    void *m_params;
}
....

ifdef WIN32

DWORD WINAPI ProxyFunc(void *p_data)

else

void *ProxyFunc(void *p_data)

endif

{ // Convertimos los datos a ProxyData ProxyData *data = (ProxyData*)p_data;

// Ejecutamos la funcion
data->m_func(data->m_params);

// Eliminamos los datos
delete data;

return 0;

}

Esta forma de invocar la función que ejecutará el thread no es ni la mejor ni la más adecuada ni la más bonita, seguramente se podría haber hecho con macros o de otra forma pero es la que menos compleja me parece.

Levantando el thread

Para levantar el thread las funciones a utilizar no solo tienen firmas diferentes sino que son completamente diferentes en Windows y en UNIX. Para el primero se usa la función CreateThread mientras que para UNIX usaremos pthread_create. Nuestro wrapper tendrá esta firma:

inline ThreadID Create(ThreadFunc p_func, void *p_param)
El método devuelve un ThreadID y toma un puntero a ThreadFunc y un puntero a void. La función levantará un nuevo thread que ejecutará p_func con p_param parámetros.
inline ThreadID Create(ThreadFunc p_func, void *p_param) {
    ThreadID th;
    // Creamos un nuevo bloque de datos ProxyData en el heap
    ProxyData *data = new ProxyData;
    // Rellenamos la estructura con los punteros a los datos
    data->m_func = p_func;
    data->m_params = p_param;

ifdef WIN32

// Creamos un thread en Windows
HANDLE h;
h = CreateThread(NULL, 0, ProxyFunc, data, 0, &th);
if (h != 0) 
{
    // Añadimos el handle al mapa de handles
    g_handlemap[th] = h;
}

else

// Creamos un thread en UNIX
pthread_create(&th, 0, ProxyFunc, data);

endif

if (th==0) 
{
    // Borramos los datos primero
    delete data;
    // Lanzamos una excepcion
    throw Exception(CreationFail);
}

return th;

}

Como podemos comprobar, es bastante más complejo crear un thread en Windows que en UNIX, para que luego digan que UNIX es más difícil :). Los comentarios del código son bastante descriptivos así que voy a obviar la explicación del código anterior por que creo que está bastante claro.

Aún tenemos que aprender a obtener el ID de un thread, matarlo, esperar a que termine y echarlo a dormir, algo que veremos en el próximo post de la serie.


En Genbeta Dev | Introducción al multiprocesamiento en C++

Portada de Genbeta