Compartilhar via


Programação assíncrona em F#

A programação assíncrona é um mecanismo essencial para aplicativos modernos por diversos motivos. Há dois casos de uso primários que a maioria dos desenvolvedores encontrará:

  • Apresentar um processo de servidor que pode 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
  • Manter uma interface do usuário responsiva ou um thread principal durante o progresso simultâneo do trabalho em segundo plano

Apesar de o trabalho em segundo plano, muitas vezes, envolver o uso 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 múltiplas threads - merece ser explicado um pouco mais. Às vezes, há três conceitos relacionados, mas estritamente independentes um do outro:

  • 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 uma única computação são executados exatamente ao mesmo tempo.
  • Assíncrona; 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, talvez seja necessário executar vários cálculos assíncronos em paralelo. Essa relação não significa que paralelismo ou assincronia impliquem um no outro.

Se você considerar a etimologia da palavra "assíncrona", há duas partes envolvidas:

  • "a" significa "não".
  • "síncrono", que significa "ao mesmo tempo".

Ao juntar esses dois termos, você verá que "assíncrono" significa "não ao mesmo tempo". É isso! Não há implicação de simultaneidade ou paralelismo nesta definição. Isso também é verdade na prática.

Em termos práticos, cálculos assíncronos em F# são agendados para serem executados independentemente do fluxo principal do programa. Essa execução independente não implica simultaneidade ou paralelismo, nem implica que uma computação sempre ocorre em segundo plano. Na verdade, cálculos assíncronos podem até mesmo 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 vantagem que você deve ter é que os cálculos assíncronos são independentes do fluxo principal do programa. Embora haja poucas garantias sobre quando ou como uma computação assíncrona é executada, há algumas abordagens para orquestrar e agendar. O restante deste artigo explora os principais conceitos de assincronismo no F# e como usar os tipos, funções e expressões integrados ao F#.

Conceitos fundamentais

No F#, a programação assíncrona é centralizada em torno de dois conceitos principais: cálculos assíncronos e tarefas.

  • O tipo Async<'T> com async { }expressões , que representa uma computação assíncrona componível que pode ser iniciada para formar uma tarefa.
  • O tipo Task<'T> com task { } expressões, que representa uma tarefa .NET em execução.

Em geral, você deve considerar o uso task {…} em vez de async {…} em um novo código se estiver interoperando com bibliotecas .NET que usam tarefas e se você não depender de tailcalls de código assíncronas ou propagação implícita de token de cancelamento.

Principais conceitos de assíncrono

Você pode ver os conceitos básicos da 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 realmente não executa a computação assíncrona. Em vez disso, 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, o que converte o resultado em ReadAllBytesAsync para um tipo apropriado.

Outra linha importante é a chamada para Async.RunSynchronously. Essa é uma das funções iniciais do módulo Assíncrono que você precisará chamar se quiser realmente executar uma computação assíncrona F#.

Essa é uma diferença fundamental com o estilo C#/Visual Basic de programação async. Em F#, cálculos assíncronos podem ser considerados como tarefas frias. Elas devem ser claramente iniciadas para realmente ocorrer a execução. Isso tem algumas vantagens, pois permite combinar e sequenciar o trabalho assíncrono muito mais facilmente do que no C# ou no Visual Basic.

Combinar 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. Transforma os argumentos de linha de comando em uma sequência de computações Async<unit> com Seq.map.
  2. Cria um Async<'T[]> que programa e executa as computações printTotalFileBytes em paralelo quando for executado.
  3. Crie um Async<unit> que executará a computação paralela e ignorará seu resultado (que é um unit[]).
  4. Realize explicitamente o cálculo total 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á nenhuma ordem definida na qual eles imprimem suas informações e terminam de executar. Os cálculos serão agendados em paralelo, mas sua ordem de execução não é garantida.

Computações assíncronas 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 executem 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 agendará printTotalFileBytes para ser executado na ordem dos elementos de argv em vez de agendá-los em paralelo. Como cada operação sucessiva não será agendada até que a computação anterior tenha terminado de ser executada, os cálculos são sequenciados de modo que não haja sobreposição em sua execução.

Funções importantes do módulo Async

Ao escrever código assíncrono em F#, você geralmente interagirá com uma estrutura que manipula o agendamento de cálculos para você. No entanto, esse nem sempre é o caso, portanto, é bom entender as várias funções que podem ser usadas para agendar o trabalho assíncrono.

Como as computações assíncronas F# são uma especificação de trabalho em vez de uma representação de trabalho que já está em execução, elas devem ser iniciadas explicitamente com uma função inicial. Há muitos métodos iniciais assíncronos que são úteis em contextos diferentes. A seção a seguir descreve algumas das funções iniciais mais comuns.

Async.StartChild

Inicia uma computação filho em uma computação assíncrona. Isso permite que vários cálculos assíncronos sejam executados simultaneamente. A computação filho compartilha um token de cancelamento com a computação pai. Se a computação pai for cancelada, a computação filho também será cancelada.

Assinatura:

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

Quando usar:

  • Quando você deseja executar várias computações assíncronas simultaneamente em vez de uma de cada vez, mas não as tem agendadas em paralelo.
  • Quando você deseja vincular o tempo de vida de uma computação filho à de uma computação pai.

Com o que tomar cuidado:

  • Iniciar vários cálculos com Async.StartChild não é o mesmo que agendá-los em paralelo. Se você quiser agendar cálculos em paralelo, use Async.Parallel.
  • Cancelar uma computação pai aciona o cancelamento de todos os cálculos filho iniciados.

Async.StartImmediate

Executa uma computação assíncrona, começando imediatamente no thread atual do sistema operacional. Isso será útil se você precisar atualizar algo no thread de chamada durante a computação. Por exemplo, se uma computação assíncrona precisar atualizar uma interface do usuário (como atualizar uma barra de progresso), deverá Async.StartImmediate ser usada.

Assinatura:

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

Quando usar:

  • Quando você precisa atualizar algo no thread de chamada no meio de uma computação assíncrona.

Com o que tomar cuidado:

  • O código na computação assíncrona é executado em qualquer thread em que um deles esteja programado. Isso pode ser problemático se esse thread for de alguma forma sensível, como um thread de interface do usuário. Nesses casos, Async.StartImmediate é provável que seja inadequado usar.

Async.StartAsTask

Executa uma computação no pool de threads. Retorna um Task<TResult> que será concluído no estado correspondente depois que a computação for encerrada (produz resultado, gera exceção ou é cancelada). 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 usar:

  • Quando você precisa chamar uma API .NET que produz um Task<TResult> para representar o resultado de uma computação assíncrona.

Com o que tomar cuidado:

  • Essa chamada alocará um objeto adicional Task , o que poderá aumentar a sobrecarga se ele for usado com frequência.

Async.Parallel

Agenda uma sequência de cálculos assíncronos a serem executados em paralelo, gerando uma matriz de resultados na ordem em que foram fornecidos. O grau de paralelismo pode ser ajustado/limitado opcionalmente especificando o maxDegreeOfParallelism parâmetro.

Assinatura:

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

Quando usá-lo:

  • Se você precisar executar um conjunto de cálculos ao mesmo tempo e não tiver nenhuma dependência da ordem de execução deles.
  • Se você não precisar de resultados de computações agendadas em paralelo até que todas tenham sido concluídas.

Com o que tomar cuidado:

  • Você só pode acessar a matriz resultante de valores depois que todos os cálculos tiverem sido concluídos.
  • As computações serão executadas sempre que acabarem sendo programadas. Esse comportamento significa que você não pode confiar na ordem de execução deles.

Async.Sequential

Agenda uma sequência de cálculos assíncronos a serem executados na ordem em que são passados. A primeira computação será executada, depois a próxima 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árias computações na ordem.

Com o que tomar cuidado:

  • Você só pode acessar a matriz resultante de valores depois que todos os cálculos tiverem sido concluídos.
  • Os cálculos serão executados na ordem em que forem passados para essa função, o que pode significar que mais tempo será decorrido antes que os resultados sejam retornados.

Async.AwaitTask

Retorna uma computação assíncrona que aguarda a conclusão do determinado Task<TResult> e retorna seu resultado como um Async<'T>

Assinatura:

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

Quando usar:

  • Quando você estiver usando uma API do .NET que retorna uma Task<TResult> na computação assíncrona F#.

Com o que tomar cuidado:

  • As exceções são encapsuladas em AggregateException seguindo a convenção da Biblioteca Paralela de Tarefas; esse comportamento é diferente de como F# assíncrono geralmente apresenta exceções.

Async.Catch

Cria uma computação assíncrona que executa um determinado Async<'T>, retornando um Async<Choice<'T, exn>>. Se o determinado Async<'T> for concluído com êxito, um Choice1Of2 será retornado com o valor resultante. Se uma exceção for gerada antes de ser concluída, uma Choice2of2 será retornada com a exceção gerada. Se ele for usado em uma computação assíncrona que é composta por muitos cálculos e um desses cálculos gera uma exceção, a computação abrangente será totalmente interrompida.

Assinatura:

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

Quando usar:

  • Quando você está executando um trabalho assíncrono que pode não ocorrer com uma exceção e deseja lidar com essa exceção no chamador.

Com o que tomar cuidado:

  • Ao usar computações assíncronas combinadas ou sequenciadas, a computação abrangente será totalmente interrompida se uma de suas computações "internas" gerar uma exceção.

Async.Ignore

Cria uma computação assíncrona que executa a computação fornecida, mas descarta seu resultado.

Assinatura:

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

Quando usar:

  • Quando você tem uma computação assíncrona cujo resultado não é necessário. Isso é análogo à ignore função para código não assíncrono.

Com o que tomar cuidado:

  • Se você precisar usar Async.Ignore porque deseja usar Async.Start ou outra função que exija Async<unit>, considere se descartar o resultado está bem. Evite descartar resultados apenas para ajustar uma assinatura de tipo.

Async.RunSynchronously

Executa uma computação assíncrona e aguarda o resultado no thread de chamada. Propaga uma exceção caso a computação produza uma. A chamada é bloqueada.

Assinatura:

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

Quando usá-lo:

  • Se precisar, use apenas uma vez em um aplicativo: no ponto de entrada de um arquivo 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.

Com o que tomar cuidado:

  • 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 aguarda a conclusão e/ou observa um resultado de exceção. As computações aninhadas e iniciadas com Async.Start são feitas independentemente da computação pai chamada. Seu tempo de vida não está vinculado a nenhuma computação pai. Se a computação pai for cancelada, nenhuma computação filho será cancelada.

Assinatura:

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

Use somente quando:

  • Você tem uma computação assíncrona que não gera 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.

Com o que tomar cuidado:

  • As exceções geradas por cálculos que começam com Async.Start não são propagadas para o chamador. A pilha de chamadas será completamente desfeita.
  • Qualquer trabalho (como chamar printfn) iniciado com Async.Start não fará com que o efeito ocorra na thread principal da execução de um programa.

Interoperar 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 do .NET usam os tipos Task<TResult> e Task como suas abstrações principais, isso pode alterar a forma como você escreve seu código assíncrono em F#.

Uma opção é alternar para escrever tarefas do .NET diretamente usando task { }. Como alternativa, você pode usar a Async.AwaitTask função para aguardar uma computação assíncrona do .NET:

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

Você pode usar a Async.StartAsTask função para passar uma computação assíncrona para um chamador do .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á uma Async<'T> em um 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 essa e a função definida startTaskFromAsyncUnit anteriormente, você pode iniciar e aguardar tipos Task de uma computação assíncrona F#.

Escrevendo tarefas do .NET diretamente em F#

No 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>. A execução da tarefa começa ao chamar a função. A chamada para task.Wait() aguarda a conclusão da tarefa.

Relação com multithreading

Embora o threading seja mencionado ao longo deste artigo, há duas coisas importantes a serem lembradas:

  1. Não há afinidade entre uma computação assíncrona e um thread, a menos que seja iniciada explicitamente no thread atual.
  2. A programação assíncrona em F# não é uma abstração para multithreading.

Por exemplo, uma computação pode realmente ser executada no thread do chamador, dependendo da natureza do trabalho. Uma computação também pode "saltar" entre threads, emprestando por um pequeno período de tempo para fazer um trabalho útil entre períodos de "espera" (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