cat swift/taskgroup-lavoro-dinamico.md

Lezione 32 55 min

TaskGroup per lavoro dinamico

I task group servono quando il numero di operazioni concorrenti dipende dai dati: scaricare dettagli, immagini o risorse in parallelo mantenendo struttura.

async let è ottimo per poche operazioni note. Ma cosa fai quando devi caricare i dettagli di 50 prodotti?

for id in ids {
  let prodotto = try await api.prodotto(id: id)
}

Questo è sequenziale. Per lavoro dinamico è concorrente, Swift offre i task group.

withThrowingTaskGroup

func caricaProdotti(ids: [String]) async throws -> [Prodotto] {
  try await withThrowingTaskGroup(of: Prodotto.self) { group in
    for id in ids {
      group.addTask {
        try await api.prodotto(id: id)
      }
    }

    var prodotti: [Prodotto] = []
    prodotti.reserveCapacity(ids.count)

    for try await prodotto in group {
      prodotti.append(prodotto)
    }

    return prodotti
  }
}

Ogni addTask aggiunge un figlio. Il for try await raccoglie i risultati man mano che arrivano.

Ordine dei risultati

I risultati di un task group arrivano in ordine di completamento, non necessariamente nell’ordine di input.

Se l’ordine conta, restituisci anche l’indice:

func caricaProdottiOrdinati(ids: [String]) async throws -> [Prodotto] {
  let elementi = try await withThrowingTaskGroup(of: (Int, Prodotto).self) { group in
    for (index, id) in ids.enumerated() {
      group.addTask {
        let prodotto = try await api.prodotto(id: id)
        return (index, prodotto)
      }
    }

    var risultati: [(Int, Prodotto)] = []

    for try await risultato in group {
      risultati.append(risultato)
    }

    return risultati
  }

  return elementi
    .sorted { $0.0 < $1.0 }
    .map { $0.1 }
}

Cancellazione e errori

Con withThrowingTaskGroup, se un task figlio lancia errore è l’errore viene propagato, gli altri task figli vengono cancellati.

Questo è parte della structured concurrency: il gruppo è uno scope.

TaskGroup non throwing

Se i task non lanciano:

await withTaskGroup(of: Int.self) { group in
  for numero in numeri {
    group.addTask {
      numero * 2
    }
  }

  var risultati: [Int] = []

  for await valore in group {
    risultati.append(valore)
  }

  return risultati
}

Limitare parallelismo

Un task group può avviare molti task. Se ids contiene 10.000 elementi, potresti sovraccaricare risorse.

Una strategia semplice è processare a batch:

for batch in ids.chunked(into: 20) {
  let prodotti = try await caricaProdotti(ids: batch)
  tutti.append(contentsOf: prodotti)
}

Swift standard non include direttamente chunked, ma puoi implementarlo o usare un approcciò simile.

Il punto: concurrency non significa lanciare tutto insieme senza limiti.

Evitare stato condiviso dentro il gruppo

Non modificare una variabile esterna da task figli:

var prodotti: [Prodotto] = []

group.addTask {
  let prodotto = try await api.prodotto(id: id)
  prodotti.append(prodotto) // problematico
}

I task figli dovrebbero restituire valori. Il task genitore raccoglie i risultati.

Questo riduce data race e rende il codice compatibile con Swift 6.

Checklist

Usa TaskGroup quando:

  • il numero di lavori dipende dai dati;
  • vuoi raccogliere risultati man mano;
  • vuoi figli strutturati;
  • vuoi propagazione di errori/cancellazione;
  • vuoi evitare task non controllati.

Attenzione a:

  • ordine dei risultati;
  • parallelismo eccessivo;
  • stato condiviso;
  • errori parziali;
  • cancellazione.

Esercizio

Scrivi:

func caricaDettagliProdotti(ids: [String]) async throws -> [Prodotto]

usando withThrowingTaskGroup.

Poi crea una seconda versione che preserva l’ordine degli ids.

Infine spiega perché non modifichi un array esterno dentro addTask.