cat swift/actors-isolamento-stato.md

Lezione 34 55 min

Actors e isolamento dello stato

Gli actor proteggono stato mutabile condiviso serializzando l'accesso: sono uno strumento chiave per evitare data race.

Un problema classico della concurrency è lo stato mutabile condiviso. Se più task leggono e scrivono lo stesso dato contemporaneamente, puoi avere data race.

Swift offre gli actor per isolare stato.

Problema: cache condivisa

final class ProductCache {
  private var storage: [String: Prodotto] = [:]

  func get(_ id: String) -> Prodotto? {
    storage[id]
  }

  func set(_ prodotto: Prodotto, for id: String) {
    storage[id] = prodotto
  }
}

Se più task accedono a questa classe insieme, storage può essere modificato simultaneamente.

Actor

actor ProductCache {
  private var storage: [String: Prodotto] = [:]

  func get(_ id: String) -> Prodotto? {
    storage[id]
  }

  func set(_ prodotto: Prodotto, for id: String) {
    storage[id] = prodotto
  }
}

Un actor isola il suo stato. Da fuori, l’accesso ai metodi actor-isolated richiede await:

let cached = await cache.get("p-1")
await cache.set(prodotto, for: "p-1")

Perché await?

Chiamare un actor può sospendere, perché il task deve entrare nell’isolamento dell’actor.

await segnala: qui potrei aspettare il mio turno per accedere allo stato isolato.

Stato interno

Dentro l’actor puoi accedere allo stato senza await:

actor ProductCache {
  private var storage: [String: Prodotto] = [:]

  func value(for id: String, loader: () async throws -> Prodotto) async throws -> Prodotto {
    if let cached = storage[id] {
      return cached
    }

    let prodotto = try await loader()
    storage[id] = prodotto
    return prodotto
  }
}

Questa forma è comoda, ma attenzione alla reentrancy.

Actor reentrancy

Quando un metodo actor fa await, l’actor può eseguire altro lavoro prima di riprendere.

Nel metodo sopra:

let prodotto = try await loader()
storage[id] = prodotto

Durante await loader(), un altro task potrebbe entrare e caricare lo stesso prodotto.

Non è una data race, ma può essere una duplicazione di lavoro.

Per cache robuste, potresti memorizzare task in corso:

actor ProductCache {
  private var storage: [String: Prodotto] = [:]
  private var inFlight: [String: Task<Prodotto, Error>] = [:]
}

Questo è un livello più avanzato che useremo nel laboratorio.

nonisolated

Un membro nonisolated non richiede accesso all’isolamento actor.

actor ProductCache {
  nonisolated let name = "ProductCache"
}

Usalo per dati immutabili o funzioni che non accedono allo stato isolato.

Non usarlo per aggirare l’actor.

Actor non sostituisce tutto

Usa actor quando:

  • hai stato mutabile condiviso;
  • più task devono leggere/scrivere;
  • vuoi protezione dal modello di concurrency Swift.

Non usarlo per ogni tipo.

Un value object come Prezzo deve restare una struct.

Actor e performance

Gli actor serializzano accesso al loro stato. Questo protegge, ma può diventare collo di bottiglia se usato male.

Non mettere tutto lo stato dell’app in un solo actor gigante.

Preferisci actor piccoli con responsabilità chiare:

  • cache prodotti;
  • session store;
  • token refresh coordinator;
  • contatore condiviso.

Checklist

Prima di creare un actor:

  • esiste stato mutabile condiviso?
  • più task accedono contemporaneamente?
  • una struct immutabile basterebbe?
  • l’actor ha responsabilità piccola?
  • ci sono await dentro metodi actor che possono causare reentrancy?
  • devo memorizzare operazioni in corso?

Esercizio

Crea:

actor ProductCache

con:

  • get(_:);
  • set(_:for:);
  • remove(_:);
  • clear().

Poi aggiungi:

func value(for id: String, loader: () async throws -> Prodotto) async throws -> Prodotto

e scrivi una nota sul rischio di caricare due volte lo stesso id.