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
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
-
Léase la explicación de los hilos POSIX de Daniel
Parte 2 y Parte 3.
-
Ver la documentación de Linux
threads, por Sean Walton, KB7rfa
-
Seguir el tutorial
de los 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
-
Siempre hay que consultar las agradables páginas del manual pthread Linux
(man -k pthread)
-
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
-
Echar un vistazo al libro de David R. Butenhof
Programming with POSIX Threads, en el que cubre, entre otras cosas,
todas las posibles permutaciones de no usar mutexes
-
Comprobar el libro de W. Richard Stevens
UNIX Network Programming: Network APIs: Sockets and XTI, Volume 1
|
|
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.
|
|
|