12. Hilos
Tiempo de lectura: 36 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. 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.
Cada hilo tiene una serie de recursos propios dentro del proceso (véase la Figura 37):
-
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 37):
-
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. |
En la Figura 38 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 su proceso. Hay dos formas de implementar una librería de hilos: en el espacio de usuario o en el núcleo.
12.3.1. Librería de hilos en el espacio de usuario
La librería de hilos se puede implementar en el espacio de usuario, junto al código y los datos del proceso, sin requerir ningún soporte especial por parte del núcleo.
Estos hilos no existen para el núcleo del sistema operativo, solo para el proceso que los ha creado. Por ese motivo se los denomina hilos de usuario o hilos del nivel de usuario.
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. Librería de hilos en el núcleo
Si la librería de hilos se implementa en el núcleo, es el núcleo del sistema el que se encarga de darles soporte. Por ese motivo se los denomina hilos de núcleo o hilos del nivel de núcleo.
Aparte del PCB que vimos en el Apartado 9.3, cada hilo tiene una estructura llamada bloque de control del hilo o TCB (Thread Control Block) que representa a cada hilo en el sistema operativo y que guarda información sobre su estado de actividad actual.
En estos sistemas, es el hilo la unidad básica de uso de la CPU. Son los hilos los que se mueven por los estados del Figura 28 y las colas de la Figura 29 y no los procesos. 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.
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.
Como el código y los datos de la librería residen en el espacio del núcleo, invocar una función de la misma requiere frecuentemente hacer una llamada al sistema. Obviamente, la librería del sistema ofrece funciones para no tener que hacer la llamada al sistema directamente.
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 (véase «Using Processes and Threads — Microsoft Docs») 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
Las distintas formas de implementar los hilos comentadas anteriormente —en espacio de usuario o en el núcleo— no son excluyentes, ya que en un sistema operativo concreto se pueden implementar ambas, una de las dos o ninguna.
A continuación veremos los modelos a los que han dado lugar las distintas combinaciones.
12.4.1. Muchos a uno
En el modelo muchos a uno los hilos que ve el proceso se mapean en una única «entidad planificable en la CPU» del núcleo.
Este, por lo general, es el modelo utilizado cuando el núcleo no soporta múltiples hilos de ejecución. En ese caso, la única entidad planificable en la CPU que conoce el núcleo es el proceso, la librería de hilos se implementa en el espacio de usuario —dentro del proceso— y los hilos que ve el proceso son hilos de usuario (véase la Figura 39).
Las principales características de este modelo son:
-
La gestión de hilos se hace con una librería en el espacio de usuario, por lo que los hilos se pueden crear de forma rápida y con poco coste. Como hemos visto anteriormente, la invocación de las funciones de la librería se hace por medio de simples llamadas a funciones.
-
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 bloqueado, no pudiendo ejecutarse otros hilos del mismo proceso mientras tanto. Eso significa que si nuestros hilos hacen ese tipo de operaciones, el resultado es como si no tuviéramos hilos.
-
Como solo un hilo puede ser asignado al proceso, los hilos de un mismo proceso no se pueden ejecutar en paralelo en sistemas multiprocesador. El planificador de la librería de hilos es el encargado de determinar qué hilo de usuario es asignado al proceso y este solo puede ejecutarse en una única CPU al mismo tiempo.
El problema del bloqueo de procesos 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.
Por ejemplo, si un hilo llamase a las funciones recv() o send() de la librería del sistema, habría que hacer que realmente se invocase una versión diferente que utilizase estas funciones de forma asíncrona. Mientras la operación es ejecutada por el sistema operativo, en lugar de retornar de la función, se llama al planificador de la librería de hilos para que la ejecución continúe con otro hilo del proceso, dejando suspendido el que tiene pendiente la operación. Obviamente, el planificador de la librería de hilos debe estar al tanto de cuándo las operaciones asíncronas son completadas para poder volver a planificar los hilos de usuario suspendidos.
Este procedimiento es a todas luces bastante complejo y requiere versiones no bloqueantes de todas las llamadas al sistema —que no siempre existen— así como modificar o interceptar de alguna forma las funciones bloqueantes de la librería del sistema para implementar el comportamiento descrito.
Implementaciones
A este modelo de hilos frecuentemente se lo llama Green Threads. En Java 1.1 era el único modelo soportado, pero debido a sus limitaciones se implementó el soporte del modelo uno a uno en versiones posteriores.
Otras implementaciones de este modelo son las fibras de Windows API, Stackless Python y GNU Portable Threads. Estas implementaciones son muy útiles en los sistemas monohilo, de cara a poder ofrecer cierto soporte de hilos a las aplicaciones. Pero también lo son en los sistemas multihilo, ya que debido a su bajo coste en recursos y a su alta eficiencia son ideales cuando la cantidad de hilos a crear —el nivel de concurrencia— va a ser previsiblemente muy alta.
12.4.2. Uno a uno
En el modelo uno a uno cada hilo que ve el proceso se mapea en una «entidad planificable en la CPU» diferente del núcleo.
Este, por lo general, es el modelo utilizado cuando el núcleo del sistema operativo soporta hilos de ejecución. 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.
Las principales características 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.
-
Crear un hilo para un proceso implica crear 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 siempre es más lento que invocar simplemente una función, como ocurre en el modelo muchos a uno.
Este modelo 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 utiliza el modelo uno a uno.
12.4.3. Muchos a muchos
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 se ejecuten sobre los hilos de núcleo.
El planificador de la librería de hilos se encarga de determinar qué hilo de usuario es asignado a qué hilo de núcleo. Mientras que el planificador de la CPU asigna la CPU a alguno de los hilos de núcleo del sistema.
En el modelo muchos a muchos se mapean los hilos de usuario en un menor o igual número de hilos de núcleo del proceso (véase la Figura 41).
-
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.
-
Permite a otro hilo de usuario del mismo proceso ejecutarse cuando un hilo hace una llamada al sistema que debe bloquearse, puesto que si esto ocurre el correspondiente hilo de núcleo se queda bloqueado. Sin embargo, el resto de los hilos de usuario pueden seguir ejecutándose en los otros hilos de núcleo del proceso.
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. También Microsoft Windows —a partir de Windows 7— soporta este modelo gracias a incorporar un mecanismo denominado planificación en modo usuario (véase «User-Mode Scheduling — Microsoft Docs»).
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 y Elixir.
Activación del planificador
Tanto en el modelo muchos a muchos como en el de dos niveles es necesario 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.
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 va a bloquear un hilo de un proceso. 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. Así, el planificador de la librería puede asignarle alguno de los otros hilos de usuario, evitando el bloqueo completo del proceso y ajustando el número de hilos de núcleo dinámicamente.
Debido a la complejidad del mecanismo descrito anteriormente y a la dificultad de coordinar el planificador de la librería de hilos con el de la CPU para obtener un rendimiento óptimo, sistemas como Linux y Solaris —a partir de la versión 9— han optado finalmente por el modelo uno a uno.
Con el objetivo de evitar los problemas derivados del coste de dicho modelo, los desarrolladores de Linux han preferido concentrar sus esfuerzos en conseguir un planificador de CPU más eficiente, así como en reducir los costes de la creación de hilos de núcleo.
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 42).
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 5 se puede ver cómo se usa pthread_create() en sistemas POSIX que implementan POSIX Threads para crear varios hilos y esperar a que terminen con pthread_join().
Vamos a calcular el factorial de 122 repartiendo la tarea entre dos hilos con el objeto de paralelizar los cálculos en procesadores multinúcleo.
El código fuente completo de este ejemplo está disponible en pthreads.cpp y, además, permite indicar el número que queramos para calcular el factorial. En threads.cpp se puede estudiar un ejemplo equivalente, pero usando std::thread de la librería estándar de C++, por lo que también compila en sistemas no POSIX, como Microsoft Windows.
struct factorial_thread_args (9)
{
BigInt number;
BigInt lower_bound;
BigInt result;
};
void* factorial_thread (void* arg) (7) (8) (9)
{
std::cout << fmt::format( "Hilo creado: 0x{:x}\n", pthread_self() ); (10)
factorial_thread_args* args = static_cast<factorial_thread_args*>(arg); (9)
args->result = calculate_factorial( args->number, args->lower_bound );
return &args->result; (12)
}
int main()
{
int return_code;
pthread_t thread1, thread2; (1) (2)
factorial_thread_args thread1_args {
122, // El primer hilo calcula el factorial multiplicando
61, // desde 122 a 61.
0
};
factorial_thread_args thread2_args {
60, // El segundo hilo calcula el factorial multiplicando
2, // desde 60 a 2
0
};
int return_code = pthread_create( (1)
&thread1, (3)
nullptr, (6)
factoria_thread, (7)
&thread_args ); (8) (9)
if (return_code) (4)
{
std::cerr << fmt::format( "Error ({}) al crear el hilo: {}\n",
return_code, strerror(return_code) ); (5)
return EXIT_FAILURE;
}
return_code = pthread_create( &thread2, /* ... */ );
if (return_code)
{
// ...
}
BigInt* thread1_result, *thread2_result;
pthread_join( (11)
thread1,
reinterpret_cast<void**>(&thread1_result) ); (13)
pthread_join( thread2, reinterpret_cast<void**>(&thread2_result) );
// Multiplicar ambos resultados para obtener el factorial
auto result = *thread1_result * *thread2_result;
std::cout << fmt::format( "El factorial de {} es {}\n",
number.to_string(), result.to_string() );
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, si todo ha ido bien, contenga el manejador del hilo. |
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. |
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, lo más sencillo es crear una estructura. |
9 | En este ejemplo definimos factorial_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 factorial_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 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 factorial_thread_args
ofrece una manera más cómoda de obtener el resultado del cálculo 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
ySIGFE
, 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
ySIGHUP
respectivamente. También lo son las señales enviadas desde otro proceso, como cuando el proceso init envíaSIGTERM
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.