cat swift/cancellation-progettare-interruzione.md

Lezione 33 50 min

Cancellation: progettare l'interruzione

La cancellazione non è un dettaglio: un task deve sapere quando fermarsi e come lasciare il sistema in uno stato coerente.

In Swift, cancellare un task non significa ucciderlo immediatamente. La cancellazione è cooperativa: il task riceve un segnale e deve controllarlo nei punti opportuni.

Questo è fondamentale per UI, networking, ricerca, scroll, retry, download e task lunghi.

Cancellare un task

let task = Task {
  try await caricaRisultati()
}

task.cancel()

Il task viene marcato come cancellato. Il codice dentro deve rispettare la cancellazione.

Task.isCancelled

func elabora(_ elementi: [Elemento]) async -> [Risultato] {
  var risultati: [Risultato] = []

  for elemento in elementi {
    if Task.isCancelled {
      return risultati
    }

    risultati.append(await trasforma(elemento))
  }

  return risultati
}

Questa forma è utile quando vuoi uscire in modo controllato.

Task.checkCancellation

Se vuoi lanciare un errore:

func elabora(_ elementi: [Elemento]) async throws -> [Risultato] {
  var risultati: [Risultato] = []

  for elemento in elementi {
    try Task.checkCancellation()
    risultati.append(await trasforma(elemento))
  }

  return risultati
}

Task.checkCancellation() lancia CancellationError se il task è cancellato.

Gestire CancellationError

La cancellazione spesso non è un errore da mostrare all’utente.

do {
  let risultati = try await cerca(query)
  mostra(risultati)
} catch is CancellationError {
  // L'utente ha cambiato query o schermata: nessun messaggio.
} catch {
  mostraErrore(error)
}

Questo evita toast o alert inutili quando l’utente sta semplicemente digitando.

Ricerca con cancellazione

final class SearchViewModel {
  private var searchTask: Task<Void, Never>?

  func cerca(_ query: String) {
    searchTask?.cancel()

    searchTask = Task {
      do {
        try await Task.sleep(for: .milliseconds(300))
        let risultati = try await api.cerca(query)
        await aggiornaUI(risultati)
      } catch is CancellationError {
        return
      } catch {
        await mostraErrore(error)
      }
    }
  }
}

Ogni nuova query cancella la precedente. Il debounce con Task.sleep è cancellabile.

Cleanup

Se un task gestisce risorse, deve lasciarle in uno stato coerente.

func scaricaFile() async throws {
  let file = try FileTemporaneo()

  do {
    try await download(in: file)
    try file.spostaADestinazioneFinale()
  } catch {
    file.eliminaSeEsiste()
    throw error
  }
}

La cancellazione passa dal catch come errore.

Cancellazione nei task group

Se il task padre viene cancellato, anche i figli dovrebbero ricevere cancellazione.

Questo è un vantaggio della structured concurrency. Ma i figli devono comunque controllare cancellazione se fanno lavoro lungo.

Retry e cancellazione

Un retry deve rispettare cancellazione:

func retry<T>(
  tentativi: Int,
  operazione: () async throws -> T
) async throws -> T {
  var ultimoErrore: Error?

  for _ in 0..<tentativi {
    try Task.checkCancellation()

    do {
      return try await operazione()
    } catch is CancellationError {
      throw CancellationError()
    } catch {
      ultimoErrore = error
      try await Task.sleep(for: .milliseconds(250))
    }
  }

  throw ultimoErrore ?? APIError.sconosciuto
}

Non ritentare dopo cancellazione.

Checklist

Quando scrivi codice async, chiediti:

  • chi può cancellare il task?
  • dove controllo cancellazione?
  • devo restituire parziale o lanciare?
  • CancellationError va mostrato all’utente?
  • retry e sleep rispettano cancellazione?
  • le risorse vengono pulite?
  • i task figli sono strutturati?

Esercizio

Implementa una ricerca prodotti:

  • ogni query cancella la precedente;
  • aggiungi debounce con Task.sleep;
  • ignora CancellationError;
  • mostra gli altri errori;
  • cancella il task in deinit.

Poi scrivi una nota: cosa succede se l’utente digita rapidamente 5 query?