Como o uso de aguardar difere do uso de ContinueWith ao processar tarefas assíncronas?

8

Aqui está o que eu quero dizer:

public Task<SomeObject> GetSomeObjectByTokenAsync(int id)
    {
        string token = repository.GetTokenById(id);
        if (string.IsNullOrEmpty(token))
        {
            return Task.FromResult(new SomeObject()
            {
                IsAuthorized = false
            });
        }
        else
        {
            return repository.GetSomeObjectByTokenAsync(token).ContinueWith(t =>
            {
                t.Result.IsAuthorized = true;
                return t.Result;
            });
        }
    }

Acima método pode ser esperado e eu acho que se assemelha ao que o T perguntar-based A síncrona P attern sugere fazer? (Os outros padrões que conheço são os padrões APM e EAP .)

Agora, o que acontece com o seguinte código:

public async Task<SomeObject> GetSomeObjectByToken(int id)
    {
        string token = repository.GetTokenById(id);
        if (string.IsNullOrEmpty(token))
        {
            return new SomeObject()
            {
                IsAuthorized = false
            };
        }
        else
        {
            SomeObject result = await repository.GetSomeObjectByTokenAsync(token);
            result.IsAuthorized = true;
            return result;
        }
    }

As principais diferenças aqui são que o método é asynce utiliza as awaitpalavras-chave - então o que isso muda em contraste com o método escrito anteriormente? Eu sei que isso também pode ser esperado. Qualquer método que retorne Task pode, a menos que eu esteja enganado.

Estou ciente da máquina de estado criada com essas instruções switch sempre que um método é rotulado como asynce sei que awaitele próprio não usa thread - ele não bloqueia nada, o thread simplesmente faz outras coisas, até que seja chamado de volta para continuar a execução do código acima.

Mas qual é a diferença subjacente entre os dois métodos, quando os chamamos usando a awaitpalavra - chave? Existe alguma diferença e, se houver - qual é a preferida?

EDITAR: Eu sinto que o primeiro trecho de código é o preferido, porque eliminamos efetivamente as palavras-chave async / wait, sem nenhuma repercussão - retornamos uma tarefa que continuará sua execução de forma síncrona ou uma tarefa já concluída no caminho ativo (que pode ser em cache).

SpiritBob
fonte
3
Muito pouco, neste caso. No seu primeiro exemplo, result.IsAuthorized = trueele será executado no pool de encadeamentos, enquanto no segundo exemplo, ele poderá ser executado no mesmo encadeamento que foi chamado GetSomeObjectByToken(se houvesse um SynchronizationContextinstalado nele, por exemplo, era um encadeamento da interface do usuário). O comportamento se GetSomeObjectByTokenAsynclança uma exceção também será um pouco diferente. Em geral, awaitprefere-se ContinueWith, pois quase sempre é mais legível.
canton7
1
No presente artigo é chamado de "eliding", que parece ser uma palavra muito bom para ele. Stephen definitivamente conhece seus negócios. A conclusão do artigo é essencialmente que não importa muito, em termos de desempenho, mas existem algumas armadilhas de codificação se você não esperar. Seu segundo exemplo é um padrão mais seguro.
John Wu
1
Você pode estar interessado nesta discussão do twitter do Partner Software Architect da Microsoft na equipe do ASP.NET: twitter.com/davidfowl/status/1044847039929028608?lang=en
Remy
É chamado de "máquina de estado", não de "máquina virtual".
Enigmativity
@ Enigmativity Obrigado, sábio, esqueci de editar isso!
SpiritBob 6/11/19

Respostas:

5

O mecanismo async/ awaitfaz o compilador transformar seu código em uma máquina de estado. Seu código será executado de forma síncrona até o primeiroawait que atingir um aguardável que não foi concluído, se houver.

No compilador Microsoft C #, essa máquina de estado é um tipo de valor, o que significa que terá um custo muito pequeno quando todas as await s forem concluídos aguardáveis, pois não alocará um objeto e, portanto, não gerará lixo. Quando qualquer aguardável não é concluído, esse tipo de valor é inevitavelmente encaixotado.

Observe que isso não evita a alocação de Tasks se esse for o tipo de aguardável usado nas awaitexpressões.

Com ContinueWith, você evita alocações (exceto Task) se a sua continuação não tiver um fechamento e se você não usar um objeto de estado ou reutilizar um objeto de estado o máximo possível (por exemplo, de um pool).

Além disso, a continuação é chamada quando a tarefa é concluída, criando um quadro de pilha, não é embutido. A estrutura tenta evitar estouros de pilha, mas pode haver um caso em que não será evitado, como quando grandes matrizes são alocadas.

A maneira como tenta evitar isso é verificando quanta pilha resta e, se por alguma medida interna a pilha for considerada cheia, ele agendará a continuação para execução no agendador de tarefas. Ele tenta evitar exceções fatais de estouro de pilha ao custo do desempenho.

Aqui está uma diferença sutil entre async/ awaite ContinueWith:

  • async/ awaitagendará continuações, SynchronizationContext.Currentse houver, caso contrário, em TaskScheduler.Current 1

  • ContinueWithagendará continuações no agendador de tarefas fornecido ou nas TaskScheduler.Currentsobrecargas sem o parâmetro do agendador de tarefas

Para simular async/ await's comportamento padrão:

.ContinueWith(continuationAction,
    SynchronizationContext.Current != null ?
        TaskScheduler.FromCurrentSynchronizationContext() :
        TaskScheduler.Current)

Para simular async/ await's comportamento com Task' s .ConfigureAwait(false):

.ContinueWith(continuationAction,
    TaskScheduler.Default)

As coisas começam a ficar complicadas com loops e manipulação de exceções. Além de manter seu código legível, async/ awaitfunciona com qualquer que seja esperado .

Seu caso é melhor tratado com uma abordagem mista: um método síncrono que chama um método assíncrono quando necessário. Um exemplo do seu código com esta abordagem:

public Task<SomeObject> GetSomeObjectByTokenAsync(int id)
{
    string token = repository.GetTokenById(id);
    if (string.IsNullOrEmpty(token))
    {
        return Task.FromResult(new SomeObject()
        {
            IsAuthorized = false
        });
    }
    else
    {
        return InternalGetSomeObjectByTokenAsync(repository, token);
    }
}

internal async Task<SomeObject> InternalGetSomeObjectByToken(Repository repository, string token)
{
    SomeObject result = await repository.GetSomeObjectByTokenAsync(token);
    result.IsAuthorized = true;
    return result;
}

Na minha experiência, encontrei muito poucos lugares no código do aplicativo em que adicionar essa complexidade realmente compensa o tempo para desenvolver, revisar e testar essas abordagens, enquanto no código da biblioteca qualquer método pode ser um gargalo.

O único caso em que tendem a executar tarefas é quando um TaskouTask<T> método retorno simplesmente retorna o resultado de outro método assíncrono, sem ele próprio ter executado nenhuma E / S ou pós-processamento.

YMMV.


  1. A menos que você use ConfigureAwait(false)ou espere por alguns que aguardem que usem agendamento personalizado
acelent
fonte
1
A máquina de estado não é um tipo de valor. Dê uma olhada na fonte gerado de um programa assíncrono simples: private sealed class <Main>d__0 : IAsyncStateMachine. As instâncias dessa classe podem ser reutilizáveis.
Theodor Zoulias
Você diria que, se eu violasse apenas o resultado de uma tarefa, seria bom usá-lo ContinueWith?
SpiritBob
3
@TheodorZoulias, o Release de segmentação de origem gerado, em vez de Debug, gera estruturas.
acelent
2
O @SpiritBob, se você realmente quer mexer nos resultados das tarefas, acho que está tudo bem, .ContinueWithdeveria ser usado programaticamente. Especialmente se você estiver lidando com tarefas intensivas da CPU em vez de tarefas de E / S. Mas quando você chega ao ponto de ter que lidar com Taskas propriedades de, AggregateExceptione com a complexidade das condições de manipulação, ciclos e tratamento de exceções quando outros métodos assíncronos são invocados, dificilmente há motivo para ficar com ela.
Acelent
2
@ SpitBob, desculpe, eu quis dizer "loops". Sim, você pode criar um campo estático somente leitura com a Task.FromResult("..."). No código assíncrono, se você armazenar em cache valores por chave que exigem a obtenção de E / S, poderá usar um dicionário em que os valores sejam tarefas, por exemplo, em ConcurrentDictionary<string, Task<T>>vez de ConcurrentDictionary<string, T>usar GetOrAddchamada com uma função de fábrica e aguardar o resultado. Isso garante que apenas uma solicitação de E / S seja feita para preencher a chave do cache; ele desperta os garçons assim que a tarefa é concluída e serve como uma tarefa concluída posteriormente.
Acelent 5/11
2

Usando ContinueWithvocê estiver usando as ferramentas que, quando disponível antes da introdução do async/ awaitfuncionalidade com C # 5 para trás em 2012. Como uma ferramenta que é detalhado, não é facilmente combináveis, e requer trabalho extra para desembrulhar AggregateExceptions e Task<Task<TResult>>valores de retorno (você obter estes quando você passa delegados assíncronos como argumentos). Oferece poucas vantagens em troca. Você pode considerar usá-lo quando desejar anexar várias continuações à mesma Taskou, em alguns casos raros, em que não puder usar async/ awaitpor algum motivo (como quando você está em um método com outparâmetros ).


Atualização: removi o conselho enganoso que o ContinueWithdeve usar TaskScheduler.Defaultpara imitar o comportamento padrão de await. Na verdade o awaitpor horários predefinidos sua continuação usando TaskScheduler.Current.

Theodor Zoulias
fonte