Expressões de tarefa

Este artigo descreve o suporte em F# para expressões de tarefas, que são semelhantes a expressões assíncronas , mas permitem que você crie tarefas .NET diretamente. Como as expressões assíncronas, as expressões de tarefa executam código de forma assíncrona, ou seja, sem bloquear a execução de outros trabalhos.

O código assíncrono é normalmente criado usando expressões assíncronas. O uso de expressões de tarefa é preferível ao interoperar extensivamente com bibliotecas .NET que criam ou consomem tarefas .NET. As expressões de tarefas também podem melhorar o desempenho e a experiência de depuração. No entanto, as expressões de tarefa vêm com algumas limitações, que são descritas mais adiante no artigo.

Sintaxe

task { expression }

Na sintaxe anterior, a computação representada por expression é configurada para ser executada como uma tarefa .NET. A tarefa é iniciada imediatamente após a execução desse código e é executada no thread atual até que sua primeira operação assíncrona seja executada (por exemplo, uma suspensão assíncrona, E/S assíncrona ou outra operação assíncrona primitiva). O tipo da expressão é Task<'T>, onde 'T é o tipo retornado pela expressão quando a palavra-chave return é usada.

Vinculação usando let!

Em uma expressão de tarefa, algumas expressões e operações são síncronas e outras assíncronas. Quando você aguarda o resultado de uma operação assíncrona, em vez de uma ligação comum let , você usa let!. O efeito do let! é permitir que a execução continue em outras computações ou threads à medida que a computação está a ser realizada. Depois que o lado direito da let! vinculação retorna, o restante da tarefa retoma a execução.

O código a seguir mostra a diferença entre let e let!. A linha de código que usa let apenas cria uma tarefa como um objeto que você pode aguardar mais tarde usando, por exemplo, task.Wait() ou task.Result. A linha de código que usa let! inicia a tarefa e aguarda seu resultado.

// let just stores the result as a task.
let (result1 : Task<int>) = stream.ReadAsync(buffer, offset, count, cancellationToken)
// let! completes the asynchronous operation and returns the data.
let! (result2 : int)  = stream.ReadAsync(buffer, offset, count, cancellationToken)

As expressões F# task { } podem aguardar os seguintes tipos de operações assíncronas:

return expressões

Dentro de expressões de tarefa, return expr é usado para retornar o resultado de uma tarefa.

return! expressões

Dentro de expressões de tarefa, return! expr é usado para retornar o resultado de outra tarefa. É equivalente a usar let! e, em seguida, retornar imediatamente o resultado.

Fluxo de controle

As expressões de tarefa podem incluir as construções for .. in .. dode fluxo de controle , , while .. do, try .. with ..try .. finally .., if .. then .. elsee if .. then ... Estes, por sua vez, podem incluir outras construções de tarefas, exceto para os with manipuladores e finally , que são executados de forma síncrona. Se você precisar de um assíncrono try .. finally .., use uma use associação em combinação com um objeto do tipo IAsyncDisposable.

use e use! vinculações

Nas expressões de tarefa, use as associações podem ser associadas a valores do tipo IDisposable ou IAsyncDisposable. Para o último caso, a operação de limpeza do descarte é executada de forma assíncrona.

Além de let!, você pode usar use! para executar associações assíncronas. A diferença entre let! e use! é a mesma que a diferença entre let e use. Por use!, o objeto é eliminado no fechamento do escopo atual. Observe que em F# 6, use! não permite que um valor seja inicializado como nulo, mesmo que use o faça.

open System
open System.IO
open System.Security.Cryptography
task {
    // use IDisposable
    use httpClient = new Net.Http.HttpClient()
    // use! Task<IDisposable>
    use! exampleDomain = httpClient.GetAsync "https://example.com/data.enc"

    // use IDisposable
    use aes = Aes.Create()
    aes.KeySize <- 256
    aes.GenerateIV()
    aes.GenerateKey()
    // do! Task
    do! File.WriteAllTextAsync("key.iv.txt", $"Key: {Convert.ToBase64String aes.Key}\nIV: {Convert.ToBase64String aes.IV}")

    // use IAsyncDisposable
    use outputStream = File.Create "secret.enc"
    // use IDisposable
    use encryptor = aes.CreateEncryptor()
    // use IAsyncDisposable
    use cryptoStream = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write)
    // do! Task
    do! exampleDomain.Content.CopyToAsync cryptoStream
}

Tarefas de valor

As tarefas de valor são estruturas usadas para evitar alocações na programação baseada em tarefas. Uma tarefa de valor é um valor efêmero que se transforma em uma tarefa real usando .AsTask()o .

Para criar uma tarefa de valor a partir de uma expressão de tarefa, use |> ValueTask<ReturnType> ou |> ValueTask. Por exemplo:

let makeTask() =
    task { return 1 }

makeTask() |> ValueTask<int>

and! ligações (a partir de F# 10)

Dentro das expressões de tarefas, é possível aguardar simultaneamente por várias operações assíncronas (Task<'T>, ValueTask<'T>, Async<'T> etc). Comparar:

// We'll wait for x to resolve and then for y to resolve. Overall execution time is sum of two execution times.
let getResultsSequentially() =
    task {
        let! x = getX()
        let! y = getY()
        return x, y
    }

// x and y will be awaited concurrently. Overall execution time is the time of the slowest operation.
let getResultsConcurrently() =
    task {
        let! x = getX()
        and! y = getY()
        return x, y
    }

Adicionar tokens de cancelamento e verificações de cancelamento

Ao contrário das expressões assíncronas do F#, as expressões de tarefa não passam implicitamente um token de cancelamento e não executam implicitamente verificações de cancelamento. Se o seu código requer um token de cancelamento, você deve especificar o token de cancelamento como um parâmetro. Por exemplo:

open System.Threading

let someTaskCode (cancellationToken: CancellationToken) =
    task {
        cancellationToken.ThrowIfCancellationRequested()
        printfn $"continuing..."
    }

Se você pretende tornar seu código cancelável corretamente, verifique cuidadosamente se você passa o token de cancelamento para todas as operações da biblioteca .NET que oferecem suporte ao cancelamento. Por exemplo, Stream.ReadAsync tem várias sobrecargas, uma das quais aceita um token de cancelamento. Se você não usar essa sobrecarga, essa operação de leitura assíncrona específica não poderá ser cancelada.

Concorrência limitada e pipelines de resultados

Para limitar o número de operações que começam ao mesmo tempo, o FSharp.Core 11 fornece Task.parallelLimit. Passe por fábricas que aceitam tokens de cancelamento, em vez de tarefas que já tenham começado:

open System.IO
open System.Threading

let readFiles (paths: seq<string>) (ct: CancellationToken) =
    paths
    |> Seq.map (fun path ->
        fun token -> File.ReadAllTextAsync(path, token))
    |> Task.parallelLimit 4 ct

O array resultante mantém a ordem de entrada. Uma falha solicita o cancelamento de trabalhos irmãos, e cada operação deve observar o seu token. Task.sequential faz as fábricas uma de cada vez; Task.parallelDoLimit e Task.sequentialDo regressam unit em vez de uma matriz.

Para Task.map, , e Task.catch pipelines de resultados e ajudantes Async/ValueTask correspondentes, consulte Assíncrono e funções de tarefaTask.bind.

Tarefas em segundo plano

Por padrão, as tarefas do .NET são agendadas usando, se SynchronizationContext.Current presente. Isso permite que as tarefas sirvam como agentes cooperativos e intercalados em execução em um thread de interface do usuário sem bloquear a interface do usuário. Se não estiverem presentes, as continuações de tarefas serão agendadas para o pool de threads do .NET.

Na prática, muitas vezes é desejável que o código da biblioteca que gera tarefas ignore o contexto de sincronização e, em vez disso, sempre alterne para o pool de threads .NET, se necessário. Você pode conseguir isso usando backgroundTask { }:

backgroundTask { expression }

Uma tarefa em segundo plano ignora qualquer SynchronizationContext.Current uma no seguinte sentido: se iniciada em um thread com não-nulo SynchronizationContext.Current, ela alterna para um thread em segundo plano no pool de threads usando Task.Run. Se iniciado em um thread com null SynchronizationContext.Current, ele é executado nesse mesmo thread.

Observação

Na prática, isso significa que as chamadas para ConfigureAwait(false) normalmente não são necessárias no código de tarefa F#. Em vez disso, as tarefas que se destinam a ser executadas em segundo plano devem ser criadas usando backgroundTask { ... }o . Qualquer ligação de tarefa externa a uma tarefa em segundo plano será ressincronizada com a tarefa em segundo plano após a SynchronizationContext.Current conclusão da tarefa.

Limitações de tarefas em relação a tailcalls

Ao contrário das expressões assíncronas do F#, as expressões de tarefa não suportam tailcalls. Ou seja, quando return! é executada, a tarefa atual é registrada como aguardando a tarefa cujo resultado está sendo retornado. Isso significa que funções recursivas e métodos implementados usando expressões de tarefa podem criar cadeias ilimitadas de tarefas, e estas podem usar pilha ou pilha não limitada. Por exemplo, considere o seguinte código:

let rec taskLoopBad (count: int) : Task<string> =
    task {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! taskLoopBad (count-1)
    }

let t = taskLoopBad 10000000
t.Wait()

Esse estilo de codificação não deve ser usado com expressões de tarefas — ele criará uma cadeia de 10000000 tarefas e causará um StackOverflowExceptionarquivo . Se uma operação assíncrona for adicionada em cada invocação de loop, o código usará um heap essencialmente ilimitado. Considere alternar esse código para usar um loop explícito, por exemplo:

let taskLoopGood (count: int) : Task<string> =
    task {
        for i in count .. 1 do
            printfn $"looping... count = {count}"
        return "done!"
    }

let t = taskLoopGood 10000000
t.Wait()

Se forem necessárias tailcalls assíncronas, use uma expressão assíncrona F#, que suporta tailcalls. Por exemplo:

let rec asyncLoopGood (count: int) =
    async {
        if count = 0 then
            return "done!"
        else
            printfn $"looping..., count = {count}"
            return! asyncLoopGood (count-1)
    }

let t = asyncLoopGood 1000000 |> Async.StartAsTask
t.Wait()

Execução da tarefa

As tarefas são implementadas usando o Código Retomável, um novo recurso no F# 6. As tarefas são compiladas em "Resumable State Machines" pelo compilador F#. Eles são descritos em detalhes no RFC do código Resumable e em uma sessão da comunidade do compilador F#.

Experiências assíncronas em tempo de execução

Os autores do builder podem experimentar com .NET em tempo de execução assíncrono. Uma máquina de estados gerada por compiladores normalmente armazena o estado necessário para continuar após uma espera. Com o assíncrono em tempo de execução, o tempo de execução pausa e retoma um método especialmente marcado.

Observação

Este protótipo utiliza uma funcionalidade F# 11.2. No SDK .NET 11.0.100, ative <LangVersion>preview</LangVersion> com um compilador que contenha suporte assíncrono em tempo de execução. Também precisa de montagens em tempo de execução e de referência que MethodImplOptions.Async expõem e System.Runtime.CompilerServices.AsyncHelpers.Await. Os assemblies de referência descrevem as APIs em tempo de compilação; o runtime .NET em execução deve implementar o comportamento de pausa/retomar.

A combinação verificada de componentes do exemplo é:

Component Versão ou requisito
Compilador O compilador F#11 fornecido pelo FSharp.Compiler.Service 43.13.101-rc2.26473.112.
FSharp.Core Embalar 11.0.101-rc2.26473.112, usando lib/net10.0/FSharp.Core.dll. Esta build fornece StateMachineHelpers.__runtimeAsyncReturn; as netstandard2.0 builds e netstandard2.1 não.
Project Target net11.0.
Execução e montagens de referência .NET11.0.0-rc.1.26425.128.

Estes componentes testados não são uma única distribuição de SDK. .NET 10.0.12 expõe as APIs de referência, mas a verificação de pausa/retomada falha no seu tempo de execução com TypeLoadException. Use a combinação verificada de runtime/reference acima; nem o modo de pré-visualização nem a presença da API fornecem suporte em tempo de execução.

O seguinte site de chamadas utiliza um construtor definido pela aplicação para ler um ficheiro de forma assíncrona:

let readManifest (path: string) =
    runtimeTask {
        let! text = System.IO.File.ReadAllTextAsync path
        return text.Length
    }

O construtor liga a expressão de computação ao retorno intrínseco e await helper em tempo de execução-assíncrono. Não é um novo construtor incorporado task :

open System.Threading.Tasks
open System.Runtime.CompilerServices
open Microsoft.FSharp.Core.CompilerServices

type RuntimeTaskBuilder() =
    member inline _.Delay([<InlineIfLambda>] code: unit -> 'T) = code
    member inline _.Run([<InlineIfLambda>] code: unit -> 'T) : Task<'T> =
        StateMachineHelpers.__runtimeAsyncReturn (code())
    member inline _.Return(value: 'T) = value
    member inline _.Bind(work: Task<'T>, [<InlineIfLambda>] next: 'T -> 'U) =
        next (AsyncHelpers.Await work)

let runtimeTask = RuntimeTaskBuilder()

__runtimeAsyncReturn marca o método de encerramento para o assíncrono em tempo de execução. Dentro desse método, AsyncHelpers.Await pode suspender no trabalho inacabado e devolver o controlo ao chamador, retomando depois com o resultado. Não é uma chamada de bloqueio .Result .

O compilador verifica padrões de suspensão suportados; os byrefs não podem permanecer ativos através de uma suspensão. O exemplo define Delayapenas , Run, Return, e uma Bind sobrecarga, não um construtor de produção com cancelamento, ciclos ou eliminação assíncrona.

Ative a funcionalidade F# 11.2 onde o construtor inline é definido e expandido. Os utilizadores de uma API já compilada readManifest não precisam disso. As expressões ordinárias task mantêm a sua implementação. Use AsyncHelpers.Await apenas métodos assíncronos em tempo de execução; veja a especificação assíncrona em tempo de execução.

O projeto de exemplo completo inclui um ponto de entrada da consola. A partir do seu diretório, executa dotnet run -- manifest.txt com ferramentas compatíveis e um ficheiro para ler. Um ficheiro contendo exatamente manifest, sem uma nova linha final, produz 8. O ponto de entrada da consola bloqueia a conclusão; readManifest ele próprio devolve Task<int>.

Ver também