Semáforos: el primer mecanismo que resolvió la exclusión mutua sin espera activa

Por: Artiko
sistemas-operativosadaelixirconcurrenciasemaforosexclusion-mutuasincronizacionproductor-consumidordeadlock

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ónNombres alternativosEfecto conceptual
waitP, down, acquire, pend, takeIntenta consumir un permiso; si no hay, bloquea al llamador
signalV, up, release, post, giveDevuelve 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.

AspectoSemáforo binarioMutex
Noción de dueñoNo tieneEl hilo que lo toma
Quién puede liberarloCualquier hiloSolo el dueño
Uso típicoSeñalización entre tareas, exclusión mutua simpleExclusión mutua exclusivamente
Herencia de prioridadGeneralmente no la soportaSuele soportarla (evita inversión de prioridades)
ReentranteNoExisten variantes recursivas
Seguro desde una interrupciónsignalNo
Detección de doble liberaciónNo, se convierte en un contador rotoEl 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ísticaBinarioContadorContador con signo
Rango del valor0 a 10 a N−∞ a N
Valor inicial típico1 (mutex) o 0 (señal)N recursosN recursos
Interpretación del valorLibre / ocupadoPermisos disponiblesPositivo: permisos libres. Negativo: procesos esperando
signal extraSe ignora o es errorIncrementa el contadorIncrementa el contador
Uso canónicoExclusión mutua, rendezvousPool de recursos, buffer acotadoIgual, con diagnóstico incluido
Ejemplo realsem_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ónQué 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:

  1. El productor no debe escribir si el buffer está lleno.
  2. El consumidor no debe leer si el buffer está vacío.
  3. Productor y consumidor no deben manipular los índices del buffer al mismo tiempo.

Tres necesidades, tres semáforos:

SemáforoValor inicialQué cuentaQuién hace waitQuién hace signal
vaciasN (tamaño del buffer)Posiciones libresProductorConsumidor
llenas0Elementos disponiblesConsumidorProductor
mutex1El permiso de tocar el bufferAmbosAmbos
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).
  • Cuenta es de tipo Natural, que excluye los negativos por definición del tipo. Si un error de lógica intentara decrementar por debajo de cero, el programa levantaría Constraint_Error en vez de corromper silenciosamente el estado. Esta es una ventaja concreta del sistema de tipos de Ada aplicada a sincronización.
  • Wait'Count es 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ónEfecto
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.

NecesidadSolución idiomática en Elixir
Limitar tareas paralelas sobre una colecciónTask.async_stream con max_concurrency
Pool de recursos costosos (conexiones, puertos)poolboy, nimble_pool
Serializar el acceso a un estadoUn GenServer que sea dueño del estado
Contador atómico sin bloqueoEl módulo :counters de Erlang
Semáforo genérico entre procesos no relacionadosUn GenServer como el de este capítulo

Comparación de los tres enfoques

AspectoSemáforo POSIX (C)Objeto protegido (Ada)GenServer (Elixir)
Quién garantiza la atomicidadEl kernel, con instrucciones atómicas del hardwareEl runtime del lenguajeEl modelo de actores: un mensaje a la vez
Dónde vive la cola de esperaEn el kernelEn el runtime, por entryEn el estado del GenServer
Condición de despertarContador mayor que ceroBarrera booleana arbitrariaLa lógica que escribas en handle_cast
Riesgo de olvidar el signalAltoAlto, salvo que uses el objeto protegido directamenteAlto, mitigado con try/after
Qué pasa si el dueño muereEl permiso se pierdeEl permiso se pierdeSe puede monitorear y recuperar
Costo de una operación sin contenciónNanosegundosNanosegundosMicrosegundos (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.

ErrorSíntoma observableCausaSolución
signal olvidado (por un return, una excepción o una rama del código)El sistema funciona un rato y luego se congela definitivamenteCada camino que no libera consume un permiso permanentementeEnvolver 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 olvidadoCorrupción de datos intermitente, resultados que no cuadranSe accede al recurso sin permiso; el semáforo protege solo a quien lo respetaEncapsular 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 hilosDeadlock que aparece bajo carga y no en las pruebasEspera circular: A tiene S1 y pide S2, B tiene S2 y pide S1Definir un orden global de adquisición y respetarlo en todo el código sin excepción
wait(mutex) antes de wait(contador) en productor-consumidorEl sistema se congela justo cuando el buffer se llena o se vacíaUn hilo se duerme sosteniendo el mutex y nadie puede entrar a liberarloSiempre el semáforo contador primero, el mutex después. Regla: nunca bloquearse sosteniendo un candado
signal de másMás hilos de los permitidos entran a la región críticaEl contador crece por encima de su valor inicial y regala permisos inexistentesVerificar 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 reproducirEl semáforo no tiene dueño, no lo detectaUsar 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 élCondición de carrera reintroducidaEntre la lectura y la decisión otro hilo ya cambió el valorUsar sem_trywait, que pregunta y actúa atómicamente
Ignorar el retorno de sem_waitSe entra a la sección crítica sin permisoEn Unix una señal puede interrumpir la espera y devolver EINTRComprobar siempre el retorno y reintentar el wait si el error fue EINTR
Semáforo inicializado con el valor equivocadoCon 0 en un mutex, deadlock inmediato. Con N mayor al debido, exclusión mutua rotaEl valor inicial es el contrato del semáforoDocumentar junto a cada semáforo qué cuenta exactamente. Mutex es 1, señalización es 0, pool es N
Inversión de prioridadesUna tarea de alta prioridad queda bloqueada indefinidamente por una de bajaLa de baja prioridad tiene el semáforo y es expropiada por una de prioridad mediaUsar un mutex con herencia de prioridad. Los semáforos contadores no pueden ofrecerla porque no tienen dueño
Señal perdidaUn 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áforoEl semáforo recuerda el signal en su contador; ese es justamente su valor frente a las notificaciones sin memoria
El titular de un permiso muereEl permiso desaparece del sistema y el pool se agota gradualmenteNadie ejecuta el signal correspondienteEn 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 invarianteDeadlocks al necesitar dos datos a la vezGranularidad demasiado fina sin un orden definidoProteger 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áforoInicialSignifica
capacidad7Aforo total del local (3 sillas de corte + 4 de espera)
sillas3Sillas de corte libres
barberos_listos0Un barbero disponible avisa por aquí
cliente_listo0Un cliente sentado avisa por aquí
corte_terminado0El barbero avisa que terminó el corte
caja1Exclusión mutua sobre la caja registradora
pago_hecho0El cliente avisa que pagó
recibo_dado0El 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 signal olvidado 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

  1. 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 + llenas sea siempre igual a N. Introduce a propósito un signal de más y observa exactamente en qué momento el buffer empieza a entregar datos corruptos.

  2. Provoca el deadlock. Modifica el productor en Ada para que haga Mutex.Wait antes de Vacias.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.

  3. Implementa un semáforo con timeout en Elixir. Extiende el GenServer para que wait acepte un plazo. Cuando el plazo vence, el llamador debe salir sin permiso y el servidor debe sacarlo de la cola. Piensa qué pasa si el signal llega justo cuando el timeout vence: es una carrera real y hay que resolverla explícitamente.

  4. Semáforo con recuperación ante caídas. Agrega Process.monitor al 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 un sem_t de POSIX en la misma situación.

  5. 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.

  6. 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.

  7. 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.

  8. Rendezvous en Ada sin objetos protegidos. Reescribe el ejemplo de señalización usando el rendezvous nativo de Ada entre tareas (accept y llamada a entrada de tarea) en vez de Suspension_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.