marzo 28, 2024

BitCuco

¡Hola Mundo!

Productores y Consumidores con OpenMp y Pthreads

productores y consumidores

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:

productores y consumidores

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:

productores y consumidores

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.