DEV Community

GoyesDev
GoyesDev

Posted on

[GCD] Sincronización implícita y explícita con DispatchGroup

En artículos anteriores ya apareció DispatchGroup, pero solo como herramienta auxiliar para que el código de prueba esperara a que terminara trabajo despachado con async. Este artículo lo cubre como tema central: cómo coordinar varias tareas independientes y ejecutar algo (o desbloquear un hilo) solo cuando todas hayan terminado, sin importar el orden en que terminen ni en qué cola corran.

Marcar entrada y salida de forma explícita: enter(), leave() y notify(queue:execute:)

group.enter() marca que una tarea empezó, y group.leave() marca que terminó. enter() se llama antes de despachar la tarea; leave() se llama dentro del closure despachado, en el punto donde el trabajo realmente termina.

Consideremos el siguiente fragmento de código, donde tres tareas se despachan con async sobre una cola concurrente, cada una marcando su propio enter()/leave():

let group = DispatchGroup()
let queue = DispatchQueue(label: "dev.goyes.gcd.group", attributes: .concurrent)
var result: [Int] = []

for i in 1...3 {
  group.enter()
  queue.async {
    Thread.sleep(forTimeInterval: 0.3)
    result.append(i)
    group.leave()
  }
}

group.notify(queue: .main) {
  print("Todas las tareas terminaron: \(result)")
}
Enter fullscreen mode Exit fullscreen mode

notify(queue:execute:) registra un closure que se ejecuta, en la cola indicada, cuando el número de leave() recibidos iguala al número de enter() registrados. No bloquea ningún hilo: el closure se encola automáticamente cuando el grupo se vacía. El orden en que las tres tareas terminan y anotan su valor en result no está garantizado (la cola es concurrente), pero notify solo corre después de que las tres, sin importar el orden, ya hayan llamado leave().

Error común: llamar enter() dentro del bloque async

enter() tiene que ejecutarse en el hilo que despacha la tarea, antes de queue.async, no dentro del closure que se despacha. Moverlo adentro es un error frecuente porque el código compila y parece razonable, pero rompe la sincronización.

El siguiente fragmento repite el ejemplo anterior, con ese error introducido:

let group = DispatchGroup()
let queue = DispatchQueue(label: "dev.goyes.gcd.group-bug", attributes: .concurrent)
var result: [Int] = []

for i in 1...3 {
  queue.async {
    group.enter() // ⚠️ Debería llamarse antes de queue.async, no dentro
    Thread.sleep(forTimeInterval: 0.3)
    result.append(i)
    group.leave()
  }
}

group.notify(queue: .main) {
  print("Todas terminaron: \(result)") // Puede imprimir "[]", sin haber esperado a ninguna
}
Enter fullscreen mode Exit fullscreen mode

group.notify se llama justo después del for, en el hilo que hace el llamado, y en ese momento ninguna de las tres tareas ha alcanzado a ejecutar su enter() todavía (recién se despacharon, corren en otro hilo). Desde la perspectiva del grupo, en ese instante no hay ningún enter() pendiente: el conteo está en cero. notify considera que el grupo ya está vacío y programa su closure para correr de inmediato, sin haber esperado a que ninguna tarea real termine.

Forma implícita: async(group:execute:)

En lugar de enter()/leave() manuales, async(group:execute:) los maneja de forma implícita: enter() ocurre justo antes de que el closure empiece a correr, y leave() ocurre justo cuando ese mismo closure retorna.

Cuando todo el trabajo de la tarea es código síncrono contenido por completo dentro del closure (sin llamadas asíncronas anidadas), esa forma implícita es segura y funciona exactamente igual que la forma explícita.

El siguiente fragmento reemplaza el enter()/leave() manual del primer ejemplo por la forma implícita, sin cambiar el comportamiento:

let group = DispatchGroup()
let queue = DispatchQueue(label: "dev.goyes.gcd.group-implicit-safe", attributes: .concurrent)
var result: [Int] = []

for i in 1...3 {
  queue.async(group: group) {
    Thread.sleep(forTimeInterval: 0.3)
    result.append(i)
  }
}

group.notify(queue: .main) {
  print("Todas las tareas terminaron: \(result)")
}
Enter fullscreen mode Exit fullscreen mode

Cada closure hace todo su trabajo (Thread.sleep y result.append(i)) antes de retornar, así que el leave() implícito ocurre exactamente en el punto donde la tarea realmente termina. group.notify sigue esperando correctamente a las tres.

El riesgo de async(group:execute:): callbacks anidados

leave() se dispara cuando el closure retorna, no cuando el trabajo que ese closure representa termina de verdad. Para saber si async(group:execute:) es seguro en un caso concreto, hay que revisar si el código que va dentro del closure tiene, a su vez, algún bloque de callback propio (una llamada de red, un temporizador, cualquier API que reciba un closure de finalización). Si lo tiene, ese callback interno es el verdadero punto donde el trabajo termina, y ahí es donde debería ir leave(), no al final del closure exterior. Por eso leave() no siempre va en la última línea del bloque: cuando hay un callback anidado, leave() tiene que pasarse dentro de ese callback, y eso obliga a usar la forma explícita.

Tomemos como ejemplo una función que simula una llamada 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 async(group:execute:) con esta función, de forma incorrecta:

let group = DispatchGroup()
let queue = DispatchQueue(label: "dev.goyes.gcd.group-implicit-wrong")
var result: Int?

queue.async(group: group) {
  fetchRemoteValue { value in
    result = value // ⚠️ El closure exterior ya retornó y leave() ya se llamó antes de que esto corra
  }
}

group.notify(queue: .main) {
  print("result: \(String(describing: result))") // Puede imprimir "nil"
}
Enter fullscreen mode Exit fullscreen mode

El closure exterior retorna de inmediato después de llamar a fetchRemoteValue, porque fetchRemoteValue en sí también retorna de inmediato (solo agenda el trabajo real). async(group:execute:) marca leave() en ese momento, antes de que el closure de finalización (completion) siquiera haya corrido. La forma correcta es usar enter()/leave() explícitos, con leave() dentro del callback real:

let group = DispatchGroup()
let queue = DispatchQueue(label: "dev.goyes.gcd.group-implicit-correct")
var result: Int?

group.enter()
queue.async {
  fetchRemoteValue { value in
    result = value
    group.leave() // El callback es el verdadero punto de finalización del trabajo
  }
}

group.notify(queue: .main) {
  print("result: \(String(describing: result))") // Siempre tiene el valor 42
}
Enter fullscreen mode Exit fullscreen mode

Sincronización bloqueante con wait()

Además de notify, que no bloquea nada, wait() bloquea el hilo que hace el llamado hasta que el grupo se vacía por completo.

El fragmento de abajo despacha tres tareas sobre una cola concurrente y usa wait() para bloquear hasta que las tres hayan terminado:

let group = DispatchGroup()
let queue = DispatchQueue(label: "dev.goyes.gcd.group-wait", attributes: .concurrent)
var result: [Int] = []

for i in 1...3 {
  group.enter()
  queue.async {
    Thread.sleep(forTimeInterval: 0.3)
    result.append(i)
    group.leave()
  }
}

group.wait()
print("Todas terminaron: \(result)")
Enter fullscreen mode Exit fullscreen mode

print("Todas terminaron: \(result)") solo se ejecuta después de que wait() retorna, y wait() no retorna hasta que las tres tareas ya llamaron leave(). Es la misma relación que existe entre sync y async al despachar una sola tarea: notify es la versión no bloqueante, wait() es la versión bloqueante.

Balance estricto entre enter() y leave()

Cada enter() tiene que tener su leave() correspondiente, ni más ni menos. Los dos desbalances posibles fallan de formas distintas, como muestran los siguientes dos fragmentos.

Más leave() que enter()

Si se llama leave() más veces que enter(), el conteo interno del grupo baja por debajo de su valor inicial, lo cual es un error fatal detectado por libdispatch en tiempo de ejecución: la app termina con un crash del tipo BUG IN CLIENT OF LIBDISPATCH: Unbalanced call to dispatch_group_leave().

El siguiente fragmento provoca ese crash llamando leave() una vez de más:

let group = DispatchGroup()

group.enter()
group.leave()
group.leave() // ⚠️ Crash: BUG IN CLIENT OF LIBDISPATCH: Unbalanced call to dispatch_group_leave()
Enter fullscreen mode Exit fullscreen mode

Más enter() que leave(): el grupo nunca se vacía

Si se llama enter() más veces que leave(), el grupo nunca llega a un conteo de cero. wait() se queda bloqueado para siempre y el closure de notify nunca se ejecuta.

Este otro fragmento ilustra ese caso, con una tarea que nunca llama a leave():

group.enter()
queue.async {
  // ⚠️ Si esta tarea nunca llama a group.leave(), wait() y notify() se quedan esperando para siempre
}
Enter fullscreen mode Exit fullscreen mode

Lo que viene

Falta ver cómo proteger el acceso a estado compartido en una cola concurrente sin serializar también las lecturas, con DispatchBarrier. Eso es lo que cubre el siguiente artículo.


Bibliografía

Top comments (0)