Monitores: exclusión mutua encapsulada, variables de condición y las semánticas de Hoare y Mesa
Monitores: exclusión mutua encapsulada, variables de condición y las semánticas de Hoare y Mesa
En el capítulo 8 construimos con semáforos todo lo que hacía falta: exclusión mutua, señalización entre hilos, buffers acotados, lectores y escritores. Y funcionó. Pero también quedó una incomodidad difícil de ignorar: la corrección del programa dependía de que cada hilo, en cada camino de ejecución, recordara hacer las operaciones correctas en el orden correcto. Un wait de más y el sistema se cuelga. Un signal olvidado en la rama de error y el sistema se cuelga. Dos wait en orden invertido en un solo procedimiento de los treinta que tiene el módulo y aparece un interbloqueo que se manifiesta una vez cada diez mil ejecuciones.
El problema de fondo no es el semáforo: es que el semáforo es una variable global sin dueño. Nada en el lenguaje ni en el compilador vincula el semáforo mutex_cuenta con la estructura cuenta que protege. Esa asociación vive únicamente en la cabeza del programador y en los comentarios del código, que es exactamente el peor lugar donde puede vivir una regla de corrección.
El monitor es la respuesta a esa incomodidad. La idea, formulada por Per Brinch Hansen y C. A. R. Hoare a comienzos de los años setenta, es simple de enunciar: si los datos compartidos y las operaciones que los tocan son lo mismo, que el lenguaje los encierre en una sola construcción y garantice por sí mismo que solo un hilo esté dentro a la vez. El programador deja de escribir wait y signal para entrar y salir; el compilador los pone. Lo que el programador sí sigue escribiendo, porque nadie puede escribirlo por él, es la lógica de cuándo conviene esperar. Y ahí entran las variables de condición, que son el verdadero contenido técnico de este capítulo.
Del semáforo al monitor: qué problema quedó abierto
Recordemos cómo se veía una cuenta bancaria protegida con semáforos. El patrón se repite en cada operación:
#include <semaphore.h>
static long saldo = 0;
static sem_t mutex; /* inicializado en 1 */
void depositar(long monto) {
sem_wait(&mutex);
saldo += monto;
sem_post(&mutex);
}
int retirar(long monto) {
sem_wait(&mutex);
if (monto > saldo) {
return -1; /* BUG: sale sin liberar el semáforo */
}
saldo -= monto;
sem_post(&mutex);
return 0;
}
El error está señalado, pero conviene nombrar su naturaleza exacta: no es un error de concurrencia, es un error de disciplina. El return temprano es una construcción perfectamente normal en programación secuencial; solo se vuelve catastrófico porque existe una obligación invisible de ejecutar sem_post antes de abandonar la función. Nada en el código expresa esa obligación, así que nada la verifica.
Enumeremos las debilidades estructurales que el monitor viene a corregir:
| Debilidad del semáforo | Consecuencia típica | Qué hace el monitor |
|---|---|---|
| El dato protegido y el semáforo son entidades separadas | Alguien accede al dato sin tomar el semáforo | Los datos son privados del monitor; solo se llegan a través de sus procedimientos |
| La entrada y la salida son operaciones explícitas | Salida olvidada en una rama de error, return o excepción | La entrada y la salida las genera el compilador o el runtime |
El mismo wait sirve para excluir y para esperar una condición | Se confunden dos usos distintos y se ordenan mal | La exclusión es implícita; la espera por condición usa una primitiva aparte |
| El semáforo recuerda las señales pasadas | Una señal emitida “por si acaso” desbalancea el contador para siempre | Las variables de condición no tienen memoria: señalar sin nadie esperando no deja rastro |
| La corrección es global: depende de todo el programa | Un módulo ajeno rompe la invariante | La corrección es local: se revisa leyendo un solo archivo |
Esa última fila es la más importante y la que suele pasar desapercibida. Con semáforos, para convencerte de que un programa concurrente es correcto tienes que revisar todo el programa, porque cualquier función en cualquier archivo puede tocar la variable compartida o el semáforo. Con monitores, si el lenguaje realmente impide el acceso directo a los datos internos, basta con revisar el cuerpo del monitor. Se pasa de una demostración global a una demostración local, y esa es la diferencia entre un sistema verificable y uno que solo se prueba a punta de ejecuciones.
Qué es exactamente un monitor
Un monitor es un módulo que reúne tres cosas: unos datos que solo él puede tocar, un conjunto de procedimientos públicos que son la única manera de tocarlos, y la garantía de que a lo sumo un hilo está ejecutando alguno de esos procedimientos en cualquier instante. Esa última garantía se llama exclusión mutua implícita: no la programa quien usa el monitor, la impone la construcción.
Conviene insistir en que el monitor es un tipo de dato, no un objeto único del sistema. Puedes tener cien instancias de un monitor BufferAcotado y cada una excluye por separado; un hilo dentro de la instancia A no impide que otro esté dentro de la instancia B.
Las cuatro partes de un monitor
flowchart TB
subgraph MON["Monitor BufferAcotado"]
direction TB
DATOS["Datos privados<br/>arreglo, cabeza, cola, cuenta<br/>+ INVARIANTE"]
PROCS["Procedimientos publicos<br/>Poner / Sacar / Ocupados"]
CV1["Variable de condicion<br/>no_lleno<br/>cola de espera"]
CV2["Variable de condicion<br/>no_vacio<br/>cola de espera"]
PROCS --- DATOS
PROCS -.- CV1
PROCS -.- CV2
end
ENTRADA["Cola de entrada<br/>hilos que quieren entrar<br/>y aun no tienen el cerrojo"]
H1["Hilo productor"]
H2["Hilo consumidor"]
H3["Hilo consumidor"]
H1 --> ENTRADA
H2 --> ENTRADA
H3 --> ENTRADA
ENTRADA -->|"solo uno a la vez"| PROCS
PROCS -->|"sale y libera"| FUERA["Fuera del monitor"]
style DATOS fill:#1e3a5f,color:#fff
style ENTRADA fill:#5f3a1e,color:#fff
style CV1 fill:#1e5f3a,color:#fff
style CV2 fill:#1e5f3a,color:#fff
Las cuatro partes, una por una:
- Datos privados. El estado compartido. Nadie fuera del monitor puede leerlos ni escribirlos. Si el lenguaje permite exponer una referencia interna hacia afuera, esa referencia es un agujero por el que se escapa toda la garantía.
- Procedimientos públicos. La interfaz. Cada llamada es atómica respecto de las demás llamadas al mismo monitor.
- Cola de entrada. Donde esperan los hilos que llamaron a un procedimiento mientras otro hilo está dentro. Es la cola del cerrojo, no una cola de condición.
- Variables de condición con sus colas. Donde esperan los hilos que ya entraron, descubrieron que no pueden avanzar, y se suspendieron voluntariamente cediendo el monitor. Aquí es donde se juega todo lo interesante.
El estado de un hilo respecto de un monitor
stateDiagram-v2
[*] --> Fuera
Fuera --> ColaEntrada: llama a un procedimiento
ColaEntrada --> Dentro: obtiene el cerrojo
Dentro --> ColaCondicion: ejecuta wait(c)<br/>libera el cerrojo
ColaCondicion --> ColaReingreso: alguien ejecuta signal(c)<br/>o broadcast(c)
ColaReingreso --> Dentro: recupera el cerrojo
Dentro --> Fuera: retorna del procedimiento<br/>libera el cerrojo
Fuera --> [*]
note right of ColaCondicion
Aqui el hilo NO tiene el cerrojo.
Otros hilos pueden entrar y
cambiar el estado del monitor.
end note
note right of ColaReingreso
Esta cola solo existe de verdad
en la semantica Mesa. En Hoare el
paso de senalizador a senalizado
es inmediato y sin intermediarios.
end note
Este diagrama de estados es el mapa que hay que tener en la cabeza durante el resto del capítulo. Fíjate en el detalle decisivo: entre ColaCondicion y Dentro hay un paso intermedio en el que el hilo despertado ya no está dormido pero todavía no tiene el cerrojo. Qué ocurre exactamente en ese intervalo es lo que distingue a la semántica de Hoare de la de Mesa.
El invariante del monitor
Antes de tocar las variables de condición hay que fijar un concepto que las hace comprensibles: el invariante del monitor. Es una afirmación sobre los datos privados que debe ser verdadera en todo momento en que ningún hilo esté ejecutando dentro del monitor.
Para un buffer acotado de capacidad N con contador cuenta e índices cabeza y cola, el invariante es: 0 <= cuenta <= N; cabeza y cola están dentro del rango del arreglo; y las cuenta posiciones que empiezan en cabeza contienen los elementos válidos, en orden de inserción.
El invariante puede romperse temporalmente mientras un hilo trabaja dentro del monitor: si estás insertando un elemento, hay un instante en que ya escribiste el dato pero todavía no incrementaste cuenta, y en ese instante el invariante es falso. No importa, porque nadie más puede observarlo: la exclusión mutua garantiza que ese estado inconsistente es privado.
De aquí sale la regla que gobierna las variables de condición y que muchos programadores descubren por las malas:
El invariante debe ser verdadero antes de esperar en una variable de condición, no solo al retornar del procedimiento.
La razón es directa: wait libera el cerrojo. En el momento en que un hilo ejecuta wait, otros hilos pueden entrar y van a observar los datos. Si dejaste el monitor a medio actualizar y te dormiste, acabas de exponer un estado inconsistente. En términos prácticos: nunca ejecutes wait en medio de una actualización de varios campos que deben moverse juntos.
Variables de condición
Una variable de condición es una cola de hilos dormidos asociada a un monitor. No guarda ningún valor. No es un booleano. No recuerda nada. Su único contenido es la lista de quiénes están esperando.
Tiene tres operaciones, con nombres que varían según el lenguaje pero con semántica constante:
| Operación | Nombres habituales | Qué hace |
|---|---|---|
| Esperar | wait, cwait, await | Encola al hilo actual, libera el cerrojo del monitor y lo duerme. Al despertar, vuelve a tomar el cerrojo antes de continuar. |
| Señalar a uno | signal, csignal, notify | Si hay al menos un hilo en la cola, despierta a uno. Si la cola está vacía, no hace absolutamente nada. |
| Señalar a todos | broadcast, notifyAll, signalAll | Despierta a todos los hilos de la cola. Todos deberán reintentar tomar el cerrojo, uno por uno. |
Las tres operaciones solo pueden ejecutarse teniendo el monitor. Llamar a wait sin tener el cerrojo es un error de programa; en POSIX es comportamiento indefinido, en Java lanza IllegalMonitorStateException, en Python lanza RuntimeError.
Por qué una variable de condición no es un semáforo
Esta es la confusión más cara del capítulo, así que vale la pena aislarla:
| Aspecto | Semáforo | Variable de condición |
|---|---|---|
| Estado interno | Un contador entero | Ninguno: solo la cola de espera |
| Señal sin nadie esperando | Se recuerda: incrementa el contador y el siguiente wait pasa de largo | Se pierde por completo, sin efecto observable |
| ¿Requiere un cerrojo asociado? | No, es autónomo | Sí, siempre va acompañado de un cerrojo o monitor |
| ¿Puede usarse para exclusión mutua? | Sí, inicializándolo en 1 | No, no sirve para eso |
Efecto de wait sobre el cerrojo | Ninguno, no hay cerrojo | Lo libera al dormir y lo recupera al despertar |
| Predicado asociado | Implícito en el valor del contador | Explícito: lo escribe el programador y hay que reevaluarlo |
La fila que hay que memorizar es la segunda. Un sem_post sobre un semáforo vacío deja crédito para el futuro; un signal sobre una condición sin esperadores es una operación nula. Esto no es un defecto: es precisamente lo que hace que las variables de condición se compongan bien con predicados sobre el estado. La señal no lleva información; la información está en los datos del monitor, y por eso el hilo que despierta tiene que ir a mirarlos.
El patrón canónico de espera
De todo lo anterior sale una plantilla que debe escribirse de memoria y sin variantes:
/* Dentro de un procedimiento del monitor */
while (!predicado_que_necesito) {
cond_wait(&condicion, &cerrojo);
}
/* Aquí, y solo aquí, el predicado es verdadero
y tengo el cerrojo del monitor */
hacer_el_trabajo();
Tres reglas incrustadas en esas cinco líneas:
while, noif. Al despertar hay que volver a comprobar el predicado. Las razones se detallan en la sección de semánticas, pero el resumen es: entre que te señalan y que retomas el cerrojo, el mundo pudo cambiar.- El predicado se escribe explícitamente. La variable de condición no lo conoce; es una cola, no un guardián.
- La comprobación ocurre con el cerrojo tomado. Si evalúas el predicado fuera del monitor, lo que leas ya puede ser falso cuando entres.
Las tres semánticas de señalización
Cuando un hilo A ejecuta signal(c) y hay un hilo B esperando en c, aparece un problema que no tiene solución obvia: ambos quieren estar dentro del monitor, y solo puede haber uno. ¿Quién sigue ejecutando, A o B?
Las tres respuestas históricas a esa pregunta son las tres semánticas: Hoare, Mesa y señalar y salir (Brinch Hansen). No son variantes cosméticas: cambian lo que puedes suponer al despertar y, por lo tanto, cambian el código que hay que escribir.
Semántica de Hoare: señalar y esperar
En la propuesta original de Hoare (1974), signal transfiere el monitor inmediatamente al hilo señalado. El señalizador queda suspendido en una cola especial —la cola urgente— y recuperará el monitor cuando el señalado salga o vuelva a esperar. La cola urgente tiene prioridad sobre la cola de entrada.
sequenceDiagram
participant B as Hilo B (consumidor)
participant M as Monitor
participant A as Hilo A (productor)
B->>M: entra en Sacar
Note over B,M: cuenta = 0, no puede sacar
B->>M: wait(no_vacio)
Note over B: duerme, libera el monitor
A->>M: entra en Poner
A->>M: inserta el dato, cuenta = 1
A->>M: signal(no_vacio)
Note over A: A se suspende en la COLA URGENTE
Note over M: el monitor pasa directo a B
B->>M: despierta DENTRO del monitor
Note over B: cuenta = 1 garantizado:<br/>nadie pudo tocarlo entremedio
B->>M: extrae el dato, cuenta = 0
B-->>M: sale del monitor
Note over M: se atiende primero la cola urgente
M-->>A: A recupera el monitor
A-->>M: termina Poner y sale
La propiedad que da Hoare es fuerte y se puede enunciar en una línea: la condición que motivó la señal sigue siendo verdadera cuando el hilo señalado despierta, porque entre la señal y el despertar no se ejecutó ninguna instrucción de ningún otro hilo. Bajo Hoare, escribir if en lugar de while es formalmente correcto.
El precio es alto:
- Se necesita una cola urgente adicional y contabilidad para gestionarla.
- Cada
signalimplica al menos dos cambios de contexto: el señalizador se va, el señalado entra; y luego a la inversa. - El señalizador queda a merced del señalado. Si el hilo señalado es lento, el que señaló espera.
- Es difícil de implementar de forma eficiente sobre planificadores reales, que no garantizan transferencia inmediata de CPU.
Por eso casi ningún sistema real usa Hoare puro. Es el modelo con el que se razona y se demuestra correcto un algoritmo; no es el que corre en tu máquina.
Semántica de Mesa: señalar y continuar
Mesa fue un lenguaje desarrollado en Xerox PARC a finales de los setenta, y su decisión de diseño se impuso en todo lo que vino después: POSIX, Java, C#, Python, Go. La regla es la contraria a la de Hoare: signal no cede el monitor. El señalizador continúa ejecutando. El hilo señalado simplemente pasa de “dormido en la cola de condición” a “compitiendo por el cerrojo del monitor”, igual que cualquier recién llegado.
sequenceDiagram
participant B as Hilo B (consumidor)
participant M as Monitor
participant A as Hilo A (productor)
participant C as Hilo C (otro consumidor)
B->>M: entra en Sacar
Note over B,M: cuenta = 0
B->>M: wait(no_vacio)
Note over B: duerme, libera el monitor
A->>M: entra en Poner
A->>M: inserta el dato, cuenta = 1
A->>M: signal(no_vacio)
Note over B: B pasa a la cola del cerrojo,<br/>NO entra todavia
Note over A: A SIGUE ejecutando dentro
A-->>M: sale del monitor
C->>M: C llega justo ahora y gana el cerrojo
C->>M: saca el dato, cuenta = 0
C-->>M: sale
M-->>B: por fin B obtiene el cerrojo
Note over B: cuenta vale 0 otra vez.<br/>Si uso "if", leo basura.<br/>Con "while", vuelvo a dormir.
Ese es el motivo exacto de la regla del while. En Mesa, la señal no es una promesa de que la condición sea cierta: es solo una pista de que pudo volverse cierta en algún momento. El hilo que despierta está obligado a verificar.
El fenómeno del diagrama tiene nombre propio: robo de la condición (un tercero se lleva el recurso entre la señal y el despertar). A eso se suma otro caso que exige la misma defensa: los despertares espurios, en los que la implementación de hilos devuelve de pthread_cond_wait sin que nadie haya señalado. POSIX los permite explícitamente porque evitarlos encarece la implementación en algunos sistemas. Con el patrón while correcto, un despertar espurio es inofensivo: se reevalúa el predicado, sigue siendo falso, se vuelve a dormir.
Señalar y salir (Brinch Hansen)
La tercera variante es la más restrictiva: signal solo puede aparecer como última instrucción de un procedimiento del monitor, y ejecutarla equivale a salir. El monitor pasa al hilo señalado sin ambigüedad y sin cola urgente.
Es fácil de implementar y fácil de razonar, pero limita la expresividad: no puedes señalar a la mitad de una operación y seguir trabajando, ni señalar dos condiciones distintas en la misma llamada. Aparece en Concurrent Pascal, el lenguaje de Brinch Hansen, y sobrevive como restricción de estilo en algunos entornos.
Comparación de las tres semánticas
| Criterio | Hoare (señalar y esperar) | Mesa (señalar y continuar) | Brinch Hansen (señalar y salir) |
|---|---|---|---|
Quién sigue dentro tras signal | El hilo señalado | El señalizador | El hilo señalado |
¿Dónde puede aparecer signal? | En cualquier punto | En cualquier punto | Solo como última instrucción |
| Estado al despertar | La condición se garantiza cierta | No se garantiza nada | La condición se garantiza cierta |
| Espera correcta | if basta | Obliga a while | if basta |
| Cambios de contexto por señal | Dos o más | Cero forzados | Uno |
| Necesita cola urgente | Sí | No | No |
| Facilidad de demostración formal | Alta | Media | Alta |
| Uso en sistemas reales | Casi nulo | Dominante: POSIX, Java, Python, C#, Go | Histórico |
| Riesgo de inanición del señalado | Bajo | Existe si hay tráfico alto | Bajo |
La conclusión práctica es inequívoca: escribe siempre para Mesa. El patrón while es correcto bajo las tres semánticas; el patrón if solo lo es bajo dos, y ninguna de ellas es la que usa tu sistema operativo.
signal contra broadcast
Con semántica Mesa aparece una segunda decisión: ¿despertar a uno o a todos?
signal(uno) basta cuando todos los hilos que esperan en esa variable esperan exactamente lo mismo y despertar a cualquiera resuelve el problema: varios consumidores enno_vacio, donde da igual cuál se lleve el elemento.broadcast(todos) es obligatorio cuando los hilos que esperan en una misma variable de condición esperan cosas distintas, o cuando un solo evento habilita a varios a la vez. Ejemplo: un asignador de memoria donde unos esperan 100 bytes y otros 4000; liberar un bloque grande habilita a unos y no a otros, ysignalpuede despertar justo al que no puede avanzar mientras el que sí podía se queda dormido. Ese fallo se llama despertar equivocado y produce interbloqueos intermitentes.
Regla de bolsillo: si dudas, usa broadcast. Es correcto siempre y solo cuesta rendimiento (el llamado “rebaño atronador”, muchos hilos despertando para que solo uno avance). signal es una optimización que debe justificarse mostrando que todos los esperadores son intercambiables.
El buffer acotado: la implementación canónica
Vamos al problema de referencia. Un buffer circular de capacidad fija, productores que insertan y consumidores que extraen, con dos condiciones: no insertar si está lleno, no extraer si está vacío.
flowchart LR
P["Productor<br/>llama a Poner"] --> E1{"cuenta == N ?"}
E1 -->|"si, lleno"| W1["wait(no_lleno)<br/>libera el monitor"]
W1 --> E1
E1 -->|"no"| INS["escribe en cola<br/>cuenta++"]
INS --> S1["signal(no_vacio)"]
S1 --> SAL1["sale del monitor"]
C["Consumidor<br/>llama a Sacar"] --> E2{"cuenta == 0 ?"}
E2 -->|"si, vacio"| W2["wait(no_vacio)<br/>libera el monitor"]
W2 --> E2
E2 -->|"no"| EXT["lee de cabeza<br/>cuenta--"]
EXT --> S2["signal(no_lleno)"]
S2 --> SAL2["sale del monitor"]
style W1 fill:#5f1e1e,color:#fff
style W2 fill:#5f1e1e,color:#fff
style S1 fill:#1e5f3a,color:#fff
style S2 fill:#1e5f3a,color:#fff
Observa las flechas de retorno desde wait hacia el rombo de decisión: esas flechas son el while. Un diseño con if tendría la flecha yendo directo desde wait hacia la acción, y ese es exactamente el error.
C con pthreads
POSIX no tiene monitores como construcción del lenguaje: tiene los ingredientes (pthread_mutex_t y pthread_cond_t) y deja que el programador los ensamble. La disciplina es manual, pero el modelo mental es el del monitor.
/* buffer_monitor.c
Compilar: gcc -O2 -pthread -o buffer_monitor buffer_monitor.c */
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define CAPACIDAD 4
#define POR_PRODUCTOR 8
#define N_PRODUCTORES 2
#define N_CONSUMIDORES 3
typedef struct {
int datos[CAPACIDAD];
int cabeza;
int cola;
int cuenta;
bool cerrado;
pthread_mutex_t cerrojo;
pthread_cond_t no_lleno;
pthread_cond_t no_vacio;
} buffer_t;
static void buffer_init(buffer_t *b) {
b->cabeza = 0; b->cola = 0; b->cuenta = 0; b->cerrado = false;
pthread_mutex_init(&b->cerrojo, NULL);
pthread_cond_init(&b->no_lleno, NULL);
pthread_cond_init(&b->no_vacio, NULL);
}
static void buffer_destroy(buffer_t *b) {
pthread_mutex_destroy(&b->cerrojo);
pthread_cond_destroy(&b->no_lleno);
pthread_cond_destroy(&b->no_vacio);
}
/* Procedimiento del monitor: insertar */
static void buffer_poner(buffer_t *b, int valor) {
pthread_mutex_lock(&b->cerrojo);
while (b->cuenta == CAPACIDAD) { /* while, nunca if */
pthread_cond_wait(&b->no_lleno, &b->cerrojo);
}
b->datos[b->cola] = valor;
b->cola = (b->cola + 1) % CAPACIDAD;
b->cuenta++;
pthread_cond_signal(&b->no_vacio); /* el invariante ya es cierto */
pthread_mutex_unlock(&b->cerrojo);
}
/* Procedimiento del monitor: extraer.
Devuelve true si entregó un valor, false si el buffer se cerró. */
static bool buffer_sacar(buffer_t *b, int *destino) {
pthread_mutex_lock(&b->cerrojo);
while (b->cuenta == 0 && !b->cerrado) {
pthread_cond_wait(&b->no_vacio, &b->cerrojo);
}
if (b->cuenta == 0 && b->cerrado) {
pthread_mutex_unlock(&b->cerrojo);
return false;
}
*destino = b->datos[b->cabeza];
b->cabeza = (b->cabeza + 1) % CAPACIDAD;
b->cuenta--;
pthread_cond_signal(&b->no_lleno);
pthread_mutex_unlock(&b->cerrojo);
return true;
}
/* Cierre ordenado: hay que despertar a TODOS los consumidores dormidos */
static void buffer_cerrar(buffer_t *b) {
pthread_mutex_lock(&b->cerrojo);
b->cerrado = true;
pthread_cond_broadcast(&b->no_vacio);
pthread_mutex_unlock(&b->cerrojo);
}
static buffer_t buffer;
static void *productor(void *arg) {
long id = (long) arg;
for (int i = 0; i < POR_PRODUCTOR; i++) {
int valor = (int) (id * 100 + i);
buffer_poner(&buffer, valor);
printf("productor %ld puso %d\n", id, valor);
}
return NULL;
}
static void *consumidor(void *arg) {
long id = (long) arg;
int valor;
while (buffer_sacar(&buffer, &valor)) {
printf(" consumidor %ld saco %d\n", id, valor);
}
printf(" consumidor %ld termina\n", id);
return NULL;
}
int main(void) {
pthread_t hp[N_PRODUCTORES], hc[N_CONSUMIDORES];
buffer_init(&buffer);
for (long i = 0; i < N_CONSUMIDORES; i++) pthread_create(&hc[i], NULL, consumidor, (void *) i);
for (long i = 0; i < N_PRODUCTORES; i++) pthread_create(&hp[i], NULL, productor, (void *) i);
for (int i = 0; i < N_PRODUCTORES; i++) pthread_join(hp[i], NULL);
buffer_cerrar(&buffer);
for (int i = 0; i < N_CONSUMIDORES; i++) pthread_join(hc[i], NULL);
buffer_destroy(&buffer);
printf("fin, elementos pendientes: %d\n", buffer.cuenta);
return 0;
}
Cuatro detalles de este programa merecen comentario:
pthread_cond_waitrecibe el mutex. No es un adorno: la liberación del cerrojo y el encolamiento del hilo deben ser una sola operación atómica. Si fueran dos pasos separados, existiría una ventana en la que el hilo ya liberó el cerrojo pero aún no está en la cola; una señal emitida en esa ventana se perdería y el hilo dormiría para siempre. Ese fallo se conoce como señal perdida y es la razón técnica por la que las variables de condición no pueden existir sin un cerrojo asociado.- El cierre usa
broadcast, nosignal. Un solosignaldespertaría a un consumidor y dejaría a los otros dos dormidos eternamente. - El predicado del consumidor tiene dos términos (
cuenta == 0 && !cerrado) porque hay dos motivos para despertar: llegó un dato o se cerró el buffer. Esto es habitual y es otra razón para usarwhile: al despertar hay que averiguar por qué despertaste. - Las señales se emiten con el cerrojo tomado y con el invariante ya restablecido. Señalar antes de terminar de actualizar el estado es correcto en POSIX pero conceptualmente sucio; señalar después de
unlockes válido y a veces más rápido, pero abre la puerta a que el hilo despertado encuentre el estado ya modificado por un tercero.
Python: el monitor con threading.Condition
Python ofrece threading.Condition, que empaqueta un cerrojo y una cola de espera, y cuyo método wait_for(predicado) es azúcar sintáctico exacto de:
while not predicado():
self.wait()
Usarlo elimina de raíz el error del if. La segunda regla al usarlo es pasar explícitamente el mismo objeto Lock a todas las condiciones del monitor —threading.Condition(cerrojo)—, porque son varias colas de espera de un mismo monitor: si cada Condition creara su propio cerrojo tendrías monitores distintos y ninguna exclusión real sobre los datos. Ambas ideas están aplicadas en el monitor de lectores y escritores que aparece más adelante en este capítulo.
Un apunte sobre Python: el GIL hace que dos hilos no ejecuten bytecode a la vez, lo que enmascara muchas condiciones de carrera, pero no todas. Un append sobre una lista seguido de un incremento de contador puede interrumpirse entre medias. El monitor sigue siendo necesario.
Java: el monitor incorporado en el lenguaje
Java es el caso más literal de monitor integrado: cada objeto tiene un cerrojo y una única variable de condición implícita, accesible con wait, notify y notifyAll.
// BufferMonitor.java — javac BufferMonitor.java && java BufferMonitor
import java.util.ArrayDeque;
import java.util.Deque;
public class BufferMonitor {
/** Monitor clásico: estado privado + métodos synchronized. */
static class BufferAcotado {
private final int capacidad;
private final Deque<Integer> cola = new ArrayDeque<>();
private boolean cerrado = false;
BufferAcotado(int capacidad) { this.capacidad = capacidad; }
public synchronized void poner(int valor) throws InterruptedException {
while (cola.size() == capacidad && !cerrado) wait(); // libera el cerrojo
if (cerrado) throw new IllegalStateException("buffer cerrado");
cola.addLast(valor);
notifyAll(); // una sola cola implicita: notify seria peligroso
}
public synchronized Integer sacar() throws InterruptedException {
while (cola.isEmpty() && !cerrado) wait();
if (cola.isEmpty()) return null; // cerrado y vacio
int valor = cola.removeFirst();
notifyAll();
return valor;
}
public synchronized void cerrar() { cerrado = true; notifyAll(); }
public synchronized int ocupados() { return cola.size(); }
}
public static void main(String[] args) throws InterruptedException {
BufferAcotado buf = new BufferAcotado(4);
Thread[] consumidores = new Thread[3];
Thread[] productores = new Thread[2];
for (int i = 0; i < consumidores.length; i++) {
final int id = i;
consumidores[i] = new Thread(() -> {
try {
Integer v;
while ((v = buf.sacar()) != null)
System.out.println(" consumidor " + id + " saco " + v);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
consumidores[i].start();
}
for (int i = 0; i < productores.length; i++) {
final int id = i;
productores[i] = new Thread(() -> {
try {
for (int k = 0; k < 8; k++) buf.poner(id * 100 + k);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
productores[i].start();
}
for (Thread t : productores) t.join();
buf.cerrar();
for (Thread t : consumidores) t.join();
System.out.println("fin, pendientes: " + buf.ocupados());
}
}
El detalle instructivo aquí es la limitación: el monitor implícito de Java tiene una sola variable de condición por objeto. Productores y consumidores duermen en la misma cola, y por eso notify sería peligroso —podría despertar a un productor cuando lo que hacía falta era despertar a un consumidor— y hay que usar notifyAll. Para tener condiciones separadas, Java ofrece ReentrantLock con newCondition(), que devuelve objetos Condition con await, signal y signalAll, equivalentes a las de POSIX. La semántica sigue siendo Mesa en ambos casos.
Ada: objetos protegidos y barreras
Ada resuelve el mismo problema con una construcción distinta y más estricta: el objeto protegido (protected). Un objeto protegido tiene tres clases de operaciones:
| Operación | Palabra clave | Exclusión | Puede esperar |
|---|---|---|---|
| Función | function | Acceso concurrente de solo lectura entre varias funciones | No |
| Procedimiento | procedure | Exclusiva | No |
| Entrada | entry | Exclusiva | Sí, mediante una barrera |
La barrera es la gran diferencia respecto de POSIX o Java: en lugar de que el programador escriba while ... wait, la entrada declara una condición booleana sobre el estado privado, y el runtime se encarga de suspender al llamador hasta que sea verdadera. No hay variables de condición explícitas ni signal: las barreras se reevalúan automáticamente cada vez que termina una operación protegida que pudo cambiar el estado.
sequenceDiagram
participant T1 as Tarea productora
participant PO as Objeto protegido
participant T2 as Tarea consumidora
T2->>PO: llama a Sacar
PO->>PO: evalua la barrera<br/>"when Cuenta > 0" es FALSA
Note over T2,PO: la llamada queda encolada<br/>en la entrada Sacar
T1->>PO: llama a Poner
PO->>PO: barrera "when Cuenta < Capacidad"<br/>es VERDADERA
PO->>PO: ejecuta el cuerpo, Cuenta = 1
Note over PO: fin de la accion protegida:<br/>se REEVALUAN todas las barreras
PO->>PO: "Cuenta > 0" ahora es VERDADERA
PO->>T2: se ejecuta el cuerpo de Sacar<br/>en nombre de la tarea encolada
Note over T2: la tarea despierta con el<br/>trabajo YA hecho, sin recomprobar
PO-->>T1: Poner retorna
Ese modelo se conoce como el de la cáscara de huevo: mientras alguien está dentro del objeto protegido nadie más entra, y al salir el runtime revisa las barreras y deja pasar a quien corresponda antes de admitir nuevas llamadas. El efecto para el programador es una semántica de tipo Hoare —cuando tu entrada se ejecuta, la condición es cierta por construcción— pero sin que tengas que gestionar señales.
Buffer acotado en Ada
-- buffer_ada.adb
-- Compilar: gnatmake buffer_ada.adb Ejecutar: ./buffer_ada
with Ada.Text_IO; use Ada.Text_IO;
procedure Buffer_Ada is
Capacidad : constant := 4;
type Indice is mod Capacidad;
type Almacen is array (Indice) of Integer;
protected type Buffer_Acotado is
entry Poner (Valor : in Integer);
entry Sacar (Valor : out Integer);
function Ocupados return Natural;
private
Datos : Almacen;
Cabeza : Indice := 0;
Cola : Indice := 0;
Cuenta : Natural := 0;
end Buffer_Acotado;
protected body Buffer_Acotado is
-- La barrera sustituye al par "while + wait" de POSIX.
entry Poner (Valor : in Integer) when Cuenta < Capacidad is
begin
Datos (Cola) := Valor;
Cola := Cola + 1; -- aritmetica modular: no hay desbordamiento
Cuenta := Cuenta + 1;
end Poner;
entry Sacar (Valor : out Integer) when Cuenta > 0 is
begin
Valor := Datos (Cabeza);
Cabeza := Cabeza + 1;
Cuenta := Cuenta - 1;
end Sacar;
-- Las funciones no modifican el estado: varias pueden ejecutarse a la vez.
function Ocupados return Natural is
begin
return Cuenta;
end Ocupados;
end Buffer_Acotado;
Buffer : Buffer_Acotado;
task type Productor (Id : Natural; Cuantos : Natural);
task type Consumidor (Id : Natural; Cuantos : Natural);
task body Productor is
Valor : Integer;
begin
for I in 1 .. Cuantos loop
Valor := Id * 100 + I;
Buffer.Poner (Valor);
Put_Line ("productor" & Natural'Image (Id) & " puso" & Integer'Image (Valor));
end loop;
end Productor;
task body Consumidor is
Valor : Integer;
begin
for I in 1 .. Cuantos loop
Buffer.Sacar (Valor);
Put_Line (" consumidor" & Natural'Image (Id) & " saco" & Integer'Image (Valor));
end loop;
end Consumidor;
-- 16 elementos producidos y 16 consumidos: el programa termina solo.
P1 : Productor (1, 8);
P2 : Productor (2, 8);
C1 : Consumidor (1, 8);
C2 : Consumidor (2, 8);
begin
null; -- el procedimiento principal espera a que terminen las tareas
end Buffer_Ada;
Compara este programa con la versión en C. Desaparecieron: la inicialización del mutex, la de las dos condiciones, los dos bucles while, las cuatro llamadas a signal y wait, y las liberaciones del cerrojo. Lo único que queda es la lógica del buffer y dos expresiones booleanas. Todo lo eliminado era código de disciplina, y era exactamente donde vivían los errores.
Repara también en que aquí el productor y el consumidor deben coincidir en la cantidad total de elementos (8 + 8 producidos, 8 + 8 consumidos). Si un consumidor pidiera un elemento de más, quedaría bloqueado en la barrera para siempre y el programa nunca terminaría: la barrera es una espera indefinida, sin tiempo límite. Ada permite acotarla con una llamada temporizada, que también es un bloque select.
Cuando la condición depende del parámetro: requeue
Las barreras tienen una restricción severa: no pueden mencionar los parámetros de la llamada, solo el estado privado del objeto protegido. Esto es deliberado —evaluar N barreras distintas, una por llamador, sería costoso— pero deja fuera un caso frecuente: “espera hasta que haya al menos Monto disponible”.
La solución de Ada es la sentencia requeue, que reencola una llamada en curso hacia otra entrada, conservando sus parámetros y sin que el llamador se entere.
-- cuenta_requeue.adb
-- Compilar: gnatmake cuenta_requeue.adb Ejecutar: ./cuenta_requeue
with Ada.Text_IO; use Ada.Text_IO;
procedure Cuenta_Requeue is
protected Caja is
entry Retirar (Monto : in Positive);
procedure Depositar (Monto : in Positive);
function Saldo return Natural;
private
-- Entrada privada: solo se llega a ella por requeue.
entry Cola_Espera (Monto : in Positive);
Fondos : Natural := 0;
Por_Purgar : Natural := 0;
end Caja;
protected body Caja is
-- Mientras haya una purga en curso, los recien llegados no entran:
-- asi no le roban los fondos a quien lleva mas tiempo esperando.
entry Retirar (Monto : in Positive) when Por_Purgar = 0 is
begin
if Monto <= Fondos then
Fondos := Fondos - Monto;
else
requeue Cola_Espera with abort;
end if;
end Retirar;
-- Un deposito habilita exactamente a los que ya estaban esperando.
procedure Depositar (Monto : in Positive) is
begin
Fondos := Fondos + Monto;
Por_Purgar := Cola_Espera'Count;
end Depositar;
entry Cola_Espera (Monto : in Positive) when Por_Purgar > 0 is
begin
Por_Purgar := Por_Purgar - 1; -- cada uno consume su turno
if Monto <= Fondos then
Fondos := Fondos - Monto;
else
requeue Cola_Espera with abort; -- vuelve al final, sin turno
end if;
end Cola_Espera;
function Saldo return Natural is
begin
return Fondos;
end Saldo;
end Caja;
task type Cliente (Id : Natural; Monto : Positive);
task body Cliente is
begin
Caja.Retirar (Monto);
Put_Line ("cliente" & Natural'Image (Id) & " retiro" &
Positive'Image (Monto) & ", saldo" & Natural'Image (Caja.Saldo));
end Cliente;
task Cajero;
task body Cajero is
begin
for I in 1 .. 6 loop
Caja.Depositar (40);
end loop;
end Cajero;
C1 : Cliente (1, 30);
C2 : Cliente (2, 90);
C3 : Cliente (3, 50);
begin
null;
end Cuenta_Requeue;
El idioma que aparece aquí —contar cuántos esperan, darles a todos exactamente un turno, y reencolar a los que aún no pueden— es la traducción a Ada del broadcast de Mesa. El atributo 'Count sobre una entrada da el número de llamadas encoladas, y Por_Purgar evita que la operación entre en un ciclo infinito: cada pasada consume turnos, no los crea.
Elixir: el monitor como proceso
Elixir no tiene memoria compartida entre procesos, así que la pregunta “¿quién tiene el cerrojo?” no se plantea. Y sin embargo el patrón del monitor está presente, solo que trasladado: el estado privado vive dentro de un proceso, y la única manera de tocarlo es enviarle un mensaje. La exclusión mutua es gratuita porque un proceso atiende sus mensajes de a uno, en orden de llegada. La cola de entrada del monitor es, literalmente, el buzón del proceso.
Lo que sí hay que reconstruir es la variable de condición. El mecanismo es elegante: en un handle_call, en lugar de responder de inmediato, se guarda el identificador del llamador (from) en el estado y se retorna {:noreply, estado}. El llamador queda bloqueado esperando su respuesta. Más tarde, cuando la condición se cumple, otro mensaje dispara GenServer.reply(from, resultado) y el llamador despierta. Eso es exactamente wait y signal, con from cumpliendo el papel de “hilo encolado”.
# buffer_monitor.exs
# Ejecutar: elixir buffer_monitor.exs
defmodule BufferAcotado do
@moduledoc """
Monitor implementado como proceso: el estado es privado del GenServer y
la exclusion mutua la da el hecho de procesar un mensaje a la vez.
Las "variables de condicion" son las colas de llamadores diferidos.
"""
use GenServer
# --- API publica: los "procedimientos del monitor" ---
def start_link(capacidad) do
GenServer.start_link(__MODULE__, capacidad, name: __MODULE__)
end
def poner(valor), do: GenServer.call(__MODULE__, {:poner, valor}, :infinity)
def sacar, do: GenServer.call(__MODULE__, :sacar, :infinity)
def ocupados, do: GenServer.call(__MODULE__, :ocupados)
# --- Implementacion ---
@impl true
def init(capacidad) do
# productores/consumidores son las dos "colas de condicion" del monitor
{:ok,
%{
capacidad: capacidad,
cola: :queue.new(),
n: 0,
productores: :queue.new(),
consumidores: :queue.new()
}}
end
@impl true
def handle_call({:poner, valor}, from, estado) do
cond do
# Hay un consumidor dormido: se lo entrego directamente.
not :queue.is_empty(estado.consumidores) ->
{{:value, cliente}, resto} = :queue.out(estado.consumidores)
GenServer.reply(cliente, {:ok, valor})
{:reply, :ok, %{estado | consumidores: resto}}
# Hay espacio: guardo y respondo.
estado.n < estado.capacidad ->
{:reply, :ok, %{estado | cola: :queue.in(valor, estado.cola), n: estado.n + 1}}
# Lleno: el llamador se queda esperando. Equivale a wait(no_lleno).
true ->
{:noreply, %{estado | productores: :queue.in({from, valor}, estado.productores)}}
end
end
@impl true
def handle_call(:sacar, from, estado) do
if estado.n > 0 do
{{:value, valor}, resto} = :queue.out(estado.cola)
estado = %{estado | cola: resto, n: estado.n - 1}
{:reply, {:ok, valor}, admitir_productor(estado)}
else
# Vacio: equivale a wait(no_vacio).
{:noreply, %{estado | consumidores: :queue.in(from, estado.consumidores)}}
end
end
@impl true
def handle_call(:ocupados, _from, estado), do: {:reply, estado.n, estado}
# Equivale a signal(no_lleno): se libero un hueco, entra un productor.
defp admitir_productor(estado) do
case :queue.out(estado.productores) do
{{:value, {prod, valor}}, resto} ->
GenServer.reply(prod, :ok)
%{estado | cola: :queue.in(valor, estado.cola), n: estado.n + 1, productores: resto}
{:empty, _} ->
estado
end
end
end
defmodule Demo do
def run do
{:ok, _pid} = BufferAcotado.start_link(4)
consumidores =
for id <- 1..3 do
Task.async(fn ->
for _ <- 1..5 do
{:ok, v} = BufferAcotado.sacar()
IO.puts(" consumidor #{id} saco #{v}")
Process.sleep(30)
end
end)
end
productores =
for id <- 1..3 do
Task.async(fn ->
for k <- 1..5 do
:ok = BufferAcotado.poner(id * 100 + k)
IO.puts("productor #{id} puso #{id * 100 + k}")
Process.sleep(10)
end
end)
end
Enum.each(productores ++ consumidores, &Task.await(&1, :infinity))
IO.puts("fin, pendientes: #{BufferAcotado.ocupados()}")
end
end
Demo.run()
Tres observaciones sobre esta versión:
- No hay
while. Y no es una omisión: como el estado nunca cambia entre que se decide despertar a un llamador y que se le responde —el propio GenServer hace ambas cosas dentro del mismohandle_call, sin ceder el control—, la semántica efectiva es la de Hoare. El “señalizador” hace el trabajo en nombre del señalado, igual que en los objetos protegidos de Ada. - El timeout es explícito.
GenServer.callaborta a los 5 segundos por omisión y hace caer al llamador. Para una espera que puede ser larga hay que pasar:infinity, como en el ejemplo; si no, el “bloqueo” se convierte en un error intermitente bajo carga. - El proceso monitor es un cuello de botella deliberado. Toda operación pasa por un solo proceso secuencial. Esa es la fuente de la garantía y también su límite de escalabilidad: si el trabajo dentro del monitor es pesado, conviene mover el cálculo fuera y dejar dentro solo la actualización del estado. La regla es la misma en todos los lenguajes: el cuerpo de un monitor debe ser corto.
Lectores y escritores con un monitor
El buffer acotado ilustra la mecánica, pero el problema de lectores y escritores muestra por qué las variables de condición se prestan mejor que los semáforos para políticas complejas. Recordemos las reglas: varios lectores pueden leer a la vez; un escritor necesita acceso exclusivo; y queremos evitar que los lectores dejen a los escritores esperando indefinidamente.
#!/usr/bin/env python3
"""lectores_escritores.py — monitor con preferencia a escritores."""
import random
import threading
import time
class CerrojoLE:
"""Monitor que reparte el acceso a un recurso entre lectores y escritores.
Invariante: escritores_activos in {0, 1}; si vale 1 entonces
lectores_activos == 0; lectores_activos >= 0.
"""
def __init__(self):
self._cerrojo = threading.Lock()
self._puede_leer = threading.Condition(self._cerrojo)
self._puede_escribir = threading.Condition(self._cerrojo)
self._lectores_activos = 0
self._escritores_activos = 0
self._escritores_esperando = 0
def adquirir_lectura(self) -> None:
with self._cerrojo:
# Preferencia a escritores: un lector nuevo cede el paso
# si ya hay escritores en cola. Esto evita su inanicion.
self._puede_leer.wait_for(
lambda: self._escritores_activos == 0
and self._escritores_esperando == 0
)
self._lectores_activos += 1
def liberar_lectura(self) -> None:
with self._cerrojo:
self._lectores_activos -= 1
if self._lectores_activos == 0:
self._puede_escribir.notify() # el ultimo en salir habilita
def adquirir_escritura(self) -> None:
with self._cerrojo:
self._escritores_esperando += 1
self._puede_escribir.wait_for(
lambda: self._escritores_activos == 0
and self._lectores_activos == 0
)
self._escritores_esperando -= 1
self._escritores_activos = 1
def liberar_escritura(self) -> None:
with self._cerrojo:
self._escritores_activos = 0
if self._escritores_esperando > 0:
self._puede_escribir.notify() # otro escritor primero
else:
self._puede_leer.notify_all() # si no, entran los lectores
class BaseDeDatos:
def __init__(self):
self._valor = 0
self._cerrojo = CerrojoLE()
def leer(self, ident: str) -> int:
self._cerrojo.adquirir_lectura()
try:
print(f" lector {ident} ve {self._valor}", flush=True)
time.sleep(0.02)
return self._valor
finally:
self._cerrojo.liberar_lectura()
def escribir(self, ident: str, nuevo: int) -> None:
self._cerrojo.adquirir_escritura()
try:
print(f"ESCRITOR {ident} pone {nuevo}", flush=True)
self._valor = nuevo
time.sleep(0.05)
finally:
self._cerrojo.liberar_escritura()
def tarea_lector(db: BaseDeDatos, n: int) -> None:
for _ in range(5):
db.leer(f"L{n}")
time.sleep(random.uniform(0.01, 0.05))
def tarea_escritor(db: BaseDeDatos, n: int) -> None:
for k in range(4):
db.escribir(f"E{n}", n * 10 + k)
time.sleep(random.uniform(0.02, 0.06))
def main() -> None:
db = BaseDeDatos()
hilos = [threading.Thread(target=tarea_lector, args=(db, i)) for i in range(6)]
hilos += [threading.Thread(target=tarea_escritor, args=(db, i)) for i in range(2)]
random.shuffle(hilos)
for h in hilos:
h.start()
for h in hilos:
h.join()
print("fin")
if __name__ == "__main__":
main()
Fíjate en el uso mezclado de notify y notify_all en liberar_escritura: cuando entrega el turno a otro escritor basta despertar a uno, porque solo uno podrá escribir; cuando entrega el turno a los lectores hay que despertarlos a todos, porque todos pueden entrar simultáneamente. Esa asimetría es el ejemplo más limpio de cuándo cada primitiva es la correcta, y se decide mirando el predicado, no por costumbre.
La política implementada da preferencia a los escritores. Cambiar de política es cambiar los predicados: quitar self._escritores_esperando == 0 del predicado de los lectores produce preferencia a los lectores, con riesgo de inanición de escritores. En una implementación con semáforos, ese mismo cambio obligaría a reordenar operaciones y a introducir contadores auxiliares protegidos por más semáforos. La diferencia de esfuerzo es enorme, y es el argumento práctico a favor del monitor.
Cómo se implementa un monitor sobre semáforos
Que el monitor sea de más alto nivel no significa que sea otra cosa: por debajo hay semáforos o instrucciones atómicas. Reconstruir un monitor de semántica Hoare usando semáforos es un ejercicio clásico que además explica de dónde sale el costo de esa semántica.
Los ingredientes son tres: mutex, inicializado en 1, que da la exclusión mutua del monitor; urgente, inicializado en 0, que es la cola donde esperan los señalizadores suspendidos; y cuenta_urgente, que cuenta cuántos hay en esa cola. Cada variable de condición aporta además su propio semáforo en 0 más un contador de esperadores.
/* monitor_hoare.c — monitor de semantica Hoare construido sobre semaforos.
Compilar: gcc -O2 -pthread -o monitor_hoare monitor_hoare.c */
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#define CAPACIDAD 3
typedef struct {
sem_t sem; /* inicializado en 0 */
int cuenta; /* esperadores encolados */
} condicion_t;
typedef struct {
sem_t mutex; /* 1: exclusion del monitor */
sem_t urgente; /* 0: senalizadores suspendidos */
int cuenta_urgente;
} monitor_t;
static monitor_t M;
static void monitor_init(void) {
sem_init(&M.mutex, 0, 1);
sem_init(&M.urgente, 0, 0);
M.cuenta_urgente = 0;
}
static void cond_init(condicion_t *c) { sem_init(&c->sem, 0, 0); c->cuenta = 0; }
static void monitor_entrar(void) { sem_wait(&M.mutex); }
/* Al salir: la cola urgente tiene prioridad sobre la cola de entrada. */
static void monitor_salir(void) {
if (M.cuenta_urgente > 0) sem_post(&M.urgente);
else sem_post(&M.mutex);
}
static void cond_wait_hoare(condicion_t *c) {
c->cuenta++;
if (M.cuenta_urgente > 0) sem_post(&M.urgente); /* cedo a un senalizador */
else sem_post(&M.mutex); /* o a la cola de entrada */
sem_wait(&c->sem); /* duermo hasta la senal */
c->cuenta--;
}
static void cond_signal_hoare(condicion_t *c) {
if (c->cuenta > 0) {
M.cuenta_urgente++;
sem_post(&c->sem); /* el senalado entra AHORA */
sem_wait(&M.urgente); /* yo me suspendo en la cola urgente */
M.cuenta_urgente--;
}
/* Si nadie esperaba, la senal se pierde: eso es una condicion. */
}
/* --- Buffer acotado usando el monitor anterior --- */
static int buf[CAPACIDAD];
static int cabeza = 0, cola = 0, cuenta = 0;
static condicion_t no_lleno, no_vacio;
static void poner(int v) {
monitor_entrar();
if (cuenta == CAPACIDAD) cond_wait_hoare(&no_lleno); /* bajo Hoare, "if" vale */
buf[cola] = v;
cola = (cola + 1) % CAPACIDAD;
cuenta++;
cond_signal_hoare(&no_vacio);
monitor_salir();
}
static int sacar(void) {
int v;
monitor_entrar();
if (cuenta == 0) cond_wait_hoare(&no_vacio);
v = buf[cabeza];
cabeza = (cabeza + 1) % CAPACIDAD;
cuenta--;
cond_signal_hoare(&no_lleno);
monitor_salir();
return v;
}
static void *productor(void *arg) {
long id = (long) arg;
for (int i = 0; i < 6; i++) poner((int) (id * 100 + i));
return NULL;
}
static void *consumidor(void *arg) {
long id = (long) arg;
for (int i = 0; i < 6; i++) printf(" consumidor %ld saco %d\n", id, sacar());
return NULL;
}
int main(void) {
pthread_t p[2], c[2];
monitor_init();
cond_init(&no_lleno);
cond_init(&no_vacio);
for (long i = 0; i < 2; i++) pthread_create(&p[i], NULL, productor, (void *) i);
for (long i = 0; i < 2; i++) pthread_create(&c[i], NULL, consumidor, (void *) i);
for (int i = 0; i < 2; i++) pthread_join(p[i], NULL);
for (int i = 0; i < 2; i++) pthread_join(c[i], NULL);
printf("fin, pendientes: %d\n", cuenta);
return 0;
}
Este programa hace explícito el costo de Hoare: cada cond_signal_hoare con alguien esperando implica un sem_post y un sem_wait inmediatos, es decir, dos suspensiones. En la versión Mesa con pthread_cond_signal, el señalizador no se suspende nunca. Multiplica esa diferencia por millones de operaciones y entenderás por qué POSIX eligió Mesa.
Y hay un segundo detalle importantísimo: en este monitor Hoare el if es correcto, pero solo porque el monitor es Hoare. Copiar ese código a un entorno POSIX cambiando cond_wait_hoare por pthread_cond_wait produce un programa roto que además funciona bien la mayor parte del tiempo. Esa es la clase de error que sobrevive a las pruebas y aparece en producción.
Monitores frente a semáforos: cuándo usar cada uno
| Criterio | Semáforos | Monitores |
|---|---|---|
| Nivel de abstracción | Bajo: contador y dos operaciones | Alto: módulo con datos y operaciones |
| Vínculo con los datos | Ninguno, es convención | Estructural: el dato vive dentro |
| Riesgo de olvidar liberar | Alto | Bajo o nulo según el lenguaje |
| Expresar condiciones complejas | Costoso: contadores auxiliares y semáforos extra | Directo: un predicado por variable de condición |
| Verificación de corrección | Global: hay que revisar todo el programa | Local: basta el cuerpo del monitor |
| Rendimiento crudo | Ligeramente mejor: menos indirección | Comparable; el cerrojo interno es un semáforo binario |
| Señalización entre procesos distintos | Sirve: los semáforos con nombre cruzan procesos | Difícil: el monitor vive en un espacio de direcciones |
| Sincronización desde manejadores de interrupción | Sirve: sem_post es seguro en ese contexto | No sirve: no se puede bloquear en un manejador |
| Anidamiento | Peligroso pero posible | Peligroso: llamar a otro monitor desde dentro puede causar interbloqueo |
| Composición de operaciones | Mala | Buena dentro del mismo monitor; mala entre monitores |
La lectura resumida: los monitores son la herramienta por omisión para coordinar hilos dentro de un programa, y los semáforos siguen siendo la herramienta correcta para señalización pura, para cruzar la frontera entre procesos y para contextos donde no se puede bloquear. Nada de esto contradice lo aprendido en el capítulo anterior; lo ordena.
Queda una advertencia sobre la última fila. Componer dos monitores es donde el modelo se rompe: si el monitor A llama a un procedimiento del monitor B mientras B llama a A, hay interbloqueo. La disciplina que evita esto es la misma que con cerrojos: fijar un orden global de adquisición y no llamar nunca a código ajeno con el monitor tomado. Un monitor que invoca una función pasada por el usuario mientras mantiene el cerrojo está dejando la puerta abierta a que ese código, sin saberlo, vuelva a entrar y se bloquee a sí mismo.
Errores comunes
| Error | Causa | Síntoma | Solución |
|---|---|---|---|
Usar if en lugar de while al esperar | Suponer semántica Hoare en un entorno Mesa | El hilo procede con el predicado falso: lee de un buffer vacío, índices corruptos, fallos raros bajo carga | Reemplazar por while (!predicado) wait(...) o usar wait_for con el predicado |
| Comprobar el predicado sin el cerrojo | Intentar “optimizar” evitando entrar al monitor | Carrera: lo leído deja de ser cierto antes de actuar | Evaluar el predicado siempre dentro de la sección protegida |
| Señal perdida | Señalar entre que el otro hilo comprobó el predicado y se durmió | El hilo duerme para siempre aunque la condición se cumplió | Es imposible si wait recibe el cerrojo y este se mantiene mientras se comprueba y se señala |
notify en lugar de notifyAll con esperadores heterogéneos | Optimizar sin verificar que todos los esperadores son intercambiables | Se despierta al hilo equivocado; el que podía avanzar sigue dormido | Usar broadcast/notifyAll, o dar una variable de condición distinta a cada clase de esperador |
Dos Condition con cerrojos distintos | Crear cada condición con su propio cerrojo implícito | No hay exclusión mutua real; el estado se corrompe | Construir todas las condiciones sobre el mismo objeto de cerrojo |
Ejecutar wait con el invariante roto | Dormirse a mitad de una actualización de varios campos | Otro hilo observa estado inconsistente | Reordenar: completar la actualización o no empezarla antes de esperar |
Salir del monitor por una excepción o return temprano | Gestión manual del cerrojo en C o C++ | Interbloqueo total: nadie vuelve a entrar | Usar try/finally, RAII o construcciones del lenguaje (with, synchronized, protected) |
| Llamar a otro monitor desde dentro de un monitor | Encadenar módulos sin orden definido | Interbloqueo cruzado intermitente | Definir un orden global de adquisición; salir del monitor antes de llamar hacia afuera |
| Trabajo largo dentro del monitor | Poner cálculo, E/S o sleep en la sección protegida | Serialización total, latencia disparada | Copiar lo necesario, salir, calcular fuera y volver a entrar solo para actualizar |
| Barrera de Ada que menciona un parámetro | Intentar expresar “espera hasta tener Monto” en la barrera | Error de compilación | Usar requeue hacia una entrada privada, o una familia de entradas |
GenServer.call sin :infinity en una espera larga | Confiar en el tiempo límite por omisión de 5 segundos | El llamador cae con :timeout bajo carga, no cuando hay un error real | Pasar :infinity explícitamente cuando la espera es parte del diseño |
| Exponer una referencia a los datos internos | Devolver la estructura interna en vez de una copia | Acceso concurrente sin protección desde fuera | Devolver copias o valores inmutables |
| Confundir señal con dato | Suponer que signal transporta información | Se pierden eventos cuando nadie espera | La información va en el estado del monitor; la señal solo dice “revisa de nuevo” |
Ejercicios propuestos
Los primeros son de lectura y razonamiento; los últimos exigen escribir y ejecutar código.
Ejercicio 1 — Romper el while. Toma buffer_monitor.c y cambia los dos while por if. Ejecútalo con un productor y cinco consumidores, y con el printf del consumidor imprimiendo también cuenta. Ejecuta cien veces con un script y anota cuántas ejecuciones producen un valor imposible. Explica el resultado con el diagrama de secuencia de la semántica Mesa.
Ejercicio 2 — Contar cambios de contexto. Instrumenta monitor_hoare.c y buffer_monitor.c con un contador de suspensiones. Compara el total de suspensiones por elemento transferido en ambas versiones y relaciónalo con la tabla de las tres semánticas.
Ejercicio 3 — Señal perdida artificial. Escribe una versión de la espera que libere el cerrojo y después duerma en un semáforo, en dos pasos separados. Fuerza el intercalado con un usleep en medio y demuestra que se pierde la señal. Explica por qué pthread_cond_wait no puede fallar así.
Ejercicio 4 — De notifyAll a dos condiciones. Reescribe la versión Java usando ReentrantLock y dos objetos Condition (noLleno y noVacio) con signal en lugar de signalAll. Justifica en un párrafo por qué ahora signal es correcto y antes no lo era.
Ejercicio 5 — Preferencia a lectores. Modifica lectores_escritores.py para dar preferencia a los lectores. Instrumenta la espera media de cada clase de hilo y compara ambas políticas con la misma mezcla de carga. Describe en qué escenario cada política es preferible.
Ejercicio 6 — Equidad estricta. Implementa una tercera política sin inanición para nadie: los hilos entran en orden de llegada, y los lectores consecutivos comparten el acceso. Necesitarás un número de turno. Demuestra con la instrumentación que ningún hilo espera más de lo que dura un turno completo.
Ejercicio 7 — Buffer acotado con tiempo límite. Añade a la versión en C una operación poner_con_timeout usando pthread_cond_timedwait. Cuida el caso en que la función retorna por expiración del plazo: el predicado debe volver a comprobarse antes de decidir que hubo tiempo límite.
Ejercicio 8 — Barreras en Ada. Modifica buffer_ada.adb para añadir una entrada Sacar_Todo que entregue el contenido completo del buffer y solo pueda ejecutarse cuando el buffer esté lleno. Comprueba con 'Count cuántas tareas quedan encoladas en cada entrada e imprímelo desde una función.
Ejercicio 9 — requeue con prioridad. Extiende cuenta_requeue.adb para que, cuando varios clientes esperen fondos, se atienda primero al de menor monto. Pista: una familia de entradas indexada por rango de monto, o un campo de prioridad en el estado protegido.
Ejercicio 10 — Elixir con timeout y cierre. Añade a buffer_monitor.exs una operación sacar_con_plazo(ms) que responda {:error, :timeout} si no hay dato en el plazo, y una operación cerrar que despierte con {:error, :cerrado} a todos los llamadores diferidos. Verifica que ningún proceso queda bloqueado tras el cierre.
Ejercicio 11 — Medir el cuello de botella. En la versión Elixir, mueve un cálculo costoso (por ejemplo, un Enum.reduce sobre una lista grande) dentro del handle_call y mide el rendimiento total. Después sácalo fuera del proceso monitor y vuelve a medir. Cuantifica la diferencia y explícala.
Ejercicio 12 — Interbloqueo por anidamiento. Escribe dos monitores A y B en el lenguaje que prefieras, donde un procedimiento de A llame a B y uno de B llame a A. Provoca el interbloqueo, obsérvalo con un depurador o con pstack, y después corrígelo imponiendo un orden global de adquisición.
Lo que viene
Este capítulo cerró el arco de la memoria compartida. El monitor toma el semáforo del capítulo 8 y lo esconde donde no pueda usarse mal: dentro de un módulo que encapsula sus datos y garantiza la exclusión por construcción. Encima de esa base aparecen las variables de condición, que separan claramente dos preguntas que el semáforo mezclaba —“¿puedo entrar?” y “¿ya se cumple lo que necesito?”—, y las tres semánticas de señalización, de las que solo una, la de Mesa, describe lo que hace tu sistema. De ahí la regla que resume el capítulo entero: espera siempre dentro de un while sobre un predicado explícito, con el cerrojo tomado, y con el invariante restablecido antes de dormir.
Todo esto, sin embargo, comparte una suposición de fondo: que los hilos viven en el mismo espacio de direcciones y pueden tocar la misma memoria. Cuando esa suposición desaparece —procesos separados, máquinas distintas, una red de por medio— no hay cerrojo que tomar ni variable de condición donde dormir, y hace falta otro modelo. En el capítulo 10 pasamos a ese modelo: el paso de mensajes, con sus variantes síncrona y asíncrona, el problema del nombrado y del encuentro, las colas y los buzones, y qué significa autenticar y cifrar un mensaje cuando el canal no es de confianza. Verás además que Ada y Elixir, los dos lenguajes que acompañan el curso, ya tenían ese modelo incorporado desde el principio: el rendezvous de Ada y el buzón de procesos de Elixir son las dos caras del mismo mecanismo.
El temario completo, por si quieres ubicar este capítulo dentro del recorrido, está en el índice general.