Atlasingeniería

Arquitectura y sistemas operativosSistemas operativosTema 3

Concurrencia, semáforos y monitores

Dos hilos incrementando el mismo contador pueden perder incrementos, porque sumar uno no es una operación indivisible. De ahí salen la exclusión mutua y todas sus herramientas.

Para este tema conviene tener claro:Procesos e hilos

Dos hilos ejecutan contador++ mil veces cada uno. El resultado debería ser dos mil y muchas veces es menos. No es un bug del lenguaje: es que esa línea son tres operaciones —leer, sumar, escribir— y entre ellas puede pasar cualquier cosa.

La condición de carrera y la sección crítica

Una condición de carrera ocurre cuando el resultado depende del orden en que se intercalan las operaciones de varios hilos. El fragmento donde se toca el recurso compartido es la sección crítica, y el objetivo es que sólo un hilo esté adentro a la vez: exclusión mutua.

Una solución correcta necesita tres propiedades: exclusión mutua, progreso —si nadie está adentro, alguien puede entrar— y espera acotada —nadie espera para siempre—.

Los dos hilos ejecutan contador++, que en realidad son tres pasos: leer, sumar, escribir. El contador arranca en 0.

1 / 6
Contador más más son tres operaciones, no una, y entre ellas puede pasar cualquier cosa. Dos incrementos, resultado 1: el segundo hilo escribió encima de un valor que ya estaba viejo cuando lo leyó.

Antes de seguir, predecí

Un contador incrementado por dos hilos un millón de veces cada uno, sin sincronizar. ¿Qué queda?

Por qué no alcanza con leer y escribir

Resolverlo sólo con lecturas y escrituras es posible —el algoritmo de Peterson lo hace— pero no alcanza en hardware moderno, porque el procesador y el compilador reordenan operaciones.

Por eso hacen falta instrucciones atómicas del procesador: comparar e intercambiar, o intercambio incondicional. Son indivisibles por construcción y sobre ellas se construye todo lo demás. Además imponen barreras de memoria, que impiden el reordenamiento: la parte que se olvida y produce bugs que aparecen sólo en producción.

El candado, la herramienta básica

El candado —mutex— es la herramienta básica: se toma antes de la sección crítica y se libera después. Si está ocupado, el hilo se bloquea y el planificador le da la CPU a otro.

Hay una variante que espera girando en un ciclo en vez de bloquearse. Conviene sólo cuando la espera es de pocos nanosegundos y hay varios núcleos: en un solo núcleo, girar esperando a alguien que no puede correr es garantía de desperdicio. Y en los dos casos, la regla de oro: liberar siempre, incluso si la sección crítica lanza una excepción.

El semáforo, que además cuenta

Un semáforo es un contador con dos operaciones atómicas: bajar —que espera si el valor es cero— y subir. Con valor inicial uno funciona como candado; con valor nn limita cuántos hilos acceden a la vez, que es como se implementa un pool de conexiones.

Su otro uso es la señalización entre hilos: el productor sube, el consumidor baja, y así se coordinan sin espera activa. Son flexibles y de bajo nivel, y por eso fáciles de usar mal: un bajar sin su subir cuelga el sistema y no hay nada en el código que lo relacione con su pareja.

El monitor y las variables de condición

El monitor es la respuesta de más alto nivel: un objeto donde los métodos se ejecutan en exclusión mutua por construcción, más variables de condición para esperar a que algo se cumpla.

Las variables de condición tienen una regla que no es negociable: esperar siempre dentro de un ciclo que revisa la condición, nunca con un if. Puede haber despertares espurios, y entre el aviso y la ejecución otro hilo pudo cambiar el estado. Es el error más común al usarlas, y produce fallas intermitentes imposibles de reproducir.

Evitar el estado compartido en vez de protegerlo

La tendencia actual es evitar el estado compartido en vez de protegerlo mejor. Pasar mensajes entre tareas por canales, usar estructuras inmutables, confinar cada dato a un solo hilo.

Y cuando hay que compartir, conviene lo más alto nivel disponible: colas concurrentes, contadores atómicos, colecciones seguras ya implementadas. Escribir sincronización a mano es de las cosas más fáciles de hacer mal, porque los errores no fallan al probarlos: fallan un martes a la noche bajo carga.

Las primitivas, y cuál usar

PrimitivaPara quéTrampa
Exclusión mutuaproteger una sección críticaolvidarse de liberar ante una excepción
Semáforo contadolimitar cuántos entran a la vezno es reentrante
Variable de condiciónesperar a que algo cambielos despertares espurios: siempre en un while
Operación atómicaun contador, una banderano alcanza para invariantes de varias variables
Canal o colacomunicar en vez de compartirninguna, y por eso conviene
La última fila es la recomendación práctica: pasar mensajes en vez de compartir estado elimina la mayoría de las carreras por construcción, y es lo que hacen los modelos de concurrencia modernos.

Cierre

La sección crítica necesita exclusión mutua, progreso y espera acotada, y se construye sobre instrucciones atómicas con barreras de memoria. Candados para excluir, semáforos para contar y señalizar, monitores con condición siempre dentro de un ciclo. Mejor todavía: no compartir estado.

Autoevaluación

¿Lo entendiste?

¿Por qué contador++ desde dos hilos puede perder incrementos?
¿Qué tres propiedades necesita una solución correcta de exclusión mutua?
¿Por qué no alcanza con lecturas y escrituras, como en el algoritmo de Peterson?
¿Qué agrega un monitor sobre un semáforo?