12. Hilos

Tiempo de lectura: 49 minutos

En el modelo de proceso que hemos descrito hasta el momento, cada proceso tiene una única secuencia de instrucciones que se ejecuta en la CPU. Si los procesos solo pueden tener una única secuencia de instrucciones, solo pueden realizar una tarea a la vez. Por ejemplo, en un procesador de textos en un sistema operativo con este modelo de procesos, el usuario nunca podría escribir al mismo tiempo que se comprueba la ortografía. En ese caso, si queremos hacer varias tareas al mismo tiempo, estamos obligados a crear varios procesos y seleccionar un mecanismo de comunicación para que estos colaboren.

Por eso muchos sistemas operativos modernos han extendido el concepto de proceso para permitir que cada uno tenga múltiples secuencias de instrucciones para ejecutarse en la CPU. A cada una de estas secuencias de instrucciones se las conoce como hilo de ejecución. Los procesos con varios hilos pueden realizar varias tareas a la vez.

12.1. Introducción

Desde que introducimos el concepto de proceso hemos considerado que es la unidad básica de uso de la CPU. Es decir, que la CPU se asignaba a los procesos, que la usaban para ejecutar sus instrucciones. Sin embargo, en los sistemas operativos multihilo es el hilo la unidad básica de uso de la CPU.

procesos multihilo
Figura 12.1. Esquema comparativo entre procesos monohilo y proceso multihilo.

Cada hilo tiene una serie de recursos propios dentro del proceso (véase la Figura 12.1):

  • El identificador del hilo es único para cada hilo y sirve para identificarlos, de la misma manera que lo hace el identificador de proceso con cada proceso.

  • El contador de programa es el registro de la CPU que indica la dirección de la próxima instrucción del hilo que debe ser ejecutada por la CPU.

  • Los registros de la CPU, cuyos valores son diferentes en cada hilo, puesto que, aunque todos los hilos ejecutan el mismo programa, pueden ejecutar diferentes partes del mismo.

  • La pila contiene datos temporales como argumentos y direcciones de retorno de las funciones y variables locales. Al igual que ocurre con los registros de la CPU, cada hilo necesita su pila porque recorre el programa de manera diferente.

Sin embargo hay otros recursos que se asignan al proceso, por lo que se comparten entre todos los hilos del mismo (véase la Figura 12.1):

  • El código del programa. El programa es el mismo para todos los hilos.

  • Los segmentos BSS y de datos y el montón. Las secciones de datos diferentes de la pila son accesibles a todos los hilos. Eso quiere decir, por ejemplo, que cualquier hilo puede acceder y modificar una variable global o una asignada dinámicamente mediante malloc() o new.

  • Otros recursos del proceso como archivos, sockets, tuberías y dispositivos abiertos, regiones de memoria compartida, señales, directorio actual de trabajo, entre muchos otros recursos.

Esto significa que cada instante, en cada CPU del sistema se puede estar ejecutando un hilo, del mismo o de distintos procesos en el sistema; pero la memoria, los archivos y otros recursos pertenecen al proceso del que cada uno forma parte. Si un hilo reserva memoria o abre un archivo o un dispositivo y no lo libera antes de terminar, el recurso permanecerá reservado, no siendo liberado hasta que lo haga otro hilo o el proceso completo termine. Si un hilo ejecuta una instrucción privilegiada o intenta acceder a una zona de memoria para la que no tiene permiso, la condición de error se propaga a todo el proceso. Por tanto, por lo general, el sistema operativo detendrá el proceso completo del que formaba parte.

Si se quiere ejecutar varias tareas al mismo tiempo de forma que la fiabilidad sea máxima, haciendo que los errores en unas no puedan afectar a las otras, tendremos que olvidarnos de los hilos. Debemos utilizar diferentes procesos para cada tarea. Así cada tarea tiene sus propios recursos y su propio espacio de direcciones virtual, lo que aísla a unas tareas de las otras.
proceso multihilo en memoria
Figura 12.2. Anatomía de un proceso multihilo en memoria.

En la Figura 12.2 se puede observar como cambia la disposición de los elementos de un proceso en la memoria cuando es multihilo, respecto a lo que vimos en el Apartado 9.1.

12.2. Beneficios

Son muchos los beneficios que aporta la programación multihilo:

12.2.1. Tiempo de respuesta

Una aplicación multihilo interactiva puede continuar ejecutando tareas aunque uno o varios hilos de la misma estén bloqueados o realizando operaciones muy lentamente, mejorando así el tiempo de respuesta al usuario.

Por ejemplo, un navegador web multihilo puede gestionar la interacción del usuario a través de un hilo, mientras el contenido solicitado se descarga en otro. Para hacer lo mismo en un navegador monohilo habría que utilizar comunicaciones asíncronas, de lo contrario, mientras el proceso está en estado esperando, a la espera de que lleguen los datos a través de la red, no puede atender las acciones del usuario.

12.2.2. Compartición de recursos

En sistemas operativos monohilo se pueden crear varios procesos y comunicarlos mediante memoria compartida para conseguir algo similar a lo que ofrecen los sistemas multihilo. Sin embargo, al utilizar hilos, las tareas ejecutadas en ellos comparten los recursos automáticamente, sin que tengamos que hacer nada. Además, los hilos no solo comparten la memoria, sino también otros muchos recursos del proceso. Por lo que los hilos son una forma más conveniente de tener procesos que realizan diferentes actividades al mismo tiempo.

12.2.3. Economía

Reservar memoria y otros recursos para la creación de un proceso es muy costoso. Por eso los sistemas operativos modernos han desarrollado diversas técnicas para que sea lo más eficaz posible.

Aun así, puesto que los hilos comparten los recursos de los procesos a los que pertenecen, son mucho más económicos de crear. También es más económico el cambio de contexto entre ellos, ya que hay que guardar y recuperar menos información al cambiar entre dos hilos de un mismo proceso.

Por ejemplo, en Microsoft Windows crear un proceso puede ser 300 veces más costoso que un hilo. Mientras que en sistemas Linux es 3 veces más lento, por la eficiencia de fork().

12.2.4. Aprovechamiento de las arquitecturas multiprocesador.

En los sistemas multiprocesador diferentes hilos pueden ejecutarse en paralelo en distintos procesadores. Por el contrario, un proceso monohilo solo se puede ejecutar en una CPU a la vez, independientemente de cuantas CPU estén disponibles para ejecutarlo.

12.3. Soporte multihilo

Las librerías de hilos proporcionan al programador la interfaz de programación para crear y gestionar los hilos de un proceso. Por lo general, mediante lenguaje C se puede acceder directamente a la librería de hilos del sistema. Otros lenguajes proporcionan un librería de hilos dentro de su librería estandar, que a su vez se apoya en la librería de hilos del sistema operativo.

El soporte de hilos en un sistema operativo se puede proporcionar a nivel de usuario o a nivel de núcleo.

12.3.1. Hilos a nivel de usuario

El soporte de hilos a nivel de usuario se implementan mediante un librería de hilos en el espacio de usuario, junto al código del programa y los datos del proceso, sin requerir ningún soporte especial por parte del núcleo del sistema operativo. Por tanto, como se puede observar en el lado derecho de la Figura 12.3, estos hilos existen desde el punto de vista del proceso y del programa que ejecuta, pero no para el sistema operativo. El planificador de la CPU asigna tiempo de CPU a los procesos, mientras que la librería de hilos de cada proceso reparte el tiempo de ejecución del proceso entre los diferentes hilos de este.

comparación soporte de hilos
Figura 12.3. Comparación de hilos a nivel de usuario y a nivel de núcleo.

Como el código y los datos de la librería residen en el espacio de usuario, invocar una función de la misma se reduce a una simple llamada a una función, evitando el coste de hacer llamadas al sistema.

12.3.2. Hilos a nivel de núcleo

Se habla de hilos a nivel de núcleo, cuando el núcleo del sistema es el encargado de ofrecer el soporte multihilo. El código y los datos de la librería de hilos, mediante la cual los programadores pueden solicitar la creación y gestión de los hilos, reside en el espacio del núcleo. Por tanto, para invocar una función de la librería de hilos es necesario hacer una llamada al sistema. Obviamente, la librería del sistema ofrece las funciones necesarias para realizar estas llamadas al sistema de forma sencilla.

La implementación del soporte de hilos en el núcleo implica cambios respecto a lo que hemos visto hasta el momento de la gestión de procesos, puesto que, en estos sistemas, es el hilo la unidad básica de uso de la CPU, como se ilustra en la Figura 12.3. Es decir, son los hilos los que se mueven por los estados del Figura 9.2 y las colas de la Figura 9.3, en lugar de los procesos. En estos sistemas, el planificador de la CPU selecciona un hilo para ejecutarse en la CPU de entre todos los que están en el estado preparado en el sistema y el cambio de contexto asigna la CPU a un hilo distinto al que la tiene asignada en el momento actual, que puede ser del mismo o de diferente proceso.

Aparte del PCB que vimos en el Apartado 9.3, ahora el sistema operativo también gestiona una estructura llamada bloque de control del hilo o TCB (Thread Control Block) para cada hilo en el sistema, donde se guarda información sobre su estado de actividad actual. Por tanto, es en TCB —y no en el PCB— donde se guarda la información privada del hilo necesaria para la gestión de los estados y para el cambio de contexto, como: los valores de los registros de la CPU y el contador de programa, el estado o la información de planificación de la CPU; además de un puntero al PCB al que pertenece el hilo con el resto de la información privada del proceso.

12.3.3. Implementaciones

En la actualidad, en los diferentes sistemas operativos se pueden encontrar librerías de ambos tipos.

Por ejemplo, la librería de hilos de Windows API se implementa en el núcleo[19] mientras que la librería de hilos POSIX Threads —frecuentemente utilizada en los sistemas POSIX— puede ser de ambos tipos, dependiendo solamente del sistema donde se implemente. En Linux y en la mayor parte de los UNIX modernos, POSIX Threads se implementa en el núcleo del sistema.

12.4. Modelos multihilo

En base a lo anterior, sabemos que hay sistemas que implementan hilos a nivel de núcleo mientras otros los hacen a nivel de usuario. Sin embargo, también existen sistemas donde se combinan ambos.

Para comparar las diferentes opciones que tienen los diseñadores de un sistema en cuanto al soporte de hilos, se definen los modelos multihilo en base a la relación entre hilos de usuario e hilos de núcleo. Aquí entendemos por hilos de núcleo a estas secuencias de instrucciones tal y como las ve el programa en el espacio de usuario. Mientras que el concepto de hilos de núcleo corresponde con como las ve el núcleo del sistema.

12.4.1. Muchos a uno (N:1)

En el modelo muchos a uno muchos hilos de usuario son mapeados en un único hilo de núcleo. El planificador de la CPU en el núcleo reparte el tiempo de CPU entre los diferentes hilos de núcleo en el sistema, mientras que la librería de hilos en cada proceso reparte el tiempo de ejecución del hilo de núcleo del proceso entre múltiples hilos de usuario (véase la Figura 12.4).

modelo muchos a uno
Figura 12.4. Modelo muchos a uno (N:1).

Los sistemas modernos son multihilo, por lo que al crearse un proceso siempre se crea con un hilo de núcleo inicial, que es al que el núcleo asigna tiempo de CPU. Sin embargo, los sistemas más antiguos no tienen ningún soporte de hilos en el núcleo, por lo que no hay hilos de núcleo. En este sentido, quizás sería más correcto definir el modelo muchos a uno como aquel en el que muchos hilos de usuario son mapeados en una «única entidad planificable en la CPU». En los sistemas más antiguos estas entidades son los procesos, de forma que, bajo este modelo multihilo los hilos de usuario realmente se reparten el tiempo de ejecución del proceso al que pertenecen.

El modelo muchos a uno corresponde al caso en el que solo se implementan hilos a nivel de usuario, que es el caso ilustrado a la izquierda en la Figura 12.3.

Ventajas e inconvenientes

La principal ventaja de este modelo es su bajo coste, por lo que resulta ideal cuando la cantidad de hilos a crear —el nivel de concurrencia— va a ser muy alta:

  • Los hilos de usuario son muy baratos de crear porque la gestión de hilos se hace con una librería en el espacio de usuario, dentro del proceso. Mientras que los hilos de núcleo pueden necesitar más recursos del sistema, como espacio en la tabla de hilos del sistema y en otras estructuras de gestión.

  • La invocación de las funciones de la librería de hilos se hace por medio de simples llamadas a funciones, que son menos costosas que las llamadas al sistema necesarias cuando el soporte de hilos se implementa en el núcleo.

  • Se necesitan menos cambios de contexto. Los cambios de contexto, que ocurren cuando se transfiere el control de la CPU, pueden ser operaciones costosas. En el modelo muchos a uno pueden ocurrir cambios de contexto en el núcleo al intercambiar procesos en la CPU, pero el intercambio de hilos se gestiona en el propio proceso, lo que suele ser más rápido que hacerlo en el núcleo.

Mientras que estos son algunos posibles inconvenientes:

  • Los hilos de un mismo proceso no se pueden ejecutar en paralelo en sistemas multiprocesador, por lo que este modelo no permite aprovechar ese tipo de sistemas. El motivo es que la librería de hilos en espacio de usuario no tiene acceso a los procesadores del sistema. Solo conoce el proceso en el que se ejecuta y reparte el tiempo de ejecución del proceso entre los distintos hilos.

    El núcleo del sistema si ve los diferentes procesadores, pero desconoce la existencia de los hilos dentro de los procesos. Solo ve procesos que deben ejecutarse en las distintas CPU.

  • Un hilo puede bloquear la ejecución del resto de los hilos de su proceso, en determinadas circunstancias.

    Si uno de los hilos solicita al sistema operativo una operación que deba ser bloqueada a la espera —por ejemplo,operaciones de E/S sobre archivos, comunicaciones o esperar a que otro proceso termine— todo el proceso es puesto como esperando por el sistema operativo, no pudiendo ejecutarse otros hilos del mismo proceso en la CPU, mientras tanto. Para evitar en parte este problema, es frecuente que las implementaciones de este modelo ofrezcan versiones especiales de estas operaciones, con capacidad para evitar el bloqueo total del proceso, en función de si el sistema operativo tiene el soporte adecuado para ello.

Por tanto, este modelo no es una opción si lo que interesa es aprovechar el paralelismo en sistemas multiprocesador, para intentar realizar ciertas operaciones en menos tiempo.

Operaciones bloqueantes

El problema del bloqueo de procesos ocasionado por operaciones de E/S que pongan al proceso en estado esperando puede ser evitado interceptando las llamadas a funciones de la librería del sistema, para evitar el uso de llamadas al sistema que se puedan bloquear y sustituirlas por versiones equivalentes pero asíncronas.

Obviamente, la librería de hilos intercambia el hilo de usuario en ejecución cuando el que se está ejecutando finaliza. Además, generalmente, tiene funciones para que el hilo de usuario en ejecución se duerma —sleep()— o para que espere a que otro hilo termine —wait() o join(). En ambos casos, el hilo de usuario queda bloqueado y la librería de hilos asigna otro hilo al hilo de núcleo para su ejecución.

Por ejemplo, en la Figura 12.5 se ilustra como el Hilo 1 llama a la función yield() de la librería de hilos, provocando su intercambio con el Hilo 2. La función yield() suele estar presente en algunas librerías de hilos, con el objeto de que los hilos puedan indicar puntos del programa en los que quieren dejar tiempo de ejecución para otros hilos, de forma voluntaria.

muchos a uno operación bloqueante
Figura 12.5. Diagrama de secuencia del bloqueo del proceso en el modelo muchos a uno.

Sin embargo, en la Figura 12.5 también vemos que Hilo 2 invoca la llamada al sistema read() para leer un archivo. Desde el punto de vista del núcleo, esa llamada la realiza el proceso, por lo que es bloqueado, hasta que la operación de lectura del archivo se completa. Al proceso nunca se le asignará la CPU mientras esté en estado esperando, por lo que ni Hilo 1 ni ningún otro hilo de usuario del proceso podrá ejecutarse.

La solución, como hemos comentado, es que la librería de hilos proporcione sus propias versiones de las llamadas al sistema que pueden poner al proceso en estado esperando. Por ejemplo, en la Figura 12.5 el Hilo 2 no usa la llamada al sistema read() sino una función read() proporcionada por la librería de hilos.

Para implementar su read(), la librería de hilos usa una versión asíncrona de la llamada al sistema. Es decir, una versión donde se le indica al sistema que se quiere hacer una operación de E/S, pero el proceso no entra en estado de espera mientras ocurre. En su lugar, el proceso retorna inmediatamente de la llamada al sistema y sigue ejecutándose en la CPU, lo que permite que la librería de hilos comience a ejecutar Hilo 1 en el lugar de Hilo 2. Es decir, desde el punto de vista de Hilo 2, la función read() de la librería de hilos es bloqueante.

muchos a uno operación async
Figura 12.6. Diagrama de secuencia de la solución al bloqueo del proceso en el modelo muchos a uno.

Más adelante, el sistema operativo notificará a la librería de hilos que la operación ha sido realizada, por lo que los datos ya están disponibles en la memoria para ser usados. En ese momento, Hilo 2 vuelve al estado preparado para volver a ser planificado por la librería de hilos cuando sea posible.

Este procedimiento es complejo y requiere versiones no bloqueantes de todas las llamadas al sistema, lo que puede que no siempre se cumpla. Por lo general, cualquier sistema operativo moderno ofrece versiones asíncronas de todas las operaciones de E/S, pero puede haber otras llamadas al sistema que puedan poner al proceso en estado esperando y que no tengan versión asíncrona.

Implementaciones

A este modelo de hilos también se lo denomina Green Threads. En Java 1.1 era el único modelo soportado —ya que los hilos se implementaban en la máquina virtual de Java, independientemente del soporte del sistema operativo— pero debido a sus limitaciones se implementó el soporte de hilos nativos del sistema operativo.

Otras implementaciones de este modelo son las fibras y UMS de Windows API, Stackless Python y GNU Portable Threads.

12.4.2. Uno a uno (1:1)

En el modelo muchos a uno un hilo de usuario se mapea en un único hilo de núcleo.

Por lo general, este modelo corresponde al caso de los sistemas que solo soportan hilos a nivel de núcleo. En este caso, la librería de hilos se implementa en el núcleo, por lo que las entidades que planifica el núcleo en la CPU son los hilos de núcleo y los procesos pueden gestionar estos hilos mediante llamadas al sistema (véase la Figura 12.7).

modelo uno a uno
Figura 12.7. Modelo uno a uno (1:1).

Es importante tener en cuenta que, a efectos prácticos, los hilos de usuario de la Figura 12.7 no son hilos diferentes a los hilos de núcleo. Realmente, son los mismos hilos de núcleo, pero vistos desde la perspectiva del programa en el modo usuario, como se ilustra a la derecha en la Figura 12.3.

Ventajas e inconvenientes

Las principales ventajas de este modelo son:

  • Permite a otros hilos del mismo proceso ejecutarse aun cuando uno de ellos haga una llamada al sistema que debe bloquearse. El núcleo se encarga de ponerlo en espera y planificar en la CPU a otro de los hilos preparados para ejecutarse de entre todos los existentes en el sistema.

  • Permite paralelismo en sistemas multiprocesador, ya que diferentes hilos pueden ser planificados por el núcleo en distintos procesadores.

Mientras que estos son algunos posibles inconvenientes:

  • Crear hilos puede tener mayor coste. En este caso, crear un hilo para un proceso implica crear y gestionar ciertas estructuras de datos en el núcleo. Debido a que la cantidad de memoria disponible para el núcleo suele estar limitada, muchos sistemas restringen la cantidad máxima de hilos de núcleo soportados.

  • La gestión de los hilos se hace con una librería en el espacio de núcleo, lo que requiere que el proceso haga llamadas al sistema para gestionarlos. Esto suele tener mayor coste que invocar simplemente una función, como ocurre en el modelo muchos a uno.

Implementaciones

El modelo uno a uno se utiliza en la mayor parte de los sistemas operativos multihilo modernos. Linux, Microsoft Windows —desde Windows 95— Solaris 9 y superiores, macOS y la familia de UNIX BSD; son ejemplos de sistemas operativos que utilizan el modelo uno a uno.

12.4.3. Muchos a muchos (M:N)

En teoría debería ser posible aprovechar lo mejor de los dos modelos anteriores con una librería de hilos en el núcleo, para crear hilos de núcleo, y otra en el espacio de usuario, para crear hilos de usuario. Así los desarrolladores pueden utilizar la librería de hilos en el espacio de usuario para crear tantos hilos como quieran y que estos se ejecuten sobre los hilos de núcleo de su proceso.

El planificador de la librería de hilos en espacio de usuario se encarga de determinar como se reparte el tiempo de ejecución de cada hilo de núcleo entre los hilos de usuario. Mientras que el planificador de la CPU asigna la CPU a alguno de los hilos de núcleo del sistema (véase la Figura 12.8).

modelo muchos a muchos
Figura 12.8. Modelo muchos a muchos (M:N).
Activación del planificador

En el modelo muchos a muchos es conveniente que exista cierto grado de coordinación entre el núcleo y la librería de hilos del espacio de usuario. Dicha comunicación tiene como objeto ajustar dinámicamente el número de hilos de núcleo para garantizar la máxima eficiencia.

activación del planificador
Figura 12.9. Diagrama de secuencia del mecanismo de activación del planificador.

Uno de los esquemas de comunicación se denomina activación del planificador y consiste en que el núcleo informa a la librería de hilos en espacio de usuario que una llamada al sistema, realizada por uno de sus hilos de usuario, va a bloquear un hilo de núcleo del proceso cuyos hilos gestiona. Antes de dicha notificación, el núcleo se encarga de crear un nuevo hilo de núcleo en el proceso y se lo pasa a la librería de hilos en la notificación (véase la Figura 12.9). Así, el planificador de la librería puede asignarle alguno de los otros hilos de usuario, evitando el bloqueo completo del proceso si no quedan hilos de núcleo disponibles, y ajustando el número de hilos de núcleo dinámicamente, según las necesidades

Ventajas e inconvenientes

Las principales ventajas de este modelo son:

  • Permite paralelismo en sistemas multiprocesador, ya que diferentes hilos de núcleo pueden ser planificados en distintos procesadores y en cada uno puede ejecutarse cualquier hilo de usuario de su proceso.

  • Permite a otro hilo de usuario del mismo proceso ejecutarse cuando un hilo hace una llamada al sistema que debe bloquearse. Si esto ocurre el correspondiente hilo de núcleo se queda bloqueado, pero el resto de los hilos de usuario pueden seguir ejecutándose en los otros hilos de núcleo del proceso (véase el Apartado 12.4.3.1).

Mientras que el principal inconveniente es su complejidad para implementarlo y la dificultad de coordinar el planificador de la librería de hilos en espacio de usuario con el planificador de la CPU para obtener el mejor rendimiento.

Como veremos en el Capítulo 14, los planificadores de la CPU modernos intentan clasificar los hilos de núcleo para ordenarlos de la forma más óptima en el acceso a la CPU. Por ejemplo, no es lo mismo una tarea de cálculo, que necesita usar intensivamente la CPU, que copia datos de la red o del sistema de archivos. Generalmente, primero se planifican las segundas, dejando para el final las tareas intensivas en el uso de la CPU.

En el modelo muchos a muchos un mismo hilo de núcleo se pueden ejecutar diferentes hilos de usuario que pueden ser de distinto tipo. Por tanto, cualquier clasificación que haga el planificador de la CPU puede ser incorrecta en cuanto la librería de hilos en espacio de usuario cambie el hilo de usuario que se está ejecutando un hilo de núcleo por otro diferente. La solución a este problema pasa porque la librería de hilos en espacio de usuario y el planificador de la CPU intercambien información sobre sus hilos y se coordinen.

Debido a estas dificultades, muchos sistemas actuales han optado finalmente por el modelo uno a uno, concentrando esfuerzos en reducir el coste de creación de hilos a nivel de núcleo, con el objeto de que haya poca ventaja en implementar también hilos a nivel de usuario.

Esto no significa que actualmente no se puedan obtener las ventajas del modelo muchos a muchos en casos en los que puede ser beneficioso. Para esos casos los desarrolladores pueden utilizar librerías o lenguajes específicos, con implementaciones diseñadas para crear un gran número de hilos de usuario con un coste mínimo, sobre la librería de hilos de los sistemas operativos actuales.

Implementaciones

Este modelo se soportaba en sistemas FreeBSD y versiones antiguas de NetBSD, así como en UNIX comerciales, como: Solaris 8 y anteriores, IRIX, HP-UX y Tru64 UNIX. Microsoft Windows también soportaba este modelo —a partir de Windows 7 y hasta Windows 10— gracias a incorporar un mecanismo denominado planificación en modo usuario[18].

Algunos lenguajes de programación implementan el modelo muchos a muchos sobre el modelo uno a uno soportado por la mayoría de sistemas operativos modernos. Ese es el caso de Go, Erlang, Elixir y Java.

12.4.4. Dos niveles

Existe una variación del modelo muchos a muchos donde, además de funcionar de la forma comentada anteriormente, se permite que un hilo de usuario quede ligado indefinidamente a un único hilo de núcleo, como en el modelo uno a uno.

Esta variación se denomina, en ocasiones, modelo de dos niveles (véase la Figura 12.10).

modelo de dos niveles
Figura 12.10. Modelo de dos niveles.
Ventajas e inconvenientes

El modelo de dos niveles presenta los mismos inconvenientes que el modelo muchos a muchos, con algunas ventajas en casos de uso concretos:

  • Al vincular un hilo de usuario a un hilo de núcleo se asegura la disponibilidad del recurso, lo que puede ser interesante si un hilo de usuario es particularmente importante para el rendimiento de la aplicación.

  • En sistemas con múltiples procesadores o núcleos, puede interesar que un hilo de usuario se ejecute siempre en el mismo procesador o núcleo para aprovechar la caché y mejorar el rendimiento. Al vincular un hilo de usuario a un hilo de núcleo y configurar este último para que siempre se planifique en el mismo procesador, se está asegurando esta misma condición para el hilo de usuario correspondiente.

12.4.5. El coste de crear hilos y la elección de modelo

Hemos comentado que el modelo muchos a uno debe ser, teóricamente, «más ligero» que el modelo uno a uno. También hemos dicho que el modelo muchos a muchos, teóricamente, permite conservar esa ventaja, al tiempo que ofrece los beneficios del modelo uno a uno, en lo que respecta al aprovechamiento de los sistemas multiprocesador. Sin embargo, debemos tener en cuenta que la diferencia real puede variar en función de múltiples factores.

Un criterio común hasta la década de los 2000, era que el modelo uno a uno era adecuado hasta varias decenas hilos —a lo sumo, en torno a 100 o, quizás, a unos pocos cientos de hilos— por proceso, mientras que el modelo muchos a uno podía llegar a varios miles de hilos.

La principal limitación en la cantidad de hilos de usuario del modelo muchos a uno estaba en el tamaño del espacio de direcciones del proceso. Por ejemplo, en las versiones de Windows de 32 bits, los procesos dispone de 2 GiB de su espacio de direcciones para código y datos. Como cada fibra —que es como se llama a los hilos del modelo multihilo muchos a uno que implementa la API Win32 de Windows— necesita 1 MiB de memoria para su pila, el límite teórico era de 2048 fibras. Este límite era realmente algo inferior porque en el mismo espacio se almacena el código y los datos del programa y las librerías que este utiliza.

Una forma de soslayar este problema es indicar al sistema que reserve menos memoria para la pila al crear cada fibra. Esta posibilidad la suelen soportar los sistemas operativos, independientemente del modelo multihilo. En el caso de Microsoft Windows, esto permite llegar hasta cerca de las 32 KiB fibras en sistemas de 32 bits.

Posteriormente, se optimizó la creación de hilos a nivel de núcleo, hasta el punto de equiparar, en gran medida, el coste de cada hilo en ambos modelos. Mientras que el pasar a sistemas de 64 bits, permitió superar las limitaciones derivadas por el pequeño tamaño del espacio de direcciones de los procesos en los sistemas de 32 bits, que limitaba a ambos modelos multihilo

Actualmente, la creación de hilos a nivel de núcleo se ha optimizado hasta el punto de que usando el modelo uno a uno se puede llegar a decenas o cientos de miles de hilos por proceso, equiparándose a muchas implementaciones del modelo muchos a uno.

Con suficiente memoria y ajustando el tamaño de la pila de los hilos, se puede llegar a millones de hilos en ambos modelos. Sin embargo, cuando se tienen requisitos tan específicos, suele recomendarse utilizar librerías o lenguajes específicos, con implementaciones concretas del modelo muchos a uno o muchos a muchos —siendo actualmente más interesante porque permite aprovechar el paralelismo de las CPU modernas— específicamente diseñadas para escalar hasta dicha cantidad de hilos.

Algunas de estas implementaciones son:

  • Stackless Python, una implementación del modelo muchos a uno para Python. Permite crear un millón de hilos a nivel de usuario —llamados tasklets— consumiendo solo 100 MiB de memoria.

  • Go, un lenguaje de programación orientado a la creación de servicios de alto rendimiento, que incluye su propia implementación del modelo muchos a muchos. Go puede crear un millón de hilos a nivel de usuario —llamadas gorutinas— 10 veces más rápido de lo que se pueden crear la misma cantidad de hilos del sistema operativo en Linux[9], utilizando 2 GiB de memoria para las pilas.

  • Java, soporta hilos virtuales —a partir de la versión 19 como feature preview— una implementación del modelo muchos a muchos, dirigida a cargas de trabajo que necesitan una cantidad extremadamente alta de hilos.

12.5. Operaciones sobre los hilos

Como ocurre con los procesos, es necesario que los hilos pueden ser creados y eliminados dinámicamente, por lo que los sistemas operativos deben proporcionar servicios para la creación y cancelación de los mismos.

12.5.1. Creación de hilos

En un sistema operativo con librería de hilos implementada en el núcleo, todo proceso se crea con un hilo, denominado hilo principal. Este es con el que comienza a ejecutarse el programa al entrar en main() y el que provoca la terminación de todo el proceso —incluida la terminación de los otros hilos que existan— al retornar de dicha función.

El hilo principal puede crear otros hilos y estos, a su vez, crear los hilos que necesiten. Pero, a diferencia de lo que ocurre con los procesos, no existe una relación de padres a hijos ni se crea un árbol de hilos. Excepto por la característica especial del hilo principal de que su finalización significa la terminación del proceso, todos los hilos son iguales entre sí.

En el Ejemplo 12.1 se puede ver cómo se usa std::thread de la librería estándar de C++ para crear hilos.

Ejemplo 12.1. Creación de hilos con std::thread en C++.

El código fuente completo de este ejemplo está disponible en threads.cpp. En pthreads.cpp se puede ver un ejemplo equivalente, pero usando POSIX Threads de la librería de sistema POSIX. En threads-factorial.cpp se puede examinar un ejemplo más interesante donde los hilos se utilizan para dividir el cálculo del factorial de un número, con el objetivo de calcularlo en menos tiempo.

void thread_function(int thread_id)  (2) (3)
{
    fmt::print( "[Hilo {}] Creado\n", thread_id );
    for(int i = 0; i < 10; ++i)
    {
        // Dormir el hilo para simular que hace trabajo
        // ...
    }
}

int main()
{
    // Crear 3 hilos dentro del proceso
    std::thread thread1( thread_function, 1 );   (1) (2) (3)
    std::thread thread2( thread_function, 2 );
    std::thread thread3( thread_function, 3 );

    fmt::print( "[Main] Hilo 1 - Id: {}, Manejador del sistema: 0x{:x}\n",
        thread1.get_id(),           (4)
        thread1.native_handle() );  (5)

    // ...

    thread1.join(); (6)
    thread2.join();
    thread3.join();

    return EXIT_SUCCESS;
}
1 En C++ se usa la clase std::thread para crear hilos. Los objetos std::thread no son los hilos sino objetos que sirven para gestionar hilos creados en el sistema.
2 Todo hilo tiene una función principal que será donde comience la ejecución del hilo y que se debe indicar como primer argumento al crear el objeto std::thread. Cuando esa función termine, el hilo finalizará.
3 Los hilos pueden recibir varios argumentos. Se indican como argumentos adicionales al crear el objeto std::thread, tras el argumento de la función principal del hilo. Se pueden usar argumentos pasados por referencia o punteros, cuando se quiere usar dichos argumentos para devolver resultados.
4 Cada objeto std::thread guarda un identificador del hilo. Este identificador no tiene que tener ninguna relación con los identificadores o manejadores utilizados por el sistema operativo para gestionar los hilos.
5 El método std::thread::native_handle() permite obtener el manejador del sistema operativo del hilo gestionado por ese objeto std::thread en concreto.
6 El hilo que invoca std::thread::join() se queda dormido —en este caso, el hilo principal— hasta que el hilo indicado en el primer argumento termine. Si el hilo principal sale de main() sin esperar a que todos los hilos del proceso terminen, C++ lanza una excepción y el programa termina inesperadamente.

En los sistemas POSIX, std::thread se implementa usando la librería del sistema POSIX Threads, mientras que en los sistemas Microsoft Windows utiliza la API Win32. En el Ejemplo 12.2 se puede ver código equivalente al del Ejemplo 12.1 pero usando directamente la API de POSIX Threads.

Ejemplo 12.2. Creación de hilos con POSIX Threads.

El código fuente completo de este ejemplo está disponible en pthreads.cpp. En pthreads-factorial.cpp se puede estudiar un ejemplo similar, pero dónde se usan los hilos para dividir el cálculo del factorial de un número.

struct thread_args (9)
{
    int id;
    int result;
};

void* thread_function(void* arg)   (7) (8) (9)
{
    thread_args* args = static_cast<thread_args*>(arg); (9)

    fmt::print( "[Hilo {}] Creado. Manejador del sistema: 0x{:x}\n", args->id, pthread_self() ); (10)

    for(int i = 0; i < 5; ++i)
    {
        // Dormir el hilo para simular que hace trabajo
        // ...
    }

    args->result = args->id;
    return &args->result; (12)
}

int main()
{
    int return_code = 0;
    pthread_t thread1, thread2, thread3;  (1) (2)

    thread_args thread1_args { .id = 1 };
    thread_args thread2_args { .id = 2 };
    thread_args thread3_args { .id = 3 };

    int return_code = pthread_create( (1)
        &thread1,           (3)
        nullptr,            (6)
        thread_function,    (7)
        &thread1_args );    (8) (9)

    if (return_code) (4)
    {
        fmt::print( stderr, "Error ({}) al crear el hilo: {}\n",
            return_code, std::strerror(return_code) ); (5)
        return EXIT_FAILURE;
    }

    return_code = pthread_create( &thread2, /* ... */ );
    if (return_code)
    {
        // ...
    }

    int* thread1_result, *thread2_result, *thread3_result;

    pthread_join( (11)
        thread1,
        reinterpret_cast<void**>(&thread1_result) ); (13)
    pthread_join( thread2, reinterpret_cast<void**>(&thread2_result) );
    pthread_join( thread3, reinterpret_cast<void**>(&thread3_result) );

    // ...

    return EXIT_SUCCESS;
}
1 En POSIX Threads se usa pthread_create() para crear hilos. Devuelve un manejador de tipo pthread_t que podemos usar con otras funciones de la API para indicar el hilo que queremos gestionar.
2 pthread_t no es el equivalente al PID de los hilos. Si el sistema implementa la librería de hilos en el núcleo, por lo general, cada hilo tiene un identificador único; pero POSIX Threads no ofrece una forma de obtenerlo.
3 La variable pthread_t se pasa a pthread_create() como puntero para que al retornar contenga el manejador del hilo, si todo ha ido bien.
4 Si el hilo se puede crear, pthread_create() devuelve 0. En caso contrario devuelve un código de error.
5 Los códigos de error son los mismos que hasta ahora veíamos en errno. Así que podemos llamar a strerror() pasando el valor retornado, para obtener un texto descriptivo del error.
6 Es opcional pasar a pthread_create() una estructura con atributos tales como: tamaño y posición de la pila, política y parámetros de planificación, entre otros. En este caso, como no estamos interesados, pasamos nullptr
7 Todo hilo tiene una función principal que será donde comience la ejecución del hilo. Cuando esa función termine, el hilo finalizará.
8 Los hilos pueden recibir un argumento en la forma de un puntero a void*. Si queremos pasar varios argumentos, lo más sencillo es crear una estructura y pasar un puntero a esta.
9 En este ejemplo definimos thread_args para pasar los argumentos a los hilos y lo pasamos como void* a la función principal. Allí hacemos un typecast para recuperar el puntero a la estructura thread_args y poder acceder a sus campos.
10 En cualquier momento se puede llamar a pthread_self() para obtener el manejador pthread_t del hilo actual.
11 El hilo que invoca pthread_join() se queda dormido —en este caso, el hilo principal— hasta que el hilo indicado en el primer argumento termine. Si el hilo principal sale de main() sin esperar a que todos los hilos del proceso terminen, estos mueren inmediatamente, junto con el proceso.
12 El hilo puede retornar un resultado mediante un puntero 'void*'. Esto se indica en la sentencia return de la función principal del hilo o invocando pthread_exit() para terminar.
13 La función pthread_join() acepta un puntero a void* para devolver ese valor de retorno al hilo que la invoca.

Como los hilos de POSIX Threads devuelven punteros, es importante no intentar devolver variables locales, ya que se destruirán cuando el hilo termine y el punto devuelto no será válido.

Una alternativa es devolver los resultados a través de la estructura pasada como argumento. Por ejemplo, el campo result de la estructura thread_args ofrece una manera más cómoda de obtener el resultado del trabajo de cada hilo.

12.5.2. Cancelación de hilos

La cancelación es la operación de terminar un hilo antes de que termine su trabajo. Por ejemplo, en un navegador web un hilo se puede encargar de la interfaz de usuario mientras otros hilos se encargan de descargar las páginas y las imágenes de la misma. Si el usuario pulsa el botón Cancelar es necesario que todos los hilos que intervienen en la descarga sean cancelados.

Esto puede ocurrir de dos maneras:

  • En la cancelación asíncrona el hilo termina inmediatamente. Esto puede causar problemas al no liberarse los recursos reservados en el proceso por parte del hilo Por ejemplo, antes de terminar no se cierran archivos abiertos ni se libera memoria de los que solo este hilo tiene los descriptores de archivo y los punteros, respectivamente.

    Además, si el hilo que termina estaba modificando datos que compartía con otros hilos, estos cambios podrían quedar a medias. Esto puede dejar las estructuras de datos compartidas en un estado inconsistente, causando problemas en otros hilos.

  • En la cancelación en diferido el hilo comprueba periódicamente cuando debe terminar. Si no se tiene cuidado, los problemas pueden ser similares a los de la cancelación asíncrona. La diferencia es que ahora el desarrollador conoce de antemano los puntos donde podría terminar el hilo, lo que da una oportunidad de introducirlos solo donde sea seguro terminar.

Se denomina fuga de memoria al error que ocurre cuando un bloque de memoria reservada no se libera durante la ejecución del programa. También pueden ocurrir fugas con otros recursos del sistema operativo, como: archivos, sockets, colas de mensajes o regiones de memoria compartida.

Generalmente ocurre porque se pierden todas las referencias a un recurso, por lo que ya no hay oportunidad de liberarlo. Por ejemplo, cuando se cancela un hilo que es el único que tiene algunas referencias, sin liberar antes esos recursos.

12.5.3. Cancelación en POSIX Threads

En POSIX Threads un hilo puede solicitar la cancelación de otro hilo usando pthread_cancel().

int return_code = pthread_cancel(thread);

El hilo identificado por el manejador thread será cancelado si está configurado como cancelable. Por defecto todos los hilos son cancelables, pero eso lo puede cambiar el propio hilo llamando a pthread_setcancelstate():

int return_code = pthread_setcancelstate(
    PTHREAD_CANCEL_DISABLE, (1)
    &oldstate               (2)
);
1 Con PTHREAD_CANCEL_DISABLE se desactiva la cancelación en el hilo que llama la función. El otro valor posible es PTHREAD_CANCEL_ENABLE.
2 La función devuelve a través de un puntero a int el valor anterior del estado de cancelación.

El tipo de cancelación se puede configurar con pthread_setcanceltype():

int return_code = pthread_setcanceltypr(
    PTHREAD_CANCEL_DEFERRED,    (1)
    &oldtype                    (2)
);
1 Con PTHREAD_CANCEL_DEFERRED se activa la cancelación en diferido, que de todas formas es el tipo de cancelación por defecto. El otro valor posible es PTHREAD_CANCEL_ASYNCHRONOUS, que corresponde con la cancelación asíncrona.
2 La función devuelve a través de un puntero a int el valor anterior del tipo de cancelación.

Se pueden cambiar entre estado y tipo de cancelación en cualquier momento, según lo que encaje mejor con las características de las distintas partes del código.

Cancelación asíncrona

Por los motivos comentados anteriormente, no es recomendable la cancelación asíncrona, a menos que estemos muy seguros de que no puede causar problemas. Uno de los pocos casos con los que es compatible es en bucles 100% dedicados a ejecutar cálculos en la CPU, como el siguiente:

int factorial = 1;
for ( int i = 2; i <= number; i++ )
{
    factorial = factorial * i;
}

La cancelación asíncrona no se debe usar si el código reserva memoria dinámicamente o solicita otros recursos del sistema operativo, porque el hilo podría terminar en cualquier momento sin liberarlos. Tampoco si se modifican estructuras de datos, porque los cambios pueden quedar a medias.

Por ejemplo, si la cancelación ocurre en medio de una llamada a malloc() o new no hay forma de saber si ocurrió antes de que la memoria fuera reservada o después. Incluso puede haber ocurrido en medio de la operación, dejando en estado inconsistente las estructuras de datos que sirven para seguir la pista de las zonas de memoria reservadas y libres.

El estándar POSIX solo indica que las funciones pthread_setcancelstate() y pthread_setcanceltype() deben ser seguras frente a la cancelación asíncrona del hilo. En general, no se puede llamar a otras funciones de la librería del sistema de forma segura en un hilo cancelable asíncronamente.

Cancelación en diferido

Por tanto, la cancelación en diferido es la mejor alternativa. Con este tipo de cancelación, la terminación del hilo ocurre en puntos concretos del código.

En la terminología de POSIX Threads a estos puntos se los denomina puntos de cancelación y la inmensa mayoría de las llamadas al sistema que puede poner el hilo en estado esperando lo son por sí mismas. Por ejemplo, open(), close(), read(), write(), recv(), send(), poll() y sleep(), entre muchas otras (véase la lista en la sección «Cancellation points» de la documentación de POSIX Threads). Eso significa que seguramente también sean puntos de cancelación, las funciones de la librería del sistema y de la librería estándar del lenguaje que utilizan esas llamadas al sistema.

Sabiendo esto, se puede estudiar cada caso. Si no es seguro permitir la cancelación de un hilo en la invocación de una de estas funciones en nuestro código, se puede usar pthread_setcancelstate() para desactivar temporalmente el mecanismo de cancelación. Por ejemplo, una llamada a printf() como ayuda para depurar, en medio de los pasos para modificar una estructura de datos —como una lista enlazada o una cola— introduce un punto de cancelación en lugar poco seguro; porque si el hilo se cancela en ese punto, la estructura de datos quedará en estado inconsistente. La solución es eliminar la llamada a printf() o desactivar temporalmente el mecanismo de cancelación.

De forma inversa, se pueden introducir manualmente puntos de cancelación llamando a pthread_testcancel(). Por ejemplo, el siguiente bucle no hace llamadas al sistema, por lo que no tiene puntos de cancelación:

int factorial = 1;
for ( int i = 2; i <= number; i++ )
{
    factorial = factorial * i;
}

Eso significa que ese código para calcular el factorial de number podría ejecutarse durante bastante tiempo sin ofrecer una oportunidad para cancelar el hilo; incluso aunque es un código muy seguro desde el punto de vista de la cancelación. La solución es introducir manualmente un punto de cancelación:

int factorial = 1;
for ( int i = 2; i <= number; i++ )
{
    factorial = factorial * i;
    pthread_testcancel();   (1)
}
1 Comprobar si se ha solicitado la cancelación del hilo y si es así, cancelar el hilo.

La cancelación en diferido también presenta retos desde el punto de vista de evitar las fugas de memoria y de otros recursos cuando un hilo es cancelado. Por ejemplo, supongamos que tenemos una función que abre una tubería, crea un hilo para gestionar los mensajes que llegan y devuelve un puntero a una estructura de datos que se puede usar en otras funciones de la librería —de forma similar a fopen() y FILE*—:

CONN* conn_open( /* ... */ )
{
    CONN* handler = malloc( sizeof(CONN) );
    if(handler == NULL)
    {
        return NULL;
    }

    handler->fifofd = open( /* ... */ );
    if (fifofd < 0)
    {
        free( handler ); (1)
        return NULL;
    }

    int return_code = pthread_create(&handler->thread, /* ... */, handler );
    if (return_code)
    {
        close( handler->fifofd ); (2)
        free( handler ); (1)
        return NULL;
    }

    return handler;
}
1 Evitar la fuga de memoria si open() o pthread_create() fallan.
2 Evitar la fuga del socket si pthread_create() falla.

Este código y la forma en que maneja los errores funcionan bien en programas monohilo, porque estamos seguros de que al salir de la función o se completaron todas las etapas o ninguna. Es decir, si alguna de las peticiones al sistema falla, las hechas anteriormente se «deshacen» para evitar la fuga de recursos.

Pero no es correcto en programas multihilo porque open() y close() son puntos de cancelación. Si conn_open() es llamada desde un hilo y ese hilo es cancelado, el hilo podría terminar a mitad de la función, sin liberar handler, creando una fuga de memoria que no se liberará hasta que el proceso termine. Si conn_open() es llamada en múltiples ocasiones, cada una es una oportunidad para perder memoria.

El código anterior se puede mejorar usando manejadores de limpieza. Esos manejadores se organizan en una pila de la que se pueden insertar o extraer llamando a pthread_cleanup_push() y pthread_cleanup_pop(), respectivamente. Cuando el hilo es cancelado, la librería extrae los manejadores de la pila y los va ejecutando en orden, antes de terminar.

CONN* conn_open( /* ... */ )
{
    CONN* handler = malloc( sizeof(CONN) );
    if(handler == NULL)
    {
        return NULL;
    }

    pthread_cleanup_push(&free, handler); (1)

    handler->fifofd = open( /* ... */ );  (2)
    if (fifofd < 0)
    {
        pthread_cleanup_pop(1);     // free(handler) (3)
        return NULL;
    }

    pthread_cleanup_push(&cleanup_fd, &handler->fifofd);

    int return_code = pthread_create(&handler->thread, /* ... */, handler );
    if (return_code)
    {
        close( handler->fifofd ); (2)
        pthread_cleanup_pop(1);     // free(handler) (3)
        return NULL;
    }

    pthread_cleanup_pop(0); (4)

    return handler;
}
1 Nada más reservar la memoria de CONN se añade un manejador de limpieza que llamará a free(handler) si el hilo va a ser cancelado. Así nos aseguramos que handler será liberado si el hilo es cancelado.
2 La cancelación solo puede ocurrir en los puntos de cancelación que son las llamadas a open() y close().
3 En caso de error, el manejador de limpieza ya no hace falta, así que se extrae antes de salir de la función. Se llama a pthread_cleanup_pop() con valor distinto de 0 porque así la función extrae el manejador y lo invoca. A fin de cuentas se sale a causa de un error, por lo que sigue siendo necesario ejecutar free(handler) para evitar una fuga de memoria.
4 Al terminar la función se extraen todos los manejadores de señal, puesto que ya no hacen falta. El argumento 0 hace que pthread_cleanup_pop() no ejecute el manejador de limpieza extraído.

Ahora conn_open() maneja correctamente la cancelación del hilo donde se ejecuta, por lo que puede usarse sin problemas en aplicaciones multihilo.

12.5.4. Cancelación de hilos en lenguajes de alto nivel

El mecanismo de cancelación de hilos descrito funciona razonablemente bien en C, pero no con lenguajes de más alto nivel, como C++, Java o C#. Las librerías de hilos suelen ser librerías en C, que no conocen nada de objetos ni de otras particularidades de esos lenguajes.

Por ejemplo, en C++, antes de terminar un hilo, deberían ser llamados todos los destructores de los objetos locales, para evitar fugas de memoria y de otros recursos, datos sin escribir y otros problemas derivados de tener objetos que no se destruyen adecuadamente. Lamentablemente, el mecanismo de cancelación de POSIX Threads —y el de otras librerías de hilos, como la de Windows API— no sabe hacer nada de eso. Cada lenguaje debe implementar su propia solución.

En Java y C#, por ejemplo, cuando un punto de cancelación detecta una petición de cancelación emite la excepción Thread.Interrupt, que retrocede por la pila de llamadas, liberando las variables locales hasta salir por el método principal del hilo. A este mecanismo se lo denomina cancelación coordinada.

En C++ no se ha incluido un mecanismo de cancelación en el estándar hasta C++20. Antes de C++20, la forma recomendada de implementar la cancelación es pasando a los hilos una variable de tipo bool con la que señalarles cuándo deben terminar. El código de los hilos debe comprobar frecuentemente el valor de dicha variable y, llegado el momento, terminar retornando ordenadamente por la función principal del hilo.

En C++20 esta estrategia de cancelación cooperativa se ha formalizado e incluido en el estándar al introducir la clase std::jthread. Esta nueva clase de hilo puede pasar a la función principal lo que se llama un token de cancelación —en lugar de una variable tipo bool— que se debe comprobar regularmente para saber si hay que terminar el hilo prematuramente.

Por ejemplo, la función del factorial podría hacer uso de esa funcionalidad para terminar cuando se lo indiquen:

void compute_factorial(std::stop_token stoken, int& factorial, int number) (2)
{
    factorial = 1;
    for ( int i = 2; i <= number; i++ )
    {
        if(stoken.stop_requested()) return; (4)
        factorial = factorial * i;
    }
}

int main()
{
    // ...

    int factorial;
    std::jthread thread(compute_factorial, std::ref(factorial), 122);  (1) (2)

    // ...

    thread.request_stop(); (3)

    // ...
}
1 Crear e iniciar el hilo con std::jthread para calcular el factorial de 122.
2 Al crear el hilo se pasa a la función el token de cancelación stoken.
3 En algún momento de la ejecución del programa pedimos al hilo que se detenga antes de terminar los cálculos.
4 El código del hilo debe comprobar el token de cancelación regularmente. Si se ha pedido la cancelación, se termina el hilo retornando desde la función principal.

Java y C# han terminado incluyendo también este tipo de cancelación cooperativa usando un token de cancelación, debido a los problemas que tienen los desarrolladores para recordar usar correctamente la excepción de la cancelación coordinada.

12.6. Otras consideraciones sobre los hilos

12.6.1. Las llamadas al sistema fork() y exec() en procesos multihilo

La llamada al sistema fork() de los sistemas POSIX es anterior a la existencia del concepto de hilo. Así que cuando estos aparecieron surgió el problema de si al llamar a fork() en un proceso multihilo:

  • El nuevo proceso debía tener un duplicado de todos los hilos.

  • O el nuevo proceso debía tener un único hilo copia del que invocó a fork().

Como hemos comentado anteriormente, la llamada al sistema fork() sustituye el programa en ejecución con un nuevo programa e inicia su ejecución en main(). Esto incluye liberar toda la memoria reservada y la destrucción de todos los hilos del programa original, por lo que duplicar los hilos en el proceso hijo creado por fork(), si luego se va a llamar a exec() parece algo innecesario.

El estándar POSIX establece que si se utiliza fork() en un programa multihilo, el nuevo proceso debe ser creado con un solo hilo, que será una réplica del que hizo la llamada, así como un duplicado completo del espacio de direcciones del proceso.

Sin embargo, algunos sistemas UNIX tienen una segunda llamada no estándar denominada forkall(), capaz de duplicar todos los hilos del proceso padre. Obviamente solo resulta conveniente emplearla si no se va a utilizar la llamada exec() a continuación. La inclusión de forkall() en el estándar POSIX fue considerada y rechazada.

12.6.2. Manejo de señales en procesos multihilo

En el Apartado 10.5.2 hablamos del uso de las señales como mecanismo de comunicación, pero en general sirven para informar a un proceso del suceso de ciertos eventos.

Existen dos tipos de señales:

  • Las señales síncronas se deben a alguna acción del propio proceso. Ejemplos de señales de este tipo son SIGSEV y SIGFE, originadas por accesos ilegales a memoria o divisiones por 0, respectivamente.

    Las señales síncronas son enviadas al mismo proceso que las origina.

  • Las señales asíncronas son debidas a acciones externas. Un ejemplo de este tipo de señales es la terminación de procesos con teclas especiales como CTRL+C o CTRL-D, que envían al proceso las señales SIGINT y SIGHUP respectivamente. También lo son las señales enviadas desde otro proceso, como cuando el proceso init envía SIGTERM al resto de procesos para informales que deben terminar porque el sistema se va a apagar.

Como hemos visto, las señales que llegan a un proceso pueden ser interceptadas por una función definida por el programador llamada manejador de señal.

Las señales también son anteriores a los hilos, por lo que cuando aparecieron los hilos se tuvieron que tomar decisiones sobre cómo iban a encajar ambos conceptos. Por ejemplo, decidir cuál de los hilos del proceso, será interrumpido cuando llegue una señal, para ejecutar el manejador de señales.

Señales enviadas por otros hilos

En los sistemas POSIX multihilo se pueden enviar señales a un proceso:

kill(pid, SIGTERM);

En ese caso uno cualquiera de los hilos podrá ser interrumpido para ejecutar el manejador de señal.

Cada hilo puede enmascarar las señales que considere llamando a pthread_sigmask() Es decir, cada hilo puede elegir qué señales quiere bloquear para no tener que atenderlas:

sigset_t set;               (1)

sigemptyset(&set);          (2)
sigaddset(&set, SIGINT);    (3)
sigaddset(&set, SIGUSR1);   (4)

pthread_sigmask(SIG_BLOCK, &set, NULL); (5)
1 Las máscaras de señales se definen mediante sets de señales. El tipo de los sets de señales es sigset_t, de cuyo tipo real no deberíamos preocuparnos, por portabilidad.
2 Para manipular los sets se proporcionan una serie de funciones. sigemptyset() es para asegurar que el set está vacío.
3 Añadimos al set la señal SIGINT.
4 Añadimos al set la señal SIGINT.
5 Bloqueamos en el hilo actual las señales en el set set,es decir, SIGINT y SIGUSR1.

Así una señal enviada a un proceso interrumpirá a uno de los hilos que no la haya bloqueado.

También se puede enviar una señal a un hilo en concreto usando pthread_kill(). El hilo será interrumpido si no la ha bloqueado.

pthread_kill(thread, SIGTERM);

Sin embargo, hay que tener en cuenta que el manejo de señales es un recurso del proceso, compartido por todos sus hilos. Esto quiere decir que si la señal está configurada para ser manejada usando la acción por defecto y dicha acción es terminar, terminará todo el proceso, aunque la señal haya sido dirigida a un hilo en concreto.

Señales enviadas por el sistema

Lo que queda por ver es a quién va dirigida una señal, cuando es el sistema quién la envía para notificar un evento:

  • Las señales síncronas son causadas por un error en la ejecución, que en un proceso multihilo es debido a la fallida ejecución de un hilo en particular. Por eso estas señales se dirigen al hilo que las causa.

  • Las señales asíncronas llegan por causas externas, así que se dirigen al proceso, pudiendo ser entregada a uno de los hilos que no la tenga bloqueada.

La recomendación es elegir un hilo para el manejo de señales asíncronas, de tal forma que sea el único que no las tenga bloqueadas. El resto de hilos deberían bloquear estas señales nada más iniciar su ejecución.

Si se destina un hilo para esta tarea en exclusiva, este puede utilizar sigwait() para bloquearse hasta que llegue una señal. Cuando eso ocurre, la función sigwait() retorna indicando el número de señal recibida —sin necesitar manejadores de señal—. Entonces el hilo puede solicitar la cancelación de los otros hilos para, por ejemplo, terminar el proceso.

Esta estrategia facilita el desarrollo de programas que manejen las señales adecuadamente. Al utilizar un hilo para manejar las señales, tenemos a nuestra disposición cualquier función segura en hilos y sabemos cómo proteger las variables y estructuras de datos compartidas mediante el uso de mecanismos de sincromización (véase el Capítulo 13). Mientras que al trabajar con manejadores de señal estamos mucho más limitados, porque el código de estos debe ser reentrante y solo pueden usar alguna de las pocas funciones de la librería del sistema marcadas como seguras en señales.