Partilhar via


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 com async { } expressões, que representa uma computação assíncrona compostável que pode ser iniciada para formar uma tarefa.
  • O Task<'T> tipo, com task { } 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:

  1. Transforme os argumentos de linha de comando em uma sequência de Async<unit> cálculos com Seq.map.
  2. Crie um Async<'T[]> que programe e execute os printTotalFileBytes cálculos em paralelo quando for executado.
  3. Crie um Async<unit> que executará a computação paralela e ignore seu resultado (que é um unit[]).
  4. 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, use Async.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 usar Async.Start ou outra função que exija Async<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 com Async.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:

  1. Não há afinidade entre um cálculo assíncrono e um thread, a menos que explicitamente iniciado no thread atual.
  2. 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.

Consulte também