Hilos: Líneas de ejecución de procesos.

¿Hilos?, ¿concurrencia?, ¿y eso cómo funciona? (Threads)

Por Juan Bernal (@bernalcodes)

En las ciencias de la computación, de acuerdo con Lamport (1979), un hilo de ejecución corresponde a la más pequeña secuencia de instrucciones programadas que pueden ser administradas de forma independiente por el programador, parte del sistema operativo.

La implementación de los hilos y procesos es diferente en los sistemas operativos. En su libro Moden Operating Systems, Tanenbaum (1992) muestra que muchos modelos de organización de procesos son posibles. En muchos casos, un hilo es un componente de un proceso. Los múltiples hilos asignados a un proceso pueden ser ejecutados de forma concurrente (multithreading), compartiendo recursos como la memoria, mientras que diferentes procesos no comparten estos recursos.

En particular, los hilos de un proceso comparten su código ejecutable, y los valores de sus variables asignadas de forma dinámica y las variables globales, que no son locales para cada hilo, en cualquier momento.

Los hilos aparecen bajo el nombre de tareas (tasks) en el sistema operativo OS/360 con de la implementación de la Multiprogramación con número Variable de Tareas (MVT) en 1967. Saltzer (1966) nombra a Victor A. Vyssotsky como el pionero del término "hilo" (thread).

El uso de hilos en aplicaciones de software se hizo más común durante la primera mitad de los 2000s a medida que las CPU empezaron a utilizar más núcleos (cores). Las aplicaciones deseaban aprovechar los múltiples núcleos por las ventajas de rendimiento que se necesitaban para emplear la concurrencia al utilizarlos.

¿Qué es la concurrencia?


Concurrencia es la habilidad de diferentes partes o unidades de un programa, algoritmo o problema para ser ejecutadas
fuera de orden, o en orden parcial, sin afectar la salida/resultado del mismo. Esto permite la ejecución en paralelo de unidades concurrentes, que puede aumentar significativamente la velocidad de ejecución en sistemas multi-procesador y multi-núcleo.

Lamport (1978) indica que la concurrencia, en un sentido más técnico, se refiere a la descomponibilidad de un programa, algoritmo, o problema en componentes/unidades de cómputo de orden independiente o parcial.

Hilos del Kernel

Un hilo de kernel es una unidad de poco peso de programación de kernel (kernel scheduling). Al menos un hilo de kernel existe por cada proceso. Si existen múltiples hilos dentro de un proceso, estos comparten los mismos recursos de memoria y archivos. Estos hilos no son dueños de recursos, además de un una pila (stack), una copia de los registros incluyendo el contador del programa, y el almacenamiento local del hilo, disminuyendo su coste de creación y destrucción.

De acuerdo a la documentación de IBM (2023), un hilo de kernel se define como la entidad programable, manejada por el programador del sistema (system scheduler). Son fuertemente dependientes de su implementación, por lo que, para facilitar el desarrollo de programas portables, las librerías proveen hilos de usuario.

Hilos de Usuario

En ocasiones, los hilos son implementados en librerías del espacio de usuario (userspace). El kernel no está consciente de estos, por lo que son programados y administrados en el espacio de usuario. Algunas implementaciones basan sus hilos de usuario encima de múltiples hilos de kernel, para beneficiarse de las máquinas multi-procesadoras. Estos hilos son implementados por las máquinas virutales, donde también se les denomina hilos verdes (green threads).

El cambio de contexto entre los hilos de usuario de un mismo proceso es extremadamente eficiente porque no requiere de ninguna interacción con el Kernel en absoluto. El cambio de contexto puede ser realizado mediante el guardado local de los registros de CPU usados por hilo de usuario en ejecución y después cargando los requeridos por el usuario para ser ejecutados.

En la imagen anterior podemos ver la ejecución de una prueba de concepto de un chat desde la terminal, que implementa hilos y listeners para la ejecución concurrente desde 2 clientes diferentes y un servidor.

A continuación el código fuente de un programa corto, escrito en lenguaje C para identificar cómo funcionan los hilos desde su creación, hasta su integración con el hilo de ejecución principal de un programa.

      #include "stdio.h"	// Librería estándar
      #include "pthread.h"	// Librería para manipulación de hilos

      // Prototipo de función para ejecutar en cada hilo
      void *computation(void *add);

      // Función principal
      int main(void)
      {
          // Declaración del hilo
          pthread_t thread1;
          pthread_t thread2;

          long value1 = 1;
          long value2 = 2;

          // Llamado individual de cada función
          computation((void *) &value1);
          computation((void *) &value2);

          // Creación del hilo y join al finalizar su ejecución
          pthread_create(&thread1, NULL, computation, (void *) &value1);
          pthread_join(thread1, NULL);

          pthread_create(&thread2, NULL, computation, (void *) &value2);
          pthread_join(thread2, NULL);

          // Creación de ambos hilos y join al finalizar sus ejecuciones respectivas
          pthread_create(&thread1, NULL, computation, (void *) &value1);
          pthread_create(&thread2, NULL, computation, (void *) &value2);

          pthread_join(thread1, NULL);
          pthread_join(thread2, NULL);

          return 0;
      }

      // Función que retorna un puntero a void, en este caso NULL
      void *computation(void *add)
      { 
          long sum = 0;
          long *add_num = (long *) (add);

          for (long i = 0; i < 1000000000; ++i) {
              sum += *add_num;
          }

          return NULL;
      }
    
   
Ahora miremos cómo nos vemos beneficiados si entendemos cómo implementar correctamente el uso de hilos dentro de nuestro código:

1er caso: Llamado secuencial de la función computation()

Ubicamos dentro de comentarios los demás casos. Nuestra función principal debería lucir así.

Ahora miremos el tiempo que tardó en ejecutarse nuestro primer caso:

Un total de 3.738s.


2do caso: Creación de un hilo y llamado de su pthread_join() respectivo de forma secuencial con cada hilo


Ubicamos dentro de comentarios los demás casos. Nuestra función principal debería lucir así.

Ahora miremos el tiempo que tardó en ejecutarse nuestro segundo caso:




Un total de 3.748s




3er caso: Creación de cada hilo y llamado de sus pthread_join() respectivos tras finalizar la ejecución de ambos

Ubicamos dentro de comentarios los demás casos. Nuestra función principal debería lucir así.

Ahora miremos el tiempo que tardó en ejecutarse nuestro segundo caso:

Un total de 1.904s


Como podemos notar en el último caso, ambos hilos creados están cubriendo la ejecución de la función computation() de forma concurrente y no secuencial, para luego unirse al hilo principal de ejecución. Esto nos permitió reducir en, aproximadamente, la mitad del tiempo de ejecución necesario para un bucle for de mil millones de iteraciones por llamado.


Bibliografía consultada para la escritura de este post:

- Lamport, L. (Septiembre de 1979). "How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs". IEEE Transactions on Computers. C-28 (9): 690–691. doi:10.1109/tc.1979.1675439

- Tanenbaum, A. (1992). "Modern Operating Systems". Prentice-Hall International Editions. ISBN 0-13-595752-4.

-  Saltzer, J. (Julio de 1966). "Traffic Control in a Multiplexed Computer System" Tesis de Doctorado en Ciencias. p.20.

- Lamport, L. (Julio de 1978). "Time, Clocks, and the Ordering of Events in a Distributed System". Communications of the ACM. 21 (7): 558–565. doi:10.1145/359545.359563.

- IBM Documentation. (20 de enero de 2023). "Kernel Threads and User Threads". IBM. https://www.ibm.com/docs/da/aix/7.2?topic=processes-kernel-threads-user-threads

- Portfolio Courses. (25 de febrero de 2023). "Introduction To Threads (pthreads) | C Programming Tutorial". Portfolio Courses. https://www.youtube.com/watch?v=ldJ8WGZVXZk

Comentarios