El problema de Productores y Consumidores es uno de los ejemplos clásicos de acceso a recursos compartidos que debe arbitrarse mediante algún mecanismo de concurrencia que implemente la exclusión mutua.
Definición del problema productores y consumidores
La competencia por un recurso en memoria es un estado mediante en el cual uno o varios procesos tienen dependencia de algún recurso que podría no estar disponible en algún momento.
La dependencia de recursos en el estado con un solo proceso generalmente es secuencial, en donde el proceso dependiente tiene que consumir ciclos de reloj en espera de la liberación del recurso que necesita para continuar con su procesamiento.
No obstante el tiempo de ejecución en el panorama secuencial se puede alargar mucho más cuando el recurso tarda en llegar o bien por labores de intentos de sincronización fuera de tiempo.
Por tal motivo uno de los primeros planteamientos realizados para evitar la competencia de recursos es el uso de hilos de ejecución (threads) con la capacidad de comunicarse entre sí a través de un procedimiento de exclusión mutua.
Sin embargo el mecanismo es pensado en un solo procesador. Más adelante nacieron los procesadores multicore, en donde cada core es capaz de ejecutar instrucciones independientes uno de otro, lo cuál ha modificado el esquema tradicional de programación secuencial.
Aquí es donde nace el concepto de programación paralela. Un programa paralelo tiene la capacidad de ejecutar instrucciones en el mismo ciclo de reloj, pero para preservar sus beneficios de reducir enormemente los tiempos de ejecución, éstas instrucciones deben ser independientes de ejecución, es decir, reducir la sincronización hacia el mínimo de comunicación cuando existe dependencia de recursos, distribución de recursos en los procesadores y los mecanismos de exclusión mutua.
Existen varios procedimientos de exclusión mutua. Un ejemplo clásico es el uso de semáforos en productores y consumidores y también han surgido otros problemas en base al acceso concurrente.
El acceso concurrente por parte de procesos productores y consumidores sobre un recurso común se realiza por medio un buffer de elementos. Los productores tratan de introducir elementos en el buffer de uno en uno, y los consumidores tratan de extraer elementos de uno en uno.
Desarrollo del programa productores y consumidores
Para ejemplificar el desarrollo de un programa para productores y consumidores, asumimos que el productor es el que genera los recursos que van a utilizar los consumidores, por lo tanto los consumidores necesitan un proceso de sincronización con el productor para saber cuando el momento de consumir ha llegado.
En el ejemplo, hacemos uso del lenguaje C, lenguaje que se lleva muy bien con la programación paralela, ya que puede manejar apuntadores de memoria en forma más directa, y también para aprovechar las bondades que utiliza el manejo de hilos pthreads con el uso de la biblioteca OpenMp para procesamiento en paralelo en productores y consumidores.
Bibliotecas y declaración de variables
Como primer requisito para los productores y consumidores, requerimos las bibliotecas estándar, pthreads y OpenMp, así como declarar el buffer de recursos.
// #include#include #include #include #include #include #define TAMBUFF 30 typedef struct _recurso{ int buff[TAMBUFF]; int posactual; int total; }Recurso; Recurso buffer; pthread_mutex_t mutex_procons; int max;
De aquí podemos observar el recurso compartido, que consiste de un buffer, la posición actual y el total de elementos a producir/consumir. Para acelerar el acceso al recurso compartido se ha decidido utilizar un arreglo como buffer (localidades de memoria contiguas).
Así también tenemos un semáforo mutex para controlar el acceso (ya que un consumidor no puede consumir un recurso inexistente o un productor no puede producir más elementos de los que es el tamaño de buffer)
void *productor(void *id){ unsigned int milisegs; int id_t=(int)id; while(buffer.total<max){ pthread_mutex_lock(&mutex_procons); //Buffer lleno if(buffer.posactual==TAMBUFF-1){ fflush(stdout); pthread_mutex_unlock(&mutex_procons); //Esperar… milisegs=(1+rand()%2)1000; usleep(milisegs); continue; } //Insertamos elemento buffer.buff[buffer.posactual]+=1; buffer.posactual+=1;buffer.total+=1; fflush(stdout); pthread_mutex_unlock(&mutex_procons); milisegs=(1+rand()%2)1000; usleep(milisegs); } //Límite de producciones printf("El productor %d terminó\n",id_t); fflush(stdout); pthread_mutex_unlock(&mutex_procons); pthread_exit((void *)0); }
Éste es el proceso productor, su función es generar recursos para que el consumidor los consuma. Al producir un recurso, éste se posiciona en la posición siguiente del buffer desde donde nos encontramos en ese momento.
Al llegar a su total de elementos permitidos (buffer lleno), éste deja de producir más elementos hasta que se libere cuando menos un espacio en el buffer.
//Proceso consumidor void *consumidor(void *id){ int id_t=(int)id; unsigned int milisegs; while(buffer.total<max||buffer.posactual!=0){ pthread_mutex_lock(&mutex_procons); //Buffer vacío if(buffer.posactual==0){ fflush(stdout); pthread_mutex_unlock(&mutex_procons); milisegs=(1+rand()%2)*1000; usleep(milisegs); continue; } //Consumimos elemento buffer.posactual-=1; fflush(stdout); pthread_mutex_unlock(&mutex_procons); //Tiempo aleatorio de espera al consumir un recurso milisegs=(1+rand()%2)*1000; usleep(milisegs); } //Terminó el consumidor printf("El consumidor %d terminó\n",id_t); fflush(stdout); pthread_mutex_unlock(&mutex_procons); pthread_exit((void *)0); }
Éste es el proceso consumidor, su función es consumir los recursos generados por el productor. Al consumir el recurso desde el buffer, éste se libera.
Si el buffer está vacío, el consumidor espera hasta que se produzca al menos un elemento para consumir, si éste termina de consumir todos los elementos indicados en los argumentos, el programa termina.
int main(int argc,char *argv[]){ int i; double inicio,fin; //Verificación de argumentos if(argc!=4){ printf("Sintaxis: %s \n",argv[0]); return 1; } int totprod=atoi(argv[1]); int totcons=atoi(argv[2]); srand(time(NULL)); max=atoi(argv[3]); buffer.posactual=0; pthread_t product[totprod]; pthread_t consumi[totcons]; pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_JOINABLE); //Inicializamos mutex pthread_mutex_init(&mutex_procons,NULL); inicio=omp_get_wtime(); //Creamos los hilos for(i=0;i<totprod;i++) pthread_create(&product[i],&attr,productor,(void *)i); for(i=0;i<totcons;i++) pthread_create(&consumi[i],&attr,consumidor,(void *)i); //Ejecutamos los hilos, sin enviarles argumentos for(i=0;i<totprod;i++) pthread_join(product[i],NULL); for(i=0;i<totcons;i++) pthread_join(consumi[i],NULL); fin=omp_get_wtime(); pthread_attr_destroy(&attr); pthread_mutex_destroy(&mutex_procons); //Mostramos resultados printf("Total de productores: %d\n",totprod-1); printf("Total de consumidores: %d\n",totcons-1); printf("Ocupación del buffer\n"); for(i=0;i<TAMBUFF;i++) { printf("Celda [%d] = %d veces\n",i,buffer.buff[i]); } printf("Tiempo transcurrido: %lf segundos\n", ((double)fin-inicio)); return 0; }
Ésta es la función principal de productores y consumidores. Como se aprecia se genera un hilo por cada productor y consumidor invocado, al igual de toma el tiempo utilizando la biblioteca paralela openmp. Se inicializa un semáforo mutex para controlar el acceso al recurso compartido.
Para compilar el programa, se utiliza la siguiente línea de código:
gcc Productor_Consumidor.c -o Productor_Consumidor -lpthread -lgomp.
Para ejecutar el programa productores y consumidores, se ejecuta el programa así:
./Productor_Consumidor.c -o Productor_Consumidor [total de productores] [total de consumidores]
Al ejecutar el programa productores y consumidores con 100000 productos podemos ver los resultados deseados.
Por ejemplo al introducir 3 productores y 1 consumidor, 2 productores y 1 consumidor y 1 productor y 1 consumidor respectivamente se obtuvieron los siguientes tiempos, así como la ocupación de las celdas del buffer:

Y al hacerlo con 1 productor y 2 consumidores y además 1 productor y 3 consumidores respectivamente, vemos una tendencia al embotellamiento en las primeras casillas del buffer:

Otros problemas clásicos de programación paralela
Otro problema similar al de productores y consumidores, de concurrencia clásico es el barbero durmiente, el cuál maneja el acceso a una región crítica a través de un barbero que otorga el acceso a la región crítica y se duerme si no hay hilos pendientes.
3 comentarios en “Productores y Consumidores con OpenMp y Pthreads”