Como render e esperar implementar o fluxo de controle no .NET?

105

Pelo que entendi a yieldpalavra - 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, awaitnão apenas espera pelo receptor, mas também retorna o controle ao chamador, apenas para retomar de onde parou quando o chamador awaitso 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 awaits-- 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?

John Wu
fonte
4
Você pode dar uma olhada no código gerado no TryRoslyn compilador on-line
Xanatos
1
Você pode querer verificar a série de artigos Eduasync por Jon Skeet.
Leonid Vasilev
Leitura interessante relacionada: stackoverflow.com/questions/37419572/…
Jason C

Respostas:

115

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.

Quando a espera é alcançada, como o tempo de execução sabe qual parte do código deve ser executada a seguir?

await é gerado como:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

É basicamente isso. Await é apenas um retorno elegante.

Como sabe quando pode retomar de onde parou e como se lembra de onde?

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.

O que acontece com a pilha de chamadas atual, ela é salva de alguma forma?

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 .

E se o método de chamada fizer outras chamadas de método antes de esperar - por que a pilha não é sobrescrita?

Essas chamadas de método retornam e, portanto, seus registros de ativação não estão mais na pilha no ponto de espera.

E como diabos o runtime trabalharia por tudo isso no caso de uma exceção e uma pilha se desenrolaria?

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ê.

Quando o rendimento é alcançado, como o tempo de execução mantém o controle do ponto onde as coisas devem ser coletadas? Como o estado do iterador é preservado?

Da mesma maneira. O estado dos locais é movido para o heap e um número que representa a instrução na qual MoveNextdeve 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.

Eric Lippert
fonte
1
devido ao histórico dos autores das perguntas (assembler et al), possivelmente vale a pena mencionar que ambas as construções não são possíveis sem memória gerenciada. sem memória gerenciada, tentar coordenar o tempo de vida de um fechamento definitivamente faria você tropeçar em seus bootstraps.
Jim
Todos os links das páginas não foram encontrados (404)
Digital3D
Todos os seus artigos estão indisponíveis agora. Você poderia repassá-los?
Michał Turczyn
1
@ MichałTurczyn: Eles ainda estão na internet; A Microsoft continua avançando para onde está o arquivo do blog. Vou migrar todos eles gradualmente para o meu site pessoal e tentar atualizar esses links quando tiver tempo.
Eric Lippert
38

yield é o mais fácil dos dois, então vamos examiná-lo.

Digamos que temos:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

Isso é compilado um pouco como se tivéssemos escrito:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

Portanto, não é tão eficiente quanto uma implementação escrita à mão de IEnumerable<int>e IEnumerator<int>(por exemplo, provavelmente não perderíamos tendo um separado _state, _ie _currentneste 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 com yieldmétodos de uso muito complicados .

E claro desde

foreach(var a in b)
{
  DoSomething(a);
}

É o mesmo que:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

Em seguida, o gerado MoveNext()é chamado repetidamente.

O asynccaso é praticamente o mesmo princípio, mas com um pouco de complexidade extra. Para reutilizar um exemplo de outro código de resposta como:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

Produz código como:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

É mais complicado, mas um princípio básico muito semelhante. A principal complicação extra é que agora GetAwaiter()está sendo usado. Se algum tempo awaiter.IsCompletedfor marcado, ele retorna trueporque a tarefa awaited 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 MoveNexte continuará com a próxima parte do trabalho (até a próxima await) ou terminará e retornará, caso em que o Taskque está implementando será concluído.

Jon Hanna
fonte
Você demorou para fazer sua própria tradução? O_O uao.
CoffeDeveloper de
4
@DarioOO O primeiro que posso fazer rapidamente, tendo feito muitas traduções de yieldpara 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.
Jon Hanna de
13

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 asyncmétodo é dividido em várias partes pelo compilador; as awaitexpressõ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 minha asyncintrodução ).

Quando a espera é alcançada, como o tempo de execução sabe qual parte do código deve ser executada a seguir?

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 awaitIU capturada SynchronizationContexte a I / O concluída no pool de threads).

Como sabe quando pode retomar de onde parou e como se lembra de onde?

São apenas chamadas de retorno. Quando um aguardável é concluído, ele invoca seus retornos de chamada e qualquer asyncmétodo que já o tenha awaiteditado é 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.

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 esperar - 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?

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 asyncmétodo que conclui sua tarefa, que pode retomar um asyncmétodo que conclui sua tarefa, etc.

Assim, com síncrona código de Achamada Bvocação C, o seu callstack pode ser parecido com este:

A:B:C

enquanto o código assíncrono usa callbacks (ponteiros):

A <- B <- C <- (I/O operation)

Quando o rendimento é alcançado, como o tempo de execução mantém o controle do ponto onde as coisas devem ser coletadas? Como o estado do iterador é preservado?

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 .

Stephen Cleary
fonte
7

yielde awaitsã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 uma yieldinstruçã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á- MoveNextlo, passe mais uma vez por esse loop. Então, quando você faz um loop foreach como este:

foreach(var item in mything.items()) {
    dosomething(item);
}

o código gerado é semelhante a:

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

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.

asynce await, 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 asynce awaitem geral.

Chris Tavares
fonte
1
"Está dizendo ao sistema para não usar o contexto de sincronização padrão e, em vez disso, usar o padrão, que usa o pool de threads", você pode esclarecer o que quer dizer com isso? "não usar padrão, usar padrão"
Kroltan
3
Desculpe, confundi minha terminologia, vou consertar o post. Basicamente, não use o padrão para o ambiente em que você está, use o padrão para .NET (ou seja, o pool de threads).
Chris Tavares
muito simples, foi capaz de entender, você teve meu voto :)
Ehsan Sajjad
4

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 retorna IEnumerable<T>ou IEnumerator<T>pode conter um yield, 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ódigo yieldinterno. O motivo é que o código que você escreve é ​​translocado para o IEnumerator<T>.MoveNextmé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.

awaitrequer um pouco de suporte da biblioteca de tipos e funciona de maneira um pouco diferente. Leva um argumento Taskou Task<T>, então resulta em seu valor se a tarefa for concluída ou registra uma continuação via Task.GetAwaiter().OnCompleted. A implementação completa do sistema async/ awaitlevaria 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 yielde awaittê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.

IllidanS4 quer Monica de volta
fonte
5
Observação lateral : awaitnão é necessário realizar uma tarefa . Qualquer coisa com INotifyCompletion GetAwaiter()é suficiente. Um pouco parecido com como foreachnão precisa IEnumerable, qualquer coisa com IEnumerator GetEnumerator()é suficiente.
IllidanS4 quer Monica de volta em