Introducción al multiprocesamiento en C++



El multiprocesamiento es una característica de la programación de sistemas imprescindible hoy en día, y aunque existen mecanismos alternativos a la creación de hilos de ejecución para ciertas tareas como las llamadas al sistema para operaciones de Entrada/Salida que pueden ser ejecutadas de forma asíncrona con IOCP en Windows, EPOLL en Linux y KQUEUE en otros Unix, el multiprocesamiento puede ofrecernos muchas ventajas sobre todo con los procesadores multi núcleo de los que hoy disponemos.

Como no podía ser de otra manera, y para ponernos las cosas más difíciles, UNIX y Windows implementan su paquete de subprocesos y su API de forma diferente. Existen algunas diferencias a nivel de implementación entre una y otra de las que no voy a hablar en esta Introducción al mutiprocesamiento en C++ pues sobrepasa el objeto de esta entrada.

Linux, al igual que otros UNIX, implementa el paquete de subprocesos POSIX 1003.1c estandarizada por el IEEE como parte de los estándares POSIX. Los subprocesos POSIX se conocen comúnmente como pthreads y en Linux podemos referirnos a ellos como Native POSIX Threads Library (NPTL) o pthreads de forma indistinta, estamos hablando del mismo paquete.

¿Qué es el multiprocesamiento?

El multiprocesamiento o multihilo o por su nombre en Inglés multithreading, es la habilidad de un sistema informático de ejecutar más de un hilo de ejecución a la vez. En una máquina moderna, el procesador no ejecuta los procesos de forma secuencial sino que cambia de contexto (de proceso o hilo) ejecuta unas pocas instrucciones, y vuelve a cambiar de contexto repitiendo ese ciclo de manera infinita.

Esto nos da la sensación de que la máquina está ejecutando muchos procesos en paralelo pero eso es una falacia, en realidad, el procesador solo puede ejecutar un proceso a la vez. En el caso de procesadores con varios núcleos, el procesador puede ejecutar tantos procesos o hilos en paralelo como núcleos disponga y cada núcleo trabaja de la misma forma.

El Thread

Un thread es básicamente una sección de código independiente que el procesador puede ejecutar de forma concurrente junto a otros threads o hilos de ejecución. En Linux y otros UNIX un thread se define como un contexto de ejecución mientras que Windows define un thread como algo separado de un proceso.

Siempre que ejecutamos una aplicación, se crea un thread llamado el main thread, desde él, se pueden crear nuevos threads que ejecuten otras partes del código de nuestra aplicación en paralelo o que escuchen en un socket o cualquier otra cosa que se nos ocurra y necesitemos.

Los threads deben interactuar entre si, por lo tanto es necesario implementar mecanismos de comunicación y sincronización pues la memoria de los mismos se comparte entre todos ellos por igual y todos ellos tienen acceso al mismo segmento de memoria en cualquier momento por lo que la integridad de los datos puede verse comprometida si no usamos primitivas de sincronización.

Sincronización

El problema principal con el multiprocesamiento es sin duda la sincronización. Por ejemplo, podemos tener definido un buffer de 128 bytes y decirle al main thread que el buffer está listo para su lectura. Pero, ¿qué pasa si otro thread comienza a reescribir el buffer mientras el main thread está aún leyendo del buffer?. El buffer será sobreescrito por el segundo thread y como consecuencia nuestra aplicación estará leyendo datos incorrectos lo que provocará en el mejor de los casos la segmentación de la misma y en el peor errores arbitrarios y difíciles de depurar.

A este tipo de problemas se les suele llamar secciones críticas. Por lo tanto se hace necesario un mecanismo de sincronización de threads que permita acceder a las variables compartidas por varios hilos de ejecución de forma segura, las primitivas de sincronización.

Mutex

Un mutex (que proviene del Inglés mutual exclusion) es la primitiva de sincronización disponible más simple (y más efectiva casi siempre). Esencialmente, un mutex es una variable de tipo boleano asociada a un objeto que determina si el objeto esta bloqueado (locked) o desbloqueado (unlocked).

Cuando un thread quiere hacer uso del objeto bloquea el mutex indicando a cualquier otro thread (incluido el main thread) que si quiere acceder a los datos del objeto, debe esperar hasta que el mutex esté desbloqueado, momento en el que el thread en espera, adquirirá (bloqueará) el mutex y hará uso de los datos.

Por regla general en los sistemas operativos modernos, cuando un thread intenta adquirir un mutex que está siendo utilizado por otro thread se echa a dormir para no gastar ciclos de CPU inútilmente hasta que el mutex se libera. Aunque esto último es deseable pues dota al sistema de multiprocesamiento de mayor efectividad, también causa uno de los problemas más temidos y comunes con los threads, el deadlock.

Un deadlock se produce cuando un thread trata de adquirir un mutex que hemos olvidado liberar, todo aquel thread que intente adquirir el mutex que hemos olvidado liberar se echará a dormir para siempre dando como resultado un subproceso zombie (cereeeeebroooooos…).

Semáforos

Un semáforo funciona de forma similar al semáforo que hay al final de tu calle. El semáforo permite a varios vehículos (threads) acceder a los datos de un objeto, pero a diferencia de un semáforo normal que permite el paso en el tiempo, nuestro semáforo solo permite el paso de un número predefinido de vehículos (threads) hacia nuestros datos.

Aunque los semáforos son más flexibles que los mutex no son tan útiles como éstos últimos. Los semáforos son más útiles cuando se usan conjuntamente con los mutex para crear sistemas que por ejemplo permitan el acceso a los datos de un objeto para lectura a muchos threads pero solo uno que pueda escribir en ellos cuando ningún otro thread está leyendo.

Variables de condición

Las variables de condición, también conocidas como objetos de condición o condition objects en Inglés, nos ofrecen un gran control sobre el tiempo de ejecución de los threads. Básicamente una variable de condición dispone de dos métodos: wait y signal.

Por ejemplo, podemos tener varios threads que procesan la llegada a través de un socket de una estructura de datos compleja pero no necesitamos que estén siempre activos hasta que no haya datos que procesar. Podemos mandarlos a dormir hasta que una señal sea disparada y “se despierten“. Ese sistema puede ser implementado utilizando variables de condición y señales.

Problemas del Multiprocesamiento

Los threads son muy potentes, y útiles, pero también pueden ser como un grano en el culo. Cuando muchos programadores descubrimos los threads por primera vez abusamos de ellos sin tener apenas experiencia y es entonces cuando depurar nuestras flamantes aplicaciones multithreading se convierte en un verdadero infierno.

Algunos de los inconvenientes de los threads se detallan a continuación.

Los threads consumen más memoria

Tanto en UNIX como en Windows, cada nuevo thread requiere de su propia pila (o stack) donde almacenar variables locales. Al fin y al cabo, cada thread es un subproceso y cada proceso necesita su propia pila.

El tamaño de la pila, varía de implementación a implementación pero puede ir desde el rango de unos pocos kilobytes a múltiples megabytes. Hay que tener esto en cuenta antes de crear miles de threads sin ninguna consideración.

Los threads requieren más procesamiento

En sistemas con un único procesador, los threads provocan una sobrecarga puesto que el task manager del sistema operativo debe realizar ciertos cálculos para saber que thread debe ser ejecutado y durante cuanto tiempo y eso consume ciclos de CPU. Cada nuevo thread añade un poco de sobrecarga al sistema.

En sistemas donde existen más procesadores o núcleos la sobrecarga es despreciable y nuestra aplicación se ejecutará más deprisa al estar programada para aprovechar las ventajas del multiprocesamiento.

Corrupción de datos

La corrupción de datos es un gran problema relevante al multiprocesamiento. Si no desarrollamos nuestras aplicaciones con una política de gestión de acceso a recursos y unos mecanismos de sincronización adecuados, tendremos serios problemas.

Cuando un thread escribe datos en un objeto debemos estar completamente seguros de que ningún otro thread está accediendo a esos datos para leer o escribir en ellos. La corrupción de datos puede dar lugar a la aparición de muchos bugs inexplicables que te hagan perder una ingente cantidad de tiempo hasta encontrarlos.

Deadlock

Ver sección Mutex

Conclusión

En esta primera Introducción al multiprocesamiento en C++ hemos aprendido lo que es el multiprocesamiento, que es un thread y qué es y por qué es necesaria la sincronización de threads. En la próxima entrega entraremos en materia creando un Wrapper en C++ alrededor de las librerías nativas para threads de Windows y UNIX.



Más Información :


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

Portada de Genbeta