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 2
1.
Esas pequeñas cosas llamadas mutexes
¡Hazme un mutex!
En mi anterior artículo, hablé
de código en hilos que realizaba varias cosas inesperadas. Dos hilos que
incrementaban cada uno una variable global 20 veces. Se suponía que la variable
terminaría con un valor de 40, pero concluía con un valor de 21 en su lugar.
¿Qué ocurría? El problema se originaba debido a que un hilo "cancelaba" el
incremente realizado por el otro hilo. Veamos el código corregido empleando
mutex para solucionar el problema:
Listado de Código 1.1: thread3.c |
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
int myglobal;
pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;
void *thread_function(void *arg) {
int i,j;
for ( i=0; i<20; i++ ) {
pthread_mutex_lock(&mymutex);
j=myglobal;
j=j+1;
printf(".");
fflush(stdout);
sleep(1);
myglobal=j;
pthread_mutex_unlock(&mymutex);
}
return NULL;
}
int main(void) {
pthread_t mythread;
int i;
if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
printf("error creating thread.");
bort();
}
for ( i=0; i<20; i++) {
pthread_mutex_lock(&mymutex);
myglobal=myglobal+1;
pthread_mutex_unlock(&mymutex);
printf("o");
fflush(stdout);
sleep(1);
}
if ( pthread_join ( mythread, NULL ) ) {
printf("error joining thread.");
abort();
}
printf("\nmyglobal equals %d\n",myglobal);
exit(0);
}
|
Momento de comprenderlo
Si se compara este código con la versión mostrada en mi artículo anterior, se observará la
adición de las llamadas pthread_mutex_lock() y pthread_mutex_unlock(). Estas
llamadas realizan una función extraordinariamente necesaria en los programas
por hilos. Proporcionan el significado de una mutua exclusión (de ahí su
nombre). Dos hilos no pueden mantener el mismo mutex bloqueado al mismo tiempo.
Así es como los mutexes trabajan. Si el hilo "a" intenta bloquear un mutex
mientras que el hilo "b" tiene el mismo mutex bloqueado, el hilo "a" se marcha
a dormir. Tan pronto como el hilo "b" realice el mutex (a través de una llamada
pthread_mutex_unlock()), el hilo "a" será capaz de bloquear el mutex (en otras
palabras, retornará desde la llamada pthread_mutex_lock() con el mutex
bloqueado). Del mismo modo, si el hilo "c" trata de bloquear el mutex mientras
que el hilo "a" lo mantiene bloqueado, el hilo "c" se quedará dormido durante
algún tiempo. Todos los hilos que se marchen a dormir desde una llamada
pthread_mutex_lock() en un mutex previamente bloqueado, serán "puestos en cola"
para acceder a dicho mutex.
pthread_mutex_lock() y pthread_mutex_unlock() se usan normalmente para proteger
estructuras de datos. Esto es, nos aseguramos de que un solo hilo a la vez
puede acceder a una cierta estructura de datos bloqueándola y desbloqueándola.
Como puede haberse supuesto, la librería de hilos de POSIX garantizará un
bloqueo sin tener que poner al hilo a dormir en absoluto si el hilo trata de
bloquear un mutex no bloqueado.
Ilustración 1.1: Para disfrutarlo, cuatro znurts re-habilitan una escena de llamadas pthread_mutex_lock() recientes |
 |
El hilo que tiene en esta imagen el mutex bloqueado tiene acceso a la compleja
estructura de datos sin preocuparse de que hayan otros hilos pretendiendo
confundir al mismo en ese preciso momento. La estructura de datos está
efectivamente "congelada" hasta que el mutex se desbloquee. Es como si las
llamadas pthread_mutex_lock() y pthread_mutex_unlock() estuvieran "en
construcción" hasta que una parte de los datos sea modificada o leída. Las
llamadas actúan como avisos para los otros hilos, que se van a dormir mientras
se realiza cualquier otra llamada y esperan a que llegue su turno en el bloqueo
del mutex. Por supuesto, ésto es únicamente cierto de someter cada lectura y
escritura a una estructura particular de datos con llamadas a
pthread_mutex_lock() y pthread_mutex_unlock().
¿Por qué no evitar los mutex por completo?
Suena interesante, ¿pero por qué íbamos a querer mandar a nuestros hilos a
dormir? Después de todo, ¿no es la mayor ventaja de los hilos su habilidad
para trabajar independientemente y, en muchos casos, simultáneamente? Sí, esto
es completamente cierto. Pero, de todas formas, cada programa con hilos no
triviales requerirá algún uso de los mutexes. Vamos a referirnos a nuestro
programa de ejemplo una vez más para entender porqué:
Si echamos un vistazo a thread_function(), notaremos que mutex se bloquea al
principio del bucle y culmina cerca del final. En este ejemplo de programa,
mymutex se usa para proteger el valor de myglobal. Si se mira con detenimiento
thread_function(), notaremos que el incremento en el código copia myglobal a
una variable local, incrementa la variable local, se duerme durante un segundo,
y únicamente thread_function() sobreescribirá el valor incrementado de nuevo a
myglobal. Sin el mutex, thread_function() sobreescribirá el valor incrementado
cuando despierte, si nuestro hilo principal incrementa myglobal durante el
retraso de un segundo de thread_function(). Usar un mutex nos asegurará que
esto no ocurrirá. (En caso de que se esté preguntando, he añadido el segundo de
retraso al disparador, un resultado dividido. No hay una razón real para
dormir thread_function() durante un segundo antes de escribir el valor local de
nuevo a myglobal.) Nuestro nuevo programa usando mutex produce el resultado
deseado:
Listado de Código 1.2: Mensaje de un programa usando mutex |
$ ./thread3
o..o..o.o..o..o.o.o.o.o..o..o..o.ooooooo
myglobal equals 40
|
Para una exploración más minuciosa de éste concepto extremadamente importante,
veamos el código incrementado a nuestro programa:
Listado de Código 1.3: Código incrementado |
Código incrementado a thread_function():
j=myglobal;
j=j+1;
printf(".");
fflush(stdout);
sleep(1);
myglobal=j;
Código incrementado al hilo principal:
myglobal=myglobal+1;
|
Si este código estuviera incrementado en un programa de un simple hilo,
esperaríamos que el código de thread_function() se ejecutara por completo.
La ejecución debería continuarse con el código (o del de la otra alternativa
para hacerlo). En un programa hebrado sin mutexes, el código puede (y a
menudo lo hará, gracias a la llamada sleep()) ejecutarse de la siguiente
forma y en el siguiente orden:
Listado de Código 1.4: Orden de ejecución |
thread_function() thread main thread
j=myglobal;
j=j+1;
printf(".");
fflush(stdout);
sleep(1); myglobal=myglobal+1;
myglobal=j;
|
Cuando este código se ejecuta en este orden concreto, la modificación global
del hilo principal a myglobal se sobreescribe. Entonces terminamos con un valor
incorrecto al final de nuestro programa. Si estuviésemos manejando punteros,
probablemente acabaríamos con una violación de segmento. Hay que notar que
nuestro hilo thread_function() ejecuta todas sus instrucciones en orden. El
problema es que tenemos otro hilo realizando otras modificaciones a la misma
estructura de datos al mismo tiempo.
Interioridades de los hilos 1
Antes de explicar cómo figurarse dónde debemos usar mutexes. Ofreceré una
pequeña introducción al trabajo interno que realizan los hilos. He aquí nuestro
primer ejemplo:
Digamos que se tiene un hilo principal que crea tres nuevos hilos: los hilos
"a", "b" y "c". El hilo "a" se creará primero, el hilo "b" después y por último
el tercer hilo "c".
Listado de Código 1.5: Orden de creación de los hilos |
pthread_create( &thread_a, NULL, thread_function, NULL);
pthread_create( &thread_b, NULL, thread_function, NULL);
pthread_create( &thread_c, NULL, thread_function, NULL);
|
Después de que la primera llamada pthread_create() concluya, puede asumirse que
el hilo "a" existe o que ha finalizado y ha parado. Después de la segunda
llamada pthread_create(), tanto el hilo principal como el hilo "b" pueden
asumir que el hilo "a" existe (o está parado).
De cualquier forma, después de que la segunda llamada create() retorne, el hilo
principal no puede asumir cuál hilo (a o b) empezará a ejecutarse antes. Aunque
ambos hilos existen es tarea del kernel y de la librería de hilos asignarles
una porción del tiempo de la CPU. Y no hay ninguna regla establecida acerca de
cuál empezará a ejecutarse antes. Aunque es muy probable que el hilo "a"
empiece a ejecutarse antes que el hilo "b", pero no está garantizado. Esto es
realmente cierto en máquinas con más de un procesador. Si escribimos un
programa que asume que el hilo "a" comienza a ejecutarse antes que el hilo "b",
acabaremos con un programa que funciona correctamente el 99% de las veces. Peor
aún, con un programa que funcionará el 99% de veces en nuestro sistema y el 0%
en un servidor con cuatro procesadores.
Otra cosa que podemos aprender de este ejemplo es que la librería de hilos
mantiene el orden de ejecución del código para cada hilo individualmente. En
otras palabras, las tres llamadas pthread_create() se ejecutarán en el orden en
el que aparecen. Desde la perspectiva del hilo principal, todo el código se
está ejecutando ordenadamente. A veces podemos tomar esto como una ventaja para
optimizar partes de nuestros programas con hilos. Por ejemplo, en nuestro
código, el hilo "c" puede asumir que los hilos "a" y "b" se están ejecutando o
han terminado de hacerlo. No debe preocuparle el hecho de que los hilos "a" y
"b" no se hayan creado aún. Puede usarse esta lógica para optimizar nuestros
programas con hilos.
Interioridades de los hilos 2
Bien, ahora, para otro ejemplo hipotético. Digamos que tenemos gran cantidad de
hilos que están ejecutando el siguiente código:
Listado de Código 1.6: Código en ejecución |
myglobal=myglobal+1;
|
¿Debemos bloquear y desbloquear el mutex antes y después del incremento
respectivamente? Algunos podrían decir que "no". El compilador después de todo
compilará dicha expresión en una simple instrucción de la máquina. Como ya
sabremos, una sola instrucción no puede ser interrumpida "a medias". Incluso
las interrupciones hardware respetarán los átomos de las instrucciones de la
máquina. Debido a ello, es tentador dejar de lado por completo las llamadas
pthread_mutex_lock() y pthread_mutex_unlock(). No debe hacerse.
¿Estoy tratando de hacer el tonto? Realmente no. Primero, no debe asumirse que
la anterior expresión se compilará en una única instrucción de la máquina a
menos que verifiquemos el código máquina por nosotros mismos. Incluso si
insertamos algo de ensamblador para asegurarnos de que el incremento ocurre
atómicamente -- ¡o incluso si escribimos el compilador por nosotros mismos! --
se pueden tener problemas.
He aquí el porqué. Usando un único opcode de ensamblador funcionará
maravillosamente en una máquina con un solo procesador. Cada incremento
ocurrirá atómicamente y probablemente obtendremos el resultado deseado. Pero
una máquina con más de un procesador es otra historia. En una máquina
multi-procesador, podemos tener dos procesadores separados ejecutando la
anterior asignación aproximadamente (a veces exactamente) al mismo tiempo.
Recordemos que esta modificación en la memoria debe pasar de la cache L1 a la
caché L2 y después a la memoria principal. (Una máquina SMP no es sólo una
máquina con un procesador adicional; también tiene componentes adicionales que
median en el acceso a la memoria.) Al final, no tenemos ni la más remota idea
de la CPU que "gana" en la carrera al escribir en la memoria principal. Para
crear código predecible, querremos usar mutexes. Mutexes introducirá una
"barrera de memoria" que se asegurará de que las escrituras en la memoria
principal ocurran en el orden en que los hilos bloquean el mutex.
Consideremos una arquitectura SMP que actualiza la memoria en bloques de
32-bit. Si se está incrementando un entero de 64-bit sin mutexes, los 4 bytes
superiores pueden provenir de una CPU y los otros 4 de otra. ¡Decepción! Lo
peor de todo, usar una técnica pobre hará que nuestro programa explote en
cuanto salga la Luna, o a las 3 AM en un sistema muy importante de nuestro
cliente. David R. Butenhof cubre las posibles permutaciones de no usar mutexes,
en su libro "Programming with POSIX Threads" (ver
Recursos al final de este artículo).
Demasiados mutexes
Si se colocan demasiados mutexes, nuestro código no tendrá ningún tipo de
concurrencia y se ejecutará mucho más lentamente que una solución con un simple
hilo. Si se colocan muy pocos, el código tendrá errores muy embarazosos.
Afortunadamente, hay un término medio. En primer lugar, los mutexes se usan
para serializar el acceso a *datos compartidos*. No deben usarse para datos que
no se van a compartir, y tampoco deben usarse si la lógica interna de nuestro
programa asegura que sólo un hilo está accediendo a una estructura concreta de
datos a la vez.
En segundo lugar, si se están usando datos compartidos, hay que usar los
mutexes tanto para la lectura como para la escritura. Hay que subordinar las
secciones de lectura y escritura a pthread_mutex_lock() y
pthread_mutex_unlock(), o usarlas cada vez que un programa invariable está
causando errores temporalmente. Hay que aprender a ver el programa desde la
perspectiva de un solo hilo y asegurarnos de que cada hilo individual de
nuestro programa tiene un consistente y respetuoso uso de memoria.
Probablemente nos llevará horas escribiendo nuestro propio código aprender a
usar los mutexes, pero pronto comenzarán a formar parte del mismo y seremos
capaces de usarlos adecuadamente sin pensar demasiado en ellos.
Usar las llamadas: inicialización
Bien, es el momento de ver todas las distintas formas que hay de usar mutexes.
Primero, empezaremos con inicialización. En nuestro ejemplo thread3.c, usamos un método de inicialización estático.
Esto implica declarar una variable pthread_mutex_t y asignarle la constante
PTHREAD_MUTEX_INITIALIZER:
Listado de Código 1.7: ejemplo de inicialización |
pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;
|
Esto es muy sencillo. Pero pueden crearse mutex dinámicamente. Hay que usar
este código cada vez que nuestro programa localice un nuevo mutex con malloc().
En este caso, el método de inicialización estática no funcionará y se deberá
usar la rutina pthread_mutex_init():
Listado de Código 1.8: Creación dinámica de un mutex |
int pthread_mutex_init( pthread_mutex_t *mymutex, const pthread_mutexattr_t*attr)
|
Como puede verse, pthread_mutex_init acepta un puntero a una localización ya
creada de memoria para inicializarse como un mutex. Como segundo argumento,
puede aceptar también un puntero adicional pthread_mutexattr_t. Puede usarse
esta estructura para configurar varios atributos mutex. Pero normalmente no se
necesitan estos argumentos, así pues, especificar NULL es lo normal.
Cada vez que se inicializa un mutex usando pthread_mutex_init(), debe
destruirse con pthread_mutex_destroy(). pthread_mutex_destroy() acepta un solo
puntero para pthread_mutex_t y libera todos los recursos empleados por el mutex
cuando se crea. Hay que notar que pthread_mutex_destroy() no libera la memoria
usada para almacenar pthread_mutex_t. Es tarea nuestra hacer uso de free() para
liberar la memoria. También hay que recordar que tanto pthread_mutex_init()
como pthread_mutex_destroy() retornan cero en caso de no encontrar errores.
Usar las llamadas: bloqueo
Listado de Código 1.9: Ejemplo de bloqueo |
pthread_mutex_lock(pthread_mutex_t *mutex)
|
pthread_mutex_lock() acepta un único puntero para bloquear un mutex. Si el
mutex ya se encuentra bloqueado, la llamada se duerme. Cuando la función
retorna, (obviamente) la llamada despertará y, a partir de ese momento,
mantendrá el bloqueo. La llamada o bien devuelve cero en caso de no encontrar
un error o bien devuelve un valor distinto de cero con el código de error.
Listado de Código 1.10: Ejemplo de desbloqueo |
pthread_mutex_unlock(pthread_mutex_t *mutex)
|
pthread_mutex_unlock() es complementario a pthread_mutex_lock() y desbloquea un
mutex que el hilo había bloqueado. Debe desbloquearse un mutex bloqueado en
cuanto sea posible y seguro (para incrementar el rendimiento). Nunca debe
desbloquearse un mutex que no estuviese bloqueado (o de lo contrario, la
llamada pthread_mutex_unlock() fallará retornando un valor EPERM distinto de
cero).
Listado de Código 1.11: Ejemplo de intento de bloqueo |
pthread_mutex_trylock(pthread_mutex_t *mutex)
|
Esta llamada es muy práctica cuando se quiere bloquear un mutex mientras el
hilo está haciendo alguna otra cosa (dado que el mutex ya se encuentra
bloqueado). Cuando se realiza la llamada pthread_mutex_trylock() se intenta
bloquear el mutex. Si el mutex está desbloqueado, entonces se bloqueará, y la
función retornará un valor cero. De cualquier forma, si el mutex se encuentra
bloqueado esta llamada no funcionará. En su lugar, devolverá un valor que no es
cero como error EBUSY. Entonces, pueden hacerse otras cosas y tratar de
bloquearla después.
Espera condicional
Los mutexes son herramientas necesarias para programas con hilos, pero no
pueden hacerlo todo. ¿Qué ocurre, por ejemplo, si nuestro hilo está aguardando
a que se cumpla una determinada condición en datos compartidos? El código
bloqueará y desbloqueará el mutex, comprobando si ha habido cambios en el
valor. Al mismo tiempo desbloqueará el mutex para que se puedan hacer dichas
modificaciones. Pero esta es una solución horrible dado que este hilo
necesitará entrar en un bucle muy ocupado para detectar cualquier cambio en un
periodo de tiempo razonable.
Se podría dormir el hilo de la llamada durante un breve periodo de tiempo,
digamos 3 segundos entre cada comprobación, pero entonces el código con hilos
no sería óptimo. Lo único que se necesita es dormir el hilo mientras espera
a que se cumpla una condición. Una vez que la condición se cumple se necesita
un método para despertar al hilo (o los hilos) que están esperando a que dicha
condición se cumpla. Si puede hacerse ésto, el código con hilos será realmente
eficiente y no desperdiciará valiosos bloqueos de mutex. ¡Esto precisamente es
lo que las variables condicionales POSIX pueden hacer por nosotros!
Las variables condicionales POSIX son el tema que trataré en mi siguiente
artículo, donde mostraré cómo usarlas correctamente. Entonces se tendrán todos
los recursos para crear programas con hilos sofisticados que crean un modelo
de trabajo en equipo, ensamblan líneas y mucho más. Voy a dar un paso adelante
en el siguiente artículo ahora que estamos familiarizados con los hilos. Espero
que esto me permita exponer un programa algo más sofisticado con hilos al final
del siguiente artículo. Hablando de espera condicional, ¡nos vemos en el
siguiente artículo!
2.
Recursos
-
Leer la explicación de los hilos POSIX de Daniel
Parte 1 y Parte 3.
-
Ver la documentación de los hilos
Linux, por Sean Walton, KB7rfa.
-
Leer la agradable documentación del manual de pthread Linux (man -k
pthread).
-
Ver la Librería
LinuxThreads.
-
Proolix
es un simple sistema operativo compatible con POSIX para i8086+ en
permanente desarrollo.
-
Consultar el libreo de David R. Butenhof Programming
with POSIX Threads, en el que cubre, entre otras cosas, las posibles
permutaciones de no usar mutexes.
-
Buscar el libro de W. Richard Stevens "UNIX
Network Programming".
-
Encontrar más recursos para desarrolladores Linux en developerWorks Linux
zone.
-
Involucrarse en la comunidad developerWorks participando en los
developerWorks
blogs.
|