DEV Community

GoyesDev
GoyesDev

Posted on • Edited on

[GCD] DQ Concurrente: ejecución síncrona

[GCD] DQ Concurrente: ejecución síncrona

La API es la misma que en una cola serial: sync bloquea al llamador hasta que el closure despachado termina. Lo que cambia es qué hace la cola con el resto de su trabajo mientras tanto.

Consideremos el siguiente fragmento de código, donde se despacha un bloque con sync sobre una cola concurrente:

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

result.append(1)

concurrentQueue.sync {
  Thread.sleep(forTimeInterval: 1)
  result.append(2)
}

result.append(3)

// result == [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Para una sola tarea despachada con sync, el comportamiento observado es idéntico al de una cola serial: el llamador espera, y el orden queda garantizado: result termina en [1, 2, 3], nunca en otro orden. La diferencia aparece cuando hay más tareas además de la que se está esperando.

sync bloquea por esa tarea, no por la cola

En una cola concurrente, varias tareas pueden estar corriendo en hilos distintos al mismo tiempo. sync bloquea al llamador hasta que su tarea termine. No detiene ni espera a las demás tareas que la cola esté procesando en paralelo.

El siguiente fragmento despacha tres tareas con async y luego una más con sync sobre la misma cola concurrente:

let concurrentQueue = DispatchQueue(label: "dev.goyes.concurrente", attributes: .concurrent)

for i in 1...3 {
  concurrentQueue.async {
    print("Tarea \(i) empieza, hilo: \(Thread.current)")
    Thread.sleep(forTimeInterval: 1)
    print("Tarea \(i) termina")
  }
}

concurrentQueue.sync {
  print("Tarea de sincronización")
}
Enter fullscreen mode Exit fullscreen mode

Las tres tareas despachadas con async se despachan, en orden de código, antes que la "Tarea de sincronización". El sync final espera a que su propio closure termine, pero no espera a que las tres tareas anteriores terminen. Esas siguen corriendo en paralelo, en sus propios hilos, independientemente de lo que haga el llamador.

El orden de entrega FIFO no garantiza el orden real de inicio

Al correr exactamente este código, una salida real fue:

Tarea de sincronización
Tarea 2 empieza, hilo: <NSThread: 0x104b29bc0>{number = 7, name = (null)}
Tarea 3 empieza, hilo: <NSThread: 0x1048e06c0>{number = 3, name = (null)}
Tarea 1 empieza, hilo: <NSThread: 0x1048e0b80>{number = 5, name = (null)}
Enter fullscreen mode Exit fullscreen mode

"Tarea de sincronización", despachada después de las tres, imprimió primero. Y entre las tres tareas async, el orden tampoco fue 1, 2, 3, sino 2, 3, 1.

Esto no contradice el FIFO de la cola: el FIFO garantiza el orden en que GCD entrega los closures para ejecución, no el orden real en que cada hilo ejecuta su primera instrucción. Una vez entregado un closure a un hilo, es el scheduler del sistema operativo quien decide cuándo ese hilo corre, no GCD. Y sync, a diferencia de async, puede reutilizar el hilo que hace el llamado en lugar de pedir uno nuevo al pool concurrente: no tiene que pagar el costo de adquisición de un hilo nuevo, que sí pagan las tres tareas despachadas con async. Por eso terminó primero pese a haberse despachado después.

Esa ventaja no garantiza que sync siempre termine primero

Reutilizar el hilo invocador solo elimina el costo de adquisición de un hilo nuevo. No dice nada sobre cuánto tarda el propio trabajo del bloque. Si el bloque despachado con sync hace más trabajo que las tareas async, termina después de ellas, sin importar que se haya ahorrado ese costo de adquisición.

A continuación se modifica el ejemplo anterior: el bloque de sync ahora duerme dos segundos antes de imprimir, mientras las tres tareas async conservan su segundo de espera:

let concurrentQueue = DispatchQueue(label: "dev.goyes.concurrente", attributes: .concurrent)

for i in 1...3 {
  concurrentQueue.async {
    print("Tarea \(i) empieza")
    Thread.sleep(forTimeInterval: 1)
    print("Tarea \(i) termina")
  }
}

concurrentQueue.sync {
  Thread.sleep(forTimeInterval: 2)
  print("Tarea de sincronización")
}
Enter fullscreen mode Exit fullscreen mode

Al correr exactamente este código, una salida real fue:

Tarea 1 empieza
Tarea 2 empieza
Tarea 3 empieza
Tarea 1 termina
Tarea 2 termina
Tarea 3 termina
Tarea de sincronización
Enter fullscreen mode Exit fullscreen mode

El orden entre las tres tareas async puede variar entre ejecuciones, como ya se mostró arriba. Lo que no varía es que las tres terminan, empiezan y terminan, antes que la tarea de sincronización imprima su línea.

El orden de entrega FIFO sigue siendo el mismo: las tres tareas async se entregan antes que el bloque sync, sin excepción. Lo que cambió frente al ejemplo anterior es cuánto tarda cada tarea en completar su propio trabajo una vez que arranca: cada async duerme un segundo, mientras que sync duerme dos. La ventaja de reutilizar el hilo invocador evita el costo de adquisición, pero no compensa una diferencia de un segundo completo en el trabajo del bloque.

Esto muestra que la ventaja de sync sobre async es evitar un costo de arranque, no una garantía de terminar primero. sync termina primero solo cuando, sumando ese ahorro más el tiempo de su propio trabajo, sigue siendo más rápido que las alternativas. Si su trabajo tarda más, pierde esa carrera, sin importar que haya arrancado con ventaja.

Encadenar varios sync sí produce orden determinista

Si en lugar de mezclar sync con tareas async independientes se despachan varias llamadas sync seguidas sobre la misma cola concurrente, el resultado es tan predecible como en una cola serial: cada sync bloquea por completo antes de que el código siga a la siguiente línea, así que nunca hay dos llamados sync compitiendo entre sí por arrancar.

Este otro fragmento encadena tres llamadas sync seguidas sobre una cola concurrente:

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

result.append(1)

for i in 2...4 {
  concurrentQueue.sync {
    Thread.sleep(forTimeInterval: 1)
    result.append(i)
  }
}

result.append(5)

// result == [1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

Esto no contradice lo mostrado arriba: la falta de garantía de orden real de inicio aparece cuando varias tareas se despachan de forma independiente y pueden competir por arrancar en paralelo. Aquí no hay competencia posible, porque el código nunca llega a despachar la siguiente tarea hasta que la anterior ya terminó. Que la cola sea concurrente es irrelevante para este patrón de uso específico.

El riesgo: sync no implica exclusión mutua

Como una cola concurrente no serializa el acceso, usar sync para leer un recurso no protege contra una escritura que se esté ejecutando en paralelo en otro hilo de la misma cola.

El fragmento de abajo muestra una clase UnsafeCache que usa una cola concurrente sin ningún mecanismo adicional de sincronización:

final class UnsafeCache {
  private let queue = DispatchQueue(label: "dev.goyes.cache-concurrente", attributes: .concurrent)
  private var storage: [String: Data] = [:]

  func write(_ value: Data, forKey key: String) {
    queue.async {
      self.storage[key] = value // ⚠️ Puede correr al mismo tiempo que una lectura
    }
  }

  func read(forKey key: String) -> Data? {
    queue.sync {
      storage[key]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A diferencia del Cache con cola serial del artículo anterior, aquí read y write pueden ejecutarse en paralelo, en hilos distintos, sobre el mismo diccionario. Esto es justamente una carrera de datos. El FIFO de la cola solo garantiza el orden en que las tareas se entregan para ejecución, no que una tarea espere a que la anterior termine antes de arrancar, y mucho menos que se ejecuten en exclusión mutua entre sí. Proteger el acceso en este escenario requiere DispatchBarrier, cubierto en un artículo posterior.

Lo que viene

Falta ver el mismo tipo de cola concurrente, pero despachando con async en lugar de sync. Ese es el siguiente artículo.


Bibliografía

Top comments (0)