Programação assíncrona em F#
A programação assíncrona é um mecanismo essencial para aplicações modernas por diversas razões. Há dois casos de uso principais que a maioria dos desenvolvedores encontrará:
- Apresentar um processo de servidor que possa atender a um número significativo de solicitações de entrada simultâneas, minimizando os recursos do sistema ocupados enquanto o processamento de solicitações aguarda entradas de sistemas ou serviços externos a esse processo
- Mantendo uma interface do usuário responsiva ou thread principal enquanto o trabalho em segundo plano progride simultaneamente
Embora o trabalho em segundo plano geralmente envolva a utilização de vários threads, é importante considerar os conceitos de assincronia e multithreading separadamente. Na verdade, são preocupações separadas, e uma não implica a outra. Este artigo descreve os conceitos separados com mais detalhes.
Assincronia definida
O ponto anterior - que a assincronia é independente da utilização de vários threads - vale a pena explicar um pouco mais. Existem três conceitos que por vezes estão relacionados, mas estritamente independentes uns dos outros:
- Simultaneidade; quando vários cálculos são executados em períodos de tempo sobrepostos.
- Paralelismo; quando vários cálculos ou várias partes de um único cálculo são executados exatamente ao mesmo tempo.
- Assincronia; quando um ou mais cálculos podem ser executados separadamente do fluxo do programa principal.
Todos os três são conceitos ortogonais, mas podem ser facilmente confundidos, especialmente quando são usados juntos. Por exemplo, pode ser necessário executar vários cálculos assíncronos em paralelo. Esta relação não significa que o paralelismo ou a assincronia se impliquem mutuamente.
Se considerarmos a etimologia da palavra "assíncrono", há duas peças envolvidas:
- "a", que significa "não".
- "síncrono", que significa "ao mesmo tempo".
Quando você juntar esses dois termos, verá que "assíncrono" significa "não ao mesmo tempo". Está feito! Não há implicação de simultaneidade ou paralelismo nesta definição. O mesmo se aplica na prática.
Em termos práticos, cálculos assíncronos em F# são programados para serem executados independentemente do fluxo do programa principal. Esta execução independente não implica simultaneidade ou paralelismo, nem implica que um cálculo aconteça sempre em segundo plano. Na verdade, os cálculos assíncronos podem até ser executados de forma síncrona, dependendo da natureza da computação e do ambiente em que a computação está sendo executada.
A principal conclusão que você deve ter é que os cálculos assíncronos são independentes do fluxo principal do programa. Embora existam poucas garantias sobre quando ou como uma computação assíncrona é executada, existem algumas abordagens para orquestrar e programá-las. O restante deste artigo explora os principais conceitos para assincronia de F# e como usar os tipos, funções e expressões incorporados ao F#.
Conceitos-chave
Em F#, a programação assíncrona é centrada em dois conceitos principais: cálculos assíncronos e tarefas.
- O
Async<'T>
tipo comasync { }
expressões, que representa uma computação assíncrona compostável que pode ser iniciada para formar uma tarefa. - O
Task<'T>
tipo, comtask { }
expressões, que representa uma tarefa .NET em execução.
Em geral, você deve considerar o uso task {…}
em async {…}
novo código se estiver interoperando com bibliotecas .NET que usam tarefas e se não depender de tailcalls de código assíncronas ou propagação de token de cancelamento implícito.
Principais conceitos de assíncrono
Você pode ver os conceitos básicos de programação "assíncrona" no exemplo a seguir:
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
No exemplo, a printTotalFileBytesUsingAsync
função é do tipo string -> Async<unit>
. Chamar a função não executa, na verdade, a computação assíncrona. Em vez disso, ele retorna um Async<unit>
que atua como uma especificação do trabalho que deve ser executado de forma assíncrona. Ele chama Async.AwaitTask
em seu corpo, que converte o resultado de ReadAllBytesAsync para um tipo apropriado.
Outra linha importante é a chamada para Async.RunSynchronously
. Esta é uma das funções iniciais do módulo Assíncrono que você precisará chamar se quiser realmente executar uma computação assíncrona F#.
Esta é uma diferença fundamental com o estilo de async
programação C#/Visual Basic. Em F#, cálculos assíncronos podem ser considerados como tarefas frias. Eles devem ser explicitamente iniciados para realmente executar. Isso tem algumas vantagens, pois permite combinar e sequenciar o trabalho assíncrono muito mais facilmente do que em C# ou Visual Basic.
Combine cálculos assíncronos
Aqui está um exemplo que se baseia no anterior, combinando cálculos:
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
Como você pode ver, a main
função tem mais alguns elementos. Conceitualmente, ele faz o seguinte:
- Transforme os argumentos de linha de comando em uma sequência de
Async<unit>
cálculos comSeq.map
. - Crie um
Async<'T[]>
que programe e execute osprintTotalFileBytes
cálculos em paralelo quando for executado. - Crie um
Async<unit>
que executará a computação paralela e ignore seu resultado (que é umunit[]
). - Execute explicitamente o cálculo geral composto com
Async.RunSynchronously
, bloqueando até que ele seja concluído.
Quando este programa é executado, printTotalFileBytes
é executado em paralelo para cada argumento de linha de comando. Como os cálculos assíncronos são executados independentemente do fluxo do programa, não há uma ordem definida na qual eles imprimem suas informações e concluem a execução. Os cálculos serão programados em paralelo, mas a sua ordem de execução não é garantida.
Cálculos assíncronos de sequência
Como Async<'T>
é uma especificação de trabalho em vez de uma tarefa já em execução, você pode executar transformações mais complexas facilmente. Aqui está um exemplo que sequencia um conjunto de cálculos assíncronos para que eles sejam executados um após o outro.
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
Isso será agendado printTotalFileBytes
para ser executado na ordem dos elementos, em vez de argv
programá-los em paralelo. Como cada operação sucessiva não será programada até que o cálculo anterior tenha terminado de executar, os cálculos são sequenciados de modo que não haja sobreposição em sua execução.
Funções importantes do módulo assíncrono
Quando você escreve código assíncrono em F#, geralmente interage com uma estrutura que lida com o agendamento de cálculos para você. No entanto, nem sempre é esse o caso, por isso é bom entender as várias funções que podem ser usadas para agendar trabalho assíncrono.
Como os cálculos assíncronos F# são uma especificação de trabalho em vez de uma representação do trabalho que já está em execução, eles devem ser explicitamente iniciados com uma função inicial. Há muitos métodos de início assíncronos que são úteis em diferentes contextos. A seção a seguir descreve algumas das funções iniciais mais comuns.
Async.StartChild
Inicia uma computação filho dentro de uma computação assíncrona. Isso permite que vários cálculos assíncronos sejam executados simultaneamente. O cálculo filho compartilha um token de cancelamento com o cálculo pai. Se o cálculo pai for cancelado, o cálculo filho também será cancelado.
Assinatura
computation: Async<'T> * ?millisecondsTimeout: int -> Async<Async<'T>>
Quando utilizar:
- Quando você deseja executar vários cálculos assíncronos simultaneamente, em vez de um de cada vez, mas não tê-los agendados em paralelo.
- Quando você deseja vincular o tempo de vida de um cálculo filho ao de um cálculo pai.
O que deve estar atento:
- Iniciar vários cálculos com
Async.StartChild
não é o mesmo que programá-los em paralelo. Se você deseja agendar cálculos em paralelo, useAsync.Parallel
. - O cancelamento de um cálculo pai acionará o cancelamento de todos os cálculos filho iniciados.
Async.StartImmediate
Executa uma computação assíncrona, iniciando imediatamente no thread do sistema operacional atual. Isso é útil se você precisar atualizar algo no thread de chamada durante a computação. Por exemplo, se uma computação assíncrona deve atualizar uma interface do usuário (como atualizar uma barra de progresso), então Async.StartImmediate
deve ser usada.
Assinatura
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
Quando utilizar:
- Quando você precisa atualizar algo no thread de chamada no meio de uma computação assíncrona.
O que deve estar atento:
- O código na computação assíncrona será executado em qualquer thread em que um esteja agendado. Isso pode ser problemático se esse thread for de alguma forma sensível, como um thread da interface do usuário. Nesses casos,
Async.StartImmediate
é provavelmente inadequado usar.
Async.StartAsTask
Executa um cálculo no pool de threads. Retorna um Task<TResult> que será concluído no estado correspondente assim que o cálculo terminar (produz o resultado, lança exceção ou é cancelado). Se nenhum token de cancelamento for fornecido, o token de cancelamento padrão será usado.
Assinatura
computation: Async<'T> * ?taskCreationOptions: TaskCreationOptions * ?cancellationToken: CancellationToken -> Task<'T>
Quando utilizar:
- Quando você precisa chamar uma API .NET que produz um Task<TResult> para representar o resultado de uma computação assíncrona.
O que deve estar atento:
- Essa chamada alocará um objeto adicional
Task
, que pode aumentar a sobrecarga se for usado com frequência.
Async.Parallel
Programa uma sequência de cálculos assíncronos a serem executados em paralelo, produzindo uma matriz de resultados na ordem em que foram fornecidos. O grau de paralelismo pode ser opcionalmente ajustado/acelerado especificando o maxDegreeOfParallelism
parâmetro.
Assinatura
computations: seq<Async<'T>> * ?maxDegreeOfParallelism: int -> Async<'T[]>
Quando usá-lo:
- Se você precisa executar um conjunto de cálculos ao mesmo tempo e não confia em sua ordem de execução.
- Se você não precisar de resultados de cálculos agendados em paralelo até que todos tenham sido concluídos.
O que deve estar atento:
- Você só pode acessar a matriz de valores resultante depois que todos os cálculos tiverem terminado.
- Os cálculos serão executados sempre que acabarem sendo agendados. Esse comportamento significa que você não pode confiar em sua ordem de execução.
Async.Sequencial
Programa uma sequência de cálculos assíncronos a serem executados na ordem em que são passados. O primeiro cálculo será executado, depois o próximo e assim por diante. Nenhum cálculo será executado em paralelo.
Assinatura
computations: seq<Async<'T>> -> Async<'T[]>
Quando usá-lo:
- Se você precisar executar vários cálculos em ordem.
O que deve estar atento:
- Você só pode acessar a matriz de valores resultante depois que todos os cálculos tiverem terminado.
- Os cálculos serão executados na ordem em que são passados para esta função, o que pode significar que passará mais tempo antes que os resultados sejam retornados.
Async.AwaitTask
Retorna um cálculo assíncrono que aguarda a conclusão do dado Task<TResult> e retorna seu resultado como um Async<'T>
Assinatura
task: Task<'T> -> Async<'T>
Quando utilizar:
- Quando você está consumindo uma API .NET que retorna um Task<TResult> dentro de uma computação assíncrona F#.
O que deve estar atento:
- As exceções são encapsuladas seguindo AggregateException a convenção da Biblioteca Paralela de Tarefas, esse comportamento é diferente de como o assíncrono F# geralmente apresenta exceções.
Async.Catch
Cria uma computação assíncrona que executa um determinado Async<'T>
, retornando um Async<Choice<'T, exn>>
arquivo . Se o dado Async<'T>
for concluído com êxito, um Choice1Of2
será retornado com o valor resultante. Se uma exceção for lançada antes de ser concluída, uma Choice2of2
será retornada com a exceção levantada. Se ele for usado em uma computação assíncrona que é composta por muitos cálculos, e um desses cálculos lança uma exceção, a computação abrangente será interrompida completamente.
Assinatura
computation: Async<'T> -> Async<Choice<'T, exn>>
Quando utilizar:
- Quando você estiver executando um trabalho assíncrono que pode falhar com uma exceção e você deseja lidar com essa exceção no chamador.
O que deve estar atento:
- Ao usar cálculos assíncronos combinados ou seqüenciados, a computação abrangente será totalmente interrompida se um de seus cálculos "internos" lançar uma exceção.
Async.Ignore
Cria uma computação assíncrona que executa a computação dada, mas descarta seu resultado.
Assinatura
computation: Async<'T> -> Async<unit>
Quando utilizar:
- Quando você tem uma computação assíncrona cujo resultado não é necessário. Isso é análogo à função para código não assíncrono
ignore
.
O que deve estar atento:
- Se você deve usar
Async.Ignore
porque deseja usarAsync.Start
ou outra função que exijaAsync<unit>
, considere se descartar o resultado é ok. Evite descartar resultados apenas para ajustar uma assinatura de tipo.
Async.RunSynchronously
Executa um cálculo assíncrono e aguarda seu resultado no thread de chamada. Propaga uma exceção caso o cálculo produza uma. Esta chamada está a ser bloqueada.
Assinatura
computation: Async<'T> * ?timeout: int * ?cancellationToken: CancellationToken -> 'T
Quando usá-lo:
- Se você precisar dele, use-o apenas uma vez em um aplicativo - no ponto de entrada para um executável.
- Quando você não se importa com o desempenho e deseja executar um conjunto de outras operações assíncronas de uma só vez.
O que deve estar atento:
- A chamada
Async.RunSynchronously
bloqueia o thread de chamada até que a execução seja concluída.
Async.Start
Inicia uma computação assíncrona que retorna unit
no pool de threads. Não espera pela sua conclusão e/ou observa um resultado de exceção. Os cálculos aninhados iniciados são Async.Start
iniciados independentemente da computação pai que os chamou, seu tempo de vida não está vinculado a nenhum cálculo pai. Se o cálculo pai for cancelado, nenhum cálculo filho será cancelado.
Assinatura
computation: Async<unit> * ?cancellationToken: CancellationToken -> unit
Utilizar apenas quando:
- Você tem uma computação assíncrona que não produz um resultado e/ou requer processamento de um.
- Você não precisa saber quando uma computação assíncrona é concluída.
- Você não se importa em qual thread uma computação assíncrona é executada.
- Você não precisa estar ciente ou relatar exceções resultantes da execução.
O que deve estar atento:
- As exceções geradas por cálculos iniciados com
Async.Start
não são propagadas para o chamador. A pilha de chamadas será completamente desenrolada. - Qualquer trabalho (como chamada
printfn
) iniciado comAsync.Start
não fará com que o efeito aconteça no thread principal da execução de um programa.
Interopere com o .NET
Se estiver usando async { }
programação, talvez seja necessário interoperar com uma biblioteca .NET ou uma base de código C# que use programação assíncrona no estilo async/await. Como o C# e a maioria das bibliotecas .NET usam os tipos e Task como suas abstrações principais, isso pode alterar a forma como você escreve seu código assíncrono Task<TResult> F#.
Uma opção é alternar para escrever tarefas .NET diretamente usando task { }
o . Como alternativa, você pode usar a função para aguardar uma computação assíncrona Async.AwaitTask
do .NET:
let getValueFromLibrary param =
async {
let! value = DotNetLibrary.GetValueAsync param |> Async.AwaitTask
return value
}
Você pode usar a função para passar uma computação assíncrona Async.StartAsTask
para um chamador .NET:
let computationForCaller param =
async {
let! result = getAsyncResult param
return result
} |> Async.StartAsTask
Para trabalhar com APIs que usam Task (ou seja, cálculos assíncronos do .NET que não retornam um valor), talvez seja necessário adicionar uma função adicional que converterá um Async<'T>
em :Task
module Async =
// Async<unit> -> Task
let startTaskFromAsyncUnit (comp: Async<unit>) =
Async.StartAsTask comp :> Task
Já existe um Async.AwaitTask
que aceita um Task como entrada. Com isso e a função definida startTaskFromAsyncUnit
anteriormente, você pode iniciar e aguardar Task tipos de um cálculo assíncrono de F#.
Escrevendo tarefas .NET diretamente em F#
Em F#, você pode escrever tarefas diretamente usando task { }
, por exemplo:
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
No exemplo, a printTotalFileBytesUsingTasks
função é do tipo string -> Task<unit>
. Chamar a função começa a executar a tarefa.
A chamada para task.Wait()
aguardar a conclusão da tarefa.
Relação com multi-threading
Embora o threading seja mencionado ao longo deste artigo, há duas coisas importantes a serem lembradas:
- Não há afinidade entre um cálculo assíncrono e um thread, a menos que explicitamente iniciado no thread atual.
- A programação assíncrona em F# não é uma abstração para multi-threading.
Por exemplo, um cálculo pode realmente ser executado no thread de seu chamador, dependendo da natureza do trabalho. Um cálculo também pode "saltar" entre threads, emprestando-os por um pequeno período de tempo para fazer um trabalho útil entre períodos de "espera" (como quando uma chamada de rede está em trânsito).
Embora o F# forneça algumas habilidades para iniciar uma computação assíncrona no thread atual (ou explicitamente não no thread atual), a assincronia geralmente não está associada a uma estratégia de threading específica.