Threads en Linux

Artículo en construcción!

Introducción

Los threads son un concepto fundamental en lo que se conoce como "programación concurrente". En este artículo trato de explicar algo sobre el tema, especialmente su uso desde el lenguaje C (o C++). La siguiente sección se dedica principalmente a la historia del tema en Linux, para cuestiones más concretas se puede ir directamente a la API.

Este artículo acaba de empezar, y todavía está incompleto, lo estoy escribiendo ahora!

El hilo histórico

Repasemos primero un poco lo que son los procesos. Un proceso es el resultado de una técnica que emplean los sistemas operativos para permitir la ejecución simultánea de distintas aplicaciones. Esta técnica consiste en dejar correr a una aplicación por un tiempo, digamos 10 ms. Cuando se agota el tiempo, el kernel del SO retoma el control y se lo entrega a otra aplicación. Al mismo tiempo que el SO cambia el control de una aplicación a otra, también intercambia, por cada aplicación, información adicional tal como:

O sea, al salto se le suma el intercambio de información de contexto. Y a la unidad compuesta por toda esa información contextual se la conoce como proceso.

El concepto de thread implica solamente saltar, pero mantener el contexto. Es decir que distintas partes de la aplicación se ejecutan concurrentemente, compartiendo memoria y archivos abiertos (la pila de ejecución no se intercambia).

Cuando Linux era jovencito, no tenía threads. Los demás sistemas operativos se pavoneaban exponiendo complejos mecanismos de threads. En estos sistemas (como también es hoy en Windows), los threads son conceptos de primer nivel en el sistema. Es decir que están implementados como algo especial y fijo.

Mientras tanto Linux crecía y crecía, pero solamente tenía procesos. Las primeras versiones de Java tenían que, en Linux, simular la existencia de los threads. Linus Torvalds, el creador de Linux, siempre pensó que los threads eran algo muy parecido a los procesos, tan parecidos que debían ser lo mismo. Mientras que un proceso nace de un fork "completo", los threads vendrían a ser el resultado de un fork parcial, en el que en vez de duplicar todo el contexto, el proceso hijo mantenga parte de él compartido con el padre. Entonces se creó una llamada al sistema que permite decirla al sistema cuáles partes del contexto se desean duplicar, esta llamada es clone().

Sin embargo, la forma estándar de manipular threads en UNIX (conocida como pthreads) no era del todo compatible con esta conceptualización. Por ejemplo espera que cada thread vea el mismo "identificadoe de proceso", y con el mecanismo de Linux eso no era posible, ya que cada thread era un proceso distinto.

A través de los años Linux fue añadiendo funciones para dar mejor soporte a una API pthreads. La implementación actual de pthreads (conocida como NPTL) llegó actualmente a ofrecer el manejo exacto que ofrecen otros sistemas.

Cómo se usan: La API (pthreads)

Veamos cómo es esa API...

La función principal es obviamente la que permite crear un thread. Esta función es pthread_create:

int pthread_create(pthread_t * thread
	, pthread_attr_t * attr
	, void *(*funcion)(void *)
	, void *arg);

Los parámetros son los siguientes:

thread
Un puntero en donde la función guardará el identificador del nuevo thread creado.
attr
Atributos del thread, puede ser NULL.
funcion
Acá empieza la ejecución del nuevo thread. Es un puntero a una función declarada de esta manera: void mi_thread(void *arg);.
arg
Parámetro a pasar a la función del nuevo thread

Esta función devuelve el control inmediatamente... mientras tanto, el nuevo thread inicia su recorrido por la función apuntada por funcion. ¿Hasta cuándo? Como mucho hasta que termina ese método, cuando sale, termina el thread. Si un thread necesita esperar a que otro termina (por ejemplo el thread padre esperar a que termine el hijo) puede usar la función pthread_join(). ¿Por qué se llama así? Bueno, crear un proceso es como una bifuración, se abren dos caminos... que uno espere a otro es lo contrario, una unificación (join en inglés).

En otro artículo pongo un ejemplo extremadamente simplón de un servidor que usa threads.

Sincronización

La necesidad

La cuestión cuando se trabaja con threads es que la ejecución avanza en varias partes del programa a la vez. Cada una de esas ejecuciones simultáneas pueden tocar los mismos objetos. Eso a veces es un problema. Un ejemplo: Suponga que un thread encola pedidos e incrementa un contador. Existen además 50 threads que se fijan si el contador es mayor que cero y si lo es retiran un pedido, decrementan el contador, y procesan la tarea. Supongamos que hay un pedido en la cola, el contador vale 1, y que sucede lo siguiente:

  1. El thread A comprueba que el contador vale más que cero.
  2. El thread B comprueba que el contador vale más que cero.
  3. Basado en su comprobación el thread B decrementa el contador y toma el pedido.
  4. Basado en su comprobación el thread A decrementa el contador y toma el ped... auch, ya no hay pedido!

¿Qué pasó acá? El thread A miró, vio algo y se dispuso a actuar, pero cuando actuó alguien se le había metido en el medio. El mundo ya no era el que era cuando él tomó la decisión de actuar. El problema, generalizado, es el espacio de tiempo que hay entre mirar y actuar, cuando el mundo en el que se mira es compartido por más de un actor. A este tipo de problemas se les llama condición de carrera (en inglés “race condition”), porque son como una competencia.

La solución del problema

Para evitar el caso que expuse lo que se hace es establecer un “lock”, un bloqueo. La API de threads provee algunos conceptos para hacer esto. Uno de esos conceptos es el mutex. Un sólo thread puede tomar el mutex por vez, Si otro thread intenta tomar el mismo mutex, el segundo thread se bloquea hasta que el primero suelte ese mutex.

Entonces, el ejemplo anterior modificado por la sabiduría de los bloqueos quedaría así:

  1. El thread A intenta tomar el mutex correspondiente a la cola. Lo consigue: es suyo.
  2. El thread A comprueba que el contador vale más que cero.
  3. El thread B intenta tomar el mutex correspondiente a la cola. No lo consigue: lo tiene A, inicia espera.
  4. Basado en su comprobación el thread A decrementa el contador y toma el pedido.
  5. El thread A libera el mutex.
  6. Al quedar el mutex liberado el thread B continúa. Ahora tiene el mutex.
  7. El thread B comprueba que el contador vale cero.
  8. Basado en su comprobación el thread B ve que no tiene nada que hacer.
  9. El thread B libera el mutex.

Bueno, hasta acá. En un futuro cercano iré completando progresivamente este artículo.