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.
Antes de seguir, predecí
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 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
| Primitiva | Para qué | Trampa |
|---|---|---|
| Exclusión mutua | proteger una sección crítica | olvidarse de liberar ante una excepción |
| Semáforo contado | limitar cuántos entran a la vez | no es reentrante |
| Variable de condición | esperar a que algo cambie | los despertares espurios: siempre en un while |
| Operación atómica | un contador, una bandera | no alcanza para invariantes de varias variables |
| Canal o cola | comunicar en vez de compartir | ninguna, y por eso conviene |
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?
Práctica