In questa sezione

Questa guida mostra come un programma Lean chiede all’applicazione di fare qualcosa a cui risponde più tardi (leggere un database, scaricare una pagina, aspettare un utente) e prosegue con la risposta. Ogni export che restituisce un programma del genere diventa una funzione asincrona nativa in ogni linguaggio, che riceve un handler che esegue le operazioni. L’esempio è il crate async-store nella directory examples di lungo-rs: programmi su un archivio chiave-valore tenuto dall’host.

Dichiara le operazioni

Le operazioni sono i costruttori di un tipo induttivo; un’istanza di Lungo.Async.Interface dice con che cosa si risponde a ciascuna, e @[lungo_facility] ne fa una facility asincrona. Il progetto richiede la libreria Lean di lungo.

import Lungo

namespace Store

open Lungo.Async

/-- What a program asks of the store. -/
inductive StoreOp where
  | get (key : String)
  | put (key value : String)

@[lungo_facility "store.kv"]
instance storeOps : Interface StoreOp where
  Ret
    | .get _ => Option String
    | .put _ _ => Unit

Il tipo delle operazioni e le sue risposte devono passare all’host come dati (LNG0707):

  • il tipo delle operazioni è un tipo induttivo senza parametri né indici, i cui campi sono dati che un host può fornire (non dimostrazioni né tipi);
  • ciò con cui si risponde a ogni costruttore si riduce a un tipo fissato dal solo costruttore, non dai suoi campi (Ret (.get k) non può dipendere da k).

@[lungo_facility] controlla entrambe le condizioni quando viene applicato, e lungo le controlla di nuovo quando legge il programma. I costruttori sono le operazioni della facility: una facility asincrona non ha extern @[lungo_operation].

Scrivi i programmi

Un programma è un Lungo.Async.Program StoreOp α; Program.perform op chiede all’host di eseguire op e ne restituisce la risposta, in notazione do:

/-- Copies the value at `src` to `dst`; whether there was one. -/
def copy (src dst : String) : Program StoreOp Bool := do
  match ← Program.perform (StoreOp.get src) with
  | none => return false
  | some v =>
    let _ ← Program.perform (StoreOp.put dst v)
    return true

Scrivi il costruttore per intero (StoreOp.get src, non .get src) dove si fa il match sulla risposta: il tipo della risposta è noto solo quando lo è l’operazione.

Un export che restituisce Program Op α è una funzione asincrona; un programma può essere solo ciò che un export restituisce, non parte di un altro valore (LNG0707).

Chiamalo

Ogni linguaggio chiama l’export con un handler che ha un metodo per operazione, che riceve i campi del costruttore e restituisce la sua risposta, in modo asincrono. Il programma procede finché chiede un’operazione, aspetta la risposta dell’handler e riprende con essa; può chiedere un numero qualsiasi di operazioni, e un numero qualsiasi di chiamate può essere in attesa contemporaneamente, con risposte in qualsiasi ordine.

Un errore dell’handler abbandona il programma: lungo lo rilascia, e la chiamata fallisce con quell’errore. Lo stesso vale per l’annullamento della chiamata. Un programma riprende al massimo una volta per ogni operazione che chiede.

Rust

Il trait generato prende il nome dal tipo delle operazioni; ogni metodo restituisce un future, quindi lo implementa una async fn, e l’export è una async fn per qualsiasi executor:

use async_store::StoreOpHandler;

struct Memory {
    data: Mutex<HashMap<String, String>>,
}

impl StoreOpHandler for Memory {
    type Error = Unavailable;

    async fn get(&self, key: String) -> Result<Option<String>, Unavailable> {
        Ok(self.data.lock().unwrap().get(&key).cloned())
    }

    async fn put(&self, key: String, value: String) -> Result<(), Unavailable> {
        self.data.lock().unwrap().insert(key, value);
        Ok(())
    }
}

let copied: bool = async_store::copy(&memory, "a".into(), "c".into()).await?;

L’errore di un metodo è ciò che l’export restituisce. Scartare il future dell’export rilascia il programma.

Go

L’export riceve un context.Context e l’handler; anche ogni metodo riceve il contesto:

type memory struct {
	mu   sync.Mutex
	data map[string]string
}

func (m *memory) Get(ctx context.Context, key string) (lungo.Option[string], error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	v, ok := m.data[key]
	return lungo.Option[string]{Value: v, Valid: ok}, nil
}

func (m *memory) Put(ctx context.Context, key, value string) (lungo.Unit, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.data[key] = value
	return lungo.Unit{}, nil
}

copied, err := store.Copy(ctx, m, "a", "c")

L’errore di un metodo, un panic o la fine di ctx abbandonano il programma: la chiamata restituisce l’errore (ctx.Err() quando il contesto è terminato), oppure va di nuovo in panic.

Python

L’export è una funzione coroutine; un metodo dell’handler è una coroutine o restituisce direttamente la sua risposta:

class Memory:
    def __init__(self):
        self.data = {}

    async def get(self, key):
        return self.data.get(key)

    async def put(self, key, value):
        self.data[key] = value

copied = await store.copy(Memory(), "a", "c")

Un’eccezione dell’handler viene sollevata dalla chiamata; annullare il task (asyncio.CancelledError) abbandona il programma.

Swift

L’handler è conforme a <Modulo><Op>Handler (qui StoreStoreOpHandler), un protocollo Sendable di metodi async throws; l’export è async throws:

actor Memory: StoreStoreOpHandler {
    var data: [String: String] = [:]

    func get(_ key: String) async throws -> String? { data[key] }

    func put(_ key: String, _ value: String) async throws -> LungoUnit {
        data[key] = value
        return LungoUnit()
    }
}

let copied = try await copy(Memory(), "a", "c")

Un errore dell’handler viene lanciato dalla chiamata; annullare il task lancia CancellationError.

TypeScript

Un metodo dell’handler restituisce la sua risposta o una promise di essa; l’export restituisce una promise e accetta un AbortSignal facoltativo:

const data = new Map();
const memory = {
  get: async (key) => (data.has(key) ? data.get(key) : null),
  put: async (key, value) => { data.set(key, value); return null; },
};
const p = await load();
const copied = await p.copy(memory, "a", "c", { signal: AbortSignal.timeout(5000) });

Un rifiuto dell’handler rifiuta la chiamata; l’interruzione del segnale la rifiuta con il motivo del segnale.

C

L’export restituisce il primo passo del programma; l’host risponde a ogni operazione con lungo_resume, finché il passo è LUNGO_STEP_DONE:

lungo_step step;
lungo_error *error = NULL;
if (store_copy(src, dst, &step, &error) != LUNGO_OK) { /* malformed arguments */ }
while (step.kind == LUNGO_STEP_CALL) {
    lungo_value *answer;
    if (lungo_value_ctor_index(step.value) == STORE_STORE_OP_GET) {
        answer = lookup(store_store_op_get_key(step.value)); /* an Option String */
    } else {
        write(store_store_op_put_key(step.value), store_store_op_put_value(step.value));
        answer = lungo_value_unit();
    }
    uint64_t resumption = step.resumption;
    lungo_step_clear(&step);
    int32_t status = lungo_resume(resumption, answer, &step, &error);
    lungo_value_free(answer);
    if (status != LUNGO_OK) { /* LUNGO_MALFORMED: an answer of another type */ }
}
bool copied = lungo_value_get_bool(step.value);
lungo_step_clear(&step);

step.value è l’operazione richiesta (un valore del tipo delle operazioni) o il valore del programma; la risposta è presa in prestito. Una risposta di un altro tipo viene rifiutata con LUNGO_MALFORMED e abbandona il programma; riprendere di nuovo una ripresa, o una abbandonata, dà LUNGO_STALE. Per abbandonare un programma, chiama lungo_async_cancel(step.resumption).

Conta ciò che è in attesa

Ogni linguaggio riporta quanti programmi sono in attesa di una risposta, numero che torna a zero quando ogni chiamata si è conclusa: lungo.Outstanding() in Go, lungo_py.outstanding() in Python, lungoOutstanding() in Swift, outstanding() del programma in TypeScript (ogni programma caricato ha il suo) e lungo_async_outstanding() in C. In Rust un programma in attesa è tenuto dal future che lo aspetta.

Dimostra che cosa fa un programma

Un programma è un dato, quindi Lean può eseguirlo rispetto a un modello dell’host. Program.run lo interpreta con un handler qualsiasi in una monade qualsiasi; qui un archivio è una funzione dalle chiavi ai valori, e il modello risponde in StateM:

abbrev Contents := String → Option String

private def Contents.set (contents : Contents) (key value : String) : Contents :=
  fun k => if k = key then some value else contents k

@[lungo_spec "lungo.model"]
noncomputable def Model : (op : StoreOp) → StateM Contents (Interface.Ret op)
  | .get key => fun s => (s key, s)
  | .put key value => fun s => ((), s.set key value)

@[lungo_claim "lungo.law" subject copy spec Model]
theorem copy_model (s : Contents) (src dst : String) :
    (Program.run Model (copy src dst)).run s =
      match s src with
      | none => (false, s)
      | some v => (true, s.set dst v) := by
  cases h : s src <;> simp [copy, Program.perform, Program.run, Model, h, bind, Program.bind,
    StateT.run, StateT.bind, pure, StateT.pure]

Contents.set è private: serve al modello e ai claim, e una definizione pubblica verrebbe esportata in ogni binding, dove una funzione su un tipo funzione non serve. Il claim dice che cosa fa copy a ogni archivio tenuto come lo tiene il modello. Per dire che vale per l’archivio dell’host, enuncialo come assunzione (vedi come dichiarare facility e assunzioni). Program.Admits e Program.trace mettono in relazione un programma con le operazioni che chiede e le risposte che riceve, per claim sugli scambi stessi (vedi come dichiarare claim sugli export).