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 1

Contenido:

1.  Una herramienta simple y ágil para compartir la memoria

Los hilos son divertidos

Saber cómo manejar adecuadamente los hilos debe ser una parte cotidiana del repertorio de todo buen programador. Los hilos son similares a procesos. A los hilos, como a los procesos, se les asignan porciones de tiempo por el núcleo. En sistemas con un solo procesador el núcleo divide el tiempo asignado a cada hilo para simular la ejecución simultánea de hilos de forma muy similar a como lo divide para los procesos. En sistemas con más de un procesador, los hilos pueden ejecutarse simultáneamente, del mismo modo que dos o más procesos pueden ejecutarse simultáneamente también.

Así que, ¿por qué son los multi-hilos preferibles a múltiples procesos independientes cooperativos? Bien, los hilos comparten la misma ubicación en memoria. Hilos independientes pueden acceder a las mismas variables en memoria. Así pues todos los hilos del programa pueden leer o escribir a los enteros (integers) declarados globalmente. Si alguna vez se ha programado algún código no trivial que use fork(), se reconocerá la importancia de esta herramienta. ¿Por qué? Mientras que fork() permite crear múltiples procesos, también crea el siguiente problema de comunicación: cómo conseguir que múltiples procesos, cada uno con su propio espacio en memoria, se comuniquen. No hay una respuesta simple a este problema. Mientras que hay muchos tipos diferentes de IPC local (comunicación entre procesos), todos ellos sufren de las dos mismas grandes desventajas:

  • Imponen una cierta sobrecarga al núcleo, disminuyendo el rendimiento.
  • En casi todas las situaciones, IPC no es una extensión "natural" del código. Muy a menudo, lo que hace es incrementar la complejidad del código.

Doble decepción: la sobrecarga y la complejidad no son buenas. Si alguna vez se han tenido que hacer modificaciones masivas a alguno de nuestros programas para que soportase IPC, se apreciará realmente la sencilla propuesta de compartir la memoria que los hilos proporcionan. Los hilos POSIX no necesitan hacer complicadas y costosas conferencias, porque resulta que todos nuestros hilos viven en la misma casa. Con una pequeña sincronización, todos los hilos podrán leer y modificar las estructuras de datos de nuestros programas. No tenemos que bombardear los datos a través de un descriptor de fichero o compactarlos en un pequeño espacio de memoria compartida. Únicamente por esta razón debe considerarse el modelo único proceso/multi-hilos en lugar del modelo multi-procesos/mono-hilo.

Los hilos son ágiles

Pero hay más. Los hilos también son extremadamente ágiles. Comparados con un fork() estándar, los hilos conllevan mucha menos sobrecarga. El núcleo no necesita crear una nueva copia independiente del espacio de memoria del proceso, de los descriptores del proceso, etc. Lo cual ahorra un considerable tiempo de la CPU, haciendo la creación de un nuevo hilo de diez a cien veces más rápida que la creación de un nuevo proceso. A causa de ello, pueden usarse una gran cantidad de hilos sin preocuparse demasiado acerca de la CPU y de la memoria requeridos. No tendrán el mismo impacto en la CPU que cuando se realizan con fork(). Lo cual significa que pueden crearse hilos en el programa cada vez que tenga sentido hacerlo.

Por supuesto, al igual que los procesos, los hilos tomarán ventaja con múltiples CPUs. Esto es realmente una gran ventaja si el programa ha sido diseñado para trabajar con una máquina con varias CPUs (si el programa es de código abierto, seguramente acabará siendo ejecutado en varias de ellas). El rendimiento de ciertos programas en hilos (los que hacen un uso intensivo de la CPU en concreto) escalarán linealmente con respecto al número de procesadores por lo menos. Si se está escribiendo un programa que hace un uso intensivo de la CPU, definitivamente se querrán encontrar formas para hacer uso de múltiples hilos en el código. En el momento en que se sea un adepto a escribir código con hilos, también seremos capaces de enfrentarnos a nuevas batallas de programación de forma creativa, sin necesidad de demasiado IPC y de muchas de sus complicaciones. Todos estos beneficios trabajan en conjunto para hacer de la programación multi-hilos algo divertido, rápido y flexible.

Creo que ahora soy un clon

Si se ha estado en el mundo de la programación para Linux durante algún tiempo, debe conocerse la llamada al sistema clone(). clone() es similar a fork(), pero puede hacer muchas cosas que los hilos permiten hacer. Por ejemplo, con __clone() se pueden compartir selectivamente partes del contexto de ejecución del padre (espacio en memoria, descriptores de fichero, etc.) con un nuevo proceso hijo. Esto es muy buena cosa. Pero hay también muchas cosas no tan buenas acerca de __clone(). Como la página del manual de clone indica:

Listado de Código 1.1: Extracto de la página del manual de __clone

    "La llamada a __clone es especifica de Linux y no debería usarse en
    aquellos programas que pretendan ser portables. Para programar
    aplicaciones con hilos (multiples hilos de control en el mismo espacio
    de memoria), es mejor usar una biblioteca que implemente la API de hilos
    POSIX 1003.1c, como la biblioteca Linux-Threads. Consulte
    pthread_create(3thr)."

Así, mientras que __clone() ofrece muchos de los beneficios de los hilos, no es portable y solo podrá usarse bajo Linux. Lo cual no significa que no deba usarse en nuestro código. Pero debemos considerar este aspecto cuando pretendamos usar __clone() en nuestro software. Afortunadamente, como se indica en la página del manual de clone, hay una mejor alternativa: los hilos POSIX. Cuando se quiera escribir código multi-hilo portable, código que funcione bajo Solaris, FreeBSD, Linux y otros, los hilos POSIX son el camino a seguir.

Iniciar hilos

He aquí un simple ejemplo de programa que usa los hilos POSIX:

Listado de Código 1.2: Ejemplo de programa con hilos POSIX

#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

void *thread_function(void *arg) {
  int i;
  for ( i=0; i<20; i++ ) {
    printf("Thread says hi!\n");
    sleep(1);
  }
  return NULL;
}

int main(void) {

  pthread_t mythread;

  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
    printf("error creating thread.");
    abort();
  }

  if ( pthread_join ( mythread, NULL ) ) {
    printf("error joining thread.");
    abort();
  }

  exit(0);

}

Para compilar este programa, sencillamente se guarda como thread1.c, y tecleamos:

Listado de Código 1.3: Compilación del anterior programa

$ gcc thread1.c -o thread1 -lpthread

Lo ejecutaremos tecleando:

Listado de Código 1.4: Ejecución de dicho programa

$ ./thread1

Comprender thread1.c

thread1.c es un programa con hilos extraordinariamente sencillo. No hace nada útil, pero nos ayudará a entender cómo funcionan los hilos. Vamos a ver paso a paso lo que hace el programa. En main() primero declaramos una variable denominada mythread, que es del tipo pthread t. El tipo pthread t, definido en pthread.h, se denomina a menudo "thread id" (suele abreviarse como "tid"). Hay que pensar en el mismo como un modo de manejar el hilo.

Después de declarar mythread (hay que recordar que mythread es solo un "tid", o un modo de manejar el hilo que vamos a crear), llamamos a la creación de una función pthread para crear un hilo real. No debe confundirnos el hecho de que la función pthread_create() se encuentre dentro de un "if". Dado que pthread_create() devuelve un valor de cero si todo va bien y un valor no igual a cero en caso de error, colocar la llamada a la función dentro de un if() es solo una forma elegante de detectar si la llamada pthread_create() ha fallado. Vamos a ver los argumentos de pthread_create. El primero es un puntero hacia mythread,&mythread. El segundo argumento, definido como NULL, puede ser usado para definir ciertos atributos para nuestro hilo. Dado que los argumentos por defecto de los hilos funcionarán en este caso, sencillamente lo definimos como NULL.

Nuestro tercer argumento es el nombre de la función que el nuevo hilo ejecutará cuando comience. En este caso, el nombre de la función es thread_function(). Cuando la función thread_function() retorna, nuestro nuevo hilo habrá terminado. En este ejemplo, la función del hilo no hace absolutamente nada considerable. Sencillamente muestra en pantalla: "Thread says hi!" 20 veces y concluye. Hay que notar que thread_function() acepta un valor vacío * como argumento y también devuelve * vacío como valor de retorno. Esto nos muestra que es posible usar un * vacío para pasar un conjunto arbitrario de datos a nuestro nuevo hilo y que nuestro nuevo hilo también puede devolver un conjunto arbitrario de datos cuando finalice. Ahora, ¿cómo le pasamos a nuestro hilo un argumento arbitrario? Fácil, usamos el cuarto argumento de la llamada a pthread_create(). En este ejemplo, lo definimos como NULL dado que no necesitamos pasar ningún dato a nuestra trivial thread_function().

Como se habrá visto. El programa consistirá en dos hilos después de que retorne pthread_create() adecuadamente. Un minuto, ¿dos hilos? ¿No hemos creado un solo hilo? Sí, efectivamente. Pero nuestro programa principal también se considera un hilo. Pensemos en ello de esta forma: si escribimos un programa y no usamos los hilos POSIX en absoluto, el programa será de un único hilo (a este hilo único se le denomina "principal" (main)). Creando un nuevo hilo, tendremos un total de dos hilos en nuestro programa.

Imagino que, llegados a este punto, nos haremos dos preguntas importantes. La primera de ellas será ¿qué hace el hilo principal cuando se crea el nuevo hilo? Se mantiene y ejecuta secuencialmente la siguiente línea del programa (en este caso, la siguiente línea es "if ( pthread_join(...))"). La segunda pregunta que nos estaremos haciendo es ¿qué le ocurre a nuestro nuevo hilo cuando sale? Se detiene y espera a combinarse con otro hilo como parte de su proceso de limpieza.

De acuerdo, ahora vayamos con pthread_join(). Así como pthread_create() rompe nuestro hilo en dos, pthread_join() une dos hilos en uno solo. El primer argumento de pthread_join() es nuestro tid mythread. El segundo argumento es un puntero hacia un puntero hacia void. si el puntero a void no es NUll, pthread_join colocará nuestro valor de retorno del hilo void * en la localización que especifiquemos. Ya que no nos importa el valor de retorno de thread_function(), lo definimos a NULL.

Se habrá notado que nuestro thread_function() tarda 20 segundos en completarse. Mucho antes de que thread_function() concluya, nuestro hilo principal ha llamado ya a pthread_join(). Cuando esto ocurre, nuestro hilo principal se detendrá (se duerme) y esperará a que thread_function() concluya. Cuando thread_function() termine, pthread_join() retornará. Ahora nuestro programa tiene un hilo principal de nuevo. Cuando nuestro programa sale, todos los nuevos hilos habrán formado una sola hebra [pthread_join()]. Así es como se deben manejar los hilos con todo nuevo hilo que creen nuestros programas. Si un nuevo hilo no se une a una hebra, seguirá contando para el límite total de hilos del sistema. Lo cual significa que, si no se hace una limpieza adecuada de hilos, podría causar que las nuevas llamadas a pthread_create() fallen.

Sin padres, no hay hijos

Si se ha usado la llamada al sistema fork() los términos proceso padre y proceso hijo nos resultarán familiares. Cuando un proceso crea otro nuevo proceso, usando fork(), al nuevo proceso se le considera hijo y al proceso de origen padre. Esto crea una relación jerárquica que puede ser muy práctica, especialmente cuando se espera a que procesos hijo concluyan. La función waitpid(), por ejemplo, le indicará al proceso actual que espere a que los procesos hijo concluyan. waitpid() se usa para implementar una sencilla rutina de limpieza en nuestro proceso padre.

Las cosas son un poco más interesantes con los hilos POSIX. Puede haberse notado que he estado evitando emplear los términos "hilo padre" e "hilo hijo" hasta ahora. Ello se debe a que en los hilos POSIX esta relación jerárquica no existe. Mientras que un hilo principal puede crear otro hilo, y este hilo puede, a su vez, crear otro nuevo hilo, el estándar de hilos POSIX ve todos los hilos como un simple conjunto de elementos idénticos. Así que el concepto de esperar a que un proceso hijo concluya no tiene sentido. El estándar de hilos POSIX no registra ninguna información "familiar". Esta falta de genealogía tiene una implicación mayor: si se quiere esperar a que un hilo concluya, se debe especificar el hilo al que estamos esperando indicando la tid adecuada a pthread_join(). La librería de hilos no lo puede asumir por sí misma.

Para muchas personas estas no son buenas noticias, dado que puede complicar programas que consistan en más de dos hilos. No debe preocuparnos. El estándar de hilos POSIX proporciona todas las herramientas necesarias para manejar varios hilos adecuadamente. Actualmente, el hecho de que no haya una relación padres/hijos abre muchas nuevas formas creativas para usar los hilos en nuestros programas. Por ejemplo, si tenemos un hilo llamado hilo 1, y el hilo 1 crea un nuevo hilo 2, no es necesario para el hilo 1 llamar a pthread_join() para el hilo 2. Cualquier otro hilo en el programa puede hacerlo. Esto permite posibilidades muy interesantes cuando se están creando programas con gran cantidad de multi-hilos. Se puede crear, por ejemplo, una "lista muerta" global que contenga todos los hilos detenidos y tener otro hilo de limpieza especial, que sencillamente espera a que algún elemento se añada a esta lista. El hilo de limpieza llama a pthread_join() para enhebrarlo consigo mismo. Ahora, todo el proceso de limpieza será manejado de forma cómoda y eficiente con un simple hilo.

Natación sincronizada

Es el momento de echar un vistazo a código que realizará algo ligeramente inesperado. He aquí thread2.c:

Listado de Código 1.5: thread2.c

#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>

int myglobal;

 void *thread_function(void *arg) {
  int i,j;
  for ( i=0; i<20; i++ ) {
    j=myglobal;
    j=j+1;
    printf(".");
    fflush(stdout);
   sleep(1);
    myglobal=j;
  }
  return NULL;
}

int main(void) {

  pthread_t mythread;
  int i;

  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
    printf("error creating thread.");
    abort();
  }

  for ( i=0; i<20; i++) {
    myglobal=myglobal+1;
    printf("o");
    fflush(stdout);
    sleep(1);
  }

  if ( pthread_join ( mythread, NULL ) ) {
    printf("error joining thread.");
    abort();
  }

  printf("\nmyglobal equals %d\n",myglobal);

  exit(0);

}

Comprender thread2.c

Este programa, como el primero, crea un nuevo hilo. Tanto el hilo principal como el nuevo hilo incrementan una variable global, llamada myglobal, 20 veces. Pero el programa en sí mismo produce ciertos resultados inesperados. Lo compilamos tecleando:

Listado de Código 1.6: Compilación del programa

$ gcc thread2.c -o thread2 -lpthread

y lo ejecutamos:

Listado de Código 1.7: Ejecución del programa

$ ./thread2
..o.o.o.o.oo.o.o.o.o.o.o.o.o.o..o.o.o.o.o
myglobal equals 21

¡Realmente inesperado! Desde que myglobal comienza con un valor de cero, y tanto el hilo principal como el nuevo hilo lo incrementan en 20, deberíamos ver que myglobal es igual a 40 al final del programa. Dado que es igual a 21, algo raro está ocurriendo aquí. Pero ¿qué es?

¿No tenemos una respuesta aún? Bien, mostraré porqué ocurre esto. Echemos un vistazo a thread_function(). ¿Notamos que se copia myglobal a una variable local llamada "j"? ¿Y como incrementamos j, después lo dormimos un segundo, y solo entonces copiamos nuestro nuevo valor j a myglobal? Esta es la clave. Imaginemos qué ocurriría si nuestro hilo principal incrementa myglobal justo después de que nuestro nuevo hilo copie el valor de myglobal en j. Cuando thread_function() vuelve a escribir el valor de j en myglobal, sobreescribirá la modificación que el hilo principal ha hecho.

Cuando se escriben programas con hilos, se querrán evitar efectos secundarios inútiles como el que acabamos de ver porque son una pérdida de tiempo (excepto cuando se están escribiendo artículos acerca de los hilos POSIX, por supuesto :). Ahora, ¿qué podemos hacer para eliminar esta molestia?

Dado que el problema ocurre porque copiamos myglobal a j y lo mantenemos ahí durante un segundo antes de volverlo a escribir de nuevo, podríamos intentar evitar el uso de una variable local temporal e incrementar myglobal directamente. Mientras que esta solución funcionará con este ejemplo en concreto, no es correcta. Y si estuviésemos realizando una operación matemática relativamente compleja en myglobal en lugar de incrementarla tan solo, realmente fallará. Pero ¿por qué?

Para comprender el problema, debemos recordar que los hilos se ejecutan simultáneamente. Incluso en máquinas con un solo procesador (donde el núcleo usa la división de tiempo para simular multitarea real) podemos, desde el punto de vista de un programador, imaginar que ambos hilos se ejecutan simultáneamente. thread2.c tiene problemas porque el código en thread_function() asume que myglobal no será modificada durante ~1 segundo antes de que sea incrementada. Tenemos que encontrar una forma de que un hilo le indique al otro que "espere" mientras se están haciendo los cambios a myglobal. Explicaré esto en el siguiente artículo.

2.  Recursos



Imprimir

Página actualizada 9 de octubre, 2005

Sumario: Los hilos POSIX (Interfaz de Sistemas Operativos Portátil) son una excelente forma de incrementar el rendimiento y la respuesta del código fuente. En estas series, Daniel Robbins muestra cómo usar los hilos en el código fuente. Se cubren muchos detalles no evidentes, así que al final de esta serie será capaz de crear sus propios programas 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.