13. Sincronización

Tiempo de lectura: 34 minutos

En el Capítulo 11 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 12 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 cooperativos que comparten regiones de la memoria, con el fin de mantener la consistencia de los datos.

En este capítulo hablaremos de hilos y de procesos que comparten memoria indistintamente. En ambos casos el problema es el mismo y las soluciones similares.

13.1. El problema de las secciones críticas

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.

13.1.1. Problema del productor-consumidor

Para ilustrarlo, veamos un problema clásico de concurrencia: el problema del productor-consumidor.

Supongamos que dos hilos o procesos comparten una región de la memoria que contiene un vector de elementos y un contador con el número de elementos del vector.

El primer hilo realiza varias tareas, que no entraremos a describir. Lo importante es que, como resultado de esas tareas, en ocasiones añade un elemento al vector e incrementa el contador que indica el número de elementos en el vector. Es decir, el primer hilo actúa como un productor de elementos del vector.

A continuación mostramos una porción de la función del productor:

while(/* ... */)
{
    item = produce_item();

    // Si el vector está lleno, esperar
    while (count == VECTOR_SIZE);

    // Añadir el elemento al vector
    vector[count] = item;
    ++count;
}

El segundo hilo también realiza varias tareas que no describiremos. Pero para realizar esas tareas en ocasiones debe tomar un elemento del vector compartido, decrementando el contador para indicar que ahora hay un elemento menos en el vector. Es decir, el segundo hilo actúa como un consumidor de elementos del vector.

A continuación mostramos una porción de la función del consumidor:

while(/* ... */)
{
    // Si el vector está vacio, esperar
    while (count == 0);

    // Extraer un elemento del vector
    --count;
    item = vector[count];

    consume_item(item);
}

Aunque el problema del productor-consumidor parezca artificial, lo cierto es que es muy común.

Por ejemplo, en una herramienta de grabación de audio, el productor es un hilo dedicado a obtener bloques de muestras grabadas a través de la API multimedia del sistema operativo. Mientras tanto, otro hilo puede dedicarse a tomar las muestras y realizar diversas transformaciones, como: reducir el ruido, mezclar con otras fuentes de sonido o aplicar algún tipo de efecto digital. Este segundo hilo es el consumidor. La manera de conectar ambos es tener un vector compartido, donde se depositan los bloques de muestras cuando llegan y de dónde se extraen para su tratamiento. Así, ambos hilos pueden trabajar a su propio ritmo, de forma casi independiente.

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 (véase el Apartado 14.1) dado que en esos sistemas un hilo puede ser interrumpido en cualquier momento 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:

++count
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:

--count
registro2 = [count];
registro2 = registro2 - 1;
[count] = registro2;

Donde nuevamente registro2 representa un registro de la CPU.

Aunque registro1 y registro2 pueden ser el mismo registro físico, el contenido de los registros se guarda y se recupera durante los cambios de contexto de un hilo al otro, por lo que es indiferente si son el mismo o registros diferentes.

En sistemas monoprocesador, el que las sentencias ++count y --count se ejecute de forma concurrente, es similar a que las instrucciones de lenguaje máquina de ambas sentencias en ambos hilos o procesos 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:

// Entra ++count
registro1 = [count];        // registro1 = 5
registro1 = registro1 + 1;  // registro1 = 6
// Sale ++count y entra --count
registro2 = [count];        // registro2 = 5
registro2 = registro2 - 1;  // registro2 = 4
// Sale --count y entra ++count
[count] = registro1;        // count = 6 (2)
// Entra --count
[count] = registro2;        // count = 4 (1) (2)
1 Así llegamos al resultado incorrecto count = 4, indicando que hay 4 elementos en el vector cuando realmente hay 5.
2 Si invertimos el orden de las sentencias obtendremos el resultado, también incorrecto, count = 6.

Como se puede apreciar, hemos llegado a estos valores incorrectos porque hemos permitido la manipulación concurrente de la variable count. Según como se entrelacen las instrucciones de ++count y --count en la CPU, el resultado final podría ser: 4, 5 o 6. Sin embargo, 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.

13.1.2. Manipular estructuras de datos

Obviamente, el problema comentado no aparece solo en sentencias simples, sino también en bloques de código destinados a hacer tareas complejas, como manipular estructuras de datos.

Por ejemplo, supongamos que vector no es un simple array de elementos, sino una lista enlazada, de tal forma que ahora extraer un elemento sería así:

--count;
item = vector.extract(count);

y el método extract() tendría que dar los siguientes pasos:

  1. Iterar sobre la lista para buscar el nodo en la posición count.

  2. Al encontrarlo, preservar en variables locales el puntero a ese nodo y al previo.

  3. Cambiar en el nodo previo el puntero al siguiente nodo, para que apunte al nodo tras el que queremos extraer. En este momento el nodo a extraer ya no pertenece a la lista enlazada.

  4. Extrae el item del campo que lo contiene en el nodo.

  5. Destruir el nodo.

  6. Salir del método retornando el elemento.

Esto genera varios momentos cruciales en torno al paso 3, que puedan dar lugar a condiciones de carrera. Por ejemplo, si el hilo es interrumpido tras guardar el puntero al nodo en una variable local y llega otro hilo que extrae —y destruye— antes ese mismo nodo, el puntero ya no es válido —es un dangling pointer o referencia colgante— al continuar la ejecución del primer hilo. Y lo mismo ocurre con el puntero al nodo previo o al siguiente, si el hilo es interrumpido y otro hilo destruye antes alguno de ellos.

Los problemas que esto puede causar son diversos, según el momento exacto en el que ocurra. Puede haberlos al intentar leer el elemento guardado en el nodo en el paso 4, porque este último ya no exista. También, al intentar actualizar, en el paso 3, el puntero al siguiente nodo en el nodo previo, porque el nodo previo no exista. Incluso puede que la función termine con aparente normalidad, pero dejando que el nodo previo apunte a un nodo siguiente que no existe. En este último supuesto, la lista quedaría en estado inconsistente y así el problema se lo encontraría el próximo hilo que intente usarla.

13.1.3. Exclusión mutua

Para evitar que estas situaciones lleven a la corrupción de datos y a caídas de servicios y sistemas, debemos asegurarnos 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 insert() o un extract() en una lista, otro no pueda utilizar ni estas ni otras funciones de la misma clase.

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.

13.1.4. Eventos

Las condiciones de carrera son el principal problema del código anterior del productor y el consumidor, pero no el único. En ambos ejemplos se usan bucles para que el hilo espere si el vector está lleno o vacío, antes de continuar. A esta técnica se la denomina espera ocupada o espera activa y está completamente desaconsejada usarla en código del espacio de usuario, porque contribuye a gastar tiempo de CPU inútilmente.

En su lugar, se recomienda usar mecanismos de sincronización ofrecidos por el sistema operativo; diseñados para que un hilo o proceso notifique eventos a otro, de tal forma que hasta que eso ocurre, el que espera permanezca en estado esperando, dejando la CPU para los hilos que la necesitan.

13.2. 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 ofrecidos por el sistema operativo.

13.2.1. Bloque de las interrupciones

El problema de la sección crítica puede ser resuelto 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 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 sistema multiprocesador, donde hay varios procesadores ejecutándose a la vez.

13.2.2. 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 el hardware se encargará de que sean ejecutadas secuencialmente en algún orden arbitrario.

Ejemplo 13.1. 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 caga un valor de la memoria en un registro y monitorizar 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)                 (1)
  addiu   r1, r1, 1                 (2)
  sc      r1, 0(r2)                 (3)
  beqz    r1, retry_increment       (4)
  nop                               (5)
1 Suponiendo que r2 contiene la dirección de la variable count, carga el valor de dicha variable en r1.
2 Incrementa el valor en el registro r1.
3 Intenta almacenar el valor incrementado en count. Si tiene éxito, porque el valor de count no ha sido modificado por otro proceso, r1 se pone a 1. En caso contrario, se pone a 0.
4 Si r1 vale 0, es que la instrucción sc falló porque la variable count fue modificada por otro proceso. En ese caso, se salta a retry_increment para volver a intentar el incremento de la variable.
5 Se pone una instrucción para evitar que después de beqz se ponga un instrucción que el procesador ejecute antes del salto.

Antes y después del bloque de instrucciones ll y sc, generalmente se usa la instrucción sync. Esta instrucción crear una barrera de operaciones en memoria, de forma que el procesador se ve obligado a completar todas las operaciones en memoria anteriores a sync antes de realizar las operaciones en memoria siguientes, como ll y sc.

Estas instrucciones están disponibles para los programadores de C y C++ a través de tipos especiales. Por ejemplo, en C11 <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); (1)
int old_count = atomic_fetch_add(&count, 1); (2)
1 Inicializar el valor de la variable atómica.
2 Como un count++ atómico: incrementa la variable devolviendo el valor previo.

En C++11, <atomic> declara la plantilla std::atomic que ofrece una funcionada similar:

std::atomic<int> count {0};  (1) (2)
int old_count = count++;     (3)
1 También hay un tipo std::atomic_int que es equivalente.
2 Se usa el constructor para inicializar la variable atómica.
3 Además de soportar los operadores '++' y '--', soporta los métodos std::atomic::fetch_add() y std::atomic::fetch_sub() para sumar y restar devolviendo el valor previo.

La importancia de estas instrucciones está en que pueden ser utilizadas por el sistema operativo para ofrecer soluciones sencillas al problema de la sección crítica. Por ejemplo, semáforos o mutex.

13.3. Semáforos

La exclusión mutua en las secciones críticas se asegura utilizando adecuadamente una serie de recursos que para ese fin proporciona el sistema operativo. Estos recursos utilizan internamente instrucciones y otras características de la CPU, incluidas por los diseñadores para resolver este tipo de problemas, que hemos comentado anteriormente. Ese es el caso de los semáforos.

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 el libro de texto que consultemos—.

Los semáforos tienen un contador interno que se inicializa, de tal forma que un semáforo inicializado a N solo permite que N hilos ejecuten la sección crítica al mismo tiempo.

A continuación describimos el mecanismo de funcionamiento:

semaphore S(10);    (1)

S.acquire()         (2)

// Código de la sección crítica... (3)

S.signal();         (4)
1 Crear el semáforo S inicializado a 10. Un semáforo contiene fundamentalmente un contador con el número máximo de hilos que pueden estar ejecutando el código de la sección crítica al mismo tiempo.
2 Intentar entrar en la sección crítica:
  • 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, es que hay demasiados hilos dentro de la sección crítica.

3 Aquí iría el código protegido con el semáforo. Es decir, el código de la sección crítica en sí.
4 Salir de la sección crítica:
  • Si el contador interno del semáforo es mayor que 0, release() lo incrementa y retorna para que la ejecución continúe.

  • Si el contador interno del semáforo es igual a 0, release() lo incrementa y saca a uno de los hilos en la cola de espera —donde los puso su acquire()— para meterlo en la cola de preparados, dejándolo listo para entrar en la CPU. Cuando eso ocurra, ese hilo decrementará el contador interno del semáforo y saldrá de su acquire(), donde hasta ahora estaba atrapado. Mientras tanto release() retorna y la ejecución del hilo que sale del sección crítica continúa.

Para que funcione correctamente, el semáforo S debe ser el mismo para todos los hilos que tengan secciones críticas en cuya ejecución debe haber exclusión mutua. Es decir, el semáforo S debe estar compartido entre los hilos de la misma manera que las estructuras de datos, variables y otros recursos que protege.

13.3.1. Tipos de semáforos

Tanto el estándar POSIX como Windows API soportan semáforos y ambos admiten dos tipos de semáforos:

  • Los semáforos anónimos que solo existen en el espacio de direcciones del proceso que los crea, de tal forma que están disponibles para sincronizar hilos del mismo proceso.

    La forma de usarlos para sincronizar procesos diferentes o hilos en procesos diferentes depende del sistema operativo. Con Windows API se pueden heredar de padres a hijos. Mientras que en sistemas POSIX es necesario crear el semáforo en una región de memoria compartida, que hayamos creado previamente, e indicar un valor distinto de 0 en el argumento pshared de sem_init().

  • Las semáforos con nombre son públicos al resto del sistema, por lo que teóricamente cualquier proceso con permisos puede abrirlos para utilizarlos.

Tabla 13.1. Funciones de la API para manipular semáforos.
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()

Operación acquire

sem_wait()

WaitForSingleObject()

Operación 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]

13.3.2. Ejemplos del uso de semáforos

En el ejemplo anom-shared-memory.cpp de comunicación mediante memoria compartida, se usa un semáforo para que el proceso hijo indique al proceso padre que ha terminado de calcular el factorial y el resultado ya está en la memoria.

En shared-memory-server.c está el ejemplo completo de un programa que muestra periódicamente la hora del sistema y que puede ser controlado remotamente, mediante memoria compartida, con un cliente como el de shared-memory-client.cpp.

Para enviar los mensajes entre el cliente y el servidor, en la memoria compartida se reserva hueco para un búfer en el que el cliente copia el comando que quiere enviar y para dos semáforos:

struct memory_content
{
    sem_t empty; (1)
    sem_t ready; (2)
    char command_buffer[MAX_COMMAND_SIZE];
};
1 Indica cuándo command_buffer está vacío, así que se inicializa a 1. El cliente usa sem_wait() en este semáforo antes de escribir un nuevo comando en command_buffer:
  • Si el semáforo está a 0, el cliente pasa y escribe el comando. Después llama a sem_post() en ready.

  • Si el semáforo está a 1, el cliente queda bloqueado y tiene que esperar a que el servidor use sem_post() sobre el mismo semáforo. El servidor lo hace después de leer el comando para interpretarlo.

2 Indica cuándo command_buffer tiene un comando, así que se inicializa a 0. El servidor usa sem_wait() en este semaforo antes de leer el comando en command_buffer para interpretarlo:
  • Si el semáforo está a 0, el cliente pasa y lee el comando. Después llama a sem_post() en empty.

  • Si el semáforo está a 1, el servidor queda bloqueado y tiene que esperar a que el cliente use sem_post() sobre el mismo semáforo. El cliente lo hace después de escribir un nuevo comando en command_buffer.

El detalle de cómo cliente y servidor usan ambos semáforos, se puede ver en el código de shared-memory-client.cpp y shared-memory-server.c, respectivamente.

Finalmente, para resolver el problema del productor-consumidor tenemos que considerar que:

  • Necesitamos un semáforo para que haya exclusión mutua entre ambos al insertar y extraer elementos del vector.

  • 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, uno para que cuente el número de elementos en el vector y otro para contar el número de huecos libres:

sem_t mutex;       (1)
sem_t fill_count;  (2)
sem_t empty_count; (3)

std::vector<item_t> buffer( VECTOR_SIZE );

void initialize()
{
    sem_init( &mutex, 0, 1 );  (1) (4)
    sem_init( &mutex, 0, 0 );  (2) (4)
    sem_init( &mutex, 0, VECTOR_SIZE );  (3) (4)
}

void productor()
{
    // ...

    while(/* ... */)
    {
        item_t item = produce_item();

        sem_wait( &empty_count ); (5)
        sem_wait( &mutex );       (7)

        vector.push_back(item);

        sem_post( &mutex );       (7)
        sem_post( &fill_count );  (6)
    }

    // ...
}

void consumer()
{
    // ...

    while(/* ... */)
    {
        sem_wait( &fill_count );  (6)
        sem_wait( &mutex );       (7)

        item_t item = vector.back();
        vector.pop_back();

        sem_post( &mutex );       (7)
        sem_post( &empty_count ); (5)

        consume_item(item);
    }

    // ...
}
1 Semáforo que se encarga de la exclusión mutua. Se inicializa a 1, para que el primer hilo que use sem_wait() pueda entrar.
2 Semáforo que se encarga de contar huecos ocupados en el vector. Se inicializa a 0, porque al principio no hay ningún elemento.
3 Semáforo que se encarga de contar los huecos libres en el vector. Se inicializa a VECTOR_SIZE, porque están todos vacíos.
4 El segundo argumento de sem_init() es pshared. Se pone a 0 para indicar que este semaforo no se va a compartir entre procesos diferentes.
5 Antes de insertar un elemento se decrementa empty_count. Así, si vale 0, es que el vector está lleno y el productor se bloquea. El consumidor incrementa empty_count tras extraer un elemento y dejar hueco, despertando al productor.
6 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. El productor incrementa fill_count tras insertar un elemento nuevo, despertando al consumidor.
7 El acceso al vector con los elementos es en exclusión mutua, así que tanto productor como consumidor deben usar sem_wait() sobre mutex antes de acceder a él. Esto decrementa el semáforo, así que solo uno de los dos pasa y ejecuta las líneas siguientes, mientras el otro queda bloqueado. Cuando el que haya pasado termine, debe usar sem_post() sobre mutex para incrementar el semáforo y permitir que el otro hilo entre en su sección crítica.

13.4. Mutex

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.

El estándar POSIX soporta mutex a través de POSIX Threads. Por defecto solo se pueden utilizar para sincronizar hilos del mismo proceso; pero tienen un atributo para permitir la sincronización entre procesos diferentes, aunque para eso deben ser creados en una región de memoria compartida por dichos procesos.

La API de POSIX Threads no soporta semáforos porque, como vimos antes, ya eran parte del estándar POSIX.

Tabla 13.2. Funciones de la API para manipular mutex.
C++ POSIX API Windows API

Crear

std::mutex

pthread_mutex_init()

InitializeCriticalSection()

CreateMutex()

Abrir

OpenMutex()

Operación acquire

std::mutex:::lock()

pthread_mutex_lock()

EnterCriticalSection()

WaitForSingleObject()

Operación release

std::mutex::unlock()

pthread_mutex_unlock()

LeaveCriticalSection()

ReleaseMutex()

Destruir

[Destructor]

pthread_mutex_destroy()

DeleteCriticalSection()

CloseHandle()

En Windows API hay dos tipo de objetos equiparables a los mutex: 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, como ocurre con los semáforos.

13.4.1. Ejemplos del uso de mutex

En pthreads-sync-factorial.cpp se puede estudiar el código completo de un ejemplo similar a pthreads.cpp, donde se calculaba el factorial de un número, repartiendo la tarea entre dos hilos, usando la API de POSIX Threads. La diferencia es que ahora los hilos no retornan el resultado, sino que cada uno lo mete en un vector compartido. Al terminar, el hilo principal recorre el vector multiplicando los resultados parciales.

Como ahora ambos hilos acceden a una estructura de datos compartida, esta debe ir protegida por un mutex:

pthread_mutex_t mutex;
std::vector<BigInt> partials;

Antes de meter un nuevo valor, cada hilo debe adquirir el mutex:

// Bloquear el mutex y guardar el resultado
pthread_mutex_lock( mutex );   (1)
partials.push_back( result );
pthread_mutex_unlock( mutex ); (2)
1 Adquirir mutex antes de entrar en la sección crítica.
2 Liberar mutex para salir de la sección crítica. Es importante no olvidarnos de liberar el mutex al terminar o de lo contrario uno de los hilos quedará dormido indefinidamente, al no poder entrar en la sección crítica.

En pthreads-sync-counter.cpp se puede observar un ejemplo más simple donde dos hilos intentan incrementar un contador. El resultado es correcto siempre que cada hilo adquiera el mismo mutex antes del incremento:

// Bloquear el mutex antes de incrementar el contador.
pthread_mutex_lock( &args->mutex );
args->counter++;
// Desbloquear el mutex tras incrementar el contador.
pthread_mutex_unlock( &args->mutex );

En threads-sync-counter.cpp y threads-sync-factorial.cpp se puede ver ejemplos equivalentes pero usando std::thread y std::mutex, de la librería estándar de C++.

13.5. Variables de condición

En la solución que dimos al problema del productor-consumidor usando semáforos (véase el Apartado 13.3.2) empleamos semáforos para implementar las esperas del productor y el consumidor cuando el vector está lleno o vacío, respectivamente. Lamentablemente, los mutex no se pueden usar de la misma manera para señalar eventos. En su lugar necesitamos otro tipo de objeto llamado 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 Threads, soportan variables de condición.

Tabla 13.3. Funciones de la API para manipular variables de condición.
C++ POSIX API Windows API

Crear

std::condition_variable

pthread_cond_init()

InitializeConditionVariable()

Operación wait

std::condition_variable::wait()

pthread_cond_wait()

SleepConditionVariableCS()

Operación notify

std::condition_variable::notify_one()

pthread_cond_signal()

WakeConditionVariable()

Operación notifyAll

std::condition_variable::notify_all()

pthread_cond_broadcast()

WakeAllConditionVariable()

Destruir

[Destructor]

pthread_cond_destroy()

Por defecto, las variables de condición de POSIX Threads 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.

Las variables de condición de Windows API 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[21].

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 de Windows API, no exigen un mutex para liberar al invocar su operación wait, ni admiten la operación notifyAll.

Tabla 13.4. Funciones de la API para manipular eventos de Windows API.
Windows API

Crear

CreateEvent()

Abrir

OpenEvent()

Operación wait

WaitForSingleObject()

Operación notify

SetEvent()

Resetear evento

ResetEvent()

Destruir

CloseHandle()

13.5.1. Ejemplos del uso de variables de condición

Vamos a intentar resolver el problema del productor-consumidor sin usar semáforos. Para lo que, nuevamente, tenemos que considerar que:

  • Necesitamos exclusión mutua entre ambos hilos al insertar y extraer elementos del vector 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:

std::mutext mutex;                (1)
std::condition_variable no_full;  (2)
std::condition_variable no_empty; (3)

std::vector<item_t> buffer( VECTOR_SIZE );

void productor()
{
    // ...

    while(/* ... */)
    {
        item_t item = produce_item();

        std::unique_lock lock{ mutex };        (4) (5)

        while( buffer.size() == VECTOR_SIZE )  (6) (10)
        {
            no_full.wait( mutex );            (11)
        }

        vector.push_back( item );             (11)

        no_empty.notify_one();                (7)
    }                                         (6)

    // ...
}

void consumer()
{
    // ...

    while(/* ... */)
    {
        std::unique_lock lock{ mutex };  (4) (5)

        while( buffer.size() == 0 )      (8) (10)
        {
            no_empty.wait( mutex );     (11)
        }

        item_t item = vector.back();    (11)
        vector.pop_back();

        no_full.notify_one();           (9)

        consume_item(item);
    }                                   (6)

    // ...
}
1 Mutex que se encarga de la exclusión mutua.
2 Variable de condición que se encarga de indicar cuando el vector no está lleno.
3 Variable de condición que se encarga de indicar cuando el vector no está vacío.
4 Antes de acceder al vector es necesario bloquear mutex. Ni siquiera es seguro preguntar por el número de elementos guardados en vector sin antes adquirir el mutex, puesto que el otro hilo puede estar modificando vector al mismo tiempo.
5 Los mutex se pueden adquirir y liberar con std::mutex:::lock() y cpp_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. Estos objetos bloquean el mutex al crearse y lo desbloquean automáticamente al destruirse. Así es complicado que nos olvidemos de desbloquearlo al salir de la función.
6 Antes de insertar un elemento se comprueba si hay algún hueco disponible. Si no lo hay, se pone el hilo a la espera en la variable de condición no_full.
7 El consumidor despierta al productor de esa espera tras extraer un elemento, porque es seguro que al hacerlo habrá dejado un hueco.
8 Antes de extraer un elemento se comprueba si hay alguno en el vector. Si no lo hay, se pone el hilo a la espera en la variable de condición no_empty.
9 El productor despierta al consumidor de esta espera tras insertar un nuevo elemento.
10 El estándar de C++ indica que las esperas en las variables de condición son susceptibles de despertar de forma espuria. Es decir, que el hilo puede salir de std::condition_variable::wait() sin que haya habido notificación. Por eso hay que volver a comprobar la condición antes de continuar ejecutando sentencias en la sección crítica.
11 Cuando los hilos se bloquean en std::condition_variable::wait(), mutex es liberado para que el otro hilo pueda entrar y extraer o insertar un elemento. De lo contrario, no podría hacerlo y ambos se quedarían bloqueados indefinidamente —en una situación que se denomina interbloqueo o deadlock—. Pero antes de salir de std::condition_variable::wait(), el hilo adquiere de nuevo el mutex. Así que el código que inserta y extrae elementos se ejecuta en exclusión mutua, tal y como nos interesa.

13.6. Implementación de semáforos

Como hemos visto, tanto los mutex como las variables de condición son casos particulares de los semáforos. De igual forma, si un sistema o lenguaje soporta mutex y variables de condición, es muy sencillo implementar semáforos, así como otras primitivas de sincronización.

Un semáforo se implementa de forma muy parecida a la solución anterior al problema del productor-consumidor (véase el Apartado 13.5.1), solo que eliminando lo relacionado con el vector de elementos.

Ejemplo 13.2. Implementación de un semáforo en C++.

El código fuente completo de la clase semaphore está disponible en semaphore.hpp.

class semaphore {
public:

    semaphore(int count = 0) : count_(count) {}

    void release() {
        std::unique_lock<std::mutex> lock(mutex_); (1)
        count_++;
        cv_.notify_one();                          (3)
    }

    void acquire() {
        std::unique_lock<std::mutex> lock(mutex_); (1)
        while(count_ == 0) {
            cv_.wait(lock);                        (2)
        }
        count_--;
    }

private:
    std::mutex mutex_;
    std::condition_variable cv_;
    int count_;
};
1 El acceso al contador count_ esta protegido por el mutex mutex_.
2 En semaphore::acquire(), si el contador está a 0 no pueden entrar más hilos, por lo que el hilo debe esperar en la variable de condición cv_.
3 Cada vez que un hilo llama a semaphore::release() se incrementa el contador y se despierta uno de los hilos a la espera en cv_.

En threads-sync-semaphore.cpp se puede ver un ejemplo de cómo utilizarla para limitar el número de hilos que pueden ejecutarse en una sección crítica al mismo tiempo.

void thread_function(semaphore& sem, int thread_id)
{
    sem.acquire(); (3)
    fmt::print( "Hilo {} creado\n", thread_id );

    // Dormir el hilo para simular trabajo
    // ...

    fmt::print( "Hilo {} terminado\n", thread_id );
    sem.release(); (4)
}

int main()
{
    const int num_threads = 10;
    semaphore sem(3); (1)

    std::vector<std::thread> threads;
    for(int i = 0; i < num_threads; i++) (2)
    {
        threads.push_back( std::thread( thread_function, std::ref(sem), i ));
    }

    for(auto& t : threads)
    {
        t.join();
    }

    return EXIT_SUCCESS;
}
1 Solo se van a permitir 3 hilos simultáneos.
2 Se crean 10 hilos y se guardan en un vector los objetos std::thread para luego poder esperar por ellos usando std::thread::join(), antes de terminar el programa.
3 Se llama a semaphore::acquire() antes de entrar en la sección crítica. Si hay más de 3 hilos en ella, el hilo es suspendido en la variable de condición cv_ de semaphore.
4 Se llama a semaphore::release() al salir de la sección critica. Esto hace que la variable de condición cv_ de semaphore despierte a uno de los hilos que está esperando en semaphore::acquire() para que entre en la sección crítica.

13.7. Esperas

Muchos de los objetos de sincronización que hemos visto necesitan algún mecanismo para poner en espera a los hilos que los usan. Existen dos alternativas desde el punto de vista de cómo implementar esta espera:

  • El sistema operativo puede cambiar el estado del hilo o proceso a esperado y moverlo a una cola de espera asociada al objeto de sincronización, tal y como hemos comentado en varias ocasiones. Entonces el planificador de la CPU escogerá a otro proceso para ser ejecutado.

  • El hilo puede iterar comprobando constantemente la condición hasta que se cumple. A esa técnica se la denomina espera ocupada.

Este tipo de espera ocupada desperdicia tiempo de CPU que otro hilo podría utilizar de forma más productiva, por lo que solo se utiliza en el caso de esperas previsiblemente cortas. Para evitar que las esperas ocupadas sean demasiado largas, los sistemas operativos nunca expulsan de la CPU a hilos que se estén ejecutando dentro de secciones críticas controladas por objetos de sincronización con este tipo de espera, con la idea de que salgan de la sección crítica lo antes posible.

En Windows API, por ejemplo, se puede utilizar InitializeCriticalSectionAndSpinCount() para inicializar un objeto de sección crítica donde el hilo que la intenta adquirir itera el número especificado de veces en una espera ocupada, comprobando si la sección es liberada, antes de bloquearse en el estado esperando si eso no ocurre.

La espera ocupada de estos objetos sección crítica de Windows API solo ocurre en sistemas multiprocesador, donde el hilo que tiene adquirida la sección puede estar ejecutándose en otro hilo y terminar rápidamente. En sistemas monoprocesador nunca hay espera ocupada.

A los mutex con espera ocupada también se los denomina spinlock. Los spinlocks son utilizados frecuentemente para proteger las estructuras del núcleo en los sistemas multiprocesador, cuando la tarea a realizar dentro de la sección crítica en el núcleo requiere poco tiempo y los diseñadores calculan que se desperdicia más tiempo sacando de la CPU al hilo en espera para ejecutar otro en su lugar.

13.8. Funciones reentrantes y seguras 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 podemos utilizar. A la hora de decidir utilizar una librería en un programa multihilo es necesario que tengamos en cuenta los conceptos de reentrante y seguridad de hilos.

13.8.1. Funciones reentrantes

Una función es reentrante puede ser interrumpida en medio de su ejecución y, mientras espera, volver a ser llamada con total seguridad. Obviamente las funciones recursivas deben ser reentrantes para poder llamarse a sí mismas una y otra vez con seguridad.

En el contexto de la programación multihilo, ocurre una reentrada cuando durante la ejecución de una función por parte de un hilo, este es interrumpido por el sistema operativo para planificar posteriormente a otro del mismo proceso que invoca la misma función.

En general una función es reentrante, si:

  • No modifica variables estáticas o globales. Si lo hiciera solo puede hacerlo mediante operaciones leer-modificar-escribir que sean ininterrumpibles —es decir, atómicas—.

  • No modifica su propio código y no llama a otras funciones que no sean reentrantes.

Como hemos mencionado anteriormente, los manejadores de señal deben ser funciones reentrantes.

13.8.2. Seguridad en hilos

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 u otros recursos similares ofrecidos por el sistema operativo.

En ocasiones, ambos conceptos se confunden porque es bastante común que el código reentrante también sea seguro en hilos. Sin embargo es posible crear código reentrante que no sea seguro en hilos y viceversa.

A la hora de usar una función o librería que va a ser llamada desde múltiples hilos, primero 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.

Seguridad en hilos en C++

La norma general es que las clases de la librería estándar de C++ son seguras frente a múltiples accesos de lectura desde diferentes hilos. Pero si un hilo modifica un objeto, todas las lecturas y escrituras en el mismo objeto por ese y otros hilos deben estar protegidas.

Obviamente, las clases de mecanismos de sincronización y gestión de hilos generalmente ofrecen mayores garantías, para lo que hay que consultar la documentación.

Seguridad en hilos en C

El estándar de C no menciona nada sobre seguridad en hilos, por lo que se debe suponer que las funciones de la librería estándar no lo son o consultar la documentación ofrecida por el proveedor de la librería.

Por ejemplo, todas las versiones de la librería estándar de C en Windows actualmente son seguras en hilos[16]. Pero hasta hace unos años Microsoft ofrecía varias versiones de la librería, algunas seguras en hilos, para usar en aplicaciones multihilo, y otras no seguras para usar en aplicaciones monohilo.

Seguridad en hilos en POSIX

La API POSIX 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 Thread-Safety» 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.