Programmazione asincrona in F#

La programmazione asincrona è un meccanismo essenziale per le applicazioni moderne per diversi motivi. Esistono due casi d'uso principali che la maggior parte degli sviluppatori incontrerà:

  • Presentazione di un processo server in grado di gestire un numero significativo di richieste in ingresso simultanee, riducendo al minimo le risorse di sistema occupate durante l'elaborazione delle richieste attende input da sistemi o servizi esterni a tale processo
  • Gestione di un'interfaccia utente reattiva o di un thread principale durante l'avanzamento simultaneo del lavoro in background

Anche se il lavoro in background comporta spesso l'utilizzo di più thread, è importante considerare separatamente i concetti di asincronia e multithreading. In effetti, sono preoccupazioni separate, e uno non implica l'altro. Questo articolo descrive in modo più dettagliato i concetti separati.

Asincronia definita

Il punto precedente, che l'asincronia è indipendente dall'utilizzo di più thread, vale la pena spiegare un po' più in dettaglio. Esistono tre concetti che a volte sono correlati, ma strettamente indipendenti l'uno dall'altro:

  • Concorrenza; quando più calcoli vengono eseguiti in periodi di tempo sovrapposti.
  • Parallelismo; quando più calcoli o più parti di un singolo calcolo vengono eseguite contemporaneamente.
  • Asincronia; quando uno o più calcoli possono essere eseguiti separatamente dal flusso del programma principale.

Tutti e tre sono concetti ortogonali, ma possono essere facilmente gonfiati, soprattutto quando vengono usati insieme. Ad esempio, potrebbe essere necessario eseguire più calcoli asincroni in parallelo. Questa relazione non significa che il parallelismo o l'asincronia implicano l'uno l'altro.

Se si considera l'accuratezza della parola "asincrona", ci sono due parti coinvolte:

  • "a", ovvero "not".
  • "sincrono", ovvero "allo stesso tempo".

Quando si inseriscono questi due termini insieme, si noterà che "asincrono" significa "non contemporaneamente". Ecco fatto! Non c'è alcuna implicazione della concorrenza o del parallelismo in questa definizione. Questo vale anche in pratica.

In termini pratici, i calcoli asincroni in F# vengono pianificati per l'esecuzione indipendentemente dal flusso del programma principale. Questa esecuzione indipendente non implica concorrenza o parallelismo, né implica che un calcolo avviene sempre in background. In effetti, i calcoli asincroni possono anche essere eseguiti in modo sincrono, a seconda della natura del calcolo e dell'ambiente in cui è in esecuzione il calcolo.

L'aspetto principale da tenere è che i calcoli asincroni sono indipendenti dal flusso principale del programma. Anche se esistono poche garanzie su quando o come viene eseguito un calcolo asincrono, esistono alcuni approcci per orchestrarli e pianificarli. Il resto di questo articolo illustra i concetti di base per F# asincronia e come usare i tipi, le funzioni e le espressioni incorporati in F#.

Concetti principali

In F# la programmazione asincrona è centrata su due concetti di base: calcoli asincroni e attività.

  • Tipo Async<'T> con async { } espressioni, che rappresenta un calcolo asincrono componibile che può essere avviato per formare un'attività.
  • Tipo Task<'T> , con task { } espressioni, che rappresenta un'attività .NET in esecuzione.

In generale, è consigliabile usare task {…} over async {…} in un nuovo codice se si interagisce con le librerie .NET che usano attività e se non ci si basa su codecall asincrone o la propagazione implicita dei token di annullamento.

Concetti di base di asincrona

È possibile visualizzare i concetti di base della programmazione "asincrona" nell'esempio seguente:

open System
open System.IO

// Perform an asynchronous read of a file using 'async'
let printTotalFileBytesUsingAsync (path: string) =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    printTotalFileBytesUsingAsync "path-to-file.txt"
    |> Async.RunSynchronously

    Console.Read() |> ignore
    0

Nell'esempio la printTotalFileBytesUsingAsync funzione è di tipo string -> Async<unit>. La chiamata alla funzione non esegue effettivamente il calcolo asincrono. Restituisce invece un oggetto Async<unit> che funge da specifica del lavoro da eseguire in modo asincrono. Async.AwaitTask Chiama nel corpo, che converte il risultato di ReadAllBytesAsync in un tipo appropriato.

Un'altra riga importante è la chiamata a Async.RunSynchronously. Si tratta di una delle funzioni iniziali del modulo asincrono che è necessario chiamare se si vuole effettivamente eseguire un calcolo asincrono F#.

Questa è una differenza fondamentale con lo stile di async programmazione C#/Visual Basic. In F# i calcoli asincroni possono essere considerati come attività ad accesso sporadico. Devono essere avviati in modo esplicito per l'esecuzione effettiva. Questo presenta alcuni vantaggi, in quanto consente di combinare e sequenziare il lavoro asincrono molto più facilmente rispetto a C# o Visual Basic.

Combinare calcoli asincroni

Di seguito è riportato un esempio basato su quello precedente combinando i calcoli:

open System
open System.IO

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Parallel
    |> Async.Ignore
    |> Async.RunSynchronously

    0

Come si può notare, la main funzione ha alcuni altri elementi. Concettualmente, esegue le operazioni seguenti:

  1. Trasformare gli argomenti della riga di comando in una sequenza di Async<unit> calcoli con Seq.map.
  2. Creare un oggetto Async<'T[]> che pianifica ed esegue i calcoli in parallelo durante l'esecuzione printTotalFileBytes .
  3. Creare un oggetto Async<unit> che eseguirà il calcolo parallelo e ne ignorerà il risultato (ovvero un ).unit[]
  4. Eseguire in modo esplicito il calcolo complessivo composto con Async.RunSynchronously, bloccando fino al completamento.

Quando questo programma viene eseguito, printTotalFileBytes viene eseguito in parallelo per ogni argomento della riga di comando. Poiché i calcoli asincroni vengono eseguiti indipendentemente dal flusso del programma, non esiste un ordine definito in cui stampano le informazioni e terminano l'esecuzione. I calcoli verranno pianificati in parallelo, ma l'ordine di esecuzione non è garantito.

Calcoli asincroni sequenza

Poiché Async<'T> è una specifica di lavoro anziché un'attività già in esecuzione, è possibile eseguire più facilmente trasformazioni più complesse. Di seguito è riportato un esempio che sequenzia un set di calcoli asincroni in modo che vengano eseguiti uno dopo l'altro.

let printTotalFileBytes path =
    async {
        let! bytes = File.ReadAllBytesAsync(path) |> Async.AwaitTask
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    argv
    |> Seq.map printTotalFileBytes
    |> Async.Sequential
    |> Async.Ignore
    |> Async.RunSynchronously
    |> ignore

In questo modo verrà pianificata printTotalFileBytes l'esecuzione nell'ordine degli elementi di argv anziché pianificarli in parallelo. Poiché ogni operazione successiva non verrà pianificata fino al termine dell'esecuzione del calcolo precedente, i calcoli vengono sequenziati in modo che non vi siano sovrapposizioni nell'esecuzione.

Funzioni importanti del modulo asincrono

Quando si scrive codice asincrono in F#, in genere si interagisce con un framework che gestisce automaticamente la pianificazione dei calcoli. Tuttavia, questo non è sempre il caso, quindi è bene comprendere le varie funzioni che possono essere usate per pianificare il lavoro asincrono.

Poiché i calcoli asincroni F# sono una specifica di lavoro anziché una rappresentazione di lavoro già in esecuzione, devono essere avviati in modo esplicito con una funzione iniziale. Esistono molti metodi di avvio asincroni utili in contesti diversi. La sezione seguente descrive alcune delle funzioni di avvio più comuni.

Async.StartChild

Avvia un calcolo figlio all'interno di un calcolo asincrono. Ciò consente l'esecuzione simultanea di più calcoli asincroni. Il calcolo figlio condivide un token di annullamento con il calcolo padre. Se il calcolo padre viene annullato, viene annullato anche il calcolo figlio.

Firma:

computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>

Uso consigliato:

  • Quando si vogliono eseguire più calcoli asincroni simultaneamente anziché uno alla volta, ma non sono pianificati in parallelo.
  • Quando si desidera associare la durata di un calcolo figlio a quello di un calcolo padre.

Cosa tenere d'occhio per:

  • L'avvio di più calcoli con Async.StartChild non equivale alla pianificazione in parallelo. Se si desidera pianificare i calcoli in parallelo, usare Async.Parallel.
  • L'annullamento di un calcolo padre attiverà l'annullamento di tutti i calcoli figlio avviati.

Async.StartImmediate

Esegue un calcolo asincrono, a partire immediatamente dal thread del sistema operativo corrente. Ciò è utile se è necessario aggiornare un elemento nel thread chiamante durante il calcolo. Ad esempio, se un calcolo asincrono deve aggiornare un'interfaccia utente , ad esempio l'aggiornamento di un indicatore di stato, Async.StartImmediate deve essere usata.

Firma:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Uso consigliato:

  • Quando è necessario aggiornare un elemento nel thread chiamante al centro di un calcolo asincrono.

Cosa tenere d'occhio per:

  • Il codice nel calcolo asincrono verrà eseguito su qualsiasi thread su cui si verifica una pianificazione. Questo può essere problematico se il thread è in qualche modo sensibile, ad esempio un thread dell'interfaccia utente. In questi casi, Async.StartImmediate è probabilmente inappropriato da usare.

Async.StartAsTask

Esegue un calcolo nel pool di thread. Restituisce un Task<TResult> oggetto che verrà completato sullo stato corrispondente al termine del calcolo (produce il risultato, genera un'eccezione o viene annullata). Se non viene fornito alcun token di annullamento, viene usato il token di annullamento predefinito.

Firma:

computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>

Uso consigliato:

  • Quando è necessario chiamare in un'API .NET che restituisce un oggetto Task<TResult> per rappresentare il risultato di un calcolo asincrono.

Cosa tenere d'occhio per:

  • Questa chiamata allocherà un oggetto aggiuntivo Task , che può aumentare il sovraccarico se viene usato spesso.

Async.Parallel

Pianifica una sequenza di calcoli asincroni da eseguire in parallelo, producendo una matrice di risultati nell'ordine in cui sono stati forniti. Il grado di parallelismo può essere facoltativamente ottimizzato/limitato specificando il maxDegreeOfParallelism parametro .

Firma:

computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>

Quando usarlo:

  • Se è necessario eseguire un set di calcoli contemporaneamente e non dipendere dall'ordine di esecuzione.
  • Se non è necessario ottenere risultati dai calcoli pianificati in parallelo fino a quando non sono stati completati tutti.

Cosa tenere d'occhio per:

  • È possibile accedere alla matrice di valori risultante solo al termine di tutti i calcoli.
  • I calcoli verranno eseguiti ogni volta che vengono pianificati. Questo comportamento significa che non è possibile basarsi sull'ordine dell'esecuzione.

Async.Sequential

Pianifica una sequenza di calcoli asincroni da eseguire nell'ordine in cui vengono passati. Il primo calcolo verrà eseguito, quindi il successivo e così via. Non verranno eseguiti calcoli in parallelo.

Firma:

computations: seq<Async<'T>> -> Async<'T[]>

Quando usarlo:

  • Se è necessario eseguire più calcoli in ordine.

Cosa tenere d'occhio per:

  • È possibile accedere alla matrice di valori risultante solo al termine di tutti i calcoli.
  • I calcoli verranno eseguiti nell'ordine in cui vengono passati a questa funzione, il che può significare che più tempo passerà prima che vengano restituiti i risultati.

Async.AwaitTask

Restituisce un calcolo asincrono che attende il completamento dell'oggetto specificato Task<TResult> e ne restituisce il risultato come Async<'T>

Firma:

task: Task<'T> -> Async<'T>

Uso consigliato:

  • Quando si usa un'API .NET che restituisce un Task<TResult> oggetto all'interno di un calcolo asincrono F#.

Cosa tenere d'occhio per:

  • Le eccezioni vengono incluse in AggregateException seguito alla convenzione della libreria parallela di attività. Questo comportamento è diverso dal modo in cui F# async presenta in genere le eccezioni.

Async.Catch

Crea un calcolo asincrono che esegue un determinato Async<'T>oggetto , restituendo un oggetto Async<Choice<'T, exn>>. Se l'oggetto specificato Async<'T> viene completato correttamente, viene restituito un Choice1Of2 oggetto con il valore risultante. Se viene generata un'eccezione prima del completamento, viene restituito un Choice2of2 oggetto con l'eccezione generata. Se viene usato in un calcolo asincrono composto da molti calcoli e uno di questi calcoli genera un'eccezione, il calcolo che include verrà arrestato completamente.

Firma:

computation: Async<'T> -> Async<Choice<'T, exn>>

Uso consigliato:

  • Quando si eseguono operazioni asincrone che potrebbero non riuscire con un'eccezione e si vuole gestire tale eccezione nel chiamante.

Cosa tenere d'occhio per:

  • Quando si usano calcoli asincroni combinati o sequenziati, il calcolo che include verrà arrestato completamente se uno dei calcoli "interni" genera un'eccezione.

Async.Ignore

Crea un calcolo asincrono che esegue il calcolo specificato, ma ne elimina il risultato.

Firma:

computation: Async<'T> -> Async<unit>

Uso consigliato:

  • Quando si dispone di un calcolo asincrono il cui risultato non è necessario. Questo è analogo alla funzione per il ignore codice non asincrono.

Cosa tenere d'occhio per:

  • Se è necessario usare Async.Ignore perché si vuole usare Async.Start o un'altra funzione che richiede Async<unit>, valutare se ignorare il risultato è corretto. Evitare di eliminare i risultati solo per adattare una firma di tipo.

Async.RunSynchronously

Esegue un calcolo asincrono e attende il risultato sul thread chiamante. Propaga un'eccezione se il calcolo ne restituisce uno. Questa chiamata sta bloccando.

Firma:

computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T

Quando usarlo:

  • Se necessario, usarlo una sola volta in un'applicazione, nel punto di ingresso per un eseguibile.
  • Quando non ci si preoccupa delle prestazioni e si vuole eseguire un set di altre operazioni asincrone contemporaneamente.

Cosa tenere d'occhio per:

  • La chiamata Async.RunSynchronously blocca il thread chiamante fino al completamento dell'esecuzione.

Async.Start

Avvia un calcolo asincrono che restituisce unit nel pool di thread. Non attende il completamento e/o osserva un risultato di eccezione. I calcoli annidati avviati con Async.Start vengono avviati indipendentemente dal calcolo padre che li ha chiamati; la loro durata non è associata ad alcun calcolo padre. Se il calcolo padre viene annullato, non vengono annullati calcoli figlio.

Firma:

computation: Async<unit> * ?cancellationToken: CancellationToken -> unit

Usare solo quando:

  • Si dispone di un calcolo asincrono che non produce un risultato e/o richiede l'elaborazione di uno.
  • Non è necessario sapere quando viene completato un calcolo asincrono.
  • Non è importante quale thread viene eseguito un calcolo asincrono.
  • Non è necessario conoscere o segnalare le eccezioni risultanti dall'esecuzione.

Cosa tenere d'occhio per:

  • Le eccezioni generate dai calcoli avviati con Async.Start non vengono propagate al chiamante. Lo stack di chiamate sarà completamente scollegato.
  • Qualsiasi lavoro (ad esempio la chiamata printfn) avviato con Async.Start non causerà l'effetto sul thread principale dell'esecuzione di un programma.

Interoperabilità con .NET

Se si usa async { } la programmazione, potrebbe essere necessario interagire con una libreria .NET o una codebase C# che usa programmazione asincrona asincrona/await. Poiché C# e la maggior parte delle librerie .NET usano i Task<TResult> tipi e Task come astrazioni principali, questo può cambiare il modo in cui si scrive il codice asincrono F#.

Un'opzione consiste nel passare alla scrittura di attività .NET direttamente tramite task { }. In alternativa, è possibile usare la Async.AwaitTask funzione per attendere un calcolo asincrono .NET:

let getValueFromLibrary param =
    async {
        let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
        return value
    }

È possibile usare la Async.StartAsTask funzione per passare un calcolo asincrono a un chiamante .NET:

let computationForCaller param =
    async {
        let! result = getAsyncResult param
        return result
    } |> Async.StartAsTask

Per usare le API che usano Task (vale a dire calcoli asincroni .NET che non restituiscono un valore), potrebbe essere necessario aggiungere un'altra funzione che convertirà un oggetto Async<'T> in :Task

module Async =
    // Async<unit> -> Task
    let startTaskFromAsyncUnit (comp: Async<unit>) =
        Async.StartAsTask comp :> Task

Esiste già un oggetto Async.AwaitTask che accetta come Task input. Con questa e la funzione definita startTaskFromAsyncUnit in precedenza, è possibile avviare e attendere Task i tipi da un calcolo asincrono F#.

Scrittura di attività .NET direttamente in F#

In F# è possibile scrivere attività direttamente usando task { }, ad esempio:

open System
open System.IO

/// Perform an asynchronous read of a file using 'task'
let printTotalFileBytesUsingTasks (path: string) =
    task {
        let! bytes = File.ReadAllBytesAsync(path)
        let fileName = Path.GetFileName(path)
        printfn $"File {fileName} has %d{bytes.Length} bytes"
    }

[<EntryPoint>]
let main argv =
    let task = printTotalFileBytesUsingTasks "path-to-file.txt"
    task.Wait()

    Console.Read() |> ignore
    0

Nell'esempio la printTotalFileBytesUsingTasks funzione è di tipo string -> Task<unit>. La chiamata alla funzione inizia a eseguire l'attività. La chiamata a task.Wait() attende il completamento dell'attività.

Relazione con multithreading

Anche se il threading è menzionato in questo articolo, esistono due aspetti importanti da ricordare:

  1. Non esiste affinità tra un calcolo asincrono e un thread, a meno che non venga avviata in modo esplicito nel thread corrente.
  2. La programmazione asincrona in F# non è un'astrazione per il multithreading.

Ad esempio, un calcolo può effettivamente essere eseguito sul thread del chiamante, a seconda della natura del lavoro. Un calcolo potrebbe anche "saltare" tra thread, prendendoli in prestito per una piccola quantità di tempo per svolgere un lavoro utile tra periodi di "attesa", ad esempio quando una chiamata di rete è in transito.

Anche se F# offre alcune capacità di avviare un calcolo asincrono sul thread corrente (o in modo esplicito non nel thread corrente), l'asincronia in genere non è associata a una particolare strategia di threading.

Vedi anche