14. Sincronización
Cómo coordinar hilos y procesos que comparten datos: condiciones de carrera y secciones críticas, instrucciones atómicas, semáforos, mutex, variables de condición y spinlocks en POSIX, Windows API y C++.
En el capítulo «Memoria compartida» vimos que varios procesos pueden compartir regiones de la memoria con el objeto de cooperar en las tareas que deben desempeñar. Además, en el capítulo «Hilos» vimos que en los procesos multihilo todos los hilos comparten el espacio de direcciones del proceso al que pertenecen, lo que significa que pueden acceder al mismo tiempo a las variables globales y a la memoria reservada dinámicamente.
Ambas posibilidades introducen algunos riesgos, puesto que el acceso simultáneo a los datos compartidos puede ocasionar inconsistencias. Así que ha llegado el momento de discutir cómo se puede asegurar la ejecución ordenada de hilos o procesos que comparten regiones de la memoria, con el fin de mantener la consistencia de los datos.
Condiciones de carrera
Sección titulada «Condiciones de carrera»Llamamos condición de carrera a la situación en la que varios procesos o hilos pueden acceder y manipular los mismos datos al mismo tiempo —es decir, de forma concurrente— y donde el resultado de la ejecución depende del orden particular en el que tienen lugar dichos accesos. Estas situaciones ocurren frecuentemente en los sistemas operativos, puesto que diferentes componentes del mismo manipulan los mismos recursos interfiriendo unos con otros.
Problema del productor-consumidor
Sección titulada «Problema del productor-consumidor»Para ilustrarlo, veamos un problema clásico de concurrencia: el problema del productor-consumidor o del búfer limitado. En este problema, dos hilos o procesos comparten un búfer de tamaño limitado, que es usado por uno de ellos para producir datos y por el otro para consumirlos. El objetivo es lograr que trabajen juntos de forma ordenada sin que el productor sobrepase la capacidad del búfer ni el consumidor intente extraer elementos que todavía no existen.
Por ejemplo, así podría ser la función de un hilo productor que genera elementos y los añade a un vector compartido shared_vector de tamaño VECTOR_SIZE.
La variable count indica cuántos elementos hay actualmente en el vector:
while(/* ... */){ Item item = produce_item();
// Si el vector está lleno, esperar. No podemos añadir más elementos. while (count == VECTOR_SIZE);
// Añadir el elemento al vector y apuntar count al siguiente hueco libre shared_vector[count] = item; ++count;}Mientras que el hilo productor genera elementos y los añade al vector, otro hilo extrae los elementos uno a uno para ejecutar alguna función sobre ellos:
while(/* ... */){ // Si el vector está vacío, esperar. No tenemos elementos que consumir. while (count == 0);
// Extraer un elemento del vector --count; Item item = shared_vector[count];
consume_item(item);}El problema del productor-consumidor no es un problema teórico, sino que se da en la práctica en múltiples ocasiones. Por ejemplo, en una herramienta de grabación de audio, el hilo dedicado a obtener bloques de bytes con muestras de audio grabadas a través de la API multimedia del sistema operativo es el productor. Mientras que el hilo que se dedica a tomar las muestras y codificarlas para guardarlas en formato de archivo es el consumidor. La manera de conectar ambos es tener una región de memoria —generalmente, un búfer circular—, donde se depositan los bloques de muestras cuando llegan y de donde se extraen para su procesamiento posterior. Así, ambos hilos pueden trabajar a su propio ritmo, de forma casi independiente.
Incremento y decremento concurrente de count
Sección titulada «Incremento y decremento concurrente de count»Aunque el código anterior del productor y del consumidor es correcto cuando no coinciden en el tiempo al ejecutarse, no funciona adecuadamente cuando sí lo hacen.
El motivo es que los dos hilos comparten y modifican la variable count.
Obviamente, las sentencias ++count y --count pueden modificar count al mismo tiempo si los hilos se ejecutan en núcleos diferentes en máquinas multiprocesador o multinúcleo.
Eso también puede ocurrir en máquinas monoprocesador con sistemas operativos con planificación expropiativa.
En esos sistemas, un hilo puede ser interrumpido en cualquier instrucción para dar paso a la ejecución de otro.
Por ejemplo, ++count podría dividirse por el compilador en las siguientes operaciones, al generar las instrucciones del procesador:
registro1 = [count];registro1 = registro1 + 1;[count] = registro1;Donde registro1 representa un registro de la CPU.
De forma parecida la sentencia --count podría ser implementada de la siguiente manera:
registro2 = [count];registro2 = registro2 - 1;[count] = registro2;Donde nuevamente registro2 representa un registro de la CPU.
En sistemas monoprocesador, el que las sentencias ++count y --count se ejecuten de forma concurrente es similar a que las instrucciones de lenguaje máquina de ambas sentencias en ambos hilos se entrelacen en algún orden aleatorio.
Por ejemplo, un posible entrelazado de las instrucciones en lenguaje máquina entre hilos, suponiendo que inicialmente count = 5, podría ser el siguiente:
registro1 = [count]; // registro1 = 5registro1 = registro1 + 1; // registro1 = 6
registro2 = [count]; // registro2 = 5registro2 = registro2 - 1; // registro2 = 4
[count] = registro1; // count = 6
[count] = registro2; // count = 4Como se puede observar, llegamos al resultado incorrecto count = 4, indicando que hay 4 elementos en el vector cuando realmente hay 5.
Pero si invertimos el orden de las dos últimas sentencias, obtendremos el resultado, también incorrecto, count = 6.
Hemos llegado a estos valores incorrectos porque hemos permitido la manipulación concurrente de la variable count.
Según cómo se entrelacen las instrucciones de ++count y --count en la CPU, el resultado final podría ser: 4, 5 o 6, pero el único resultado correcto es 5, que es el que obtendremos si ejecutamos las sentencias secuencialmente, sin mezclar las operaciones en las que se dividen.
La condición de carrera se origina en la modificación de count, pero sus consecuencias no se quedan ahí.
Si se pierde un incremento, el productor sobrescribe en la vuelta siguiente un elemento que nadie ha consumido.
Si se pierde un decremento, el consumidor lee un hueco que el productor todavía no ha rellenado, o que está rellenando en ese mismo instante.
Este último caso puede ser especialmente problemático si Item —la clase o estructura de los elementos producidos y consumidos— es un tipo grande.
Entonces copiar un elemento en el vector requiere varias instrucciones, así que el consumidor puede acabar leyendo una mezcla del elemento anterior y del que se está copiando en ese momento.
Manipulación concurrente de shared_vector
Sección titulada «Manipulación concurrente de shared_vector»Obviamente, el problema comentado no aparece solo en sentencias simples, sino también en cualquier bloque de código destinado a hacer tareas complejas, como manipular estructuras de datos.
Para verlo, olvidemos por un momento la variable count y usemos shared_vector como una pila, dejando que sea el propio std::vector quien lleve la cuenta de los elementos que contiene.
El productor apila cada elemento que genera:
while(/* ... */){ Item item = produce_item(); shared_vector.push_back( item );}Mientras que el consumidor extrae el último elemento que se haya añadido:
while(/* ... */){ Item item = shared_vector.back(); shared_vector.pop_back();
consume_item(item);}Podría parecer que así hemos resuelto el problema, puesto que ya no queda ninguna variable compartida que un hilo incremente mientras el otro la decrementa. Pero no es cierto: simplemente hemos movido el problema al interior del vector.
Un std::vector guarda los elementos en un bloque de memoria contiguo reservado dinámicamente y, para gestionarlo, mantiene tres datos: la dirección de memoria donde empieza el bloque, donde termina la parte ocupada por los elementos y cuál es la capacidad total.
Añadir un elemento con push_back() obliga a leer y modificar esos datos, siguiendo estos pasos:
- Comparar el final de la parte ocupada con la capacidad, para saber si el elemento cabe.
- Si no cabe, reservar un bloque de memoria nuevo de mayor tamaño, mover a él los elementos del bloque antiguo, liberar este último y actualizar los tres datos anteriores.
- Construir una copia del elemento en el primer hueco libre.
- Avanzar el final de la parte ocupada, para que incluya el elemento recién añadido.
Los pasos 1 y 4 presentan el mismo problema que la sentencia ++count que acabamos de ver, solo que ahora el contador está dentro del vector y no lo vemos.
Si dos hilos ejecutan push_back() a la vez, ambos pueden leer en el paso 1 el mismo final de la parte ocupada.
Entonces los dos construyen su elemento en el mismo hueco —con lo que uno de ellos se pierde— y avanzan el final dos veces, dejando una posición sin construir que el consumidor leerá como basura.
El paso 2 es más grave, porque lo que corrompe no es un contador sino la estructura entera. El hilo que extiende el vector debe liberar el bloque antiguo mientras el otro hilo puede tener un puntero, una referencia o un iterador a algún elemento del bloque, que queda convertido en una referencia colgante1. Y si los dos hilos llegan al paso 2 a la vez, cada uno reserva su propio bloque y ambos liberan el mismo bloque antiguo, de modo que la memoria se libera dos veces y uno de los dos bloques nuevos se pierde.
El consumidor tiene además un problema propio, que no depende de cómo esté implementado el vector.
back() y pop_back() son dos llamadas distintas, así que aunque cada una de ellas fuese indivisible por separado, la pareja no lo es.
Si dos hilos se entrelazan entre ambas, los dos obtienen el mismo elemento —que acaba usándose dos veces con consume_item()— y a continuación cada uno ejecuta su pop_back(), destruyendo el objeto consumido y un segundo elemento que nadie ha llegado a consumir.
Lo que le ocurre a std::vector no es un defecto de su implementación ni de ese tipo de estructura de datos en particular, sino consecuencia de algo que se aplica a cualquier estructura de datos: manipularla exige romper temporalmente los invariantes que la mantienen coherente, para restaurarlos posteriormente, justo antes de terminar la operación.
Mientras dura ese intervalo la estructura es inconsistente, y basta con que otro hilo la lea o la modifique dentro de él para que el daño sea permanente.
Otras estructuras habituales lo sufren igual, cada una a su manera:
-
Lista enlazada: además de los enlaces entre nodos, las implementaciones suelen mantener punteros al primero y al último, y un contador de elementos. Extraer un nodo obliga a actualizarlos por separado, de forma que entre una actualización y la siguiente, otro hilo puede leer un contador que no se corresponde con los nodos que quedan, u obtener un puntero al último elemento cuando ya ha sido destruido.
-
Lista doblemente enlazada (como
std::list): extraer un nodo obliga a reescribir un enlace en cada uno de sus dos vecinos, el anterior y el siguiente. Entre una actualización y la siguiente, el nodo no se puede alcanzar recorriendo la lista en un sentido pero sí en el otro. Así que otro hilo puede llegar hasta él —y usarlo— justo después de que haya sido destruido. -
Árbol balanceado (como
std::map): una inserción puede desencadenar rotaciones que reescriben los enlaces de varios nodos para recuperar el equilibrio. Mientras se realizan, otro hilo que recorra el árbol comparando claves puede tomar la rama equivocada y dar por ausente un elemento que sí está. O puede descender por un enlace a medio actualizar y acabar dando vueltas entre nodos que momentáneamente se apuntan entre sí. -
Tabla hash (como
std::unordered_map): al superarse el factor de carga máximo2, insertar provoca un rehash que reserva un array nuevo de buckets y redistribuye en él todos los elementos, igual que la reasignación del vector. Otro hilo que esté buscando calcularía en qué bucket mirar con el número de buckets antiguo, así que buscaría donde ya no está lo que quiere encontrar. Además, si el array viejo ha sido liberado mientras tanto, ni siquiera llega a buscar, porque accede a memoria que ya no está reservada.
Por eso los estándares de C, de C++ y de muchos otros lenguajes no prometen nada respecto al acceso concurrente a sus contenedores.
Sobre un mismo contenedor, C++ permite varias lecturas simultáneas, e incluso modificaciones simultáneas de elementos distintos, pero cualquier operación que altere la estructura —como push_back() o pop_back()— exige que ningún otro hilo o proceso la esté usando al mismo tiempo.
Garantizarlo es responsabilidad del programador, no de la librería estándar.
Esa propiedad tiene nombre: se dice que una función o una clase es segura en hilos cuando se puede usar desde varios hilos a la vez sin que el programa tenga que protegerla por fuera. Volveremos sobre ello al final del capítulo, para ver las garantías establecidas por cada estándar.
Necesidades de sincronización
Sección titulada «Necesidades de sincronización»El código del productor y el consumidor no falla por un solo motivo, sino por dos. Por un lado, ambos hilos manipulan los mismos datos sin que nada impida que sus operaciones se entrelacen. Por otro, cada uno necesita esperar a que el otro haga algo —dejar un hueco libre o depositar un elemento nuevo— antes de poder continuar.
Se trata de dos necesidades distintas y no todos los mecanismos de sincronización resuelven ambas, por lo que conviene separarlas desde el principio:
- Exclusión mutua: garantizar que mientras un hilo manipula un recurso compartido, ningún otro pueda hacerlo.
- Sincronización condicional: permitir que un hilo espere, sin gastar tiempo de CPU, hasta que otro le comunique que ya se cumple la condición que necesitaba.
Los mecanismos que veremos a partir de aquí pueden servir como instrumentos para cubrir alguna de estas dos necesidades, o para ambas.
Exclusión mutua y secciones críticas
Sección titulada «Exclusión mutua y secciones críticas»Para evitar que estas situaciones lleven a la corrupción de datos y a caídas de servicios y sistemas, debemos asegurarnos de que solo un hilo en cada momento puede manipular recursos y variables compartidas.
Por tanto, necesitamos algún tipo de mecanismo de sincronización para que mientras se ejecuta ++count no se pueda ejecutar --count en otro hilo, ni viceversa.
O para que mientras un hilo haga un push_back() o un pop_back() sobre el vector, otro no pueda utilizar ni estas ni otras funciones del mismo objeto.
Para resolver esto, debemos empezar buscando las secciones críticas de nuestro código. Una sección crítica es una porción del código donde se accede a variables, tablas, listas, archivos y otros recursos compartidos.
Para evitar condiciones de carrera, el acceso a las secciones críticas debe ser controlado, de manera que cuando un hilo se esté ejecutando en una sección de este tipo, ningún otro pueda hacerlo en la suya correspondiente para manipular los mismos recursos. En estos casos se dice que existe exclusión mutua entre las secciones críticas.
Identificar una sección crítica
Sección titulada «Identificar una sección crítica»Para buscar una sección crítica conviene fijarse en cada acceso a un dato y comprobar si se cumplen tres condiciones. Solo hace falta proteger un acceso cuando las tres se dan a la vez:
-
El dato lo pueden alcanzar dos o más hilos: por ejemplo, una variable global o estática, un miembro de un objeto compartido, memoria reservada dinámicamente cuyo puntero se ha pasado a otro hilo o una región de memoria compartida entre procesos. Lo que es local a un hilo —como las variables locales y los argumentos pasados por valor a las funciones— no lo puede tocar nadie más, por lo que no necesita protección.
-
Al menos uno de los accesos modifica datos. Varios hilos que solo leen el mismo dato no compiten entre sí, porque ninguno cambia lo que los demás ven. Basta con que uno escriba para que todos los demás, incluidos los que solo leen, queden expuestos si no se utiliza un mecanismo de sincronización.
-
La operación no es indivisible. Casi ninguna lo es, ni siquiera
++count, como ya hemos visto. Las únicas excepciones son unas pocas operaciones de bajo nivel que el hardware ejecuta de una sola vez, como las instrucciones atómicas que veremos más adelante.
Una vez identificado el acceso, tenemos que delimitar la sección crítica a su alrededor y su tamaño. Si es demasiado grande obligará a los hilos a esperarse unos a otros continuamente, hasta el punto de que el programa acabe ejecutándose como si tuviera un único hilo.
Hay tres reglas que conviene no olvidar:
-
La sección crítica abarca la operación completa, no cada acceso por separado. Si una tarea solo tiene sentido cuando se ejecuta entera, hay que protegerla entera. Por ejemplo, en el caso anterior, poner
back()en una sección crítica ypop_back()en otra distinta no arregla nada, porque entre ambas otro hilo puede intercalarse y llevarse el mismo elemento. -
Todos los hilos que usan el recurso deben emplear el mismo mecanismo de protección. La protección no está en el dato, sino en el acuerdo entre los hilos que lo comparten. Si uno de ellos accede sin respetar ese acuerdo, habrá una condición de carrera, por mucho cuidado que tengan los demás.
-
Una función que llamamos puede manipular datos compartidos que no vemos. Los criterios anteriores se aplican a los datos que maneja nuestro código, pero una función de una librería puede mantener datos propios entre llamadas. Antes de invocarla desde varios hilos hay que comprobar en su documentación que es segura en hilos.
Sincronización condicional
Sección titulada «Sincronización condicional»La exclusión mutua impide que los hilos se estorben, pero no basta para que cooperen. El productor y el consumidor necesitan además esperarse el uno al otro: el primero no puede añadir elementos nuevos mientras el vector esté lleno y el segundo no tiene nada que extraer mientras esté vacío. En ambos ejemplos esa espera se resuelve con un bucle que comprueba la condición una y otra vez:
// Si el vector está lleno, esperar. No podemos añadir más elementos.while (count == VECTOR_SIZE);A esta técnica se la denomina espera activa —o espera ocupada— y es una de las dos formas en que se puede implementar una espera:
-
Espera activa: el hilo itera comprobando la condición hasta que se cumple. No necesita ayuda del sistema operativo, pero mantiene la CPU ocupada sin hacer trabajo útil, quitándosela a otros hilos que sí lo harían.
-
Espera bloqueante: el sistema operativo cambia el estado del hilo a esperando y lo mueve a una cola de espera asociada al objeto de sincronización. El planificador escoge entonces otro hilo para ocupar la CPU en su lugar. Cuando llega la notificación de que la condición se cumple, el hilo que esperaba pasa a la cola de preparados para que pueda continuar donde lo dejó.
En el espacio de usuario la espera debe ser bloqueante, porque no hay forma de saber cuánto va a durar, ya que nada garantiza cuándo dejará el consumidor un hueco libre. Por eso el sistema operativo ofrece objetos de sincronización pensados para que un hilo notifique eventos a otro, sin que el que espera gaste tiempo de CPU mientras tanto.
La espera activa puede ser útil para esperas que se sabe de antemano que van a ser muy cortas, habitualmente dentro del núcleo del sistema operativo en sistemas multiprocesador. Volveremos sobre ella más adelante, al hablar de los spinlocks.
Sincronización por hardware
Sección titulada «Sincronización por hardware»Las soluciones ofrecidas por el sistema operativo, para resolver los problemas anteriores, suelen tener que apoyarse en características del hardware. A continuación veremos algunas de esas características, antes de profundizar en los mecanismos de mayor nivel de abstracción ofrecidos por el sistema operativo.
Bloqueo de las interrupciones
Sección titulada «Bloqueo de las interrupciones»La exclusión mutua puede ser resuelta de forma sencilla en un sistema monoprocesador. Como el núcleo del sistema operativo es un software controlado mediante interrupciones, basta con que los hilos o procesos bloqueen las interrupciones mientras se está dentro de la sección crítica. Así, el sistema operativo no puede tomar el control y asignar otro hilo a la CPU, lo que impide que se ejecute otra secuencia de instrucciones que podría modificar los datos compartidos.
Indudablemente esta solución no es práctica en sistemas multiprocesador, donde hay varios procesadores ejecutándose a la vez.
Instrucciones atómicas
Sección titulada «Instrucciones atómicas»Las CPU modernas disponen de instrucciones para comparar y modificar el contenido de una variable o intercambiar el contenido de dos variables, de forma atómica. El término atómico hace referencia a que las operaciones se ejecutan como una unidad ininterrumpible. No importa que varias CPU ejecuten estas instrucciones simultáneamente, puesto que el hardware se encargará de que sean ejecutadas secuencialmente.
Ejemplo Instrucciones atómicas en procesadores MIPS
Sección titulada « Instrucciones atómicas en procesadores MIPS»En los procesadores MIPS el acceso atómico a la memoria se realiza mediante las instrucciones load-linked ll y store-conditional sc.
La instrucción ll carga un valor de la memoria en un registro y monitoriza la dirección de memoria correspondiente para detectar si otro procesador la modifica.
Mientras que la instrucción sc intenta almacenar un valor en la dirección de memoria, siempre que dicha dirección no haya sido modificada desde la anterior instrucción ll.
Ambas instrucciones se utilizan de la siguiente manera para implementar una operación atómica de incremento:
main: la r2, count
retry_increment: ll r1, 0(r2) addiu r1, r1, 1 sc r1, 0(r2) beqz r1, retry_increment nop- Suponiendo que
r2contiene la dirección de la variablecount, carga el valor de dicha variable enr1. - Incrementa el valor en el registro
r1. - Intenta almacenar el valor incrementado en
count. Si tiene éxito, porque el valor decountno ha sido modificado por otro proceso,r1se pone a 1. En caso contrario, se pone a 0. - Si
r1vale 0, es que la instrucciónscfalló porque la variablecountfue modificada por otro proceso. En ese caso, se salta aretry_incrementpara volver a intentar el incremento de la variable. - Instrucción de relleno para que el procesador no ejecute la instrucción siguiente a
beqzantes de completar el salto.
Antes y después del bloque de instrucciones ll y sc, generalmente se usa la instrucción sync.
El ejemplo anterior quedaría así:
main: la r2, count sync
retry_increment: ll r1, 0(r2) addiu r1, r1, 1 sc r1, 0(r2) beqz r1, retry_increment nop
syncEsta instrucción crea una barrera de operaciones en memoria, evitando que el procesador reordene los accesos a memoria de forma que ejecute las instrucciones ll y sc antes de que se hayan completado las operaciones en memoria anteriores a sync.
Igualmente, el último sync evita que las instrucciones posteriores a sync se ejecuten antes de que se hayan completado las operaciones ll y sc.
Operaciones atómicas en C y C++
Sección titulada «Operaciones atómicas en C y C++»Para no tener que programar en ensamblador, los compiladores de C y C++ ofrecen funciones y tipos de datos especiales para que los programadores puedan usar instrucciones atómicas en sus programas.
Por ejemplo, así se puede declarar una variable atómica e incrementarla guardando su valor previo:
En C++, <atomic> declara la plantilla std::atomic para declarar variables atómicas de diferentes tipos: bool, int, char, float, double y punteros, entre otros.
std::atomic<int> count{0};
int old_count = count.fetch_add(1);En C, <stdatomic.h> define tipos como atomic_bool, atomic_uint o atomic_char para declarar variables atómicas de los tipos bool, unsigned int y char, respectivamente.
También declara funciones para inicializar, leer, guardar, intercambiar, sumar, restar y realizar operaciones lógicas, de forma atómica sobre estas variables:
atomic_int count;
atomic_init(&count, 0);
int old_count = atomic_fetch_add(&count, 1);De la misma forma, así se puede decrementar la variable, también devolviendo su valor previo:
int old_count = count.fetch_sub(1);int old_count = atomic_fetch_sub(&count, 1);Las instrucciones atómicas pueden ser utilizadas por el sistema operativo y los lenguajes de programación para ofrecer soluciones sencillas al problema de la sección crítica. Por ejemplo, creando abstracciones como semáforos y mutex, entre otras.
Semáforos
Sección titulada «Semáforos»Las instrucciones atómicas resuelven el problema para una variable suelta, pero una sección crítica rara vez se reduce a eso. Hace falta un objeto que permita a un hilo pedir permiso para entrar en un sección crítica y quedarse esperando si no lo consigue. El más antiguo de estos objetos es el semáforo.
Los semáforos son un tipo de objetos del sistema operativo que nos permiten controlar el acceso a una sección crítica, por medio de dos primitivas: acquire y release —o wait y signal, según la fuente que consultemos—. Tienen un contador interno que se inicializa durante la creación, de tal forma que un semáforo inicializado a N solo permite que N hilos ejecuten la sección crítica al mismo tiempo.
Por su diseño, los semáforos pueden cubrir ambas necesidades de sincronización. Un semáforo inicializado a 1 proporciona exclusión mutua, porque solo deja pasar a un hilo cada vez. Mientras que un semáforo que cuenta las unidades disponibles de un recurso proporciona sincronización condicional, porque un hilo puede quedarse esperando en él hasta que otro produzca una unidad nueva. Ambos usos aparecen juntos en la solución al problema del productor-consumidor que veremos en el ejemplo al final de esta sección.
A continuación describimos el mecanismo de funcionamiento:
-
Crear el semáforo
Sinicializado a 1, junto a los recursos compartidos que va a proteger.semaphore S(1);int shared_counter = 0;Resource shared_resource;Resultado
Scontiene un contador inicializado a 1, el número máximo de hilos que podrán ejecutar la sección crítica al mismo tiempo.shared_counteryshared_resource, que son los recursos compartidos protegidos por el semáforo, se inicializan y quedan listos para que los hilos los modifiquen una vez dentro de la sección crítica. -
Intentar entrar en la sección crítica antes de ejecutar el código protegido por el semáforo.
S.acquire();- Si el contador interno del semáforo es mayor que 0,
acquire()lo decrementa y retorna para que la ejecución continúe. - Si el contador interno del semáforo es igual a 0,
acquire()saca al hilo de la CPU y lo pone en una cola de espera, suspendiendo así su ejecución. Básicamente, esto ocurre cuando ya hay N hilos dentro de la sección crítica.
- Si el contador interno del semáforo es mayor que 0,
-
Ejecutar el código protegido por el semáforo.
modify_counter(shared_counter);shared_resource.update();// Resto del código de la sección crítica...Resultado
shared_counteres modificado yshared_resourcese actualiza sin condiciones de carrera, porque como mucho hay 1 hilo ejecutando este código al mismo tiempo. -
Salir de la sección crítica tras ejecutar el código protegido por el semáforo.
S.release();- Si no hay ningún hilo en la cola de espera,
release()incrementa el contador interno del semáforo y retorna para que la ejecución continúe. - Si hay hilos en la cola de espera —donde los puso su
acquire()—,release()saca a uno de ellos y lo mete en la cola de preparados, dejándolo listo para entrar en la CPU. Cuando eso ocurra, ese hilo saldrá de suacquire(), donde hasta ahora estaba atrapado, sin que el contador llegue a incrementarse, que el que entra lo hace a cambio del que sale. Mientras tantorelease()retorna y la ejecución del hilo que sale de la sección crítica continúa.
- Si no hay ningún hilo en la cola de espera,
Tipos de semáforos
Sección titulada «Tipos de semáforos»Tanto el estándar POSIX como Windows API soportan semáforos, y ambos admiten dos tipos:
-
Los semáforos anónimos solo existen en el espacio de direcciones del proceso que los crea, por lo que sirven para sincronizar hilos del mismo proceso. La forma de usarlos para sincronizar procesos diferentes o hilos en procesos diferentes depende del sistema operativo o de la implementación empleada:
- En Windows API, los semáforos anónimos solo se pueden compartir entre procesos mediante la herencia de padres a hijos.
- En los sistemas POSIX, el semáforo anónimo debe ser inicializado en una región de memoria compartida entre los procesos que lo van a utilizar, e indicar un valor distinto de 0 en el argumento
psharedde sem_init(). - En C++, en cambio, el estándar no contempla este uso. Nada garantiza que un std::counting_semaphore sirva para sincronizar procesos distintos, ni siquiera creándolo —mediante placement new3— sobre una región de memoria compartida. Que funcione depende por completo de la implementación de la librería estándar.
-
Los semáforos con nombre son públicos al resto del sistema, por lo que teóricamente cualquier proceso con permisos puede abrirlos para utilizarlos. Están disponibles en Windows API y en POSIX, pero no existe una implementación estándar en C++.
| POSIX API | Windows API | |
|---|---|---|
| Crear semáforo anónimo | sem_init() | CreateSemaphore() |
| Crear semáforo con nombre | sem_open() | CreateSemaphore() |
| Abrir semáforo con nombre | sem_open() | OpenSemaphore() |
| Acquire | sem_wait() | WaitForSingleObject() |
| Release | sem_post() | ReleaseSemaphore() |
| Cerrar semáforo con nombre | sem_close() | CloseHandle() |
| Destruir semáforo anónimo | sem_destroy() | (Automático) |
| Destruir semáforo con nombre | sem_unlink() | (Automático) |
Desde C++20, la librería estándar también ofrece semáforos a través de std::counting_semaphore y de su alias std::binary_semaphore, que fija el valor máximo del contador a 1. En ambos casos los métodos para adquirir y liberar el semáforo se llaman std::counting_semaphore::acquire() y std::counting_semaphore::release(), respectivamente.
Ejemplo Ejemplo del uso de semáforos
Sección titulada « Ejemplo del uso de semáforos»En el apartado «Comunicación con memoria compartida» del capítulo «Memoria compartida» vimos cómo dos procesos podían comunicarse mediante memoria compartida, utilizando semáforos para sincronizarse.
A continuación vamos a resolver el problema del productor-consumidor, que planteamos al principio del capítulo, utilizando semáforos. Para ello tenemos que considerar que:
-
Necesitamos un semáforo para que haya exclusión mutua entre ambos hilos mientras manipulan
shared_vectorycount. -
Necesitamos una forma de que el productor espere cuando el vector esté lleno y que el consumidor haga lo mismo cuando el vector esté vacío. Una solución es usar dos semáforos más, uno que cuente el número de elementos en el vector y otro que cuente el número de huecos libres.
Empezamos declarando los tres semáforos, junto a los datos que comparten ambos hilos:
constexpr int VECTOR_SIZE = 10;
std::counting_semaphore mutex{ 1 };std::counting_semaphore fill_count{ 0 };std::counting_semaphore empty_count{ VECTOR_SIZE };
std::vector<Item> shared_vector( VECTOR_SIZE );int count = 0;- Semáforo que se encarga de la exclusión mutua. Se inicializa a 1, para que el primer hilo que llame a std::counting_semaphore::acquire() pueda entrar.
- Semáforo que se encarga de contar los elementos que hay en el vector. Se inicializa a 0, porque al principio no hay ningún elemento.
- Semáforo que se encarga de contar los huecos libres del vector.
Se inicializa a
VECTOR_SIZE, porque al principio están todos vacíos.
El productor genera un elemento y lo inserta en cuanto hay un hueco libre donde ponerlo:
void productor(){ while(/* ... */) { Item item = produce_item();
empty_count.acquire(); mutex.acquire();
shared_vector[count] = item; ++count;
mutex.release(); fill_count.release(); }}- Producir el elemento no toca datos compartidos, así que se hace fuera de la sección crítica.
- Antes de insertar un elemento se decrementa
empty_count. Así, si vale 0, es que el vector está lleno y el productor se bloquea aquí hasta que el consumidor extraiga alguno. - El acceso a
shared_vectory acountes en exclusión mutua, así que hay que adquirirmutexantes de tocarlos y liberarlo al terminar. Esto decrementa el semáforo, así que solo uno de los dos hilos pasa y ejecuta las líneas siguientes, mientras el otro queda bloqueado. - La sección crítica está compuesta por el código que escribe el elemento en el primer hueco libre y avanza
countpara que apunte al siguiente. - Incrementar
fill_countanuncia que hay un elemento más disponible, despertando al consumidor si estaba esperando.
El consumidor es similar al productor, pero donde uno espera huecos libres, el otro espera elementos disponibles, y viceversa.
void consumidor(){ while(/* ... */) { fill_count.acquire(); mutex.acquire();
--count; Item item = shared_vector[count];
mutex.release(); empty_count.release();
consume_item(item); }}- Antes de extraer un elemento se decrementa
fill_count. Así, si vale 0, es que el vector está vacío y el consumidor se bloquea hasta que el productor inserte alguno. - La exclusión mutua funciona igual que en el productor, y sobre el mismo
mutex. La protección no está en el dato, sino en que ambos hilos respeten el mismo acuerdo. - La sección crítica está compuesta por el código que retrocede
counthasta el último elemento insertado y lo copia. - Incrementar
empty_countanuncia que hay un hueco libre más, despertando al productor si estaba esperando. - Consumir el elemento tampoco toca datos compartidos, así que se hace fuera de la sección crítica.
Mantener
mutexadquirido durante un trabajo largo obligaría al productor a esperar sin motivo.
Hemos implementado esta solución con los semáforos de la librería estándar de C++, pero la traducción a POSIX API sería directa: sem_t en lugar de std::counting_semaphore, sem_wait() en lugar de acquire() y sem_post() en lugar de release(), con la inicialización de los tres semáforos en sendas llamadas a sem_init().
En el ejemplo anterior, de los tres semáforos que usamos solo uno servía para proteger la sección crítica, y para eso bastaba con un contador que valiese 0 o 1. Ese caso es tan frecuente que los sistemas operativos ofrecen un objeto dedicado, más simple y más barato que un semáforo y que nos garantiza que solo el hilo que lo adquirió puede liberarlo.
Los mutex —término que tiene su origen en mutual exclusion— son un tipo de objeto del sistema operativo que permite controlar el acceso a una sección crítica, de forma que solo un hilo pueda ejecutarla al mismo tiempo. En este sentido, los mutex se comportan como semáforos inicializados a 1, motivo por el que también se denominan semáforos binarios. Por tanto, aunque un sistema o lenguaje solo soporte semáforos, es directo usarlos para implementar mutex.
Al contrario que los semáforos, un mutex solo cubre la exclusión mutua. Debe liberarlo el mismo hilo que lo adquirió, así que no se puede usar para que un hilo avise a otro de que ha ocurrido algo. Para esa segunda necesidad hacen falta las variables de condición, que veremos más adelante.
Tanto Windows API como el estándar POSIX —a través de POSIX— soportan mutex:
-
En POSIX Threads, solo se pueden utilizar para sincronizar hilos del mismo proceso, pero utilizando el atributo
psharedal inicializar un mutex se puede indicar que va a ser compartido entre procesos diferentes mediante memoria compartida. -
En Windows API hay dos tipos de objetos equiparables: los mutex y las secciones críticas. Las secciones críticas son más ligeras, pero solo se pueden utilizar para sincronizar hilos del mismo proceso. Mientras que los mutex de Windows API son objetos más costosos, pero se pueden compartir entre procesos sin utilizar memoria compartida; ya sea mediante herencia al crear un proceso hijo o asignando un nombre al mutex, igual que se puede hacer con los semáforos.
| C++ estándar | POSIX API | |
|---|---|---|
| Crear | std::mutex | pthread_mutex_init() |
| Acquire | std::mutex::lock() | pthread_mutex_lock() |
| Release | std::mutex::unlock() | pthread_mutex_unlock() |
| Destruir | (Destructor) | pthread_mutex_destroy() |
| Windows API (Sección crítica) | Windows API (Mutex) | |
|---|---|---|
| Crear | InitializeCriticalSection() | CreateMutex() |
| Abrir | (No aplica) | OpenMutex() |
| Acquire | EnterCriticalSection() | WaitForSingleObject() |
| Release | LeaveCriticalSection() | ReleaseMutex() |
| Destruir | DeleteCriticalSection() | CloseHandle() |
Spinlocks
Sección titulada «Spinlocks»Al definir la sincronización condicional dijimos que la espera activa puede ser útil cuando las esperas, previsiblemente, van a ser muy cortas. Un spinlock es un mutex cuya espera es activa, es decir, el hilo que no consigue adquirirlo no se bloquea a la espera, sino que se queda iterando hasta que lo libere el hilo que lo tiene. Así conserva la CPU durante toda la espera, en lugar de cederla a otro hilo.
Los spinlocks se utilizan frecuentemente para proteger las estructuras del núcleo en los sistemas multiprocesador, cuando la tarea a realizar dentro de la sección crítica requiere poco tiempo. En esos casos, los diseñadores del núcleo calculan que se desperdicia más tiempo sacando de la CPU al hilo en espera —y metiendo otro en su lugar— que dejándolo iterar hasta que la sección quede libre. Por eso, mientras un hilo tiene adquirido un spinlock dentro del núcleo, el sistema operativo no le expropia la CPU, para que pueda salir cuanto antes de la sección crítica y que los que esperan iteren lo menos posible.
También hay soluciones intermedias. En Windows API, por ejemplo, los programas de usuario pueden inicializar un objeto de sección crítica con InitializeCriticalSectionAndSpinCount(). El hilo que intenta adquirirla itera en una espera activa el número de veces indicado, comprobando cada vez si la sección ha sido liberada. Si al agotar las iteraciones sigue ocupada, se bloquea en el estado esperando.
La espera activa de estos objetos sección crítica solo ocurre en sistemas multiprocesador, donde el hilo que tiene adquirida la sección puede estar ejecutándose en otro procesador y terminar rápidamente. En sistemas monoprocesador nunca hay espera activa, por lo que el número de iteraciones es ignorado.
Ejemplo Ejemplo del uso de mutex
Sección titulada « Ejemplo del uso de mutex»Volvamos al problema del productor-consumidor, para intentar resolverlo ahora con un mutex en lugar de con semáforos. La exclusión mutua la tenemos cubierta, porque para eso está el mutex, pero nos falta la sincronización condicional. Sin un tipo de objeto con el que señalar eventos, al productor no le queda más remedio que comprobar una y otra vez si ha aparecido algún hueco libre, y al consumidor si ha aparecido algún elemento.
Esta vez solo hay un objeto de sincronización, porque el mutex es lo único de lo que disponemos:
constexpr int VECTOR_SIZE = 10;
std::mutex mutex;
std::vector<Item> shared_vector( VECTOR_SIZE );int count = 0;El productor no puede quedarse dormido esperando un hueco, así que da vueltas comprobando si ha aparecido alguno:
void productor(){ while(/* ... */) { Item item = produce_item();
while(true) { std::unique_lock lock{ mutex };
if (count < VECTOR_SIZE) { shared_vector[count] = item; ++count; break; } } }}- El bucle de espera activa donde el hilo itera, ocupando la CPU, hasta que el consumidor deja un hueco libre.
- Los mutex se pueden adquirir y liberar con std::mutex::lock() y std::mutex::unlock(), pero esa no es la forma recomendada. Lo recomendado es crear alguno de los objetos lock incluidos en la librería estándar, como std::unique_lock, que adquieren el mutex al construirse y lo liberan automáticamente al destruirse. Así es difícil que nos olvidemos de liberarlo al salir del bloque.
- La condición de espera se comprueba dentro de la sección crítica.
Ni siquiera es seguro leer
countsin haber adquirido antes el mutex, porque el otro hilo puede estar modificándolo en ese mismo instante. - Al cerrarse el bloque se destruye
lock, así que el mutex queda liberado tanto si se ha podido insertar el elemento como si no. Esto es imprescindible, pues si el hilo conservara el mutex mientras espera, sin liberarlo nunca, el consumidor nunca podría entrar en su sección crítica para dejar un hueco, y ambos quedarían bloqueados para siempre.
El consumidor hace lo propio, dando vueltas hasta que aparece algún elemento que extraer:
void consumidor(){ while(/* ... */) { Item item;
while(true) { std::unique_lock lock{ mutex };
if (count > 0) { --count; item = shared_vector[count]; break; } }
consume_item(item); }}itemse declara fuera del bucle de espera, porque tiene que seguir existiendo cuando se libere el mutex y se consuma el elemento.- La condición es la contraria a la del productor. En este caso se comprueba que quede algo que extraer.
consume_item()se ejecuta fuera de la sección crítica, porque no manipula datos compartidos. Mantener el mutex adquirido durante un trabajo largo obligaría al productor a esperar sin motivo.
La solución es correcta, pero desperdicia tiempo de CPU. Y solo es correcta si el sistema pueda ejecutar ambos hilos al mismo tiempo, ya sea porque es multiprocesador o porque utilice planificación expropiativa. Mientras el vector está lleno, el productor no hace nada útil salvo adquirir y liberar el mutex una y otra vez, quitándole tiempo de CPU al consumidor, que es precisamente el hilo que podría desbloquear la situación. Es la espera activa que describimos al hablar de la sincronización condicional, y es el motivo por el que hacen falta las variables de condición que veremos a continuación.
En el repositorio de ejemplos hay dos programas más que usan mutex para proteger datos compartidos entre hilos. En pthreads-sync-counter.cpp y threads-sync-counter.cpp dos hilos incrementan un millón de veces cada uno el mismo contador, y el resultado solo es correcto si ambos adquieren el mismo mutex antes del incremento. La diferencia entre ambos programas es que el primero usa POSIX mientras el segundo utiliza std::mutex de la librería estándar de C++.
En pthreads-sync-factorial.cpp y threads-sync-factorial.cpp se calcula el factorial de un número repartiendo la tarea entre dos hilos que, en lugar de retornar su resultado, lo guardan en un std::vector compartido que hay que proteger.
Variables de condición
Sección titulada «Variables de condición»En la solución que dimos al problema del productor-consumidor usando semáforos (ver la sección correspondiente) empleamos dos de ellos para implementar las esperas del productor y el consumidor cuando el vector está lleno o vacío, respectivamente. Al intentar lo mismo con un mutex tuvimos que recurrir a la espera activa, porque los mutex no se pueden usar para señalar eventos. Así que cuando la exclusión mutua la proporciona un mutex, cubrir la sincronización condicional exige otro tipo de objeto: la variable de condición.
Las variables de condición soportan tres primitivas principales:
-
wait( mutex ). Es llamada por un hilo que desea esperar a que ocurra el evento que representa la variable de condición. El hilo debe haber adquirido antes el mutex, es liberado en el momento de poner al hilo en estado esperando. Varios hilos pueden llamar a wait sobre la misma variable de condición, a la espera de que alguno use notify.
-
notify. Es llamada por un hilo que quiere notificar el suceso de un evento a los hilos que esperan en la variable de condición. Uno de esos hilos es despertado, adquiere el mutex que liberó al llamar a wait y, finalmente, retorna de wait para seguir ejecutándose.
-
notifyAll. Es llamada por un hilo que quiere notificar el suceso de un evento a los hilos que esperan en la variable de condición. Todos los hilos son despertados e intentan adquirir el mutex que liberaron al llamar a wait. Cuando lo consiguen, retornan de wait para seguir ejecutándose. Obviamente, si todos hicieron wait sobre el mismo mutex, irán retornando de wait de uno en uno, porque solo un hilo puede tener el mutex al mismo tiempo.
Tanto Windows API como el estándar POSIX —a través de POSIX— soportan variables de condición:
-
En POSIX, las variables de condición solo se pueden utilizar para sincronizar hilos del mismo proceso, pero tienen un atributo para permitir la sincronización entre procesos diferentes. Obviamente, para eso deben ser creadas en una región de memoria compartida por dichos procesos —como también ocurre con semaforos y mutex—.
-
En Windows API las variables de condición solo se pueden utilizar en hilos del mismo procesos. Como alternativa, Windows API soporta eventos, que son un tipo de objeto similar a las variables de condición, pero que sí se puede utilizar entre hilos de procesos diferentes.4
A los eventos se les puede asignar un nombre, para que sean accesibles por otros procesos, o heredarse de padres a hijos. Además son más pesados que las variables de condición, no exigen un mutex para liberar al invocar su operación wait, ni admiten la operación notifyAll.
| C++ estándar | POSIX API | |
|---|---|---|
| Crear | std::condition_variable | pthread_cond_init() |
| Wait | std::condition_variable::wait() | pthread_cond_wait() |
| Notify | std::condition_variable::notify_one() | pthread_cond_signal() |
| NotifyAll | std::condition_variable::notify_all() | pthread_cond_broadcast() |
| Destruir | (Destructor) | pthread_cond_destroy() |
| Windows API (Variable de condición) | Windows API (Evento) | |
|---|---|---|
| Crear | InitializeConditionVariable() | CreateEvent() |
| Abrir | (No aplica) | OpenEvent() |
| Wait | SleepConditionVariableCS() | WaitForSingleObject() |
| Notify | WakeConditionVariable() | SetEvent() |
| NotifyAll | WakeAllConditionVariable() | (No soportado) |
| Reset | (No aplica) | ResetEvent() |
| Destruir | (Automático) | CloseHandle() |
Ejemplo Ejemplos con variables de condición
Sección titulada « Ejemplos con variables de condición»Vamos a resolver por tercera vez el problema del productor-consumidor, ahora sin semáforos y sin espera activa. Para lo que, nuevamente, tenemos que considerar que:
-
Necesitamos exclusión mutua entre ambos hilos mientras manipulan
shared_vectorycount, para evitar condiciones de carrera, por tanto usamos un mutex para proteger la sección crítica. -
Necesitamos una forma de que el productor espere cuando el vector está lleno y que el consumidor haga lo mismo cuando el vector está vacío. Para señalar estos eventos necesitamos dos variables de condición:
constexpr int VECTOR_SIZE = 10;
std::mutex mutex;std::condition_variable no_full;std::condition_variable no_empty;
std::vector<Item> shared_vector( VECTOR_SIZE );int count = 0;- Mutex que se encarga de la exclusión mutua.
- Variable de condición con la que se señala que el vector ha dejado de estar lleno.
- Variable de condición con la que se señala que el vector ha dejado de estar vacío.
El productor, en lugar de dar vueltas comprobando la condición, se duerme en no_full hasta que el consumidor le avisa:
void productor(){ while(/* ... */) { Item item = produce_item();
std::unique_lock lock{ mutex };
while( count == VECTOR_SIZE ) { no_full.wait( lock ); }
shared_vector[count] = item; ++count;
no_empty.notify_one(); }}-
Antes de acceder a
shared_vectory acountes necesario adquirirmutex. Ni siquiera es seguro consultarcountsin hacerlo, puesto que el otro hilo puede estar modificándolo al mismo tiempo. Como en el ejemplo anterior, lo adquirimos construyendo un std::unique_lock, que lo libera automáticamente al destruirse. -
La condición de si está lleno el vector se comprueba en un
whiley no en unifporque el estándar de C++ permite los despertares espurios, es decir, un hilo puede salir de std::condition_variable::wait() sin que nadie haya notificado nada. -
La llamada a std::condition_variable::wait() libera
mutexen el momento de dormir al hilo, para que el consumidor pueda entrar en su sección crítica y extraer un elemento. De lo contrario, ninguno de los dos podría avanzar y tendríamos un interbloqueo o deadlock, igual que en la solución con solo un mutex.Antes de retornar de wait(), el hilo vuelve a adquirir
mutex, así que las líneas siguientes se ejecutan de nuevo en exclusión mutua. -
La sección crítica, idéntica a la de la solución con semáforos.
-
Al insertar un elemento, el vector ha dejado de estar vacío, así que se despierta al consumidor.
El consumidor sigue el mismo esquema, esperando en no_empty y notificando en no_full:
void consumidor(){ while(/* ... */) { std::unique_lock lock{ mutex };
while( count == 0 ) { no_empty.wait( lock ); }
--count; Item item = shared_vector[count];
no_full.notify_one();
lock.unlock();
consume_item(item); }}- La condición es la contraria: el consumidor espera mientras no haya ningún elemento que extraer.
- Al extraer un elemento, el vector ha dejado de estar lleno, así que se despierta al productor.
mutexse libera explícitamente, sin esperar a que se destruyalockal final de la vuelta. Asíconsume_item()queda fuera de la sección crítica, porque no toca datos compartidos y puede tardar. Retenerlo mientras tanto obligaría al productor a esperar sin motivo.
Esta vez ningún hilo consume CPU mientras espera. El productor y el consumidor quedan bloqueados en std::condition_variable::wait(), en la cola de espera de la variable de condición, hasta que el otro les avisa de que la situación ha cambiado. Comparada con la solución anterior, la diferencia no está en lo correcto del resultado —pues ambas soluciones son correctas—, sino en el tiempo de CPU que se ahorra.
La pareja formada por un mutex y una variable de condición no solo sirve para resolver el problema del productor-consumidor.
Con ella se puede construir cualquier espera sobre una condición que dependa de datos compartidos, incluido el propio semáforo con el que empezamos el capítulo.
Basta con guardar su contador en una variable protegida por el mutex, y usar la variable de condición para dormir a los hilos que llaman a acquire() mientras el contador vale 0 y despertarlos cuando otro hilo llama a release().
Es decir, la misma estructura lógica que acabamos de estudiar, pero sin el vector de elementos.
Así está implementada la clase semaphore del repositorio de, que se acompaña de un programa que la utiliza para limitar a tres el número de hilos que ejecutan una sección crítica al mismo tiempo.
Seguridad en hilos
Sección titulada «Seguridad en hilos»Todas estas cuestiones sobre la sincronización no solo afectan al código que escribimos, sino también a las librerías que utilizamos.
Una función es segura en hilos o thread-safe si al manipular estructuras compartidas de datos lo hace de tal manera que se garantiza la ejecución segura de la misma por múltiples hilos al mismo tiempo. Obviamente estamos hablando de un problema de secciones críticas, por lo que las funciones lo resuelven sincronizando el acceso a estos datos mediante el uso de semáforos, mutex, variables atómicas u otros mecanismos similares.
Por eso, antes de utilizar una función o una librería que va a ser llamada desde múltiples hilos, debemos consultar la documentación para averiguar si es segura en hilos. Si no lo fuera, tendríamos que buscar funciones alternativas o recordar proteger las llamadas a las funciones no seguras con mecanismos de sincronización, para asegurar que solo son invocadas desde un hilo al mismo tiempo.
Esto se aplica tanto a librerías de otros desarrolladores como a la librería estándar del lenguaje que estemos usando y a la librería del sistema. Veamos algunos ejemplos de los lenguajes y sistemas que hemos visto en el curso:
-
C estándar. Su especificación no menciona nada sobre seguridad en hilos, por lo que lo más seguro es suponer que las funciones de la librería estándar no lo son, o buscar la documentación ofrecida por el proveedor del lenguaje y las librerías que estemos utilizando.
-
C++ estándar. La regla que vimos al hablar de los contenedores se aplica a toda la librería estándar de C++: varios hilos pueden leer el mismo objeto a la vez, pero en cuanto uno lo modifica, todas las lecturas y escrituras sobre él deben estar protegidas. Las clases de la librería estándar relacionadas con los mecanismos de sincronización y gestión de hilos generalmente ofrecen mayores garantías para su uso en entornos multihilo, pero es necesario consultar la documentación en cada caso.
-
Windows API. Todas las funciones son seguras en hilos, salvo que la documentación indique lo contrario. Actualmente, la librería estándar de C de Microsoft en Windows también es segura en hilos,5 pero hasta hace unos años Microsoft ofrecía varias versiones de la librería: una segura en hilos, para usar en aplicaciones multihilo, y otra no segura para usar en aplicaciones monohilo.
-
POSIX. Esta especificación es un superconjunto de la API de la librería estándar de C, por lo que en esos sistemas el estándar POSIX es el que marca qué funciones de la librería estándar de C y del resto de la API POSIX son seguras en hilos.
En los sistemas POSIX el estándar establece que todas las funciones son seguras excepto algunas muy concretas, que se pueden consultar en el apartado «2.9.1 de la especificación. Muchas de esas funciones no se especifican como seguras en hilos porque existe alguna alternativa que sí lo es. Por ejemplo, strerror() no es segura en hilos, pero strerror_r() tiene una funcionalidad equivalente y sí lo es.
Referencias de las API
Sección titulada «Referencias de las API»POSIX
Despierta todos los hilos en espera sobre una variable de condición POSIX.
int pthread_cond_broadcast(pthread_cond_t* cond);Destruye un objeto de variable de condición POSIX.
int pthread_cond_destroy(pthread_cond_t* cond);Inicializa un objeto de variable de condición POSIX.
int pthread_cond_init(pthread_cond_t* restrict cond, const pthread_condattr_t* restrict attr);Despierta al menos un hilo en espera sobre una variable de condición POSIX.
int pthread_cond_signal(pthread_cond_t* cond);Espera en una variable de condición POSIX, liberando el mutex asociado.
int pthread_cond_wait(pthread_cond_t* restrict cond, pthread_mutex_t* restrict mutex);Destruye un objeto mutex POSIX.
int pthread_mutex_destroy(pthread_mutex_t* mutex);Inicializa un objeto mutex POSIX.
int pthread_mutex_init(pthread_mutex_t* restrict mutex, const pthread_mutexattr_t* restrict attr);Bloquea un mutex POSIX, esperando si está ocupado.
int pthread_mutex_lock(pthread_mutex_t* mutex);Desbloquea un mutex POSIX.
int pthread_mutex_unlock(pthread_mutex_t* mutex);Destruye un semáforo POSIX anónimo.
int sem_destroy(sem_t* sem);Inicializa un semáforo POSIX anónimo.
int sem_init(sem_t* sem, int pshared, unsigned int value);Abre o crea un semáforo POSIX nombrado.
sem_t* sem_open(const char* name, int oflag);sem_t* sem_open(const char* name, int oflag, mode_t mode, unsigned int value);Elimina un semáforo POSIX nombrado del sistema.
int sem_unlink(const char* name);Decrementa (espera) un semáforo POSIX, bloqueando si es necesario.
int sem_wait(sem_t* sem);Devuelve la descripción textual de un código de error.
char* strerror(int errnum);Versión reentrante de strerror().
Devuelve la descripción de un código de error.
/* POSIX version */int strerror_r(int errnum, char* buf, size_t buflen);/* GNU extension version */char* strerror_r(int errnum, char* buf, size_t buflen);Lenguaje C++
Plantilla para operaciones atómicas libres de condiciones de carrera.
constexpr atomic(T desired) noexcept;Alias de
using binary_semaphore = counting_semaphore<1>;Mecanismo de sincronización para que hilos esperen hasta que se cumpla una condición.
condition_variable();Despierta todos los hilos en espera sobre la variable de condición.
void notify_all() noexcept;Despierta uno de los hilos en espera sobre la variable de condición.
void notify_one() noexcept;Espera hasta que una condición sea notificada, liberando el mutex.
void wait(std::unique_lock<std::mutex>& lock);void wait(std::unique_lock<std::mutex>& lock, Predicate pred);Plantilla de semáforo con contador, que permite indicar cuántos hilos pueden acceder simultáneamente a un recurso.
constexpr explicit counting_semaphore(std::ptrdiff_t desired) noexcept;Decrementa el contador interno del semáforo, bloqueando el hilo si el contador está a 0.
void acquire();Incrementa el contador interno del semáforo y despierta a los hilos bloqueados en acquire() que estén esperando.
void release(std::ptrdiff_t update = 1);Mutex de exclusión mutua no recursivo.
constexpr mutex() noexcept;Bloquea el mutex, esperando si está ocupado.
void lock();Libera el mutex.
void unlock();Envoltorio que adquiere un mutex al construirse y lo libera al destruirse, permitiendo además adquirirlo y liberarlo manualmente mientras existe.
explicit unique_lock(mutex_type& m);Windows API
Cierra un handle abierto del sistema.
BOOL CloseHandle(HANDLE hObject);Crea o abre un objeto de sincronización de tipo evento.
HANDLE CreateEvent(LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset, BOOL bInitialState, LPCTSTR lpName);Crea o abre un objeto mutex con nombre o sin nombre.
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);Crea o abre un objeto semáforo con nombre o sin nombre.
HANDLE CreateSemaphore(LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG lInitialCount, LONG lMaximumCount, LPCTSTR lpName);Elimina un objeto de sección crítica.
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);Espera hasta obtener acceso exclusivo a la sección crítica.
void EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);Inicializa una variable de condición.
void InitializeConditionVariable(PCONDITION_VARIABLE ConditionVariable);Inicializa un objeto de sección crítica.
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);Inicializa una sección crítica con un recuento de espera activa.
BOOL InitializeCriticalSectionAndSpinCount( LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount);Libera la propiedad del objeto de sección crítica.
void LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);Abre un objeto de evento con nombre existente.
HANDLE OpenEvent(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);Abre un objeto mutex con nombre existente.
HANDLE OpenMutex(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);Abre un objeto semáforo con nombre existente.
HANDLE OpenSemaphore(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCTSTR lpName);Libera la propiedad de un objeto mutex.
BOOL ReleaseMutex(HANDLE hMutex);Incrementa el contador de un semáforo.
BOOL ReleaseSemaphore(HANDLE hSemaphore, LONG lReleaseCount, LPLONG lpPreviousCount);Pone un objeto evento en estado no señalizado.
BOOL ResetEvent(HANDLE hEvent);Espera en una variable de condición con una sección crítica.
BOOL SleepConditionVariableCS(PCONDITION_VARIABLE ConditionVariable, PCRITICAL_SECTION CriticalSection, DWORD dwMilliseconds);Espera hasta que el objeto especificado esté señalizado.
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);Despierta todos los hilos en espera sobre una variable de condición.
void WakeAllConditionVariable(PCONDITION_VARIABLE ConditionVariable);Despierta un hilo en espera sobre una variable de condición.
void WakeConditionVariable(PCONDITION_VARIABLE ConditionVariable);Notas al pie
Sección titulada «Notas al pie»-
Una referencia es un puntero o referencia que ya no apunta a un objeto válido, porque este ha sido destruido o liberado en memoria. ↩
-
El factor de de una tabla hash es la razón entre el número de elementos que contiene y el número de buckets que tiene. Cuanto mayor es, más elementos comparten bucket y más se parece una búsqueda a un recorrido lineal, así que la implementación fija un valor que al superarse dispara el rehash. ↩
-
El placement new es una variante del operador
newde C++ que construye un objeto en una región de memoria ya reservada, en lugar de reservar memoria en el montón. Es la forma habitual de inicializar un objeto directamente sobre una región de memoria compartida. ↩ -
Microsoft Corporation, «Synchronization: Using Event», Microsoft Learn — Desktop Win32 Apps, 16 de septiembre de 2021. ↩
-
Microsoft Corporation, «Multithreading with C and», Microsoft Learn — Microsoft C++, C, and Assembler, 1 de julio de 2025. ↩