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 encontra:

  • 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 thread principal enquanto progride simultaneamente 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 questões separadas, e uma não implica a outra. Este artigo descreve os conceitos separados com mais detalhes.

Assíncrona definida

É importante explicar um pouco mais a questão anterior, em que a assíncrona é independente do uso de vários threads. Há três conceitos que às vezes estão relacionados, mas são estritamente independentes um do outro:

  • Simultaneidade: quando várias computações são executadas em períodos de tempo sobrepostos.
  • Paralelismo: quando várias computações ou várias partes de uma única computação são executadas exatamente ao mesmo tempo.
  • Assincronia: quando uma ou mais computações podem ser executadas separadamente do fluxo principal do programa.

Todos os três são conceitos ortogonais, mas podem ser facilmente confundidos, especialmente quando estão juntos. Por exemplo, talvez seja necessário executar várias computações assíncronas em paralelo. Essa relação não significa que o paralelismo ou a assincronia pressupõem um com outro.

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

  • "a" significa "não".
  • "síncrono" 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, computações assíncronas no F# são programadas para serem executadas independente do fluxo principal do programa. Essa execução independente não implica simultaneidade ou paralelismo, nem que uma computação sempre ocorra em segundo plano. Na verdade, computações assíncronas podem até mesmo ser executadas 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 as computações assíncronas são independentes do fluxo principal do programa. Embora haja poucas garantias sobre quando ou como uma computação assíncrona é realizada, há alguns métodos para lidar e programar as computações. O restante deste artigo explora os principais conceitos para assincronia F# e como usar os tipos, funções e expressões incorporados ao F#.

Conceitos fundamentais

No F#, a programação assíncrona é centralizada em torno de dois conceitos principais: computações assíncronas e tarefas.

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

Conceitos fundamentais de assíncrono

É possível observar 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 função printTotalFileBytesUsingAsync é do tipo string -> Async<unit>. A chamada da função não executa, de fato, a computação assíncrona. Na verdade, 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. É uma das funções iniciais do módulo assíncrono que você precisa 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#, computações assíncronas podem ser consideradas como tarefas frias. Elas devem ser claramente iniciadas para realmente ocorrer a execução. Há algumas vantagens, pois permite combinar e sequenciar o trabalho assíncrono com maior facilidade do que em C# ou Visual Basic.

Combinar computações assíncronas

Veja abaixo um exemplo que se baseia no anterior pela combinação de computações:

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 função main tem mais alguns elementos. Em termos conceituais:

  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. Cria um Async<unit> que executa a computação paralela e ignora seu resultado (que é um unit[]).
  4. Executa explicitamente a computação composta geral com Async.RunSynchronously, bloqueando até que seja concluída.

Quando este programa é executado, printTotalFileBytes é executado em paralelo para cada argumento da linha de comando. Como as computações assíncronas são executadas de forma independente do fluxo do programa, não há nenhuma ordem definida na qual mostrem suas informações e terminem a execução. As computações serão programadas em paralelo, mas sua ordem de execução não é garantida.

Sequenciar computações assíncronas

Como Async<'T> é uma especificação de trabalho em vez de uma tarefa já em execução, você pode executar transformações mais complexas com facilidade. Veja abaixo um exemplo que gera sequência em um conjunto de computações assíncronas para que 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

Dessa forma, programa a execução de printTotalFileBytes na ordem dos elementos de argv em vez de programá-los em paralelo. Já que cada operação sucessiva não será programadas até que a computação anterior tenha sido concluída, as computações são sequenciadas de forma que não haja sobreposição em sua execução.

Funções importantes do módulo Assíncrono

Ao escrever código assíncrono em F#, você interage com uma estrutura que trata da programação de computações. No entanto, nem sempre é o caso. Por isso, é bom entender as várias funções que podem ser usadas para programar trabalhos assíncronos.

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árias computações assíncronas sejam executadas 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 ao invés de uma de cada vez, mas não tem programadas em paralelo.
  • Quando você deseja vincular o tempo de vida de uma computação filho à de uma computação pai.

O que prestar atenção:

  • Iniciar várias computações com Async.StartChild não é o mesmo que programá-las em paralelo. Se você quiser programar computações 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. É ú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), Async.StartImmediate deve 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.

O que prestar atenção:

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

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 não for fornecido nenhum token de cancelamento, 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 do .NET que produz um Task<TResult> para representar o resultado de uma computação assíncrona.

O que prestar atenção:

  • Essa chamada aloca um objeto adicional Task, o que pode aumentar a sobrecarga se for usado com frequência.

Async.Parallel

Programa uma sequência de computações assíncronas a serem executadas em paralelo, gerando uma matriz de resultados na ordem fornecida. O grau de paralelismo pode ser opcionalmente ajustado/limitado especificando o parâmetro maxDegreeOfParallelism.

Assinatura:

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

Quando usar:

  • Se você precisar executar um conjunto de computações ao mesmo tempo e não depender da ordem de sua execução.
  • Se você não precisar de resultados de computações agendadas em paralelo até que todas tenham sido concluídas.

O que prestar atenção:

  • Você somente pode acessar a matriz de valores resultante depois que todas as computações tiverem sido concluídas.
  • 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

Programa uma sequência de computações assíncronas a serem executadas na ordem em que são passadas. A primeira computação será executada, depois a próxima e assim por diante. Nenhuma computação será executada em paralelo.

Assinatura:

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

Quando usar:

  • Se você precisar executar várias computações na ordem.

O que prestar atenção:

  • Você somente pode acessar a matriz de valores resultante depois que todas as computações tiverem sido concluídas.
  • As computações serão executadas na ordem em que forem passadas 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 de determinada 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#.

O que prestar atenção:

  • As exceções são encapsuladas na AggregateException após a convenção da Biblioteca de Paralelismo 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 for usado em uma computação assíncrona composta por muitas computações e uma dessas computações gerar 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.

O que prestar atenção:

  • 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. É semelhante à função ignore para código não assíncrono.

O que prestar atenção:

  • Se você precisar usar Async.Ignore porque deseja usar Async.Start ou outra função que exija Async<unit>, considere se vale a pena descartar o resultado. 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 seja gerada na computação. A chamada é bloqueada.

Assinatura:

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

Quando usar:

  • 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 ao mesmo tempo.

O que prestar atenção:

  • 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 o processamento de um deles.
  • 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 informar exceções resultantes da execução.

O que prestar atenção:

  • Exceções geradas por computações iniciadas com Async.Start não são propagadas para o chamador. A pilha de chamadas será completamente desfeita.
  • Qualquer trabalho (como chamada printfn) iniciado com Async.Start não causa o efeito no thread principal da execução de um programa.

Interoperar com o .NET

Se estiver usando a programação async { }, talvez seja necessário interoperar com uma biblioteca .NET ou uma base de código C# que usa programação assíncrona de estilo async/await. Já que o C# e a maioria das bibliotecas do .NET usam os tipos Task<TResult> e Task como abstrações principais, isso pode alterar a forma como você escreve seu código assíncrono do F#.

Uma opção é alternar para a gravação de tarefas do .NET diretamente usando task { }. Como alternativa, você pode usar a função Async.AwaitTask 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 função Async.StartAsTask 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, computações assíncronas do .NET que não retornam um valor), talvez seja necessário adicionar uma função a mais que converte 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 uma 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 #

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 função printTotalFileBytesUsingTasks é do tipo string -> Task<unit>. A chamada da função começa a executar a tarefa. 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 explicitamente iniciado 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.

Confira também