Gestión de procesos
La gestión de procesos es clave en los sistemas operativos, ya que asigna recursos como
CPU, memoria y acceso a dispositivos. Además, los procesos deben cooperar entre sí para
coordinar aplicaciones y servicios, algo esencial en entornos como Internet y sistemas
modernos.
Procesos
En los primeros sistemas informáticos, sólo se permitía la ejecución de un programa a la
vez, lo que le otorgaba control total sobre el sistema y acceso a todos los recursos. Con el
tiempo, surgieron los sistemas multitarea, que permiten la ejecución concurrente de
múltiples programas, lo cual requiere una mayor compartimentación y control para evitar
interferencias entre ellos. Esta evolución llevó a la aparición del concepto de proceso, que
es la unidad de trabajo en los sistemas operativos modernos.
Aunque en este contexto se utilizarán indistintamente los términos "trabajo" y "proceso",
ambos representan la unidad de trabajo en diferentes generaciones de sistemas
informáticos. Existen dos tipos de procesos: los del sistema, que ejecutan tareas del
sistema operativo fuera del núcleo, y los de usuario, que ejecutan programas de aplicación.
El proceso
Un proceso es un programa en ejecución, y además del código del programa, incluye varios
componentes clave. El segmento de código, también conocido como segmento text o .text,
alberga las instrucciones ejecutables. El segmento de datos o .data contiene las variables
globales y estáticas inicializadas con valores predefinidos. El segmento BSS o .bss guarda
las variables globales y estáticas no inicializadas o inicializadas a 0; en el archivo
ejecutable, solo se conserva la longitud que debe tener este segmento.
La pila gestiona datos temporales como parámetros y direcciones de retorno de funciones,
además de las variables locales. El montón o heap se utiliza para la asignación dinámica de
memoria durante la ejecución. También se mantiene información sobre el estado actual de
ejecución, como el contador de programa y los valores de los registros de la CPU.
Aunque varios procesos pueden asociarse a un mismo programa, cada proceso es único,
con su propia copia del segmento de código y sus propios estados y recursos, como el
contador de programa, los registros de la CPU, la pila, el segmento de datos y el montón.
Estados de los procesos
En el ciclo de vida de un proceso, el estado de este cambia según las actividades que
realiza. Existen varios estados comunes en los sistemas operativos, aunque pueden variar
entre distintos sistemas.
- Estado Nuevo: ocurre cuando un proceso está en proceso de creación, involucrando
varias operaciones como reservar memoria, cargar el programa, inicializar
estructuras de datos y configurar el entorno de ejecución. Esta etapa no es
instantánea.
- Estado de Esperando: ocurre cuando el proceso está esperando por la finalización
de una operación de entrada/salida o por otro proceso. Múltiples procesos pueden
estar en este estado simultáneamente.
- Estado Preparado: el proceso está esperando para usar la CPU. También puede
haber múltiples procesos en este estado, listos para ser ejecutados cuando se libere
la CPU.
- Estado Terminado: es cuando el proceso ha completado su ejecución y está a la
espera de que el sistema operativo libere los recursos asignados. Al igual que el
estado nuevo, este proceso no se completa de inmediato.
Cuando un proceso está en ‘Ejecutando’, significa que está siendo ejecutado en la CPU.
Este estado es exclusivo, ya que solo un proceso puede estar ejecutándose en la CPU en
un momento dado, después de ser seleccionado por el planificador de la CPU.
Cada uno de estos estados tiene transiciones que se representan en un diagrama de
estados de los procesos, indicando cómo un proceso puede moverse de un estado a otro a
lo largo de su vida.
Bloque de control de procesos
El Bloque de Control de Proceso (PCB) es una estructura crucial en el sistema operativo,
que almacena toda la información necesaria sobre un proceso en particular. Cada proceso
tiene su propio PCB, el cual contiene varios tipos de información clave:
El estado del proceso indica en qué parte del ciclo de vida se encuentra el proceso, como
nuevo, preparado, esperando, etc.
El contador de programa almacena la dirección de la próxima instrucción que debe ejecutar
la CPU. Durante la ejecución, este contador se encuentra en la CPU, pero se guarda en el
PCB cuando el proceso deja la CPU para que pueda reanudarse correctamente más tarde.
Los registros de la CPU contienen los valores de los registros mientras el proceso está en
ejecución. Estos valores se almacenan en el PCB cuando el proceso sale de la CPU para
que se puedan restaurar cuando el proceso se reanude.
La información de planificación de la CPU abarca datos necesarios para que el planificador
de la CPU gestione el proceso, como la prioridad del proceso, punteros a las colas de
planificación y al PCB de los procesos relacionados (padre e hijos).
La información de gestión de la memoria incluye datos sobre la memoria utilizada por el
proceso, tales como los registros base y límite en la asignación contigua de memoria, o la
dirección a la tabla de páginas en sistemas que usan paginación.
Finalmente, la información de estado de la E/S detalla los dispositivos de entrada/salida que
el proceso ha reservado, así como los archivos abiertos y otros recursos de E/S utilizados
por el proceso.
Cada uno de estos componentes del PCB permite al sistema operativo gestionar
eficazmente el ciclo de vida de los procesos y garantizar que se reanuden correctamente
después de ser interrumpidos.
Colas de planificación
En los sistemas operativos, los procesos se gestionan a través de diversas colas de
planificación, cada una correspondiente a un estado particular del proceso. La cola de
trabajo es la primera en recibir los procesos cuando llegan al sistema, pero en los sistemas
modernos no se utiliza. Los procesos que están listos para ejecutarse se encuentran en la
cola de preparados, donde esperan su turno para usar la CPU. Esta cola suele estar
implementada como una lista enlazada de PCB (Process Control Block), con punteros que
conectan los PCBs de los procesos.
Cuando un proceso necesita esperar por un evento, como una operación de E/S, se mueve
a una de las colas de espera. Estas colas también están organizadas como listas enlazadas
de PCB y suelen existir varias para distintos eventos. Una subcategoría de las colas de
espera son las colas de dispositivo, que gestionan los procesos que esperan por
dispositivos específicos de E/S.
El flujo típico de un proceso dentro del sistema inicia con su llegada y colocación en la cola
de preparados. Desde allí, puede ser seleccionado para ejecutarse en la CPU. Durante la
ejecución, el proceso puede solicitar operaciones de E/S y ser transferido a la cola de
dispositivo correspondiente, o puede esperar por eventos específicos, moviéndose a una
cola de espera. Si el proceso es interrumpido debido a un temporizador, es devuelto a la
cola de preparados. Una vez que las condiciones de espera se cumplen, el proceso regresa
a la cola de preparados para continuar su ejecución.
Este ciclo se repite hasta que el proceso termina, momento en el cual es eliminado de todas
las colas y los recursos asignados son liberados para su reutilización por el sistema
operativo.
Planificación de procesos
Durante la ejecución de los procesos, estos se desplazan entre las diferentes colas de
planificación bajo la dirección del sistema operativo. Cada movimiento está regulado por un
planificador específico.
El planificador de largo plazo o planificador de trabajos es responsable de seleccionar
trabajos desde la cola de trabajos en el almacenamiento secundario y cargarlos en
memoria. Este tipo de planificador era común en los sistemas multiprogramados con cola de
trabajos, pero en los sistemas modernos y de tiempo compartido, los programas se cargan
directamente en memoria a solicitud del usuario, por lo que el planificador de trabajos ya no
se utiliza.
El planificador de corto plazo o planificador de CPU elige uno de los procesos en la cola de
preparados y lo asigna a la CPU. Este planificador se activa cuando un proceso en
ejecución termina su uso de la CPU, dejándola disponible para otro proceso.
El planificador de medio plazo se utilizaba para manejar la memoria en sistemas antiguos
que requerían que un proceso estuviera completamente cargado en la memoria para
ejecutarse. Este planificador implementaba una técnica conocida como intercambio o
swapping, que implicaba suspender procesos y almacenar su estado en disco para liberar
memoria. Sin embargo, en los sistemas modernos de propósito general, este planificador ha
sido reemplazado por técnicas de memoria virtual, que permiten mover partes de la
memoria de los procesos al disco sin interrumpir su ejecución, facilitando una gestión más
eficiente de la memoria.
Cambio de contexto
El cambio de contexto es el proceso mediante el cual la CPU se asigna a un proceso
diferente al que tenía la CPU previamente. Esto requiere salvar el estado del proceso actual
en su PCB (Process Control Block) y cargar el estado del nuevo proceso en la CPU. Entre
los datos que se deben conservar en el PCB se encuentran el contador de programa, los
registros de la CPU, el estado del proceso, y la información de gestión de la memoria, como
la configuración del espacio de direcciones del proceso.
Este proceso de cambio de contexto representa una sobrecarga para el sistema, ya que no
realiza ningún trabajo útil durante la conmutación. La velocidad del cambio de contexto está
influenciada por factores como el número de registros, la velocidad de la memoria y la
existencia de instrucciones especiales para realizar estas tareas de forma eficiente. Algunas
CPUs tienen instrucciones especiales que permiten salvar y cargar todos los registros de
manera más rápida, reduciendo el tiempo que la CPU pasa en el cambio de contexto.
Además, algunas arquitecturas de CPU, como los procesadores Sun UltraSPARC e Intel
Itanium, utilizan juegos de registros extensos. En estos casos, el juego de registros actual
se mapea sobre un banco de registros mucho más amplio. Durante un cambio de contexto,
simplemente se cambia la asignación de registros en el banco, lo que permite almacenar de
forma eficiente los valores de los registros de varios procesos sin necesidad de copiarlos al
PCB en la memoria principal cada vez.
Operaciones sobre los procesos
Creación de procesos
Un proceso padre puede crear múltiples procesos hijos, generando un árbol de procesos
con identificadores únicos (PID). En sistemas POSIX, el proceso init es el padre raíz con
PID 1. Los procesos hijos obtienen recursos ya sea directamente del sistema operativo o
compartiendo recursos del proceso padre. Los procesos pueden heredar recursos del
padre, como archivos abiertos. La ejecución del proceso padre puede continuar o esperar a
que los hijos terminen. Los espacios de direcciones de los hijos pueden ser duplicados del
padre o creados desde cero con un nuevo programa.
Terminación de procesos
Un proceso termina cuando se llama a exit, devolviendo un valor de estado al proceso
padre. Aunque se termine con return en main() en C/C++, el compilador llama a exit usando
el valor de main(). El proceso padre puede recuperar este valor con wait, y todos los
recursos del proceso terminado son liberados. Un proceso hijo puede ser terminado por
exceder recursos, porque su tarea ya no es necesaria, o si el proceso padre termina y el
sistema operativo realiza una terminación en cascada. En sistemas UNIX, los hijos se
reasignan al proceso init si el padre muere.
Procesos cooperativos
Los procesos se dividen en dos grupos según la cooperación:
- Procesos Independientes: No afectan ni son afectados por otros procesos y no
comparten datos con ellos.
- Procesos Cooperativos: Afectan o son afectados por otros procesos, compartiendo
datos en alguna forma.
Motivaciones para la colaboración entre procesos
Proporcionar un entorno que permita la cooperación entre procesos es fundamental por
diversas razones.
- Compartición de información es crucial, ya que múltiples usuarios o procesos
pueden necesitar acceder a los mismos recursos, como archivos compartidos. El
sistema operativo debe gestionar el acceso concurrente a estos recursos para evitar
conflictos y garantizar la integridad de los datos.
- La velocidad de cómputo se puede mejorar dividiendo una tarea en subtareas que
se ejecuten en paralelo. Esta mejora en la eficiencia es posible solo si el sistema
cuenta con múltiples componentes de procesamiento, como procesadores
adicionales o canales de E/S, que permiten acelerar tanto las operaciones de CPU
como las de entrada/salida.
- Modularidad también es un motivo importante, ya que permite desarrollar software
de manera más organizada, dividiendo las funciones del programa en procesos
separados que pueden comunicarse entre sí. Esto facilita el mantenimiento y la
escalabilidad del software.
- La conveniencia es un factor clave, incluso para un solo usuario que puede necesitar
realizar varias tareas simultáneamente, como editar, imprimir y compilar documentos
al mismo tiempo.
Para que los procesos cooperativos se ejecuten correctamente, es necesario implementar
mecanismos que permitan tanto la comunicación entre ellos como la sincronización de sus
acciones.
Comunicación entre procesos
Para comunicar procesos cooperativos, se pueden usar dos estrategias principales:
- Memoria Compartida: Los procesos utilizan regiones comunes de la memoria para
compartir información directamente.
- Paso de Mensajes: Los procesos envían mensajes entre sí mediante funciones del
sistema operativo, sin compartir memoria.
Comunicación mediante paso de mensajes
El paso de mensajes es un mecanismo crucial para la comunicación y sincronización de
procesos sin compartir recursos físicos como memoria o archivos. Es especialmente útil en
entornos distribuidos, donde los procesos están en diferentes ordenadores conectados por
una red. En estos casos, el sistema operativo se encarga de codificar y enviar los mensajes
a través de la red. Ejemplos de esto son la comunicación entre un navegador y un servidor
web, o en general, los servicios de Internet.
El sistema operativo proporciona este mecanismo de paso de mensajes, gestionando la
sincronización y el formato de los datos. Aunque a veces se usa el término IPC
(Interprocess Communication) exclusivamente para el paso de mensajes, es más adecuado
usar IPC para referirse a todas las técnicas de comunicación entre procesos, incluyendo la
memoria compartida y otras.
Para implementar el paso de mensajes, un sistema operativo debe ofrecer al menos dos
funciones básicas: send(message) para enviar mensajes a otro proceso y
receive(&message) para recibir mensajes de otro proceso. La comunicación entre procesos
requiere un enlace de comunicaciones, cuya implementación física puede variar, pero la
interfaz lógica para enviar y recibir mensajes es clave para su funcionamiento.
Tamaño del mensaje
Los sistemas de paso de mensajes pueden usar:
- Mensajes de Tamaño Fijo: La implementación es simple para el sistema operativo,
pero los desarrolladores deben dividir y recomponer mensajes grandes, complicando
la programación de aplicaciones.
- Mensajes de Tamaño Variable: La implementación es más compleja para el sistema
operativo, ya que debe gestionar memoria para mensajes de cualquier tamaño, pero
simplifica la programación de aplicaciones, ya que los desarrolladores pueden enviar
mensajes de cualquier tamaño sin preocupaciones adicionales.
Comunicación orientada a objetos
En sistemas con mensajes de tamaño variable y comunicación orientada a flujos, los
mensajes no se mantienen separados al recibirlos; los procesos leen una secuencia
continua de bytes. Por ejemplo, un receptor podría recibir bloques de bytes que contengan
partes de varios mensajes. Es crucial que el formato de los mensajes permita al receptor
identificar los límites y el contenido de cada mensaje dentro de esta secuencia continua.
Referenciación
Comunicación directa
En la comunicación directa entre procesos, cada proceso debe especificar explícitamente al
destinatario de la información. Por ejemplo, para enviar un mensaje al proceso «A», se usa
send(A, message), y para recibir un mensaje de «A» se utiliza receive(A, &message). En el
direccionamiento simétrico, tanto el proceso emisor como el receptor deben conocer la
identidad del otro para la comunicación.
En contraste, el direccionamiento asimétrico permite que el receptor reciba mensajes de
cualquier proceso sin necesidad de conocer la identidad del remitente, que se puede
proporcionar opcionalmente. Así, send(A, message) envía un mensaje al proceso «A», y
receive(&pid, &message) recibe un mensaje de cualquier proceso, almacenando el mensaje
en «message» y la identidad del remitente en «pid».
Un enlace de comunicación en este esquema se establece automáticamente entre los
procesos que desean comunicarse y se asocia exclusivamente a dos procesos. Solo hay un
enlace entre cada par de procesos. La principal desventaja es que un cambio en el
identificador de un proceso requiere actualizar todas las referencias en los procesos que se
comunican con él. Dado que los identificadores pueden cambiar entre ejecuciones, es
preferible usar una solución con un nivel adicional de indirección para evitar la necesidad de
que los procesos usen identificadores explícitos.
Comunicación indirecta
En la comunicación indirecta, los mensajes se envían a buzones, mailboxes o puertos, que
son objetos donde los procesos pueden dejar y recoger mensajes. Por ejemplo, send(P,
message) se utiliza para enviar un mensaje al puerto «P», mientras que receive(P,
&message) se usa para recibir un mensaje del puerto «P».
En este esquema, un enlace de comunicación se establece entre un par de procesos solo si
ambos comparten el mismo puerto. Las características de este sistema dependen del
diseño elegido:
- Restricción de Enlace: Algunos sistemas no permiten que un enlace (y por tanto, un
puerto) esté asociado a más de dos procesos.
- Permisos de Recepción: Puede haber restricciones sobre quién puede ejecutar
receive() en un puerto. En algunos sistemas, solo el proceso que crea el puerto
puede recibir mensajes, aunque puede haber mecanismos para transferir este
permiso a otros procesos.
- Selección de Receptores: En sistemas donde varios procesos pueden ejecutar
receive() simultáneamente, el sistema operativo puede elegir arbitrariamente cuál
proceso recibe el mensaje. Esta elección puede ser aleatoria, basada en un
algoritmo específico, o a criterio del planificador de la CPU.
Un puerto puede ser compartido por múltiples procesos, permitiendo que varios enlaces se
establezcan entre los mismos pares de procesos, cada uno asociado a un puerto diferente.
Buffering
En la comunicación indirecta, los mensajes se almacenan en una cola temporal antes de ser
enviados o después de ser recibidos, esperando a que el proceso receptor los reclame. Hay
tres formas principales de implementar estas colas:
➔ Sin Buffering (Capacidad Cero): La cola tiene una capacidad máxima de 0 mensajes,
lo que significa que no se puede almacenar ningún mensaje en la cola. En este
caso, el proceso transmisor se bloquea hasta que el receptor recibe el mensaje, ya
que no puede enviar el mensaje hasta que el receptor esté listo para recibirlo.
➔ Buffering Automático: Esta opción permite que la cola tenga capacidad para
almacenar mensajes, y se divide en dos tipos:
- Capacidad Limitada: La cola puede almacenar hasta N mensajes. Si la cola
se llena, el proceso transmisor se bloquea hasta que haya espacio
disponible. Mientras haya espacio en la cola, el transmisor puede seguir
enviando mensajes sin bloquearse.
- Capacidad Ilimitada: La cola tiene una longitud potencialmente infinita,
permitiendo que el transmisor nunca tenga que esperar. Aunque en la
práctica, las colas de longitud infinita no son posibles debido a las
limitaciones de recursos, el término se usa para describir colas cuya longitud
está limitada sólo por la memoria disponible, que es lo suficientemente
grande para considerar la cola como "infinita" en la mayoría de los casos.
Operaciones síncronas y asíncronas
La comunicación entre procesos se realiza a través de las llamadas send() y receive(). Por
lo general, send() se bloquea si la cola de transmisión está llena, y receive() se bloquea si la
cola de recepción está vacía. Para evitar la inactividad de la CPU debido al bloqueo, se han
desarrollado dos enfoques: síncrono y asíncrono.
En el envío asíncrono, el proceso transmisor no se bloquea; si la cola de mensajes está
llena, send() retorna con un código que indica al proceso que intente más tarde. En cambio,
el envío síncrono bloquea al proceso transmisor hasta que haya espacio disponible en la
cola de mensajes.
Para la recepción, en el modo asíncrono, el receptor tampoco se bloquea; si la cola está
vacía, el sistema operativo puede devolver un mensaje vacío o un código para que el
proceso intente más tarde. En el modo síncrono, el receptor se bloquea hasta que haya
mensajes disponibles en la cola.
Existen sistemas de comunicación que son estrictamente síncronos o asíncronos, mientras
que otros permiten elegir entre estos modos según las necesidades de la aplicación, y
algunos soportan configuraciones independientes para la transmisión y la recepción.
Ejemplos de sistemas de paso de mensajes
- Colas de mensajes POSIX
- Señales en sistemas operativos POSIX
- Tuberías
- Sockets
Memoria compartida
La memoria compartida permite que procesos intercambien información accediendo a una
región común de memoria. Aunque esta estrategia evita la intervención del sistema
operativo en la estructura y localización de los datos, los procesos deben coordinarse para
evitar conflictos de acceso simultáneo. Sus principales ventajas son la eficiencia, al
comunicarse a la velocidad de la memoria principal, y la conveniencia, al simplificar el
proceso de comunicación. La memoria compartida puede ser anónima o con nombre.
Memoria compartida anónima
La memoria compartida anónima permite la comunicación eficiente entre un proceso y sus
hijos al compartir una región de memoria. En sistemas POSIX, mmap() con el flag
MAP_SHARED se utiliza para crear esta memoria compartida, que permite que el proceso
hijo y el padre accedan y modifiquen la misma región de memoria, facilitando la
comunicación entre ellos.
Memoria compartida con nombre
La memoria compartida con nombre permite a los procesos comunicarse entre sí
accediendo a un objeto de memoria creado con la función shm_open(). Este objeto se utiliza
con mmap() para hacer la memoria compartida visible y se puede redimensionar con
ftruncate().
Hilos
En sistemas operativos modernos, los procesos pueden tener múltiples hilos de ejecución,
permitiendo realizar varias tareas simultáneamente. Esto contrasta con el modelo
tradicional, donde un proceso solo puede ejecutar una tarea a la vez, lo que requeriría
múltiples procesos para hacer tareas paralelas.
Introducción a hilos
En los sistemas operativos modernos, la unidad básica de uso de la CPU ha evolucionado
de ser el proceso a ser el hilo. Cada hilo dentro de un proceso tiene recursos propios,
incluyendo un identificador único, un contador de programa que señala la próxima
instrucción a ejecutar, registros de CPU que contienen valores específicos para cada hilo, y
una pila para datos temporales y variables locales. A pesar de estas diferencias, los hilos
comparten recursos asignados al proceso, como el código del programa, los segmentos de
datos y el montón, y otros recursos del proceso como archivos y dispositivos abiertos.
Esto implica que, en un momento dado, diferentes hilos de uno o varios procesos pueden
estar ejecutándose en la CPU. Los recursos como memoria y archivos, asignados al
proceso, permanecen accesibles para todos los hilos del proceso. Si un hilo reserva un
recurso sin liberarlo, este permanecerá reservado hasta que el proceso termine o otro hilo lo
libere. Además, los errores que afectan la memoria o accesos privilegiados suelen causar la
detención del proceso completo, afectando a todos los hilos del mismo.
Beneficios
Tiempo de respuesta
En aplicaciones multihilo, como los navegadores web, se mejora el tiempo de respuesta al
usuario permitiendo que el sistema continúe funcionando aunque algunos hilos estén
bloqueados o lentos. Por ejemplo, un navegador puede manejar la interacción del usuario
en un hilo mientras descarga contenido en otro, lo cual no sería posible en un navegador
monohilo sin usar técnicas de comunicación asíncrona.
Compartición de recursos
En comparación con sistemas monohilo, que necesitan comunicación entre procesos
mediante memoria compartida para lograr multitarea, los hilos comparten automáticamente
recursos del proceso, lo que los hace más eficientes para realizar múltiples tareas
simultáneamente. Esta compartición de recursos simplifica la programación y reduce la
necesidad de gestión manual de la comunicación entre procesos.
Economía
Crear procesos es costoso en términos de recursos y tiempo, ya que implica reservar
memoria y realizar cambios de contexto significativos. En contraste, los hilos son mucho
más económicos de crear y manejar, debido a su capacidad para compartir los recursos del
proceso y requerir menos información para los cambios de contexto. Por ejemplo, en
Windows, crear un proceso puede ser 300 veces más costoso que crear un hilo, y en Linux,
es 3 veces más lento debido a la eficiencia del método fork().
Aprovechamiento de las arquitecturas multiprocesador.
En sistemas multiprocesador, los hilos pueden ejecutarse en paralelo en diferentes
procesadores, aprovechando mejor el hardware disponible. A diferencia de los procesos
monohilo, que solo pueden ejecutarse en una CPU a la vez, los hilos permiten un uso más
eficiente de las múltiples CPUs disponibles.
Soporte multihilo
Las librerías de hilos permiten crear y gestionar hilos dentro de un proceso, accesibles
directamente desde lenguajes como C o a través de librerías estándar en otros lenguajes. El
soporte para hilos puede estar implementado a nivel de usuario o a nivel de núcleo del
sistema operativo.
Hilos a nivel de usuario
El soporte de hilos a nivel de usuario se implementa mediante una librería en el espacio de
usuario, sin necesidad de intervención del núcleo del sistema operativo. Esto permite que el
planificador de la CPU asigna tiempo a los procesos, mientras que la librería gestiona la
ejecución de hilos dentro de cada proceso. Las llamadas a funciones de la librería son
rápidas, ya que no requieren llamadas al sistema.
Hilos a nivel de núcleo
En el soporte de hilos a nivel de núcleo, el sistema operativo gestiona los hilos
directamente, con la librería de hilos residiendo en el espacio del núcleo. Esto requiere
llamadas al sistema para invocar funciones de hilos. En estos sistemas, el hilo es la unidad
básica de uso de la CPU, y el planificador selecciona hilos para la ejecución en lugar de
procesos. Cada hilo tiene un Bloque de Control del Hilo (TCB), que almacena información
sobre su estado y es gestionado junto con el Bloque de Control del Proceso (PCB).
Implementaciones
Actualmente, existen librerías de hilos tanto a nivel de usuario como a nivel de núcleo. Por
ejemplo, la librería de hilos de Windows API está implementada en el núcleo, mientras que
POSIX Threads (usada en sistemas POSIX) puede ser de ambos tipos, dependiendo del
sistema. En Linux y la mayoría de los sistemas UNIX modernos, POSIX Threads se
implementa a nivel de núcleo.
Modelos multihilos
Existen sistemas que combinan el soporte de hilos a nivel de usuario y a nivel de núcleo. La
comparación entre estos modelos multihilo se basa en cómo se relacionan los hilos de
usuario (como los ve el programa) con los hilos de núcleo (como los gestiona el sistema
operativo).
Muchos a uno (N:1)
En el modelo muchos a uno, múltiples hilos de usuario se asignan a un único hilo de núcleo.
El planificador de la CPU distribuye el tiempo de ejecución entre los hilos de núcleo,
mientras que la librería de hilos en el proceso gestiona el tiempo entre los hilos de usuario.
Los sistemas modernos crean un hilo de núcleo inicial para cada proceso, mientras que los
sistemas más antiguos sin soporte de hilos en el núcleo tienen una entidad planificable
única para los hilos de usuario, que suelen ser los procesos mismos.
El bloqueo de procesos debido a operaciones de E/S puede evitarse usando versiones
asíncronas de las llamadas al sistema. La librería de hilos sustituye las llamadas
bloqueantes por versiones que permiten que el proceso siga ejecutándose mientras espera
la finalización de la operación de E/S. Esto evita que el proceso completo quede bloqueado,
permitiendo que otros hilos continúen ejecutándose.
El modelo también se conoce como Green Threads y se usaba en Java 1.1 antes de que se
implementara el soporte para hilos nativos del sistema operativo. Otras implementaciones
incluyen fibras y UMS de la API de Windows, Stackless Python y GNU Portable Threads.
Ventajas del modelo muchos a uno:
- Bajo coste de creación de hilos: Los hilos de usuario son baratos de crear porque se
gestionan en el espacio de usuario, evitando el uso extensivo de recursos
- Llamadas a funciones económicas: Invocar funciones de la librería de hilos es
menos costoso que hacer llamadas al sistema.
- Menos cambios de contexto: Los cambios de contexto entre hilos se gestionan en el
espacio de usuario, lo que suele ser más rápido que los cambios de contexto
gestionados por el núcleo.
Inconvenientes del modelo muchos a uno:
- No aprovecha sistemas multiprocesador: Los hilos de un mismo proceso no pueden
ejecutarse en paralelo en diferentes procesadores, limitando el aprovechamiento del
paralelismo.
- Bloqueo de hilos: Si un hilo realiza una operación bloqueante, todo el proceso se
bloquea, impidiendo que otros hilos del mismo proceso se ejecuten.
- Visibilidad limitada: El núcleo solo ve procesos y no hilos individuales, lo que impide
la distribución eficiente de hilos en múltiples CPUs.
Uno a uno (1:1)
En el modelo muchos a uno, un hilo de usuario se mapea a un único hilo de núcleo. Este
modelo suele aplicarse en sistemas que solo soportan hilos a nivel de núcleo, donde la
librería de hilos está implementada en el núcleo y las entidades que se planifican en la CPU
son los hilos de núcleo. Los procesos gestionan estos hilos a través de llamadas al sistema.
Por otro lado, el modelo uno a uno es común en la mayoría de los sistemas operativos
multihilo modernos, como Linux, Microsoft Windows desde Windows 95, Solaris 9 y
versiones posteriores, macOS y la familia de UNIX BSD.
Ventajas del modelo combinado de hilos:
- Permite que otros hilos del mismo proceso continúen ejecutándose mientras uno
está bloqueado por una llamada al sistema.
- Facilita el paralelismo en sistemas multiprocesador al permitir que diferentes hilos se
ejecuten en distintos procesadores.
Inconvenientes del modelo combinado de hilos:
- Crear hilos puede ser costoso debido a la necesidad de gestionar estructuras de
datos en el núcleo, y la memoria del núcleo suele ser limitada.
- La gestión de hilos requiere llamadas al sistema, que son más costosas que las
invocaciones directas de funciones en el espacio de usuario.
Muchos a muchos (M:N)
En teoría, es posible combinar lo mejor de dos modelos de hilos mediante una librería en el
núcleo para crear hilos de núcleo, y otra en el espacio de usuario para los hilos de usuario.
Esto permite a los desarrolladores crear múltiples hilos de usuario que se ejecutan sobre
hilos de núcleo, con la librería de hilos en el espacio de usuario gestionando el tiempo de
ejecución de los hilos de núcleo, mientras que el planificador de la CPU asigna la CPU a los
hilos de núcleo.
La comunicación entre el núcleo y la librería de hilos del espacio de usuario, conocida como
activación del planificador, es crucial en el modelo muchos a muchos. Este esquema
permite al núcleo notificar a la librería cuando un hilo de usuario bloquea un hilo de núcleo,
permitiendo la creación de un nuevo hilo de núcleo para evitar el bloqueo completo del
proceso y ajustar dinámicamente el número de hilos de núcleo.
Este modelo ha sido soportado en sistemas como FreeBSD, versiones antiguas de NetBSD,
y varios UNIX comerciales, incluyendo Solaris 8, IRIX, HP-UX y Tru64 UNIX. También fue
soportado por Microsoft Windows desde la versión 7 hasta la 10, mediante un mecanismo
de planificación en modo usuario. Algunos lenguajes de programación, como Go, Erlang,
Elixir y Java, implementan el modelo muchos a muchos sobre el modelo uno a uno de los
sistemas operativos modernos.
El texto discute el modelo de hilos muchos a muchos en sistemas multiprocesador,
destacando sus ventajas, como permitir paralelismo y la ejecución continua de hilos de
usuario, y sus inconvenientes, como la complejidad de implementación y la dificultad de
coordinación entre el planificador de la CPU y el de hilos en espacio de usuario. Aunque el
modelo uno a uno es más común por su simplicidad, algunos sistemas y desarrolladores
aún usan el modelo muchos a muchos cuando es beneficioso, empleando librerías
especializadas para manejar múltiples hilos de usuario eficientemente.
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.
El modelo de dos niveles combina características de los modelos muchos a muchos y uno a
uno, ofreciendo ventajas como garantizar la disponibilidad de recursos para hilos de usuario
críticos y mejorar el rendimiento en sistemas multiprocesador al mantener hilos de usuario
en el mismo procesador. Sin embargo, enfrenta problemas similares al modelo muchos a
muchos, como la complejidad en la coordinación entre hilos de usuario y hilos de núcleo.
El coste de crear hilos y la elección de modelo
El modelo muchos a uno es teóricamente más ligero que el modelo uno a uno y puede
ofrecer beneficios similares al modelo uno a uno en sistemas multiprocesador. Sin embargo,
la diferencia real en el rendimiento puede depender de varios factores.
Se podía reservar menos memoria para cada fibra, lo cual estaba soportado por los
sistemas operativos. En Windows, esta técnica permitía alcanzar hasta cerca de 32 KiB
fibras en sistemas de 32 bits. Con la optimización de la creación de hilos a nivel de núcleo y
el paso a sistemas de 64 bits, las limitaciones del espacio de direcciones se superaron,
permitiendo un mayor número de hilos en ambos modelos.
Actualmente, la optimización en la creación de hilos a nivel de núcleo ha reducido en gran
medida el coste asociado a cada hilo, permitiendo que el modelo uno a uno pueda manejar
decenas o cientos de miles de hilos por proceso, similar al modelo muchos a uno. Con
suficiente memoria y ajustes en el tamaño de la pila, ambos modelos pueden soportar
millones de hilos. Sin embargo, para necesidades específicas y de gran escala, se
recomienda utilizar librerías o lenguajes diseñados para escalar eficazmente.
Operaciones sobre los hilos
Creación de hilos
En un sistema operativo con una librería de hilos implementada en el núcleo, cada proceso
comienza con un hilo principal que ejecuta main() y finaliza el proceso al retornar de esta
función. El hilo principal puede crear otros hilos, pero no hay una jerarquía de hilos como en
los procesos. Todos los hilos, excepto el principal, son iguales, y su gestión varía según la
plataforma: en sistemas POSIX (POSIX Threads) y en Windows (API Win32).
Cancelación de hilos
La cancelación de hilos implica terminar un hilo antes de que complete su tarea, lo que
puede ser crucial en aplicaciones como navegadores web, donde un hilo puede encargarse
de descargar páginas e imágenes, y se requiere cancelar estos hilos si el usuario decide
interrumpir la operación.
Existen dos formas principales de cancelación:
- Cancelación Asíncrona: En este método, el hilo se detiene de inmediato, lo que
puede causar problemas significativos. Al detenerse abruptamente, el hilo puede no
liberar los recursos que había reservado, como archivos abiertos o memoria, lo que
puede llevar a pérdidas o inconsistencias. Además, si el hilo estaba en medio de
modificar datos compartidos con otros hilos, estos cambios podrían quedar
incompletos, dejando las estructuras de datos en un estado inconsistente que podría
afectar a otros hilos.
- Cancelación en Diferido: En este enfoque, el hilo revisa periódicamente si debe
terminar, permitiendo al desarrollador definir puntos específicos en el código donde
la cancelación puede ocurrir de manera segura. Aunque este método ofrece un
control más preciso sobre la cancelación, aún puede haber riesgos similares a los de
la cancelación asíncrona si no se manejan adecuadamente los recursos y las
modificaciones a los datos compartidos.
Cancelación en POSIX Threads
En la gestión de hilos, se pueden cambiar entre estado y tipo de cancelación según las
necesidades del código. La cancelación asíncrona, aunque teóricamente viable, no es
recomendada debido a su potencial para causar problemas serios, especialmente si el
código maneja memoria dinámica o recursos del sistema. La cancelación asíncrona puede
interrumpir la ejecución en momentos críticos, como durante la reserva de memoria o la
modificación de estructuras de datos, dejando el sistema en un estado inconsistente.
El estándar POSIX señala que las funciones pthread_setcancelstate() y
pthread_setcanceltype() deben ser seguras frente a la cancelación asíncrona. Sin embargo,
en general, es arriesgado llamar a otras funciones de la librería del sistema en un hilo que
pueda ser cancelado asíncronamente.
La cancelación en diferido es una alternativa más segura. En este enfoque, la terminación
del hilo ocurre en puntos específicos del código conocidos como puntos de cancelación.
Muchas llamadas al sistema y funciones de librerías están diseñadas para ser puntos de
cancelación. Si la cancelación en un punto específico del código no es segura, se puede
desactivar temporalmente el mecanismo de cancelación con pthread_setcancelstate(). Por
ejemplo, si una llamada a printf() en medio de una modificación de una estructura de datos
introduce un punto de cancelación poco seguro, es mejor eliminar la llamada o desactivar la
cancelación temporalmente.
Sin embargo, la cancelación en diferido también presenta desafíos, como evitar fugas de
memoria. En programas multihilo, las funciones que manejan recursos, como conn_open(),
deben ser diseñadas para manejar cancelaciones correctamente. Si un hilo se cancela
mientras se está ejecutando conn_open(), podría terminar sin liberar recursos, provocando
fugas de memoria. Para abordar esto, se utilizan manejadores de limpieza con
pthread_cleanup_push() y pthread_cleanup_pop(). Estos manejadores se ejecutan en orden
cuando el hilo es cancelado, asegurando que los recursos se liberen adecuadamente y
evitando fugas de memoria.
Cancelación de hilos en lenguajes de alto nivel
El mecanismo de cancelación de hilos en lenguajes como C++ y Java enfrenta desafíos
debido a la falta de integración con características específicas de los lenguajes. En C++,
antes de C++20, se recomendaba usar una variable bool para la cancelación cooperativa,
pero la librería de hilos no gestionaba la destrucción adecuada de objetos. A partir de
C++20, la clase std::jthread introduce un mecanismo de cancelación más formal usando un
token de cancelación. En Java y C#, se utiliza la excepción Thread.Interrupt para la
cancelación coordinada, que permite liberar recursos y finalizar el hilo de manera ordenada.
Otras consideraciones sobre los hilos
Las llamadas al sistema fork() y exec() en procesos multihilo
Cuando se introdujo el concepto de hilos en los sistemas POSIX, surgió un problema con la
llamada al sistema fork(), que precede a los hilos. El estándar POSIX especifica que, al usar
fork() en un proceso multihilo, el nuevo proceso debe iniciar con un único hilo, una réplica
del que realizó la llamada, y un duplicado completo del espacio de direcciones del proceso
original. Esta decisión evita problemas de sincronización y gestión de recursos, ya que la
duplicación de todos los hilos no es necesaria si el nuevo proceso llamará a exec()
posteriormente. Algunos sistemas UNIX ofrecen una llamada no estándar llamada forkall()
que puede duplicar todos los hilos, pero esta no fue incorporada al estándar POSIX.
Manejo de señales en procesos multihilo
Las señales en sistemas POSIX se dividen en dos tipos: síncronas, causadas por errores
internos del proceso (como accesos ilegales a memoria), y asíncronas, causadas por
eventos externos (como CTRL+C o señales entre procesos).
Las señales síncronas afectan al hilo que las genera, mientras que las asíncronas afectan al
proceso y pueden ser entregadas a cualquier hilo no bloqueado. Para gestionar señales en
un entorno multihilo, se recomienda designar un hilo exclusivo para manejar señales
asíncronas, bloqueando las señales en los demás hilos. Este hilo puede usar sigwait() para
esperar señales sin necesidad de manejadores, facilitando la sincronización y la gestión
adecuada de recursos compartidos.
Sincronización
El acceso concurrente a regiones de memoria compartida en sistemas de múltiples
procesos o hilos puede llevar a inconsistencias en los datos. Para evitar estos problemas y
garantizar la ejecución ordenada de procesos o hilos cooperativos, es crucial implementar
mecanismos de sincronización. Estos mecanismos aseguran que el acceso a datos
compartidos sea controlado y consistente, evitando conflictos y garantizando que las
operaciones se realicen de manera segura y ordenada.
El problema de las secciones críticas
Una condición de carrera ocurre cuando varios procesos o hilos acceden y manipulan datos
simultáneamente, y el resultado depende del orden de acceso. Esto es común en sistemas
operativos, donde distintos componentes interfieren al usar recursos compartidos.
Problema del productor-consumidor
En un sistema donde dos hilos comparten una región de memoria que contiene un vector y
un contador, cada hilo tiene un rol específico: el primero añade elementos al vector e
incrementa el contador, mientras que el segundo toma elementos del vector y decrementa el
contador. Aunque el código de los hilos puede funcionar bien cuando no se ejecutan
simultáneamente, presenta problemas cuando ambos hilos operan al mismo tiempo debido
a la manipulación concurrente de la variable count.
La modificación concurrente de count puede causar que las sentencias ++count y --count
interfieran entre sí, especialmente en máquinas con múltiples núcleos o en sistemas
monoprocesador con planificación expropiativa. Esta interferencia puede llevar a resultados
incorrectos en el valor de count (por ejemplo, 4, 5 o 6), siendo 5 el valor correcto si las
operaciones se ejecutan secuencialmente. La causa de estos errores es la falta de
sincronización adecuada al acceder y modificar la variable compartida count.
Manipular estructuras de datos
El problema de la manipulación concurrente no se limita a operaciones simples, sino que
también afecta a bloques de código complejos, como los que manejan estructuras de datos.
En una operación típica para extraer un nodo de una lista enlazada, el proceso incluye
varios pasos críticos: preservar los punteros al nodo actual y al nodo previo, actualizar el
puntero del nodo previo para que apunte al nodo siguiente, extraer el ítem del nodo, destruir
el nodo y luego salir del método retornando el elemento.
Si un hilo es interrumpido después de guardar el puntero al nodo en una variable local y otro
hilo destruye ese nodo antes que el primero complete su ejecución, el puntero se convierte
en una referencia colgante o "dangling pointer". Esto puede llevar a problemas en distintos
momentos: al intentar leer el ítem del nodo, al actualizar el puntero en el nodo previo o al
finalizar la función dejando la lista en un estado inconsistente.
Exclusión mutua / Eventos
Para evitar la corrupción de datos y fallos en sistemas multitarea, es crucial implementar la
exclusión mutua. Esto asegura que solo un hilo pueda acceder a recursos compartidos a la
vez, mediante mecanismos de sincronización. Las secciones críticas son partes del código
que acceden a estos recursos y deben ser protegidas para evitar que múltiples hilos
interfieran entre sí.
Además, se debe evitar la espera activa, donde un hilo consume CPU esperando a que se
cumpla una condición. En su lugar, es mejor utilizar mecanismos de sincronización del
sistema operativo que permiten a un hilo esperar de manera eficiente hasta que se
produzca un evento, liberando la CPU para otros procesos.
Sincronización por hardware
Las soluciones del sistema operativo para los problemas de sincronización y exclusión
mutua a menudo dependen de características del hardware. A continuación, se explorarán
algunas de estas características antes de detallar los mecanismos de sincronización
proporcionados por el sistema operativo.
Bloque de las interrupciones
En un sistema monoprocesador, el problema de la sección crítica se puede resolver
bloqueando interrupciones mientras un hilo está dentro de ella, evitando así que el sistema
operativo cambie de hilo y modifique los datos compartidos. Sin embargo, esta solución no
es práctica en sistemas multiprocesador, donde múltiples procesadores pueden estar
ejecutando hilos simultáneamente.
Instrucciones atómicas
Las instrucciones atómicas permiten modificar variables o intercambiar datos de manera
indivisible, asegurando que la operación se complete sin interrupciones, incluso si varias
CPUs intentan ejecutarla simultáneamente. En procesadores MIPS, las instrucciones
load-linked (ll) y store-conditional (sc) permiten acceder y modificar memoria de forma
atómica, lo que es crucial para gestionar secciones críticas y evitar problemas de
sincronización. Estas instrucciones facilitan la implementación de mecanismos de
sincronización como semáforos y mutexes en sistemas operativos.
Semáforos (Tipos de semáforos)
La exclusión mutua en secciones críticas se logra mediante recursos del sistema operativo
como los semáforos. Los semáforos controlan el acceso a una sección crítica usando dos
operaciones: acquire (o wait) y release (o signal). Un semáforo tiene un contador interno
que, al inicializarse con un valor 𝑁 permite que hasta 𝑁 hilos accedan a la sección crítica
simultáneamente.
Tanto el estándar POSIX como la Windows API ofrecen soporte para semáforos, que se
dividen en dos tipos:
- Semáforos anónimos: Estos semáforos solo existen dentro del espacio de
direcciones del proceso que los crea, lo que significa que son útiles para sincronizar
hilos dentro del mismo proceso.
- Semáforos con nombre: Estos semáforos son accesibles públicamente en el
sistema, lo que permite que cualquier proceso con los permisos adecuados pueda
abrir y utilizarlos para sincronización entre procesos.
Mutex
Los mutex (de "mutual exclusion") son objetos del sistema operativo que garantizan que
solo un hilo pueda ejecutar una sección crítica a la vez. Funcionan de manera similar a los
semáforos binarios, inicializados en 1. En la Windows API, existen dos tipos de mecanismos
similares: mutexes y secciones críticas. Las secciones críticas son más eficientes pero solo
sincronizan hilos dentro del mismo proceso, mientras que los mutexes son más costosos
pero permiten la sincronización entre procesos mediante herencia o nombres asignados.
Variables de condición
En la solución al problema del productor-consumidor usando semáforos, se utilizaron
semáforos para manejar las esperas del productor y el consumidor en función de si el buffer
está lleno o vacío. Sin embargo, los mutex no pueden utilizarse de la misma forma para
señalar eventos, por lo que se emplean variables de condición en su lugar.
Las variables de condición tienen tres primitivas principales: wait(mutex), notify y notifyAll.
La función wait(mutex) permite a un hilo esperar a que ocurra un evento, siempre que haya
adquirido previamente el mutex. La llamada a notify despierta a uno de los hilos que está
esperando en la variable de condición, que entonces intenta adquirir el mutex y continuar su
ejecución. La función notifyAll despierta a todos los hilos en espera, y cada uno debe
intentar adquirir el mutex para continuar.
Tanto la API de Windows como el estándar POSIX soportan variables de condición. En
POSIX Threads, las variables de condición están diseñadas para sincronizar hilos dentro del
mismo proceso, aunque pueden configurarse para procesos diferentes si se crean en
memoria compartida. En contraste, la API de Windows permite el uso de eventos para la
sincronización entre procesos distintos, y estos eventos pueden ser nombrados para ser
accesibles por otros procesos. Sin embargo, los eventos de Windows son más pesados, no
requieren mutex para la operación wait y no soportan la operación notifyAll.
Implementación de semáforos
Tanto los mutex como las variables de condición son formas específicas de semáforos. Si
un sistema o lenguaje admite mutex y variables de condición, implementar semáforos y
otras primitivas de sincronización es relativamente sencillo. La implementación de un
semáforo se asemeja a la solución al problema del productor-consumidor, con la diferencia
de que no involucra un vector de elementos.
Esperas
Existen dos enfoques principales para implementar la espera en los objetos de
sincronización: el sistema operativo puede cambiar el estado del hilo a "esperado" y
moverlo a una cola de espera asociada al objeto, lo que permite al planificador de la CPU
seleccionar otro proceso para su ejecución; o el hilo puede usar una técnica de espera
ocupada, que consiste en verificar continuamente la condición hasta que se cumple.
La espera ocupada, aunque útil para esperas cortas, desperdicia tiempo de CPU que podría
ser utilizado de manera más productiva por otros hilos. Para evitar largas esperas
ocupadas, los sistemas operativos suelen evitar expulsar de la CPU a hilos que están en
secciones críticas protegidas por objetos de sincronización con espera ocupada,
permitiendo que terminen su tarea rápidamente.
Los mutex con espera ocupada se conocen como spinlocks. Estos se usan frecuentemente
para proteger estructuras del núcleo en sistemas multiprocesador cuando la tarea dentro de
la sección crítica es breve y se estima que se pierde más tiempo cambiando de hilo que
manteniendo al hilo en espera ocupada.
Funciones reentrantes y seguras en hilos
Al elegir una librería para un programa multihilo, es crucial considerar si la librería es
reentrante y segura para hilos. La reentrancia asegura que una función pueda ser
interrumpida y reanudada sin causar errores. La seguridad de hilos garantiza que la librería
maneje correctamente el acceso concurrente de múltiples hilos sin provocar problemas de
sincronización.
Funciones reentrantes
Una función se considera reentrante si puede ser interrumpida en medio de su ejecución y,
mientras está en espera, puede ser llamada nuevamente con total seguridad. Esto es
especialmente importante en la programación multihilo, donde una función puede ser
interrumpida por el sistema operativo para que otro hilo del mismo proceso ejecute la misma
función.
Para que una función sea reentrante, debe cumplir con ciertas condiciones. No debe
modificar variables estáticas o globales, a menos que lo haga mediante operaciones
atómicas, es decir, operaciones que son ininterrumpibles. Además, la función no debe
modificar su propio código ni llamar a otras funciones que no sean reentrantes. Un ejemplo
clave de funciones reentrantes son los manejadores de señales, los cuales deben ser
diseñados para ser reentrantes para garantizar su correcto funcionamiento en un entorno
multitarea.
Seguridad en hilos
Una función es segura en hilos (thread-safe) si puede manejar estructuras de datos
compartidas de manera que se garantice su ejecución segura por múltiples hilos
simultáneamente. Esto suele lograrse sincronizando el acceso a estos datos con el uso de
semáforos, mutex u otros recursos similares proporcionados por el sistema operativo.
Es importante notar que aunque a menudo el código reentrante también es seguro en hilos,
no siempre es el caso. Es posible tener código reentrante que no sea seguro en hilos, así
como código que sea seguro en hilos pero no reentrante.
Cuando se utiliza una función o librería que será llamada desde múltiples hilos, es crucial
revisar la documentación para confirmar si es segura en hilos. Si la función no es segura en
hilos, se debe buscar una alternativa que lo sea o implementar mecanismos de
sincronización para asegurar que la función solo sea invocada por un hilo a la vez.
- Seguridad en hilos en C++
- Seguridad en hilos en C
- Seguridad en hilos en POSIX