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>
conasync { }
espressioni, che rappresenta un calcolo asincrono componibile che può essere avviato per formare un'attività. - Tipo
Task<'T>
, contask { }
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:
- Trasformare gli argomenti della riga di comando in una sequenza di
Async<unit>
calcoli conSeq.map
. - Creare un oggetto
Async<'T[]>
che pianifica ed esegue i calcoli in parallelo durante l'esecuzioneprintTotalFileBytes
. - Creare un oggetto
Async<unit>
che eseguirà il calcolo parallelo e ne ignorerà il risultato (ovvero un ).unit[]
- 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, usareAsync.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 usareAsync.Start
o un'altra funzione che richiedeAsync<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 conAsync.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:
- Non esiste affinità tra un calcolo asincrono e un thread, a meno che non venga avviata in modo esplicito nel thread corrente.
- 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.