Gentoo Logo

Renuncia de responsabilidad: La versión original de este artículo fue publicada por IBM developerWorks y es propiedad de Westtech Information Services. Este documento es una versión actualizada del artículo original y contiene mejoras introducidas por el Equipo de Documentación de Gentoo.
Este documento carece de soporte activo.


Explicación de los hilos POSIX, parte 3

Contenido:

1.  Mejorar la eficiencia con variables condicionales

Explicación de las variables condicionales

Finalicé mi anterior artículo describiendo un dilema concreto acerca de cómo trata un hilo una situación en la que está esperando a que se cumpla una condición. Puede bloquear y desbloquear el mutex repetidamente, comprobando una cierta estructura de datos para verificar la presencia de un valor. Pero eso sería una pérdida de tiempo y de recursos, esta forma de manejar los datos en un bucle muy ocupado es extremadamente ineficaz. La mejor forma de hacerlo es usar la llamada pthread_cond_wait() para esperar a que una determinada condición sea cierta.

Es muy importante comprender lo que hace pthread_cond_wait() -- es el corazón del sistema de señalización de los hilos POSIX y, también, la parte más difícil de comprender.

Primero, consideremos un escenario donde un hilo ha bloqueado un mutex, para ver una lista enlazada, y resulta que la lista está vacía. Este hilo en concreto no podrá hacer nada -- está diseñado para eliminar un nodo de la lista, pero no hay nodos disponibles. Así que esto es lo que hace.

Mientras que mantiene el mutex bloqueado, nuestro hilo llamará a pthread_cond_wait(&mycond,&mymutex). La llamada pthread_cond_wait() es bastante compleja, así que veremos cada una de sus operaciones, una por una.

La primera cosa que pthread_cond_wait() hace es desbloquear simultáneamente el mutex mymutex (para que otros hilos puedan modificar la lista enlazada) y esperar a la condición mycond (para que pthread_cond_wait() despierte cuando reciba una señal desde otro hilo). Ahora que el mutex está desbloqueado, otros hilos pueden acceder y modificar la lista enlazada, posiblemente añadiendo elementos a la misma.

En este momento, la llamada pthread_cond_wait() aún no ha retornado. El desbloqueo del mutex ocurre inmediatamente, pero esperar a la condición mycond es normalmente una operación de bloqueo, lo cual significa que nuestro hilo se irá a dormir, sin consumir ciclos de CPU hasta que se despierte. Esto es exactamente lo que queremos que ocurra. Nuestro hilo está durmiendo esperando a que se cumpla una determinada condición, sin realizar ningún bucle muy ocupado que desperdicie tiempo de la CPU. Desde la perspectiva de nuestro hilo, sencillamente está esperando a que retorne la llamada pthread_cond_wait().

Ahora, para continuar con la explicación, digamos que otro hilo (lo llamaremos "thread 2") bloquea mymutex y añade un elemento a nuestra lista enlazada. Inmediatamente después de desbloquear el mutex, "thread 2" llama a la función pthread_cond_broadcast(&mycond). Haciendo esto, daremos lugar a que todos los hilos esperando a la variable condicional mycond despierten. Esto significa que nuestro primer hilo (que se encuentra en plena llamada pthread_cond_wait()) despertará.

Ahora, veamos lo que ocurre con nuestro primer hilo. Después de que "thread 2" ha llamado a pthread_cond_broadcast(&mymutex) puede pensarse que pthread_cond_wait() del primer hilo retornará inmediatamente. ¡Ni mucho menos!, en su lugar pthread_cond_wait() realizará una última operación: volver a bloquear el mutex. Una vez que pthread_cond_wait() tiene el bloqueo de nuevo, entonces retornará y permitirá al primer hilo seguir ejecutándose. En este momento, puede comprobar inmediatamente la lista para comprobar cualquier cambio interesante.

Paremos y demos un repaso

Listado de Código 1.1: queue.h

/* queue.h
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
*/
typedef struct node {
  struct node *next;
} node;
typedef struct queue {
  node *head, *tail;
} queue;
void queue_init(queue *myroot);
void queue_put(queue *myroot, node *mynode);
node *queue_get(queue *myroot);

Listado de Código 1.2: queue.c

/* queue.c
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
**
** Este conjunto de funciones de cola fue originalmente diseñado con hilos.
** Pero he rediseñado el código para que ignoren los hilos (haciendo genéricas,
** aburridas, aunque muy rápidas, rutinas para tratar las colas). ¿Por qué se
** ha cambiado? Porque tiene más sentido tener el soporte adicional de hilos
** como un añadido opcional. Consideremos una situación en la que queremos
** añadir cinco nodos a la cola. Con la versión que tiene los hilos habilitados
** cada llamada a queue_put() bloqueará y desbloqueará el mutex de la cola
** cinco veces -- esto supone una sobrecarga innecesaria. De esta forma,
** moviendo todo lo relacionado con los hilos fuera de las rutinas de cola,
** la llamada a la función puede bloquear el mutex una vez al comienzo, después
** insertar 5 elementos, y desbloquearlo por último. Eliminar el código de
** bloqueo y desbloqueo de las funciones de cola, permite optimizaciones que no
** son posibles de otra forma. También hace este código útil para aplicaciones
** que no hacen uso de hilos.
**
** También podemos habilitar los hilos en esta estructura de datos usando el
** tipo data_control definido en control.c y control.h. */
#include <stdio.h>
#include "queue.h"
void queue_init(queue *myroot) {
  myroot->head=NULL;
  myroot->tail=NULL;
}
void queue_put(queue *myroot,node *mynode) {
  mynode->next=NULL;
  if (myroot->tail!=NULL)
    myroot->tail->next=mynode;
  myroot->tail=mynode;
  if (myroot->head==NULL)
    myroot->head=mynode;
}
node *queue_get(queue *myroot) {
  //get from root
  node *mynode;
  mynode=myroot->head;
  if (myroot->head!=NULL)
    myroot->head=myroot->head->next;
  return mynode;
}

Listado de Código 1.3: control.h

#include <pthread.h>
typedef struct data_control {
  pthread_mutex_t mutex;
  pthread_cond_t cond;
  int active;
} data_control;

Listado de Código 1.4: control.c

/* control.c
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
**
** Estas rutinas proporcionan una forma fácil de hacer cualquier estructura de
** datos sensible a los hilos. Sencillamente hay que asociar una estructura
** data_control con la estructura de datos (creando un nuevo struct, por
** ejemplo). Después, sencillamente hay que bloquear y desbloquear el mutex, o
** incluir un wait/signal/broadcast en la variable condicional de la estructura
** data_control según se necesite.
**
** Los structs de data_control contienen un entero llamado "active". Este int
** puede usarse con un tipo específico de diseño multi-hilos, donde cada hilo
** comprueba el estado de "active" cada vez que bloquea el mutex. Si "active"
** es cero, el hilo sabrá que en lugar de realizar su rutina normal, deberá
** pararse por sí mismo. Si "active" es igual a 1, entonces deberá continuar
** normalmente. Por lo que definir "active" como 0, hará que un hilo de control
** pueda informar fácilmente a un conjunto de hilos que se detengan en lugar de
** procesar nuevos trabajos. Hay que usar las funciones control_activate()
** y control_deactivate(), las cuales difundirán la variable condicional struct
** data_control, para que todos los hilos detenidos por un pthread_cond_wait()
** despierten y tengan una oportunidad para notar el cambio y después
** terminarán.

*/
#include "control.h"
int control_init(data_control *mycontrol) {
  int mystatus;
  if (pthread_mutex_init(&(mycontrol->mutex),NULL))
    return 1;
  if (pthread_cond_init(&(mycontrol->cond),NULL))
    return 1;
  mycontrol->active=0;
  return 0;
}
int control_destroy(data_control *mycontrol) {
  int mystatus;
  if (pthread_cond_destroy(&(mycontrol->cond)))
    return 1;
  if (pthread_mutex_destroy(&(mycontrol->cond)))
    return 1;
  mycontrol->active=0;
  return 0;
}
int control_activate(data_control *mycontrol) {
  int mystatus;
  if (pthread_mutex_lock(&(mycontrol->mutex)))
    return 0;
  mycontrol->active=1;
  pthread_mutex_unlock(&(mycontrol->mutex));
  pthread_cond_broadcast(&(mycontrol->cond));
  return 1;
}
int control_deactivate(data_control *mycontrol) {
  int mystatus;
  if (pthread_mutex_lock(&(mycontrol->mutex)))
    return 0;
  mycontrol->active=0;
  pthread_mutex_unlock(&(mycontrol->mutex));
  pthread_cond_broadcast(&(mycontrol->cond));
  return 1;
}

Tiempo para la depuración

Un archivo más, antes de que sigamos con el más importante. Aquí tenemos dbug.h:

Listado de Código 1.5: dbug.h

#define dabort() \
 {  printf("Aborting at line %d in source file %s\n",__LINE__,__FILE__);  abort(); }

Usaremos este código para resolver errores irrecuperables en nuestro código.

El código del conjunto de trabajo

Hablando del conjunto de trabajo, aquí está:

Listado de Código 1.6: workcrew.c>

#include <stdio.h>
#include <stdlib.h>
#include "control.h"
#include "queue.h"
#include "dbug.h"
/* work_queue mantiene tareas hasta que varios hilos se completan.
*/
struct work_queue {
  data_control control;
  queue work;
} wq;
/* He añadido el número de trabajo al nodo. Normalmente, el nodo
contendrá información adicional que debe ser procesada. */
typedef struct work_node {
  struct node *next;
  int jobnum;
} wnode;
/* la cola de limpieza contiene hilos detenidos. Antes de que un
   trabajo concluya, se añade a sí misma a la lista. Desde que el hilo
   principal está esperando a que hayan cambios en esta lista, entonces
   despertará y limpiará los hilos que hayan concluido. */
struct cleanup_queue {
  data_control control;
  queue cleanup;
} cq;
/* He añadido un número de hilo (con propósito de depuración/instructivo)
   y un id al hilo de limpieza. El nodo de limpieza se le pasa al nuevo hilo al
   empezar, y justo antes de que el hilo pare, añade el nodo de limpieza a la
   cola. El hilo principal monitoriza la cola de limpieza y es el que realiza
   la misma cuando es necesaria. */
typedef struct cleanup_node {
  struct node *next;
  int threadnum;
  pthread_t tid;
} cnode;
void *threadfunc(void *myarg) {
  wnode *mywork;
  cnode *mynode;
  mynode=(cnode *) myarg;
  pthread_mutex_lock(&wq.control.mutex);
  while (wq.control.active) {
    while (wq.work.head==NULL && wq.control.active) {
      pthread_cond_wait(&wq.control.cond, &wq.control.mutex);
    }
    if (!wq.control.active)
      break;
    //we got something!
    mywork=(wnode *) queue_get(&wq.work);
    pthread_mutex_unlock(&wq.control.mutex);
    //perform processing...
    printf("Thread number %d processing job %d\n",mynode->threadnum,mywork->jobnum);
    free(mywork);
    pthread_mutex_lock(&wq.control.mutex);
  }
  pthread_mutex_unlock(&wq.control.mutex);
  pthread_mutex_lock(&cq.control.mutex);
  queue_put(&cq.cleanup,(node *) mynode);
  pthread_mutex_unlock(&cq.control.mutex);
  pthread_cond_signal(&cq.control.cond);
  printf("thread %d shutting down...\n",mynode->threadnum);
  return NULL;

}
#define NUM_WORKERS 4
int numthreads;
void join_threads(void) {
  cnode *curnode;
  printf("joining threads...\n");
  while (numthreads) {
    pthread_mutex_lock(&cq.control.mutex);
    /* a continuación, lo dormimos hasta que aparece realmente un
       nuevo nodo de limpieza. Con ello se encarga de cualquier falso
       despertar... Incluso si salimos de pthread_cond_wait(), no estaremos
       asumiendo que la condición a la que estaba esperando es cierta.
       */
    while (cq.cleanup.head==NULL) {
      pthread_cond_wait(&cq.control.cond,&cq.control.mutex);
    }
    /* en este instante, mantenemos el mutex y hay un elemento en la
       lista que necesita ser procesado. Primero, eliminamos el nodo de la
       cola. Entonces llamamos a pthread_join() en el tid almacenado en el
       nodo. Cuando pthread_join() retorna, hemos limpiado después de un hilo.
       Sólo entonces hacemos un free() con el nodo, decrementando el número de
       hilos adicionales a los que necesitamos esperar y repetimos el proceso
       entero, en caso de ser necesario. */
      curnode = (cnode *) queue_get(&cq.cleanup);
      pthread_mutex_unlock(&cq.control.mutex);
      pthread_join(curnode->tid,NULL);
      printf("joined with thread %d\n",curnode->threadnum);
      free(curnode);
      numthreads--;
  }
}
int create_threads(void) {
  int x;
  cnode *curnode;
  for (x=0; x<NUM_WORKERS; x++) {
    curnode=malloc(sizeof(cnode));
    if (!curnode)
      return 1;
    curnode->threadnum=x;
    if (pthread_create(&curnode->tid, NULL, threadfunc, (void *) curnode))
      return 1;
    printf("created thread %d\n",x);
    numthreads++;
  }
  return 0;
}
void initialize_structs(void) {
  numthreads=0;
  if (control_init(&wq.control))
    dabort();
  queue_init(&wq.work);
  if (control_init(&cq.control)) {
    control_destroy(&wq.control);
    dabort();
  }
  queue_init(&wq.work);
  control_activate(&wq.control);
}
void cleanup_structs(void) {
  control_destroy(&cq.control);
  control_destroy(&wq.control);
}
int main(void) {
  int x;
  wnode *mywork;
  initialize_structs();
  /* CREATION */

  if (create_threads()) {
    printf("Error starting threads... cleaning up.\n");
    join_threads();
    dabort();
  }
  pthread_mutex_lock(&wq.control.mutex);
  for (x=0; x<16000; x++) {
    mywork=malloc(sizeof(wnode));
    if (!mywork) {
      printf("ouch! can't malloc!\n");
      break;
    }
    mywork->jobnum=x;
    queue_put(&wq.work,(node *) mywork);
  }
  pthread_mutex_unlock(&wq.control.mutex);
  pthread_cond_broadcast(&wq.control.cond);
  printf("sleeping...\n");
  sleep(2);
  printf("deactivating work queue...\n");
  control_deactivate(&wq.control);
  /* CLEANUP  */
  join_threads();
  cleanup_structs();
}

Análisis del código

Ahora es el momento de analizar detenidamente el código. La primera struct definida se llama "wq" y contiene data_control y una cabecera de cola. La estructura data_control será usada para manejar el acceso a toda la cola, incluyendo los nodos en la cola. Nuestro siguiente trabajo es definir los nodos activos. Para reducir el código al propósito de este artículo, todo ello se mantendrá como un número de trabajo.

Después creamos la cola de limpieza. Los comentarios explican cómo funciona. Bien, evitemos por ahora las llamadas threadfunc(), join_threads(), create_threads() e initialize_structs() y volvamos a main(). Lo primero que hacemos es inicializar nuestras estructuras -- esto incluye nuestros data_controls y las colas, al igual que activar nuestra cola de trabajo.

Limpieza especial

Es el momento de inicializar nuestros hilos. Si se observa nuestra llamada create_threads(), todo parecerá absolutamente normal -- exceptuando una cosa. Hay que observar que estamos localizando un nodo de limpieza, e inicializando sus componentes treadnum y TID. También pasamos un nodo de limpieza a cada hilo en funcionamiento como argumento inicial. ¿Por qué hacemos esto?

Porque cuando un hilo en funcionamiento acaba, añadirá su nodo de limpieza a la cola y terminará. Entonces, nuestro hilo principal detectará esta adición a la cola de limpieza (por el uso de una variable condicional) y eliminará de la cola el nodo. Dado que el TID (id del hilo) se almacena en el nodo de limpieza, nuestro hilo principal sabrá exactamente el hilo que ha terminado. Entonces, el hilo principal llamará a pthread_join(tid), y lo unirá a la correspondiente hebra de trabajo. De no haber realizado esta acción, nuestro hilo principal debería unirse a otros hilos que están funcionando en un orden arbitrario, posiblemente el orden en que fueron creados. Dado que los hilos no necesitan concluir en este orden, nuestro hilo principal podría estar aguardando a unirse con un hilo mientras que podría haberse unido a otros diez distintos. ¿Puede verse cómo esta decisión de diseño puede acelerar la conclusión de nuestro código, especialmente si se está trabajando con cientos de hilos funcionales?

Creación de trabajo

Ahora que hemos empezado con nuestros hilos funcionales (y están encargándose de realizar su trabajo con threadfunc(), al que llegaremos en un momento), nuestro hilo principal empieza a insertar elementos en la cola de trabajo. Primero, bloquea el mutex de control "wq", después localiza 16000 paquetes de trabajo, insertándolos en la cola uno por uno. Después de realizar esto, se llama a pthread_cond_broadcast(), para que todos los hilos dormidos despierten y realicen su trabajo. Al acabar, nuestro hilo principal se duerme durante dos segundos y después desactiva la cola de trabajo, diciéndoles a los hilos en funcionamiento que terminen. Es cuando nuestro hilo principal llama a la función join_threads() para limpiar todos los hilos en funcionamiento.

threadfunc()

Tiempo para ver threadfunc(): el código que cada hilo en ejecución lleva a cabo. Cuando un hilo comienza su ejecución, inmediatamente bloquea el mutex de la cola de trabajos, obtiene un nodo de trabajo (si se encuentra disponible) y lo procesa. De no encontrarse disponible se llama a pthread_cond_wait(). Puede notarse que se le llama en un bucle while() muy ligero, lo cual es muy importante. Cuando se despierta desde una llamada pthread_cond_wait(), nunca debe asumirse que la condición es definitivamente cierta -- muy probablemente será cierta, pero puede no serlo. El bucle while ocasionará que se llame de nuevo a pthread_cond_wait() si el hilo se despertó equivocadamente y la lista continúa vacía.

Si hay un nodo trabajando, sencillamente imprimimos su número de trabajo, lo liberamos y salimos. Código real podría hacer algo más sustancial. Al final del bucle while(), bloquearemos el mutex para que podamos comprobar la variable activa al igual que los nodos activos al comienzo del bucle. Si se ha seguido el código exactamente, puede verse que si wq.control.active es 0, el bucle while terminará y comenzará el código al final de threadfunc().

La parte del hilo funcional con el código de limpieza es muy interesante. Primero desbloquea la cola de trabajo, dado que pthread_cond_wait() nos retorna el mutex bloqueado. Entonces, crea un bloqueo en la cola de limpieza, añade nuestro nodo de limpieza (que contiene nuestro TID, que el hilo principal usará para su llamada pthread_join()), y entonces desbloquea la cola de limpieza. Después de esto, indica a todo aquello esperando a cq (pthread_cond_signal(&cq.control.cond)) que el hilo principal sabe que hay un nuevo nodo que procesar. No usamos pthread_cond_broadcast() dado que no es necesario -- solo un hilo (el hilo principal) está esperando a nuevas entradas en la cola de limpieza. Nuestro hilo en funcionamiento emite un mensaje de finalización y después concluye, esperando a ser pthread_joined() por el hilo principal, cuando llama a join_threads().

join_threads()

Si se quiere ver un sencillo ejemplo de cómo las variables de condición deben usarse, debemos echar un vistazo a la función join_threads(). Mientras que tenemos hilos en ejecución, los bucles join_threads(), esperando a nuevos nodos de limpieza en nuestra cola de limpieza. Si hay un nuevo nodo, quitamos de la cola el nuevo nodo, desbloqueamos la cola de limpieza (para que otros nodos de limpieza puedan ser añadidos por nuestros hilos en ejecución), lo unimos a nuestro nuevo hilo (usando el TID almacenado en el nodo de limpieza), liberamos el nodo de limpieza, decrementamos el número de hilos "activos" y continuamos.

Conclusión

Hemos llegado al final de la serie "Explicación de los hilos POSIX", espero que estemos listos para empezar a añadir código multi-hilos a nuestras propias aplicaciones. Para más información, puede consultarse la sección Recursos, que también contiene un tarball de todo el código fuente mostrado en este artículo.

2.  Recursos

  • Un tarball del código fuente usado en este artículo está disponible.
  • Leer la explicación de los hilos POSIX de Daniel Parte 1 y Parte 2.
  • Leer la agradable documentación del manual de pthread Linux (man -k pthread) es un excelente recurso.
  • Para un tratamiento en profundidad de los hilos POSIX, recomiendo este libro: Programming with POSIX Threads, por David R. Butenhof (Addison-Wesley, 1997). Quizá sea este el mejor libro disponible acerca de los hilos POSIX.
  • Los hilos POSIX también se cubren en este libro: UNIX Network Programming - Networking APIs: Sockets and XTI, por W. Richard Stevens (Prentice Hall, 1997). Este es un libro clásico, pero no cubre los hilos POSIX con tanto detalle como el anterior: Programming with POSIX Threads.
  • Ver la documentación de los hilos Linux, por Sean Walton, KB7rfa.
  • Hay un tutorial de hilos POSIX, por Mark Hays de la Universidad de Arizona.
  • En Una Introducción a Pthreads-Tcl, pueden verse los cambios realizados a Tcl para habilitarle el uso de hilos POSIX
  • Otro tutorial, Iniciación a los hilos POSIX, por Tom Wagner y Don Towsley del Departamento de Ciencia Informática en la Universidad de Masssachusetts, Amherst
  • FSU PThreads es una librería C que implementa los hilos POSIX a SunOS 4.1.x, Solaris 2.x, SCO UNIX, FreeBSD, Linux, y DOS
  • La página principal de hilos POSIX y DCE para Linux
  • Ver La Librería LinuxThreads
  • Proolix es un simple sistema operativo compatible con POSIX para i8086+ en permanente desarrollo


Imprimir

Página actualizada 9 de octubre, 2005

Sumario: En este artículo, el último de una serie de tres, acerca de los hilos POSIX, Daniel explica cómo usar las variables condicionales. Las variables condicionales son estructuras de hilos POSIX que permiten "despertar" hilos cuando se cumplen ciertas condiciones. Puede pensarse en ellas como una forma segura de señalización en los hilos. Daniel traza este artículo usando todo lo que hemos aprendido para implementar un conjunto de trabajo con multi-hilos.

Daniel Robbins
Autor

Fernando M. Bueno
Traductor

Donate to support our development efforts.

Copyright 2001-2014 Gentoo Foundation, Inc. Questions, Comments? Contact us.