DEV Community

GoyesDev
GoyesDev

Posted on

[GCD] Sincronización de hilos con DispatchSemaphore

DispatchBarrier resuelve el acceso exclusivo (una sola tarea a la vez) sobre una cola concurrente. DispatchSemaphore generaliza esa idea: en lugar de limitar el acceso a una sola tarea, limita el acceso a un número arbitrario de tareas simultáneas, y no depende de ninguna cola en particular. También sirve, igual que DispatchGroup, para convertir una API basada en callbacks en código que bloquea hasta obtener un resultado.

Qué es un semáforo: wait() y signal()

Un DispatchSemaphore se crea con un contador inicial: DispatchSemaphore(value:). Ese contador representa cuántos accesos simultáneos están permitidos.

El siguiente fragmento crea un semáforo con un contador inicial de 1:

let semaphore = DispatchSemaphore(value: 1)
Enter fullscreen mode Exit fullscreen mode

wait() decrementa el contador. Si el resultado sigue siendo mayor o igual a cero, wait() retorna de inmediato. Si el contador ya estaba en cero, wait() bloquea el hilo que hace el llamado hasta que otro hilo llame signal(), que incrementa el contador y, si hay algún hilo bloqueado en wait(), lo despierta.

Limitar cuántas tareas acceden a un recurso al mismo tiempo

Un semáforo con contador inicial N permite que hasta N tareas corran a la vez, y bloquea a las demás hasta que alguna de las N termine y llame signal().

El fragmento de abajo despacha cinco tareas sobre una cola concurrente, pero limita a dos el número de tareas que pueden correr al mismo tiempo:

let semaphore = DispatchSemaphore(value: 2)
let queue = DispatchQueue(label: "dev.goyes.gcd.semaphore", attributes: .concurrent)
let group = DispatchGroup()

for i in 1...5 {
  group.enter()
  queue.async {
    semaphore.wait()
    print("Tarea \(i) empieza")
    Thread.sleep(forTimeInterval: 0.3)
    print("Tarea \(i) termina")
    semaphore.signal()
    group.leave()
  }
}

group.wait()
Enter fullscreen mode Exit fullscreen mode

Aunque las cinco tareas se despachan de inmediato, solo dos pueden pasar de semaphore.wait() a la vez. Las otras tres quedan bloqueadas en wait() hasta que alguna de las dos primeras llame signal(). DispatchGroup aquí solo sirve para que el código de prueba espere a que las cinco tareas terminen, no forma parte del límite de concurrencia: eso lo impone el semáforo.

Semáforo con contador 1: el mismo efecto que una barrera, sin cola dedicada

Con contador inicial 1, un semáforo se comporta como un lock clásico: como máximo una tarea a la vez puede estar entre wait() y signal().

Este otro fragmento protege un contador compartido con un semáforo de contador 1, en lugar de usar DispatchBarrier:

final class SafeCounter {
  private let semaphore = DispatchSemaphore(value: 1)
  private var value = 0

  func increment() {
    semaphore.wait()
    value += 1
    semaphore.signal()
  }

  func currentValue() -> Int {
    semaphore.wait()
    defer { semaphore.signal() }
    return value
  }
}
Enter fullscreen mode Exit fullscreen mode

La diferencia con DispatchBarrier es que un semáforo no necesita una cola concurrente propia ni ninguna cola en particular: wait() y signal() funcionan sin importar desde qué hilo o cola se llamen. A cambio, a diferencia de DispatchBarrier (que sigue permitiendo lecturas en paralelo mientras no haya una escritura de por medio), un semáforo con contador 1 serializa absolutamente todo acceso, tanto lecturas como escrituras, sin distinción.

Convertir una API con callback en código que bloquea

Igual que DispatchGroup, un semáforo permite esperar de forma bloqueante a que termine un trabajo asíncrono con su propio closure de finalización, pero sin necesidad de enter()/leave(): basta con inicializar el semáforo en 0.

Considérese la siguiente función, que simula una API asíncrona con su propio closure de finalización:

func fetchRemoteValue(completion: @escaping (Int) -> Void) {
  DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) {
    completion(42)
  }
}
Enter fullscreen mode Exit fullscreen mode

El siguiente fragmento usa un semáforo, inicializado en 0, para bloquear hasta que fetchRemoteValue entregue su resultado:

let semaphore = DispatchSemaphore(value: 0)
var result: Int?

fetchRemoteValue { value in
  result = value
  semaphore.signal()
}

semaphore.wait()
print(result!) // 42
Enter fullscreen mode Exit fullscreen mode

Con contador inicial 0, la primera llamada a wait() siempre bloquea, porque no hay ningún acceso disponible todavía. Solo se desbloquea cuando fetchRemoteValue termina y su closure de finalización llama signal().

wait() en el hilo principal bloquea la interfaz

Aunque parezca un detalle menor, wait() bloquea el hilo que hace el llamado exactamente igual que sync. Si ese hilo es el principal, la interfaz se congela mientras dura el bloqueo: no se procesan eventos de UI, no hay animaciones, la app deja de responder. Esto aplica sin importar cuán rápido se espere que el semáforo se libere: si fetchRemoteValue tarda 300 milisegundos en el ejemplo anterior y ese semaphore.wait() se llama desde el hilo principal, la interfaz queda congelada esos 300 milisegundos. En un caso real, donde la tarea que se espera es una llamada de red con latencia variable, ese bloqueo puede ser mucho más largo e impredecible. Por eso wait() nunca debería llamarse desde el hilo principal cuando exista alguna posibilidad de que tarde en desbloquearse.

Diferencia con DispatchGroup: signal() de más no falla de inmediato

DispatchGroup exige un balance estricto entre enter() y leave(): llamar leave() de más produce un crash inmediato. Un semáforo es más permisivo: llamar signal() sin un wait() correspondiente no falla en ese momento, solo deja el contador por encima de su valor inicial, disponible para que futuros wait() no bloqueen. El riesgo aparece más adelante: si el semáforo se libera de memoria mientras su contador no coincide con el valor inicial, libdispatch sí detecta ese estado inconsistente y termina la app con un crash (BUG IN CLIENT OF LIBDISPATCH: Semaphore object deallocated while in use).

El riesgo opuesto sigue siendo igual de real que con DispatchGroup: si se llama wait() más veces de las que se llama signal(), el hilo que hace el llamado se queda bloqueado para siempre.

let semaphore = DispatchSemaphore(value: 0)
semaphore.wait() // ⚠️ Nunca se llama semaphore.signal(): este wait() se queda bloqueado para siempre
Enter fullscreen mode Exit fullscreen mode

Lo que viene

Falta ver, juntando todo lo que se cubrió en este módulo, los problemas más comunes que aparecen al trabajar con GCD y cómo reconocerlos. Eso es lo que cubre el siguiente artículo.


Bibliografía

Top comments (0)