cat swift/task-structured-concurrency.md

Lezione 30 50 min

Task e structured concurrency

Capire cosa crea un Task, perché la structured concurrency è più sicura dei lavori staccati e dove mettere i confini asincroni.

Un Task rappresenta un’unità di lavoro asincrona. In Swift moderno, però, non vogliamo lanciare lavori nel vuoto. Vogliamo che i task abbiano una struttura: un genitore, figli, cancellazione e lifetime comprensibili.

Questa è la structured concurrency.

Creare un Task

Da codice sincrono:

Task {
  do {
    let prodotti = try await catalogo.carica()
    print(prodotti)
  } catch {
    print(error)
  }
}

Questo crea un task asincrono.

Usalo per entrare nel mondo async da:

  • azioni UI;
  • entry point sincroni;
  • callback legacy;
  • setup iniziale.

Non usarlo come scorciatoia per evitare di progettare la firma async.

Structured vs unstructured

Una funzione async che chiama altre funzioni async crea un flusso strutturato:

func aggiorna() async throws {
  let prodotti = try await api.prodotti()
  let evidenza = try await api.inEvidenza()
  salva(prodotti, evidenza)
}

Il chiamante aspetta aggiorna. Errori e cancellazione si propagano in modo naturale.

Un Task {} creato dentro una funzione può invece sfuggire:

func aggiorna() {
  Task {
    try await api.prodotti()
  }
}

La funzione ritorna subito. Chi l’ha chiamata non sa quando il lavoro finisce, se fallisce, o se va cancellato.

Regola pratica

Preferisci:

func aggiorna() async throws { ... }

invece di:

func aggiorna() {
  Task { ... }
}

Usa Task solo quando devi davvero creare un confine.

Task e priorità

Puoi dare una priorità:

Task(priority: .userInitiated) {
  try await caricaDatiVisibili()
}

Le priorità sono suggerimenti al runtime, non garanzie assolute.

Non risolvere problemi di architettura cambiando priorità. Prima organizza il lavoro.

Task.detached

Task.detached crea un task non legato al contesto corrente.

Task.detached {
  await lavoroIndipendente()
}

Usalo raramente. Un task detached non eredita allo stesso modo priorità, task local values e isolamento. È più facile creare lavoro difficile da controllare.

Se non hai una ragione forte, non usare detached.

Lifetime del task

Un task dovrebbe vivere quanto il lavoro che rappresenta.

In UI, spesso tieni un riferimento per poter cancellare:

final class CatalogViewModel {
  private var loadTask: Task<Void, Never>?

  func load() {
    loadTask?.cancel()

    loadTask = Task {
      await carica()
    }
  }

  deinit {
    loadTask?.cancel()
  }
}

Questo è importante: se l’utente cambia schermata, il lavoro non dovrebbe continuare senza motivo.

Errori nei Task

Se crei un task con errore e non osservi il risultato, puoi perdere informazioni.

Task {
  try await api.prodotti()
}

Meglio gestire:

Task {
  do {
    let prodotti = try await api.prodotti()
    await mostra(prodotti)
  } catch is CancellationError {
    // cancellazione attesa
  } catch {
    await mostraErrore(error)
  }
}

Task.value

Puoi conservare un task che restituisce valore:

let task = Task {
  try await api.prodotti()
}

let prodotti = try await task.value

value aspetta il risultato e rilancia eventuali errori.

Checklist

Quando vuoi creare un Task, chiediti:

  • sto entrando da codice sincrono?
  • chi aspetta il risultato?
  • chi gestisce l’errore?
  • chi cancella il lavoro?
  • il task deve ereditare il contesto corrente?
  • posso rendere la funzione async invece?
  • sto usando detached per una ragione reale?

Esercizio

Prendi una funzione:

func aggiornaCatalogo() {
  Task {
    ...
  }
}

Riscrivila in due versioni:

  1. func aggiornaCatalogo() async throws;
  2. wrapper sincrono che crea un Task solo al confine UI.

Spiega quale parte è structured e quale è confine.