Pelo que entendi a yield
palavra - chave, se usada de dentro de um bloco iterador, ela retorna o fluxo de controle para o código de chamada e, quando o iterador é chamado novamente, ele continua de onde parou.
Além disso, await
não apenas espera pelo receptor, mas também retorna o controle ao chamador, apenas para retomar de onde parou quando o chamador awaits
o método.
Em outras palavras - não há thread , e a "simultaneidade" de async e await é uma ilusão causada por um fluxo inteligente de controle, cujos detalhes são ocultados pela sintaxe.
Agora, sou um ex-programador de assembly e estou muito familiarizado com ponteiros de instrução, pilhas, etc. e entendo como funcionam os fluxos normais de controle (sub-rotina, recursão, loops, ramos). Mas essas novas construções - eu não as entendo.
Quando um await
é alcançado, como o tempo de execução sabe qual parte do código deve ser executada a seguir? Como ele sabe quando pode retomar de onde parou e como se lembra de onde? O que acontece com a pilha de chamadas atual, ela é salva de alguma forma? E se o método de chamada fizer outras chamadas de método antes de await
s-- por que a pilha não é sobrescrita? E como diabos o runtime trabalharia por tudo isso no caso de uma exceção e uma pilha se desenrolaria?
Quando yield
é alcançado, como o tempo de execução monitora o ponto em que as coisas devem ser selecionadas? Como o estado do iterador é preservado?
fonte
Respostas:
Vou responder às suas perguntas específicas abaixo, mas você provavelmente faria bem em simplesmente ler meus extensos artigos sobre como projetamos o rendimento e a espera.
https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/
https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/
https://blogs.msdn.microsoft.com/ericlippert/tag/async/
Alguns desses artigos estão desatualizados agora; o código gerado é diferente de várias maneiras. Mas isso certamente lhe dará uma ideia de como funciona.
Além disso, se você não entende como lambdas são gerados como classes de encerramento, entenda isso primeiro . Você não fará cara ou coroa de assíncrono se não tiver lambdas.
await
é gerado como:É basicamente isso. Await é apenas um retorno elegante.
Bem, como você faz isso sem esperar? Quando o método foo chama o método bar, de alguma forma nos lembramos de como voltar para o meio de foo, com todos os locais de ativação de foo intactos, não importa o que bar faça.
Você sabe como isso é feito no assembler. Um registro de ativação para foo é colocado na pilha; ele contém os valores dos habitantes locais. No momento da chamada, o endereço de retorno em foo é colocado na pilha. Quando a barra é concluída, o ponteiro da pilha e o ponteiro da instrução são redefinidos para onde precisam estar e foo continua de onde parou.
A continuação de um await é exatamente a mesma, exceto que o registro é colocado no heap pela razão óbvia de que a sequência de ativações não forma uma pilha .
O delegado que espera dá como continuação da tarefa contém (1) um número que é a entrada para uma tabela de pesquisa que fornece o ponteiro de instrução que você precisa para executar a seguir, e (2) todos os valores de locais e temporários.
Há algum equipamento adicional lá; por exemplo, no .NET é ilegal ramificar para o meio de um bloco try, então você não pode simplesmente inserir o endereço do código dentro de um bloco try na tabela. Mas esses são detalhes da contabilidade. Conceitualmente, o registro de ativação é simplesmente movido para o heap.
As informações relevantes no registro de ativação atual nunca são colocadas na pilha em primeiro lugar; ele é alocado fora do heap desde o início. (Bem, os parâmetros formais são passados na pilha ou em registros normalmente e, em seguida, copiados em um local de heap quando o método começa.)
Os registros de ativação dos chamadores não são armazenados; a espera provavelmente vai voltar para eles, lembre-se, então eles serão tratados normalmente.
Observe que esta é uma diferença importante entre o estilo de passagem de continuação simplificado de await e as estruturas de continuação de chamada com corrente verdadeiras que você vê em linguagens como Scheme. Nessas línguas, toda a continuação, incluindo a continuação de volta para os chamadores, é capturada por call-cc .
Essas chamadas de método retornam e, portanto, seus registros de ativação não estão mais na pilha no ponto de espera.
No caso de uma exceção não capturada, a exceção é capturada, armazenada dentro da tarefa e lançada novamente quando o resultado da tarefa é buscado.
Lembra de toda aquela contabilidade que mencionei antes? Obter a semântica de exceção certa foi uma dor enorme, deixe-me dizer a você.
Da mesma maneira. O estado dos locais é movido para o heap e um número que representa a instrução na qual
MoveNext
deve continuar na próxima vez que for chamada é armazenado junto com os locais.E, novamente, há um monte de equipamentos em um bloco iterador para garantir que as exceções sejam tratadas corretamente.
fonte
yield
é o mais fácil dos dois, então vamos examiná-lo.Digamos que temos:
Isso é compilado um pouco como se tivéssemos escrito:
Portanto, não é tão eficiente quanto uma implementação escrita à mão de
IEnumerable<int>
eIEnumerator<int>
(por exemplo, provavelmente não perderíamos tendo um separado_state
,_i
e_current
neste caso), mas não é ruim (o truque de reutilizar a si mesmo quando seguro fazê-lo em vez de criar um novo objeto é bom) e extensível para lidar comyield
métodos de uso muito complicados .E claro desde
É o mesmo que:
Em seguida, o gerado
MoveNext()
é chamado repetidamente.O
async
caso é praticamente o mesmo princípio, mas com um pouco de complexidade extra. Para reutilizar um exemplo de outro código de resposta como:Produz código como:
É mais complicado, mas um princípio básico muito semelhante. A principal complicação extra é que agora
GetAwaiter()
está sendo usado. Se algum tempoawaiter.IsCompleted
for marcado, ele retornatrue
porque a tarefaawait
ed já foi concluída (por exemplo, casos em que poderia retornar de forma síncrona), então o método continua se movendo pelos estados, mas caso contrário, ele se configura como um retorno de chamada para o aguardador.Exatamente o que acontece com isso depende do awaiter, em termos do que dispara o retorno de chamada (por exemplo, conclusão de E / S assíncrona, uma tarefa em execução em uma conclusão de thread) e quais requisitos existem para empacotar para uma thread específica ou executar em uma thread de pool de threads , qual contexto da chamada original pode ou não ser necessário e assim por diante. O que quer que seja, embora algo naquele awaiter chame o
MoveNext
e continuará com a próxima parte do trabalho (até a próximaawait
) ou terminará e retornará, caso em que oTask
que está implementando será concluído.fonte
yield
para enrolado à mão quando há uma vantagem em fazer isso (geralmente como uma otimização, mas querendo ter certeza de que o ponto de partida está próximo ao gerado pelo compilador então nada se torna desotimizado por meio de suposições erradas). A segunda foi usada pela primeira vez em outra resposta e havia algumas lacunas em meu próprio conhecimento na época, então me beneficiei preenchendo-as enquanto fornecia essa resposta descompilando manualmente o código.Já há uma tonelada de ótimas respostas aqui; Vou apenas compartilhar alguns pontos de vista que podem ajudar a formar um modelo mental.
Primeiro, um
async
método é dividido em várias partes pelo compilador; asawait
expressões são os pontos de fratura. (Isso é fácil de conceber para métodos simples; métodos mais complexos com loops e tratamento de exceções também são quebrados, com a adição de uma máquina de estados mais complexa).Em segundo lugar,
await
é traduzido em uma sequência bastante simples; Eu gosto da descrição do Lucian , que em palavras é basicamente "se o aguardável já estiver completo, pegue o resultado e continue executando este método; caso contrário, salve o estado deste método e retorne". (Eu uso uma terminologia muito semelhante na minhaasync
introdução ).O restante do método existe como um retorno de chamada para aquele aguardável (no caso de tarefas, esses retornos de chamada são continuações). Quando a espera é concluída, ele invoca seus retornos de chamada.
Observe que a pilha de chamadas não é salva e restaurada; callbacks são invocados diretamente. No caso de E / S sobreposta, eles são chamados diretamente do pool de threads.
Esses retornos de chamada podem continuar executando o método diretamente ou podem agendá-lo para ser executado em outro lugar (por exemplo, se a
await
IU capturadaSynchronizationContext
e a I / O concluída no pool de threads).São apenas chamadas de retorno. Quando um aguardável é concluído, ele invoca seus retornos de chamada e qualquer
async
método que já o tenhaawait
editado é retomado. O retorno de chamada salta para o meio desse método e tem suas variáveis locais no escopo.Os retornos de chamada não são executados em um segmento específico e não têm sua pilha de chamadas restaurada.
A pilha de chamadas não é salva em primeiro lugar; não é necessário.
Com o código síncrono, você pode acabar com uma pilha de chamadas que inclui todos os seus chamadores, e o tempo de execução sabe para onde retornar usando isso.
Com o código assíncrono, você pode acabar com um monte de ponteiros de retorno de chamada - enraizados em alguma operação de E / S que conclui sua tarefa, que pode retomar um
async
método que conclui sua tarefa, que pode retomar umasync
método que conclui sua tarefa, etc.Assim, com síncrona código de
A
chamadaB
vocaçãoC
, o seu callstack pode ser parecido com este:enquanto o código assíncrono usa callbacks (ponteiros):
Atualmente, de forma bastante ineficiente. :)
Funciona como qualquer outra variável lambda - os tempos de vida são estendidos e as referências são colocadas em um objeto de estado que vive na pilha. O melhor recurso para todos os detalhes de nível profundo é a série EduAsync de Jon Skeet .
fonte
yield
eawait
são, embora lidem com controle de fluxo, duas coisas completamente diferentes. Então, vou lidar com eles separadamente.O objetivo
yield
é facilitar a construção de sequências preguiçosas. Quando você escreve um loop de enumerador com umayield
instrução nele, o compilador gera uma tonelada de código novo que você não vê. Por baixo do capô, ele realmente gera uma classe totalmente nova. A classe contém membros que rastreiam o estado do loop e uma implementação de IEnumerable para que, cada vez que você chamá-MoveNext
lo, passe mais uma vez por esse loop. Então, quando você faz um loop foreach como este:o código gerado é semelhante a:
Dentro da implementação de mything.items () está um monte de código de máquina de estado que fará uma "etapa" do loop e depois retornará. Então, enquanto você escreve na fonte como um loop simples, por baixo do capô não é um loop simples. Então, truques do compilador. Se você quiser se ver, pegue o ILDASM ou o ILSpy ou ferramentas semelhantes e veja como é o IL gerado. Deve ser instrutivo.
async
eawait
, por outro lado, são uma outra chaleira de peixes. Await é, em abstrato, uma primitiva de sincronização. É uma maneira de dizer ao sistema "Não posso continuar até que isso seja feito." Mas, como você observou, nem sempre há um segmento envolvido.O que está envolvido é algo chamado de contexto de sincronização. Sempre há um por perto. O trabalho do contexto de sincronização é agendar tarefas que estão sendo aguardadas e suas continuações.
Quando você diz
await thisThing()
, algumas coisas acontecem. Em um método assíncrono, o compilador realmente divide o método em pedaços menores, cada pedaço sendo uma seção "antes de uma espera" e uma seção "depois de uma espera" (ou continuação). Quando o await é executado, a tarefa sendo esperada e a continuação seguinte - em outras palavras, o resto da função - é passada para o contexto de sincronização. O contexto se encarrega de agendar a tarefa e, quando termina, o contexto executa a continuação, passando qualquer valor de retorno que desejar.O contexto de sincronização é livre para fazer o que quiser, desde que agende as coisas. Ele poderia usar o pool de threads. Ele pode criar um thread por tarefa. Ele poderia executá-los de forma síncrona. Ambientes diferentes (ASP.NET vs. WPF) fornecem implementações de contexto de sincronização diferentes que fazem coisas diferentes com base no que é melhor para seus ambientes.
(Bônus: já se perguntou o que
.ConfigurateAwait(false)
faz? Está dizendo ao sistema para não usar o contexto de sincronização atual (geralmente com base no seu tipo de projeto - WPF vs ASP.NET, por exemplo) e, em vez disso, usar o padrão, que usa o pool de threads).Então, novamente, é um monte de truques do compilador. Se você olhar o código gerado, é complicado, mas você deve ser capaz de ver o que está fazendo. Esses tipos de transformações são difíceis, mas determinísticas e matemáticas, por isso é ótimo que o compilador as esteja fazendo por nós.
PS Há uma exceção à existência de contextos de sincronização padrão - os aplicativos de console não têm um contexto de sincronização padrão. Verifique o blog de Stephen Toub para mais informações. É um ótimo lugar para procurar informações sobre
async
eawait
em geral.fonte
Normalmente, eu recomendo olhar para o CIL, mas no caso destes, é uma bagunça.
Essas duas construções de linguagem são semelhantes no funcionamento, mas implementadas de maneira um pouco diferente. Basicamente, é apenas um açúcar sintático para a mágica do compilador, não há nada louco / inseguro no nível de montagem. Vamos examiná-los brevemente.
yield
é uma instrução mais antiga e simples, e é um açúcar sintático para uma máquina de estado básica. Um método que retornaIEnumerable<T>
ouIEnumerator<T>
pode conter umyield
, que então transforma o método em uma fábrica de máquina de estado. Uma coisa que você deve notar é que nenhum código no método é executado no momento em que você o chama, se houver um códigoyield
interno. O motivo é que o código que você escreve é translocado para oIEnumerator<T>.MoveNext
método, que verifica o estado em que se encontra e executa a parte correta do código.yield return x;
é então convertido em algo semelhante athis.Current = x; return true;
Se você fizer alguma reflexão, poderá facilmente inspecionar a máquina de estado construída e seus campos (pelo menos um para o estado e para os locais). Você pode até redefini-lo se alterar os campos.
await
requer um pouco de suporte da biblioteca de tipos e funciona de maneira um pouco diferente. Leva um argumentoTask
ouTask<T>
, então resulta em seu valor se a tarefa for concluída ou registra uma continuação viaTask.GetAwaiter().OnCompleted
. A implementação completa do sistemaasync
/await
levaria muito tempo para explicar, mas também não é tão místico. Ele também cria uma máquina de estado e a passa ao longo da continuação para OnCompleted . Se a tarefa for concluída, ele usa seu resultado na continuação. A implementação do awaiter decide como invocar a continuação. Normalmente, ele usa o contexto de sincronização do thread de chamada.Ambos
yield
eawait
têm que dividir o método com base em sua ocorrência para formar uma máquina de estado, com cada ramificação da máquina representando cada parte do método.Você não deve pensar sobre esses conceitos nos termos de "nível inferior", como pilhas, threads, etc. Essas são abstrações e seu funcionamento interno não requer nenhum suporte do CLR, é apenas o compilador que faz a mágica. Isso é totalmente diferente das corrotinas de Lua, que têm o suporte do runtime, ou longjmp de C , que é apenas magia negra.
fonte
await
não é necessário realizar uma tarefa . Qualquer coisa comINotifyCompletion GetAwaiter()
é suficiente. Um pouco parecido com comoforeach
não precisaIEnumerable
, qualquer coisa comIEnumerator GetEnumerator()
é suficiente.