Semáforos: el primer mecanismo que resolvió la exclusión mutua sin espera activa
Semáforos: el primer mecanismo que resolvió la exclusión mutua sin espera activa
En el capítulo 7 desarmamos el problema: cuando dos flujos de ejecución tocan el mismo dato sin coordinarse, el resultado depende del entrelazado que elija el planificador, y ese entrelazado no es reproducible. Vimos la condición de carrera, definimos la sección crítica y enunciamos las cuatro condiciones que debe cumplir cualquier solución correcta: exclusión mutua, progreso, espera acotada y ninguna suposición sobre velocidades relativas. También construimos soluciones puramente por software —el algoritmo de Peterson, la alternancia estricta— y descubrimos su defecto común: todas gastan CPU girando en un bucle mientras esperan.
Ese defecto no es cosmético. Un proceso que espera girando ocupa un procesador que otro proceso podría usar para avanzar y, en el peor caso, para liberar precisamente el recurso que el primero está esperando. La espera activa convierte la sincronización en un impuesto proporcional al tiempo de espera. La pregunta natural es: ¿y si en lugar de girar, el proceso se duerme y alguien lo despierta cuando el recurso esté libre? Dormir y despertar son operaciones que solo el sistema operativo puede hacer, porque solo él controla la cola de listos y el planificador. Ese es exactamente el salto que da el semáforo.
Este capítulo construye el semáforo desde su definición mínima hasta implementaciones ejecutables. Vamos a ver qué contiene por dentro, por qué sus dos operaciones deben ser atómicas y quién garantiza esa atomicidad, en qué se diferencian el semáforo contador y el binario, qué significa exactamente un contador negativo, y los patrones canónicos de uso. Después lo implementamos en Ada con objetos protegidos y en Elixir con un GenServer, y terminamos con el catálogo de errores que hacen del semáforo una herramienta poderosa y peligrosa a la vez.
Qué es un semáforo
Un semáforo es una variable entera compartida, con una cola de procesos asociada, sobre la que solo se permiten dos operaciones además de su inicialización. No se puede leer directamente su valor para decidir algo, no se puede asignarle un número arbitrario, no se puede comparar. Solo wait y signal. Esa restricción deliberada es lo que lo convierte en un mecanismo de sincronización y no en una variable normal más.
La idea la formuló Edsger Dijkstra a mediados de los años sesenta, y los nombres originales de las operaciones vienen del neerlandés: P de proberen (probar, intentar) y V de verhogen (incrementar). Con los años aparecieron sinónimos según el sistema o la biblioteca, pero la semántica es siempre la misma.
| Operación | Nombres alternativos | Efecto conceptual |
|---|---|---|
wait | P, down, acquire, pend, take | Intenta consumir un permiso; si no hay, bloquea al llamador |
signal | V, up, release, post, give | Devuelve un permiso; si hay alguien bloqueado, lo despierta |
La metáfora más útil no es la del semáforo de tránsito, sino la de un dispensador de permisos. El semáforo guarda una cantidad de permisos disponibles. wait toma uno; si no queda ninguno, el que pidió se queda esperando en la fila hasta que alguien devuelva el suyo. signal devuelve un permiso; si hay alguien en la fila, se le entrega directamente en vez de guardarlo. Un cine con cien butacas es un semáforo inicializado en cien. Un baño con una sola puerta es un semáforo inicializado en uno.
La estructura interna
Un semáforo no es solo un entero. La parte que hace posible eliminar la espera activa es la segunda mitad de la estructura: la lista de procesos dormidos.
typedef struct {
int count; /* permisos disponibles (o deuda, si es negativo) */
proc_queue_t waitlist; /* procesos bloqueados en este semáforo, en orden FIFO */
} semaphore_t;
Cuando un proceso ejecuta wait y no hay permisos, el kernel hace tres cosas: cambia el estado del proceso de ejecutando a bloqueado, lo saca de la cola de listos y lo encola en waitlist, y llama al planificador para que ponga a correr a otro. El proceso deja de consumir CPU por completo. Cuando otro proceso ejecuta signal, el kernel extrae el primero de waitlist, lo marca como listo y lo devuelve a la cola de listos. En algún momento el planificador lo elegirá y el proceso continuará justo después de su wait, sin haber notado nada más que una pausa.
stateDiagram-v2
[*] --> Ejecutando
Ejecutando --> Bloqueado: wait() y count <= 0<br/>se encola en waitlist
Bloqueado --> Listo: otro proceso hace signal()<br/>se extrae de waitlist
Listo --> Ejecutando: el planificador lo elige
Ejecutando --> Listo: quantum agotado o expropiación
Ejecutando --> [*]: termina
note right of Bloqueado
No consume CPU.
No aparece en la cola de listos.
Solo un signal sobre ESTE semaforo lo saca de aqui.
end note
Esa transición a bloqueado es la diferencia esencial con Peterson. En Peterson el proceso que espera sigue en estado ejecutando o listo, quemando quantums. Con un semáforo, el proceso desaparece del planificador hasta que hay una razón real para volver.
Las dos operaciones en pseudocódigo
Existen dos convenciones para el contador, y conviene entender ambas porque la literatura y las implementaciones reales usan las dos.
Convención del contador con signo (la de Dijkstra, y la que usa la mayoría de los textos académicos):
wait(S):
S.count = S.count - 1
if S.count < 0:
encolar(proceso_actual, S.waitlist)
bloquear(proceso_actual)
signal(S):
S.count = S.count + 1
if S.count <= 0:
P = desencolar(S.waitlist)
despertar(P)
Aquí el contador puede volverse negativo, y ese signo lleva información: si count es negativo, su valor absoluto es exactamente la cantidad de procesos bloqueados en la cola. Un semáforo con count == -3 significa que hay tres procesos esperando. Un count == 5 significa que hay cinco permisos libres para tomar sin bloquearse. El cero es la frontera: no hay permisos ni hay nadie esperando.
Convención del contador no negativo (la que usan POSIX, la mayoría de los kernels y las bibliotecas prácticas):
wait(S):
if S.count > 0:
S.count = S.count - 1
else:
S.bloqueados = S.bloqueados + 1
encolar(proceso_actual, S.waitlist)
bloquear(proceso_actual)
signal(S):
if S.bloqueados > 0:
S.bloqueados = S.bloqueados - 1
P = desencolar(S.waitlist)
despertar(P)
else:
S.count = S.count + 1
Aquí count nunca baja de cero y la cantidad de bloqueados se lleva en un campo aparte (o simplemente es el largo de waitlist). El comportamiento observable es idéntico; cambia solo la representación. La ventaja práctica es que sem_getvalue puede devolver un número no negativo con significado directo: “permisos disponibles ahora”.
flowchart TD
A["Proceso llama wait(S)"] --> B{"count > 0 ?"}
B -->|"si"| C["count = count - 1"]
C --> D["Entra a la seccion critica<br/>o usa el recurso"]
B -->|"no"| E["Encolar en S.waitlist"]
E --> F["Estado = BLOQUEADO<br/>llamar al planificador"]
F --> G["Espera sin consumir CPU"]
G -.->|"alguien hace signal(S)"| H["Estado = LISTO<br/>vuelve a la cola de listos"]
H --> D
D --> I["Proceso llama signal(S)"]
I --> J{"hay alguien<br/>en waitlist ?"}
J -->|"si"| K["Desencolar el primero<br/>y despertarlo"]
J -->|"no"| L["count = count + 1"]
K --> M["Fin"]
L --> M
Fíjate en la asimetría fundamental: wait puede bloquear, signal nunca bloquea. signal siempre termina de inmediato, haya o no alguien esperando. Esto tiene una consecuencia importante que veremos más adelante: un signal es seguro de ejecutar desde un manejador de interrupciones, mientras que un wait no lo es.
Por qué la atomicidad no es negociable
El pseudocódigo de arriba parece inofensivo, pero contiene exactamente el problema que el semáforo pretende resolver. S.count = S.count - 1 no es una instrucción, son tres: leer de memoria, restar, escribir a memoria. Si dos procesos ejecutan wait sobre el mismo semáforo y el planificador los entrelaza en el momento equivocado, ambos pueden leer count == 1, ambos restar, ambos escribir 0, y ambos entrar a la sección crítica. El semáforo habría fallado en la primera línea de su propia implementación.
sequenceDiagram
participant A as Proceso A
participant M as Memoria (count = 1)
participant B as Proceso B
Note over A,B: Sin atomicidad: wait() se entrelaza y ambos entran
A->>M: lee count
M-->>A: 1
Note over A: expropiado antes de escribir
B->>M: lee count
M-->>B: 1
B->>M: escribe count = 0
B->>B: 0 no es < 0, entra a la SC
Note over A: retoma con su copia vieja
A->>M: escribe count = 0
A->>A: 0 no es < 0, entra a la SC
Note over A,B: Dos procesos dentro de la misma seccion critica
Por eso wait y signal deben ser operaciones atómicas: indivisibles desde el punto de vista de cualquier otro flujo de ejecución. Nadie puede observar un semáforo a medio actualizar. Cómo se consigue eso depende de dónde se implemente el semáforo.
Atomicidad en un núcleo monoprocesador
En una máquina con un solo procesador, dos flujos solo pueden entrelazarse si ocurre un cambio de contexto, y un cambio de contexto solo ocurre por una interrupción. Deshabilitar las interrupciones durante wait y signal basta para hacerlas atómicas.
void wait(semaphore_t *s) {
disable_interrupts();
s->count--;
if (s->count < 0) {
enqueue(&s->waitlist, current_process);
block(current_process); /* cede la CPU; las interrupciones se
restauran como parte del cambio de contexto */
}
enable_interrupts();
}
Es correcto, es barato, y es una técnica que solo el kernel puede usar: deshabilitar interrupciones es una instrucción privilegiada, de las que estudiamos al hablar de modo usuario y modo kernel. Un programa de usuario no puede hacerlo, y no debería: un proceso que deshabilita interrupciones y entra en un bucle infinito congela la máquina entera.
Atomicidad en multiprocesador
Con varios núcleos, deshabilitar las interrupciones de uno no impide que otro núcleo toque el mismo semáforo al mismo tiempo. Hace falta una instrucción del hardware que lea y escriba memoria en un solo paso indivisible. Las que vimos en el capítulo anterior sirven exactamente para esto: test-and-set, compare-and-swap, fetch-and-add. La implementación real usa un spinlock corto para proteger la estructura del semáforo, y bloquea al proceso solo si de verdad hay que esperar.
void wait(semaphore_t *s) {
spin_lock(&s->guard); /* espera activa, pero de unas decenas de ciclos */
s->count--;
if (s->count < 0) {
enqueue(&s->waitlist, current_process);
spin_unlock(&s->guard); /* se suelta ANTES de dormirse */
block(current_process); /* espera larga: sin consumir CPU */
} else {
spin_unlock(&s->guard);
}
}
Aquí conviven las dos formas de espera, y esa convivencia es la clave del diseño. La espera activa se usa para proteger unas pocas instrucciones cuya duración es acotada y minúscula. La espera pasiva se usa para la espera de verdad, que puede durar milisegundos o segundos. Nunca se gira esperando a otro proceso de usuario; se gira solo esperando a que otro núcleo termine de actualizar cuatro campos.
Hay un detalle sutil y crítico en ese código: spin_unlock va antes de block. Si el proceso se durmiera todavía sosteniendo el guard, nadie más podría entrar a signal para despertarlo. Ese es un deadlock clásico en implementaciones ingenuas de primitivas de sincronización.
En Linux la pieza que hace esto en el espacio de usuario se llama futex (fast userspace mutex). La idea es que el caso sin contención se resuelve enteramente en espacio de usuario con una operación atómica, sin llamar al kernel; solo cuando hay que dormir o despertar se hace la llamada al sistema. Por eso un sem_wait sobre un semáforo libre cuesta unos nanosegundos y no una entrada al kernel.
Semáforos binarios y semáforos contadores
La distinción no está en el mecanismo —es el mismo— sino en el valor inicial y en la disciplina de uso.
Semáforo binario
Se inicializa en 1 y su valor solo oscila entre 0 y 1. Representa un recurso único: un permiso, una llave. Sirve para exclusión mutua.
mutex = semaforo(1)
proceso:
wait(mutex) # tomar la llave
... seccion critica ...
signal(mutex) # devolver la llave
Muchas implementaciones ofrecen un tipo específico para esto, y en ese tipo un signal sobre un semáforo que ya vale 1 o bien es un error, o bien se ignora (el valor satura en 1). Esa saturación importa: un semáforo binario no acumula permisos.
Semáforo contador
Se inicializa en N y representa N instancias intercambiables de un recurso. Cinco conexiones a una base de datos, tres impresoras, un buffer con diez posiciones libres.
conexiones = semaforo(5)
proceso:
wait(conexiones) # tomar una conexion; bloquea si las 5 estan en uso
... usar la conexion ...
signal(conexiones) # devolverla
Semáforo binario y mutex no son sinónimos
Esta confusión aparece constantemente y vale la pena marcarla con precisión. Un mutex tiene el concepto de dueño: el hilo que lo toma es el único que puede soltarlo. Un semáforo binario no tiene dueño: cualquier proceso puede hacer signal sobre él, incluso uno que nunca hizo wait.
Esa diferencia no es teórica. Habilita un uso que el mutex no permite —la señalización entre tareas distintas— y a la vez elimina garantías que el mutex sí ofrece.
| Aspecto | Semáforo binario | Mutex |
|---|---|---|
| Noción de dueño | No tiene | El hilo que lo toma |
| Quién puede liberarlo | Cualquier hilo | Solo el dueño |
| Uso típico | Señalización entre tareas, exclusión mutua simple | Exclusión mutua exclusivamente |
| Herencia de prioridad | Generalmente no la soporta | Suele soportarla (evita inversión de prioridades) |
| Reentrante | No | Existen variantes recursivas |
| Seguro desde una interrupción | signal sí | No |
| Detección de doble liberación | No, se convierte en un contador roto | El sistema puede detectarla |
La regla práctica: si lo que necesitas es proteger un dato compartido, usa un mutex (o un semáforo binario con la disciplina estricta de que quien hace wait es quien hace signal). Si lo que necesitas es que una tarea le avise a otra que algo ocurrió, usa un semáforo, porque ahí el “dueño” ni siquiera tiene sentido: el que señala nunca esperó.
Tabla comparativa de los tres tipos
| Característica | Binario | Contador | Contador con signo |
|---|---|---|---|
| Rango del valor | 0 a 1 | 0 a N | −∞ a N |
| Valor inicial típico | 1 (mutex) o 0 (señal) | N recursos | N recursos |
| Interpretación del valor | Libre / ocupado | Permisos disponibles | Positivo: permisos libres. Negativo: procesos esperando |
signal extra | Se ignora o es error | Incrementa el contador | Incrementa el contador |
| Uso canónico | Exclusión mutua, rendezvous | Pool de recursos, buffer acotado | Igual, con diagnóstico incluido |
| Ejemplo real | sem_init(&s, 0, 1) | sem_init(&s, 0, 10) | Semáforos internos de muchos kernels |
La interfaz POSIX, para anclar los conceptos en algo real
Antes de implementar semáforos desde cero conviene ver la API que ofrece un sistema operativo real, porque fija el vocabulario. En POSIX los semáforos sin nombre viven en <semaphore.h>.
| Función | Qué hace |
|---|---|
sem_init(sem_t *s, int pshared, unsigned value) | Inicializa el semáforo con value permisos. pshared distinto de cero lo hace compartible entre procesos |
sem_wait(sem_t *s) | Operación wait: bloquea si no hay permisos |
sem_trywait(sem_t *s) | Como sem_wait, pero si no hay permisos retorna error en vez de bloquear |
sem_timedwait(sem_t *s, const struct timespec *abs_timeout) | Como sem_wait, pero se rinde en un instante absoluto dado |
sem_post(sem_t *s) | Operación signal. Es segura de llamar desde un manejador de señal |
sem_getvalue(sem_t *s, int *val) | Lee el valor actual. Solo sirve para diagnóstico |
sem_destroy(sem_t *s) | Libera los recursos del semáforo |
Tres observaciones que se aplican a cualquier implementación, no solo a POSIX:
sem_getvalue es informativo, no accionable. Para cuando lees el valor, otro hilo ya pudo cambiarlo. Cualquier lógica del tipo “si el valor es mayor que cero, entonces hago wait” reintroduce exactamente la condición de carrera que el semáforo estaba evitando. La única forma correcta de preguntar “¿hay permiso?” y actuar en consecuencia es sem_trywait, porque la pregunta y la acción son una sola operación atómica.
sem_wait puede retornar antes de tiempo. En sistemas tipo Unix, si llega una señal mientras el hilo está bloqueado, sem_wait retorna -1 con errno == EINTR sin haber adquirido nada. Ignorar ese retorno es un error frecuente: el código sigue como si tuviera el permiso y entra a la sección crítica sin él.
El signal desde un manejador de interrupción es el patrón de un driver. El manejador no puede bloquearse, pero sí puede hacer signal. La estructura clásica de un controlador de dispositivo es: el hilo que pide datos hace wait sobre un semáforo inicializado en 0 y se duerme; cuando el dispositivo termina, la interrupción hace signal y el hilo despierta. El semáforo es el puente entre el hardware asíncrono y el código secuencial.
Los patrones canónicos de uso
Casi todo lo que se hace con semáforos es una combinación de cinco patrones. Vale la pena conocerlos por nombre porque, una vez que los reconoces, los problemas de sincronización dejan de parecer acertijos.
1. Exclusión mutua
Un semáforo inicializado en 1 rodeando la sección crítica. El caso ya visto.
2. Señalización (o rendezvous en una dirección)
Un semáforo inicializado en 0. Sirve para imponer un orden: la instrucción A del proceso 1 debe ocurrir antes que la instrucción B del proceso 2.
listo = semaforo(0)
Proceso 1: Proceso 2:
A wait(listo)
signal(listo) B
Si el proceso 2 llega primero, el contador está en 0 y se duerme. Si el proceso 1 llega primero, deja el permiso y el proceso 2 pasa sin bloquearse. En ambos casos, B ocurre después de A. Este patrón es la base de la comunicación entre tareas y no tiene nada que ver con la exclusión mutua: aquí quien hace signal nunca hace wait.
3. Rendezvous bidireccional
Dos tareas se esperan mutuamente en un punto: ninguna sigue hasta que ambas llegaron. Se logra con dos semáforos de señalización cruzados.
a_llego = semaforo(0)
b_llego = semaforo(0)
Tarea A: Tarea B:
... trabajo ... ... trabajo ...
signal(a_llego) signal(b_llego)
wait(b_llego) wait(a_llego)
... sigue ... ... sigue ...
El orden importa: primero signal, después wait. Si se invierte —wait antes de signal en ambas tareas— se produce un deadlock inmediato y garantizado, porque cada una espera una señal que la otra solo enviaría después de recibir la suya.
4. Multiplex
Un semáforo inicializado en N permite que hasta N tareas estén simultáneamente dentro de una región. No es exclusión mutua, es limitación de concurrencia. Es lo que hace un pool de conexiones, un límite de descargas paralelas o un control de aforo.
5. Barrera
Ninguna de las N tareas pasa hasta que las N llegaron. Se construye con un contador protegido por un mutex más un semáforo de señalización, y aparece en cómputo por fases: nadie empieza la fase k+1 hasta que todos terminaron la fase k.
n = 5
contador = 0
mutex = semaforo(1)
barrera = semaforo(0)
cada tarea:
wait(mutex)
contador = contador + 1
if contador == n:
for i in 1..n:
signal(barrera) # abre para todos
signal(mutex)
wait(barrera)
... fase siguiente ...
El problema del productor-consumidor con buffer acotado
Es el ejercicio central de la sincronización con semáforos porque combina tres necesidades distintas al mismo tiempo y muestra que cada una requiere su propio semáforo.
El escenario: un productor genera elementos y los deposita en un buffer circular de tamaño fijo. Un consumidor los retira. Hay tres cosas que garantizar:
- El productor no debe escribir si el buffer está lleno.
- El consumidor no debe leer si el buffer está vacío.
- Productor y consumidor no deben manipular los índices del buffer al mismo tiempo.
Tres necesidades, tres semáforos:
| Semáforo | Valor inicial | Qué cuenta | Quién hace wait | Quién hace signal |
|---|---|---|---|---|
vacias | N (tamaño del buffer) | Posiciones libres | Productor | Consumidor |
llenas | 0 | Elementos disponibles | Consumidor | Productor |
mutex | 1 | El permiso de tocar el buffer | Ambos | Ambos |
flowchart LR
subgraph P["Productor"]
P1["producir elemento"] --> P2["wait(vacias)"]
P2 --> P3["wait(mutex)"]
P3 --> P4["escribir en buffer[in]<br/>in = (in+1) mod N"]
P4 --> P5["signal(mutex)"]
P5 --> P6["signal(llenas)"]
P6 --> P1
end
subgraph B["Buffer circular de N posiciones"]
BUF["vacias + llenas = N siempre"]
end
subgraph C["Consumidor"]
C1["wait(llenas)"] --> C2["wait(mutex)"]
C2 --> C3["leer de buffer[out]<br/>out = (out+1) mod N"]
C3 --> C4["signal(mutex)"]
C4 --> C5["signal(vacias)"]
C5 --> C6["consumir elemento"]
C6 --> C1
end
P4 -.->|"deposita"| BUF
BUF -.->|"entrega"| C3
Fíjate en dos detalles que son los que hacen correcto este esquema y que se equivocan con muchísima frecuencia:
El orden de los dos wait no es arbitrario. Primero el semáforo contador (vacias o llenas), después el mutex. Si se invierte, el productor podría tomar el mutex y luego bloquearse en wait(vacias) porque el buffer está lleno. Como se durmió sosteniendo el mutex, el consumidor no puede entrar a vaciar el buffer, y el buffer nunca se vacía. Deadlock perfecto, y reproducible solo cuando el buffer se llena, es decir, en producción y no en las pruebas.
sequenceDiagram
participant Prod as Productor
participant Mx as mutex (1)
participant Vac as vacias (0, buffer lleno)
participant Cons as Consumidor
Note over Prod,Cons: ORDEN INVERTIDO: wait(mutex) antes de wait(vacias)
Prod->>Mx: wait(mutex) -> lo obtiene
Prod->>Vac: wait(vacias) -> buffer lleno, se bloquea
Note over Prod: DORMIDO sosteniendo el mutex
Cons->>Mx: wait(mutex) -> bloqueado, el mutex esta tomado
Note over Cons: DORMIDO sin poder vaciar el buffer
Note over Prod,Cons: Deadlock: cada uno espera algo que solo el otro puede dar
Los signal sí pueden ir en cualquier orden, porque signal nunca bloquea. Soltar el mutex antes de señalar llenas es apenas una optimización: despierta al consumidor con el mutex ya libre, ahorrándole un bloqueo inmediato.
La invariante del sistema es vacias + llenas + (elementos en tránsito) = N. Si en algún momento esa suma no cuadra, hay un signal de más o de menos en el código.
Implementación en Ada
Ada no expone un tipo “semáforo” en su biblioteca estándar, y la razón es de diseño: el lenguaje ofrece construcciones de más alto nivel —los objetos protegidos y el rendezvous entre tareas— que hacen innecesario el semáforo en la mayoría de los casos. Pero precisamente por eso Ada es un lenguaje excelente para implementar un semáforo y ver el mecanismo al descubierto: un objeto protegido con una entrada guardada por una barrera es exactamente un semáforo, con la cola de espera gestionada por el runtime.
Un semáforo contador con un objeto protegido
Un protected type garantiza exclusión mutua automática sobre sus operaciones. Una entry tiene una condición booleana llamada barrera: si la barrera es falsa, la tarea que llama queda encolada y suspendida hasta que la barrera se vuelva verdadera. El runtime reevalúa las barreras cada vez que termina una operación protegida.
with Ada.Text_IO; use Ada.Text_IO;
procedure Demo_Semaforo is
-- Declaracion del semaforo contador.
-- El discriminante fija la cantidad inicial de permisos.
protected type Semaforo (Inicial : Natural) is
entry Wait; -- P: puede bloquear
procedure Signal; -- V: nunca bloquea
function Disponibles return Natural;
function En_Espera return Natural;
private
Cuenta : Natural := Inicial;
end Semaforo;
protected body Semaforo is
-- La barrera "when Cuenta > 0" es el corazon del mecanismo.
-- Si es falsa, la tarea llamante se encola y se suspende.
entry Wait when Cuenta > 0 is
begin
Cuenta := Cuenta - 1;
end Wait;
procedure Signal is
begin
Cuenta := Cuenta + 1;
end Signal;
function Disponibles return Natural is
begin
return Cuenta;
end Disponibles;
-- Wait'Count es la cantidad de tareas encoladas en esa entrada.
function En_Espera return Natural is
begin
return Wait'Count;
end En_Espera;
end Semaforo;
-- Tres permisos: hasta tres tareas dentro de la region a la vez.
Recursos : Semaforo (3);
task type Cliente (Id : Positive);
task body Cliente is
begin
Put_Line ("Cliente" & Positive'Image (Id) & " pide un recurso");
Recursos.Wait;
Put_Line ("Cliente" & Positive'Image (Id) & " ENTRA");
delay 0.5;
Put_Line ("Cliente" & Positive'Image (Id) & " sale");
Recursos.Signal;
end Cliente;
-- Cinco tareas compitiendo por tres permisos.
C1 : Cliente (1);
C2 : Cliente (2);
C3 : Cliente (3);
C4 : Cliente (4);
C5 : Cliente (5);
begin
null; -- El procedimiento no termina hasta que las cinco tareas terminan.
end Demo_Semaforo;
Para compilarlo y ejecutarlo con GNAT:
gnatmake demo_semaforo.adb
./demo_semaforo
La salida muestra tres clientes entrando de inmediato y los otros dos esperando hasta que se libere un permiso. Nunca hay más de tres dentro simultáneamente.
Hay varios detalles del lenguaje que merecen explicación:
- El discriminante
(Inicial : Natural)permite crear semáforos con distinta capacidad a partir del mismo tipo:Semaforo (3),Semaforo (1),Semaforo (0). Cuentaes de tipoNatural, que excluye los negativos por definición del tipo. Si un error de lógica intentara decrementar por debajo de cero, el programa levantaríaConstraint_Erroren vez de corromper silenciosamente el estado. Esta es una ventaja concreta del sistema de tipos de Ada aplicada a sincronización.Wait'Countes un atributo real del lenguaje que devuelve cuántas tareas están encoladas en esa entrada. Es la contraparte del “contador negativo” de la convención de Dijkstra, pero explícita y sin ambigüedad.- El orden de las declaraciones importa: el cuerpo del tipo protegido y el cuerpo de la tarea deben aparecer antes de las declaraciones de objetos de esos tipos, porque declarar un objeto congela el tipo.
Un semáforo binario con Suspension_Object
Para el caso estrictamente binario, Ada ofrece un tipo estándar de bajo nivel en Ada.Synchronous_Task_Control. Es un semáforo binario de verdad, pensado para señalización eficiente.
with Ada.Text_IO; use Ada.Text_IO;
with Ada.Synchronous_Task_Control; use Ada.Synchronous_Task_Control;
procedure Demo_Senal is
-- Un Suspension_Object es un flag booleano con espera bloqueante.
-- Arranca en False, que equivale a un semaforo binario inicializado en 0.
Dato_Listo : Suspension_Object;
Valor : Integer := 0;
task Productor;
task Consumidor;
task body Productor is
begin
Put_Line ("Productor: calculando...");
delay 1.0;
Valor := 42;
Put_Line ("Productor: dato listo, envia la senal");
Set_True (Dato_Listo); -- equivale a signal / V
end Productor;
task body Consumidor is
begin
Put_Line ("Consumidor: espera el dato");
Suspend_Until_True (Dato_Listo); -- equivale a wait / P
Put_Line ("Consumidor: recibio" & Integer'Image (Valor));
end Consumidor;
begin
null;
end Demo_Senal;
La API completa del tipo es corta y conviene conocerla entera:
| Operación | Efecto |
|---|---|
Set_True (SO) | Pone el estado en verdadero. Si hay una tarea suspendida, la libera |
Set_False (SO) | Pone el estado en falso |
Suspend_Until_True (SO) | Si el estado es verdadero, lo pone en falso y sigue. Si es falso, suspende la tarea |
Current_State (SO) | Devuelve el estado actual como Boolean |
Y con una restricción que hay que respetar: solo una tarea puede estar suspendida en un Suspension_Object a la vez. Si una segunda tarea llama a Suspend_Until_True mientras otra ya está esperando, se levanta Program_Error. Esa limitación es deliberada: permite una implementación mínima y predecible, apta para sistemas de tiempo real. Si necesitas que varias tareas esperen, usa un objeto protegido con una entry, como en el ejemplo anterior.
Productor-consumidor completo en Ada
Ahora el problema del buffer acotado, resuelto con los tres semáforos del esquema clásico, para ver que el patrón se traduce directamente.
with Ada.Text_IO; use Ada.Text_IO;
procedure Buffer_Acotado is
N : constant := 4; -- tamano del buffer circular
protected type Semaforo (Inicial : Natural) is
entry Wait;
procedure Signal;
private
Cuenta : Natural := Inicial;
end Semaforo;
protected body Semaforo is
entry Wait when Cuenta > 0 is
begin
Cuenta := Cuenta - 1;
end Wait;
procedure Signal is
begin
Cuenta := Cuenta + 1;
end Signal;
end Semaforo;
-- Los tres semaforos del patron.
Vacias : Semaforo (N); -- posiciones libres
Llenas : Semaforo (0); -- elementos disponibles
Mutex : Semaforo (1); -- acceso exclusivo a los indices
-- El buffer y sus indices son datos compartidos: solo se tocan con Mutex.
type Indice is mod N;
Buffer : array (Indice) of Integer := (others => 0);
Entrada : Indice := 0;
Salida : Indice := 0;
task Productor;
task Consumidor;
task body Productor is
begin
for I in 1 .. 10 loop
-- ORDEN CORRECTO: primero el contador, despues el mutex.
Vacias.Wait;
Mutex.Wait;
Buffer (Entrada) := I * 100;
Put_Line ("Produce" & Integer'Image (I * 100)
& " en la posicion" & Indice'Image (Entrada));
Entrada := Entrada + 1; -- aritmetica modular automatica
Mutex.Signal;
Llenas.Signal;
delay 0.1;
end loop;
end Productor;
task body Consumidor is
Item : Integer;
begin
for I in 1 .. 10 loop
Llenas.Wait;
Mutex.Wait;
Item := Buffer (Salida);
Salida := Salida + 1;
Mutex.Signal;
Vacias.Signal;
Put_Line (" Consume" & Integer'Image (Item));
delay 0.3; -- mas lento que el productor: el buffer se llenara
end loop;
end Consumidor;
begin
null;
end Buffer_Acotado;
El tipo Indice declarado como mod N hace que el incremento dé la vuelta automáticamente al llegar a N, que es exactamente el comportamiento del buffer circular sin necesidad de escribir el módulo a mano. El consumidor es deliberadamente más lento que el productor, así que verás cómo el productor se detiene cuando el buffer se llena y reanuda en cuanto se libera una posición.
Implementación en Elixir
Elixir corre sobre la máquina virtual de Erlang, y ahí la premisa es distinta: no hay memoria compartida entre procesos, cada proceso tiene su propio heap y la única forma de interactuar es enviando mensajes. Sin memoria compartida no hay condición de carrera sobre datos, así que el semáforo como protector de secciones críticas pierde buena parte de su razón de ser.
Pero la otra mitad del problema sigue existiendo, y con toda su fuerza: limitar la concurrencia sobre un recurso externo. Si diez mil procesos ligeros quieren escribir en una base de datos que soporta veinte conexiones, o llamar a una API con límite de tasa, hay que contar permisos. Eso es exactamente un semáforo contador, y la forma idiomática de construirlo es un proceso que sirve de árbitro.
Semáforo contador con GenServer
La pieza técnica interesante es cómo se implementa el bloqueo. En un GenServer, handle_call/3 normalmente responde con {:reply, respuesta, estado}. Pero también puede responder {:noreply, estado} y guardarse el from para contestarle más tarde con GenServer.reply/2. Mientras tanto, el proceso que llamó queda esperando dentro de GenServer.call. Ese es exactamente el bloqueo que necesitamos: el llamador se suspende y el servidor guarda su identidad en una cola FIFO.
defmodule Semaforo do
@moduledoc """
Semaforo contador. Los permisos se conceden en orden FIFO.
El bloqueo se implementa difiriendo la respuesta de handle_call/3.
"""
use GenServer
# ---------- API publica ----------
@doc "Arranca el semaforo con :permisos permisos disponibles."
def start_link(opts) do
permisos = Keyword.fetch!(opts, :permisos)
GenServer.start_link(__MODULE__, permisos, name: Keyword.get(opts, :name))
end
@doc "Operacion wait/P. Bloquea al llamador si no quedan permisos."
def wait(sem, timeout \\ 15_000) do
GenServer.call(sem, :wait, timeout)
end
@doc "Operacion wait no bloqueante. Devuelve :ok o :vacio."
def try_wait(sem) do
GenServer.call(sem, :try_wait)
end
@doc "Operacion signal/V. Nunca bloquea."
def signal(sem) do
GenServer.cast(sem, :signal)
end
@doc "Solo para diagnostico: permisos libres y tareas encoladas."
def estado(sem) do
GenServer.call(sem, :estado)
end
@doc "Ejecuta fun con un permiso tomado, y lo devuelve pase lo que pase."
def con_permiso(sem, fun) do
:ok = wait(sem)
try do
fun.()
after
signal(sem)
end
end
# ---------- Callbacks ----------
@impl true
def init(permisos) do
{:ok, %{cuenta: permisos, cola: :queue.new()}}
end
@impl true
def handle_call(:wait, _from, %{cuenta: c} = estado) when c > 0 do
# Hay permiso disponible: se responde de inmediato.
{:reply, :ok, %{estado | cuenta: c - 1}}
end
def handle_call(:wait, from, estado) do
# No hay permisos: se guarda el `from` y NO se responde todavia.
# El proceso llamante queda suspendido dentro de GenServer.call.
{:noreply, %{estado | cola: :queue.in(from, estado.cola)}}
end
def handle_call(:try_wait, _from, %{cuenta: c} = estado) when c > 0 do
{:reply, :ok, %{estado | cuenta: c - 1}}
end
def handle_call(:try_wait, _from, estado) do
{:reply, :vacio, estado}
end
def handle_call(:estado, _from, estado) do
{:reply, %{disponibles: estado.cuenta, esperando: :queue.len(estado.cola)}, estado}
end
@impl true
def handle_cast(:signal, estado) do
case :queue.out(estado.cola) do
{{:value, from}, resto} ->
# Hay alguien esperando: se le entrega el permiso directamente,
# sin pasar por el contador. Equivale a "despertar al primero".
GenServer.reply(from, :ok)
{:noreply, %{estado | cola: resto}}
{:empty, _} ->
# No hay nadie esperando: el permiso se guarda.
{:noreply, %{estado | cuenta: estado.cuenta + 1}}
end
end
end
El servidor procesa un mensaje a la vez, en serie, así que el contador y la cola están protegidos por construcción: no hace falta ningún candado interno. La atomicidad de wait y signal la garantiza el modelo de actores, no una instrucción del hardware.
Un script ejecutable que lo pone a prueba:
#-- guardar como semaforo_demo.exs y correr con: elixir semaforo_demo.exs
Code.require_file("semaforo.ex", __DIR__)
{:ok, sem} = Semaforo.start_link(permisos: 3)
inicio = System.monotonic_time(:millisecond)
tareas =
for id <- 1..8 do
Task.async(fn ->
Semaforo.con_permiso(sem, fn ->
t = System.monotonic_time(:millisecond) - inicio
IO.puts("t=#{t}ms tarea #{id} ENTRA")
Process.sleep(300)
IO.puts("t=#{System.monotonic_time(:millisecond) - inicio}ms tarea #{id} sale")
end)
end)
end
Task.await_many(tareas, 30_000)
IO.inspect(Semaforo.estado(sem), label: "estado final")
La salida muestra las tareas 1, 2 y 3 entrando cerca de t=0, y las siguientes entrando en tandas de tres cada 300 ms aproximadamente. El estado final debe ser %{disponibles: 3, esperando: 0}: si no lo es, hay un signal desbalanceado en alguna parte.
La función con_permiso/2 con try/after merece atención especial. Es el equivalente Elixir del patrón RAII de C++ o del with de Python: garantiza que el permiso se devuelva incluso si fun levanta una excepción o hace un throw. Sin ese after, una sola excepción dentro de la sección crítica filtra un permiso para siempre, y después de N excepciones el semáforo queda permanentemente en cero. Es el error más caro de esta familia, porque el sistema no falla de inmediato: se degrada lentamente hasta congelarse.
Productor-consumidor en Elixir con dos semáforos
Aquí no hace falta el mutex del esquema clásico, porque el buffer es un proceso y ya está serializado por naturaleza. Quedan solo los dos semáforos contadores.
defmodule BufferAcotado do
@moduledoc "Buffer FIFO servido por un proceso, con control de flujo por semaforos."
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, :queue.new(), name: __MODULE__)
def poner(x), do: GenServer.call(__MODULE__, {:poner, x})
def sacar, do: GenServer.call(__MODULE__, :sacar)
@impl true
def init(q), do: {:ok, q}
@impl true
def handle_call({:poner, x}, _from, q), do: {:reply, :ok, :queue.in(x, q)}
def handle_call(:sacar, _from, q) do
{{:value, x}, resto} = :queue.out(q)
{:reply, x, resto}
end
end
defmodule Demo do
@tamano 4
def correr do
{:ok, _} = BufferAcotado.start_link([])
{:ok, vacias} = Semaforo.start_link(permisos: @tamano) # posiciones libres
{:ok, llenas} = Semaforo.start_link(permisos: 0) # elementos listos
productor =
Task.async(fn ->
for i <- 1..10 do
:ok = Semaforo.wait(vacias) # espera si el buffer esta lleno
BufferAcotado.poner(i * 100)
IO.puts("produce #{i * 100}")
Semaforo.signal(llenas)
Process.sleep(100)
end
end)
consumidor =
Task.async(fn ->
for _ <- 1..10 do
:ok = Semaforo.wait(llenas) # espera si el buffer esta vacio
item = BufferAcotado.sacar()
IO.puts(" consume #{item}")
Semaforo.signal(vacias)
Process.sleep(300)
end
end)
Task.await_many([productor, consumidor], 30_000)
end
end
Demo.correr()
El productor va a 100 ms por elemento y el consumidor a 300 ms. Como el buffer tiene cuatro posiciones, el productor llena el buffer, se bloquea en wait(vacias), y a partir de ahí avanza al ritmo del consumidor. Eso es contrapresión: el semáforo no solo sincroniza, además acopla la velocidad del productor a la del consumidor sin que ninguno de los dos tenga que saber del otro.
La alternativa idiomática: cuando no escribir un semáforo
Elixir y la OTP ya traen soluciones para los casos más frecuentes, y en código real conviene usarlas antes que un semáforo propio.
#-- Limitar la concurrencia a 5 sin escribir ni un semaforo:
1..100
|> Task.async_stream(&trabajo_pesado/1, max_concurrency: 5, timeout: 30_000)
|> Enum.to_list()
max_concurrency: 5 es un semáforo contador inicializado en 5, implementado dentro de la biblioteca estándar, con manejo correcto de fallos y de timeouts. Para pools de conexiones existen bibliotecas dedicadas como poolboy o nimble_pool, que son semáforos contadores con recursos reales asociados a cada permiso.
| Necesidad | Solución idiomática en Elixir |
|---|---|
| Limitar tareas paralelas sobre una colección | Task.async_stream con max_concurrency |
| Pool de recursos costosos (conexiones, puertos) | poolboy, nimble_pool |
| Serializar el acceso a un estado | Un GenServer que sea dueño del estado |
| Contador atómico sin bloqueo | El módulo :counters de Erlang |
| Semáforo genérico entre procesos no relacionados | Un GenServer como el de este capítulo |
Comparación de los tres enfoques
| Aspecto | Semáforo POSIX (C) | Objeto protegido (Ada) | GenServer (Elixir) |
|---|---|---|---|
| Quién garantiza la atomicidad | El kernel, con instrucciones atómicas del hardware | El runtime del lenguaje | El modelo de actores: un mensaje a la vez |
| Dónde vive la cola de espera | En el kernel | En el runtime, por entry | En el estado del GenServer |
| Condición de despertar | Contador mayor que cero | Barrera booleana arbitraria | La lógica que escribas en handle_cast |
Riesgo de olvidar el signal | Alto | Alto, salvo que uses el objeto protegido directamente | Alto, mitigado con try/after |
| Qué pasa si el dueño muere | El permiso se pierde | El permiso se pierde | Se puede monitorear y recuperar |
| Costo de una operación sin contención | Nanosegundos | Nanosegundos | Microsegundos (paso de mensajes) |
Esa penúltima fila es la ventaja estructural del enfoque de actores: como el semáforo es un proceso, puede hacer Process.monitor sobre cada titular de un permiso y liberarlo automáticamente si el titular muere. Con un semáforo POSIX, un proceso que muere sosteniendo un permiso lo deja perdido para siempre y no hay nada que el semáforo pueda hacer al respecto.
Errores típicos
Los semáforos son correctos si se usan correctamente, y ese “si” es más frágil de lo que parece. El problema de fondo es que el semáforo y el recurso que protege no están conectados por nada más que la disciplina del programador. El compilador no verifica que cada wait tenga su signal, ni que el orden sea el mismo en todas las rutas, ni que el semáforo que tomaste sea el que corresponde al dato que estás tocando. Esta tabla concentra los fallos que aparecen una y otra vez.
| Error | Síntoma observable | Causa | Solución |
|---|---|---|---|
signal olvidado (por un return, una excepción o una rama del código) | El sistema funciona un rato y luego se congela definitivamente | Cada camino que no libera consume un permiso permanentemente | Envolver la sección crítica en un bloque que garantice la liberación: try/after en Elixir, controlled types o el objeto protegido en Ada, RAII en C++, defer en Go |
wait olvidado | Corrupción de datos intermitente, resultados que no cuadran | Se accede al recurso sin permiso; el semáforo protege solo a quien lo respeta | Encapsular el recurso de modo que solo sea accesible a través de la función que toma el semáforo |
Orden inverso de dos wait en distintos hilos | Deadlock que aparece bajo carga y no en las pruebas | Espera circular: A tiene S1 y pide S2, B tiene S2 y pide S1 | Definir un orden global de adquisición y respetarlo en todo el código sin excepción |
wait(mutex) antes de wait(contador) en productor-consumidor | El sistema se congela justo cuando el buffer se llena o se vacía | Un hilo se duerme sosteniendo el mutex y nadie puede entrar a liberarlo | Siempre el semáforo contador primero, el mutex después. Regla: nunca bloquearse sosteniendo un candado |
signal de más | Más hilos de los permitidos entran a la región crítica | El contador crece por encima de su valor inicial y regala permisos inexistentes | Verificar la invariante: la suma de permisos libres más permisos tomados debe ser constante. Usar un tipo binario cuando corresponda |
| Liberar un semáforo binario desde un hilo distinto al que lo tomó | Exclusión mutua rota, con un patrón difícil de reproducir | El semáforo no tiene dueño, no lo detecta | Usar un mutex de verdad cuando lo que se quiere es exclusión mutua |
Leer el valor con sem_getvalue y decidir en función de él | Condición de carrera reintroducida | Entre la lectura y la decisión otro hilo ya cambió el valor | Usar sem_trywait, que pregunta y actúa atómicamente |
Ignorar el retorno de sem_wait | Se entra a la sección crítica sin permiso | En Unix una señal puede interrumpir la espera y devolver EINTR | Comprobar siempre el retorno y reintentar el wait si el error fue EINTR |
| Semáforo inicializado con el valor equivocado | Con 0 en un mutex, deadlock inmediato. Con N mayor al debido, exclusión mutua rota | El valor inicial es el contrato del semáforo | Documentar junto a cada semáforo qué cuenta exactamente. Mutex es 1, señalización es 0, pool es N |
| Inversión de prioridades | Una tarea de alta prioridad queda bloqueada indefinidamente por una de baja | La de baja prioridad tiene el semáforo y es expropiada por una de prioridad media | Usar un mutex con herencia de prioridad. Los semáforos contadores no pueden ofrecerla porque no tienen dueño |
| Señal perdida | Un consumidor espera para siempre un evento que ya ocurrió | Se usó una variable de condición o una notificación sin estado, en vez de un semáforo | El semáforo recuerda el signal en su contador; ese es justamente su valor frente a las notificaciones sin memoria |
| El titular de un permiso muere | El permiso desaparece del sistema y el pool se agota gradualmente | Nadie ejecuta el signal correspondiente | En sistemas con supervisión, monitorear al titular y liberar el permiso al detectar su caída |
| Un semáforo por dato en vez de por invariante | Deadlocks al necesitar dos datos a la vez | Granularidad demasiado fina sin un orden definido | Proteger la invariante completa con un semáforo, o definir un orden estricto de adquisición |
El caso del deadlock por orden inverso, en detalle
Merece una explicación aparte porque es el más común en sistemas grandes y el más difícil de reproducir.
sequenceDiagram
participant A as Hilo A
participant S1 as Semaforo 1
participant S2 as Semaforo 2
participant B as Hilo B
A->>S1: wait(S1) -> obtenido
B->>S2: wait(S2) -> obtenido
A->>S2: wait(S2) -> BLOQUEADO
B->>S1: wait(S1) -> BLOQUEADO
Note over A,B: Espera circular. Ninguno soltara lo que tiene<br/>porque ninguno puede avanzar hasta obtener lo que falta.
Las cuatro condiciones de Coffman se cumplen todas: exclusión mutua (el permiso es de uno solo), retención y espera (cada uno tiene uno y pide otro), no expropiación (nadie puede quitarle el permiso a otro) y espera circular (A espera a B y B espera a A). Basta con romper cualquiera de ellas. La forma más práctica en la industria es romper la circularidad imponiendo un orden total de adquisición: se numeran todos los semáforos del sistema y se prohíbe pedir uno de número menor teniendo uno de número mayor. Es una regla que se puede revisar mecánicamente y que no cuesta nada en tiempo de ejecución. Estudiaremos el deadlock a fondo, con sus estrategias de prevención, evitación, detección y recuperación, más adelante en el curso.
Sobre la equidad y la inanición
Que el semáforo despierte a los procesos en orden FIFO garantiza espera acotada: si te encolaste, sabes que a lo sumo pasarán delante de ti los que ya estaban antes. Esa propiedad hace que un semáforo FIFO no produzca inanición.
Pero no todas las implementaciones son FIFO. Algunas despiertan a todos los que esperan y dejan que compitan de nuevo por el permiso —lo que se conoce como thundering herd, la estampida—, y en ese esquema un proceso con mala suerte puede perder indefinidamente. Otras despiertan por prioridad, lo que es deseable en tiempo real y produce inanición de las tareas de baja prioridad por definición.
Hay además un caso de inequidad que no depende de la implementación sino del problema. Supón un semáforo contador que administra recursos de distinto tamaño: si un proceso necesita tres permisos y otro necesita uno, el que necesita tres puede ver cómo los permisos se liberan de a uno y son tomados inmediatamente por procesos de un permiso, sin llegar nunca a juntar los tres. Ese es un fenómeno real que aparece en asignación de memoria y en reservas de recursos, y la solución no es cambiar el semáforo sino cambiar el protocolo: pedir todo de una vez, o reservar de manera creciente impidiendo que otros tomen mientras se completa la reserva.
Un ejercicio completo: la barbería
Este problema, en la variante que usa la guía de referencia, integra casi todo lo anterior. El planteo: una barbería con tres barberos, tres sillas de corte y una sala de espera con cuatro asientos. Un cliente que llega y encuentra todo ocupado —tres cortándose y cuatro esperando— se va. Los que entran esperan turno, se cortan el pelo y pagan en una caja atendida por uno de los barberos.
La descomposición en semáforos:
| Semáforo | Inicial | Significa |
|---|---|---|
capacidad | 7 | Aforo total del local (3 sillas de corte + 4 de espera) |
sillas | 3 | Sillas de corte libres |
barberos_listos | 0 | Un barbero disponible avisa por aquí |
cliente_listo | 0 | Un cliente sentado avisa por aquí |
corte_terminado | 0 | El barbero avisa que terminó el corte |
caja | 1 | Exclusión mutua sobre la caja registradora |
pago_hecho | 0 | El cliente avisa que pagó |
recibo_dado | 0 | El barbero avisa que entregó el recibo |
sequenceDiagram
participant C as Cliente
participant Cap as capacidad (7)
participant B as Barbero
participant Cj as caja (1)
C->>Cap: try_wait(capacidad)
alt local lleno
Cap-->>C: sin permiso
Note over C: se va sin cortarse
else hay lugar
C->>C: wait(sillas)
C->>B: signal(cliente_listo)
C->>C: wait(barberos_listos)
Note over B: corta el pelo
B-->>C: signal(corte_terminado)
C->>C: signal(sillas)
C->>Cj: wait(caja)
C->>B: signal(pago_hecho)
C->>C: wait(recibo_dado)
C->>Cj: signal(caja)
C->>Cap: signal(capacidad)
end
Los pares cliente_listo / barberos_listos y pago_hecho / recibo_dado son rendezvous bidireccionales: fuerzan a que cliente y barbero avancen sincronizados en cada etapa. El capacidad con try_wait en vez de wait es lo que implementa “si está lleno, me voy” en vez de “si está lleno, espero”. Y caja es un mutex puro, porque la caja es un recurso único y no importa quién la use, solo que la use uno a la vez.
Cuándo el semáforo es la herramienta equivocada
Vale la pena cerrar con la crítica honesta, porque el capítulo siguiente existe justamente por ella.
El semáforo tiene un problema estructural: es una primitiva sin estructura. Un wait y su signal correspondiente pueden estar en archivos distintos, en módulos distintos, escritos por personas distintas con años de diferencia. Nada en el lenguaje los vincula. El compilador no sabe qué dato protege qué semáforo. Una revisión de código tiene que verificar a mano que todas las rutas de salida de una función liberen lo que tomaron, incluidas las rutas de excepción, las de retorno temprano y las de goto a la etiqueta de limpieza.
Las consecuencias prácticas:
- Un solo
signalolvidado en una rama poco frecuente degrada el sistema semanas después de desplegarlo, y el síntoma —“se cuelga cada tantos días”— no apunta a la causa. - La corrección no es composicional. Dos módulos correctos por separado pueden formar un deadlock al combinarse, porque cada uno adquiere sus semáforos en un orden que era coherente puertas adentro.
- El código de sincronización se mezcla con la lógica de negocio. La sección crítica no está marcada por ninguna construcción del lenguaje: hay que reconocerla leyendo dónde están las llamadas.
La respuesta a esas tres objeciones fue mover la sincronización del código que usa el recurso al código que es el recurso: encapsular los datos compartidos junto con las operaciones que los tocan, y hacer que la exclusión mutua sea automática y garantizada por el compilador, no por la disciplina de quien llama. Esa idea es el monitor, y ya la usaste sin nombrarla en este mismo capítulo: el protected type de Ada es un monitor, y el GenServer de Elixir es un monitor con la vuelta de tuerca del paso de mensajes.
Ejercicios propuestos
-
Verifica la invariante. Toma el productor-consumidor en Ada e instrumenta el semáforo con un contador global de operaciones. Después de cada ciclo, comprueba que
vacias + llenassea siempre igual a N. Introduce a propósito unsignalde más y observa exactamente en qué momento el buffer empieza a entregar datos corruptos. -
Provoca el deadlock. Modifica el productor en Ada para que haga
Mutex.Waitantes deVacias.Wait. Ajusta las velocidades para que el buffer se llene rápido. Documenta cuánto tarda el programa en congelarse y por qué el punto exacto de congelamiento varía entre ejecuciones. -
Implementa un semáforo con timeout en Elixir. Extiende el
GenServerpara quewaitacepte un plazo. Cuando el plazo vence, el llamador debe salir sin permiso y el servidor debe sacarlo de la cola. Piensa qué pasa si elsignalllega justo cuando el timeout vence: es una carrera real y hay que resolverla explícitamente. -
Semáforo con recuperación ante caídas. Agrega
Process.monitoral semáforo de Elixir, de modo que si un proceso que tenía un permiso muere, el permiso se devuelva automáticamente. Compara este comportamiento con lo que ocurre con unsem_tde POSIX en la misma situación. -
Construye una barrera reutilizable. Implementa una barrera para N tareas que funcione en varias rondas consecutivas, no solo una vez. La versión ingenua falla en la segunda ronda porque una tarea rápida puede volver a entrar a la barrera antes de que las lentas hayan salido de la primera. Investiga la solución de la barrera de dos fases o turnstile.
-
Lectores y escritores. Con semáforos, permite que varios lectores accedan simultáneamente a un dato pero que un escritor tenga acceso exclusivo. Implementa primero la versión con prioridad de lectores y comprueba experimentalmente que un flujo continuo de lectores produce inanición del escritor. Luego corrige el protocolo para evitarlo.
-
Mide el costo de la espera activa. Implementa la misma exclusión mutua dos veces: con el algoritmo de Peterson del capítulo anterior y con un semáforo. Mide el tiempo total de CPU consumido en ambos casos cuando la sección crítica dura 100 ms y hay cuatro hilos compitiendo. La diferencia es la razón de existir de este capítulo.
-
Rendezvous en Ada sin objetos protegidos. Reescribe el ejemplo de señalización usando el rendezvous nativo de Ada entre tareas (
accepty llamada a entrada de tarea) en vez deSuspension_Object. Compara la legibilidad y decide en qué casos preferirías cada mecanismo.
Lo que viene
Con este capítulo tienes la primitiva que hizo posible la programación concurrente práctica. Sabes qué hay dentro de un semáforo, por qué sus operaciones deben ser atómicas y quién se lo garantiza, cómo se distingue un binario de un contador, y cómo se combinan para resolver exclusión mutua, señalización, limitación de concurrencia y control de flujo. También sabes por qué un semáforo mal usado no falla de inmediato sino que degrada, que es la peor forma de fallar.
En el capítulo 9 atacamos justamente esa fragilidad. El monitor invierte la responsabilidad: en vez de pedirle al programador que recuerde tomar y soltar el candado correcto, encapsula los datos compartidos junto con sus operaciones y hace que la exclusión mutua sea una propiedad del tipo, no una convención. Veremos las variables de condición con sus operaciones wait y signal —que se llaman igual que las del semáforo pero significan algo distinto, y esa confusión es una fuente clásica de errores—, la diferencia entre la semántica de Hoare y la de Mesa, y por qué en la práctica casi todos los sistemas eligieron Mesa y por qué eso te obliga a escribir while en lugar de if. Los objetos protegidos de Ada y los GenServer de Elixir que ya usaste aquí serán entonces los protagonistas, y no una herramienta prestada.
El temario completo del curso está en el índice general.