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 2

Contenido:

1.  Esas pequeñas cosas llamadas mutexes

¡Transfórmame en 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

Fig. 1

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 núcleo 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 solo 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 solo 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 libro 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.


Imprimir

Página actualizada 9 de octubre, 2005

Sumario: Los hilos POSIX son una excelente forma de incrementar el rendimiento y la respuesta del código fuente. En este segundo artículo de una serie de tres, Daniel Robbins muestra como proteger la integridad de la estructura de datos compartidos usando una pequeña herramienta muy útil denominada mutexes.

Daniel Robbins
Autor

Fernando M. Bueno
Traductor

Donate to support our development efforts.

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