CTP assíncrono C # 5: por que o "estado" interno é definido como 0 no código gerado antes da chamada do EndAwait?

195

Ontem, eu estava dando uma palestra sobre o novo recurso "assíncrono" do C #, investigando em particular como era o código gerado e the GetAwaiter()/ BeginAwait()/ EndAwait()chama.

Examinamos com detalhes a máquina de estado gerada pelo compilador C # e havia dois aspectos que não conseguimos entender:

  • Por que a classe gerada contém um Dispose()método e uma $__disposingvariável, que nunca parecem ser usados ​​(e a classe não é implementada IDisposable).
  • Por que a statevariável interna é definida como 0 antes de qualquer chamada EndAwait(), quando 0 normalmente parece significar "este é o ponto de entrada inicial".

Suspeito que o primeiro ponto possa ser respondido fazendo algo mais interessante dentro do método assíncrono, embora se alguém tiver mais informações, eu ficaria feliz em ouvi-lo. Esta questão é mais sobre o segundo ponto, no entanto.

Aqui está uma parte muito simples do código de exemplo:

using System.Threading.Tasks;

class Test
{
    static async Task<int> Sum(Task<int> t1, Task<int> t2)
    {
        return await t1 + await t2;
    }
}

... e aqui está o código que é gerado para o MoveNext()método que implementa a máquina de estado. Isso é copiado diretamente do Reflector - não corrigi os nomes indizíveis das variáveis:

public void MoveNext()
{
    try
    {
        this.$__doFinallyBodies = true;
        switch (this.<>1__state)
        {
            case 1:
                break;

            case 2:
                goto Label_00DA;

            case -1:
                return;

            default:
                this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
                this.<>1__state = 1;
                this.$__doFinallyBodies = false;
                if (this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate))
                {
                    return;
                }
                this.$__doFinallyBodies = true;
                break;
        }
        this.<>1__state = 0;
        this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();
        this.<a2>t__$await4 = this.t2.GetAwaiter<int>();
        this.<>1__state = 2;
        this.$__doFinallyBodies = false;
        if (this.<a2>t__$await4.BeginAwait(this.MoveNextDelegate))
        {
            return;
        }
        this.$__doFinallyBodies = true;
    Label_00DA:
        this.<>1__state = 0;
        this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
        this.<>1__state = -1;
        this.$builder.SetResult(this.<1>t__$await1 + this.<2>t__$await3);
    }
    catch (Exception exception)
    {
        this.<>1__state = -1;
        this.$builder.SetException(exception);
    }
}

É longo, mas as linhas importantes para esta pergunta são as seguintes:

// End of awaiting t1
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

// End of awaiting t2
this.<>1__state = 0;
this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();

Nos dois casos, o estado é alterado novamente mais tarde antes de ser obviamente observado ... então, por que defini-lo como 0? Se MoveNext()fosse chamado novamente neste momento (diretamente ou via Dispose), ele efetivamente iniciaria o método assíncrono novamente, o que seria totalmente inapropriado, tanto quanto eu sei ... se e MoveNext() não for chamado, a mudança de estado é irrelevante.

Isso é simplesmente um efeito colateral do compilador que reutiliza o código de geração de blocos do iterador para assíncrono, onde pode ter uma explicação mais óbvia?

Isenção de responsabilidade importante

Obviamente, este é apenas um compilador CTP. Espero totalmente que as coisas mudem antes do lançamento final - e possivelmente até antes do próximo lançamento do CTP. Esta questão não está de forma alguma tentando afirmar que esta é uma falha no compilador C # ou algo assim. Eu só estou tentando descobrir se há uma razão sutil para isso que eu perdi :)

Jon Skeet
fonte
7
O compilador VB produz uma máquina de estado semelhante (não sei se isso é esperado ou não, mas VB não tem iterador blocos antes)
Damien_The_Unbeliever
1
@Rune: MoveNextDelegate é apenas um campo de delegado que se refere a MoveNext. É armazenado em cache para evitar a criação de uma nova ação para passar para o garçom a cada vez, acredito.
21711 Jon Skeet
5
Eu acho que a resposta é: este é um CTP. O alto nível de pedidos para a equipe foi divulgá-lo e o design do idioma foi validado. E eles fizeram isso incrivelmente rápido. Você deve esperar que a implementação fornecida (dos compiladores, não do MoveNext) seja diferente. Eu acho que Eric ou Lucian voltam com uma resposta que não há nada profundo aqui, apenas um comportamento / bug que não importa na maioria dos casos e ninguém percebeu. Porque é um CTP.
22711 Chris Burrows
2
@Tilgar: Acabei de verificar com o ildasm, e ele realmente está fazendo isso.
Jon Skeet
3
@ JonSkeet: Observe como ninguém vota nas respostas. 99% de nós realmente não sabem dizer se a resposta soa certa.
511811

Respostas:

71

Ok, finalmente tenho uma resposta real. Eu meio que resolvi isso sozinho, mas só depois que Lucian Wischik, da parte VB da equipe, confirmou que realmente há uma boa razão para isso. Muito obrigado a ele - e visite o blog dele , que é demais.

O valor 0 aqui é apenas especial porque é não um estado válido, que você pode estar em um pouco antes da awaitem um caso normal. Em particular, não é um estado que a máquina de estado possa acabar testando em outro lugar. Acredito que o uso de qualquer valor não positivo funcionaria da mesma forma: -1 não é usado para isso, pois é logicamente incorreto, pois -1 normalmente significa "concluído". Eu poderia argumentar que estamos dando um significado extra ao estado 0 no momento, mas, no final das contas, isso realmente não importa. O objetivo desta pergunta foi descobrir por que o estado está sendo definido.

O valor é relevante se a espera terminar em uma exceção capturada. Podemos voltar à mesma declaração de espera novamente, mas não devemos estar no estado que significa "Estou prestes a voltar dessa espera", pois caso contrário, todos os tipos de código seriam ignorados. É mais simples mostrar isso com um exemplo. Observe que agora estou usando o segundo CTP, portanto, o código gerado é um pouco diferente do da pergunta.

Aqui está o método assíncrono:

static async Task<int> FooAsync()
{
    var t = new SimpleAwaitable();

    for (int i = 0; i < 3; i++)
    {
        try
        {
            Console.WriteLine("In Try");
            return await t;
        }                
        catch (Exception)
        {
            Console.WriteLine("Trying again...");
        }
    }
    return 0;
}

Conceitualmente, SimpleAwaitablepode haver qualquer expectativa - talvez uma tarefa, talvez outra coisa. Para os fins dos meus testes, ele sempre retorna false para IsCompletede lança uma exceção em GetResult.

Aqui está o código gerado para MoveNext:

public void MoveNext()
{
    int returnValue;
    try
    {
        int num3 = state;
        if (num3 == 1)
        {
            goto Label_ContinuationPoint;
        }
        if (state == -1)
        {
            return;
        }
        t = new SimpleAwaitable();
        i = 0;
      Label_ContinuationPoint:
        while (i < 3)
        {
            // Label_ContinuationPoint: should be here
            try
            {
                num3 = state;
                if (num3 != 1)
                {
                    Console.WriteLine("In Try");
                    awaiter = t.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        awaiter.OnCompleted(MoveNextDelegate);
                        return;
                    }
                }
                else
                {
                    state = 0;
                }
                int result = awaiter.GetResult();
                awaiter = null;
                returnValue = result;
                goto Label_ReturnStatement;
            }
            catch (Exception)
            {
                Console.WriteLine("Trying again...");
            }
            i++;
        }
        returnValue = 0;
    }
    catch (Exception exception)
    {
        state = -1;
        Builder.SetException(exception);
        return;
    }
  Label_ReturnStatement:
    state = -1;
    Builder.SetResult(returnValue);
}

Eu tive que mudar Label_ContinuationPointpara torná-lo um código válido - caso contrário, não está no escopo da gotodeclaração - mas isso não afeta a resposta.

Pense no que acontece quando GetResultlança sua exceção. Vamos percorrer o bloco catch, incrementar ie, em seguida, dar a volta novamente (supondo que iainda seja menor que 3). Ainda estamos no estado em que estávamos antes da GetResultligação ... mas, quando entramos no trybloco, precisamos imprimir "In Try" e ligar GetAwaiternovamente ... e só faremos isso se o estado não for 1. Sem a state = 0tarefa, ele usará o garçom existente e pulará a Console.WriteLinechamada.

É um código bastante tortuoso de se trabalhar, mas isso só mostra o tipo de coisa em que a equipe precisa pensar. Estou feliz por não ser responsável por implementar isso :)

Jon Skeet
fonte
8
@ Shekhar_Pro: Sim, é um pulo. Você deve esperar para ver a abundância de Goto declarações em máquinas de estado geradas automaticamente :)
Jon Skeet
12
@ Shekhar_Pro: No código escrito manualmente, é - porque torna o código difícil de ler e seguir. Ninguém lê o código gerado automaticamente, porém, a não ser tolos como eu, que decompile-lo :)
Jon Skeet
Então, o que acontece quando aguardamos novamente após uma exceção? Começamos tudo de novo?
configurator
1
@ configurador: Chama GetAwaiter no esperado, que é o que eu esperaria que ele fizesse.
Jon Skeet
Os gotos nem sempre tornam o código mais difícil de ler. De fato, às vezes até fazem sentido usar (sacrilégio dizer, eu sei). Por exemplo, às vezes você pode precisar quebrar vários loops aninhados. O recurso menos usado do goto (e o uso mais feio da IMO) é causar cascata nas instruções do switch. Em uma nota separada, lembro-me de um dia e idade em que o gotos era o principal suporte de algumas linguagens de programação e, por isso, percebo perfeitamente por que a simples menção do goto faz os desenvolvedores estremecerem. Eles podem tornar as coisas feias se mal utilizadas.
precisa
5

se fosse mantido em 1 (primeiro caso), você receberia uma ligação EndAwaitsem uma ligação para BeginAwait. Se for mantido em 2 (segundo caso), você obterá o mesmo resultado apenas no outro garçom.

Suponho que chamar o BeginAwait retorne false se já tiver sido iniciado (um palpite do meu lado) e mantenha o valor original para retornar no EndAwait. Se for esse o caso, funcionará corretamente, enquanto que se você o definir para -1, poderá ter um não inicializado this.<1>t__$await1para o primeiro caso.

No entanto, isso pressupõe que BeginAwaiter não iniciará a ação em nenhuma chamada após a primeira e que retornará falso nesses casos. É claro que começar seria inaceitável, pois poderia ter efeito colateral ou simplesmente dar um resultado diferente. Também pressupõe que o EndAwaiter sempre retornará o mesmo valor, não importa quantas vezes seja chamado e que possa ser chamado quando BeginAwait retornar false (conforme a suposição acima)

Parece ser uma proteção contra as condições de corrida. Se alinharmos as declarações em que o movenext é chamado por um segmento diferente após o estado = 0 nas perguntas, ele parecerá com o abaixo

this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.<>1__state = 0;

//second thread
this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.$__doFinallyBodies = true;
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

//other thread
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

Se as suposições acima estiverem corretas, haverá algum trabalho desnecessário, como obter o sawiater e reatribuir o mesmo valor para <1> t __ $ waitit1. Se o estado fosse mantido em 1, a última parte seria:

//second thread
//I suppose this un matched call to EndAwait will fail
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

além disso, se fosse definido como 2, a máquina de estados assumiria que já havia obtido o valor da primeira ação que seria falsa e uma variável (potencialmente) não atribuída seria usada para calcular o resultado

Rune FS
fonte
Lembre-se de que o estado não está realmente sendo usado entre a atribuição a 0 e a atribuição a um valor mais significativo. Se ele se destina a se proteger contra as condições da corrida, eu esperaria que outro valor indicasse, por exemplo, -2, com uma verificação no início do MoveNext para detectar o uso inadequado. Lembre-se de que uma única instância nunca deve realmente ser usada por dois threads de cada vez - é para dar a ilusão de uma única chamada de método síncrona que consegue "pausar" de vez em quando.
21711 Jon Skeet
@ Jon Concordo que não deve ser um problema com uma condição de corrida no caso assíncrono, mas poderia ser em bloco iteração e poderia ser uma sobra
Rune FS
@ Tony: Acho que vou esperar até o próximo CTP ou beta sair e verificar esse comportamento.
precisa
1

Poderia ser algo a ver com chamadas assíncronas empilhadas / aninhadas?

ou seja:

async Task m1()
{
    await m2;
}

async Task m2()
{
    await m3();
}

async Task m3()
{
Thread.Sleep(10000);
}

O delegado movenext é chamado várias vezes nessa situação?

Apenas um pontapé realmente?

GaryMcAllister
fonte
Haveria três classes geradas diferentes nesse caso. MoveNext()seria chamado uma vez em cada um deles.
Jon Skeet
0

Explicação dos estados reais:

estados possíveis:

  • 0 Inicializado (acho que sim) ou aguardando o fim da operação
  • > 0 chamado MoveNext, escolhendo o próximo estado
  • -1 terminou

É possível que essa implementação queira apenas garantir que, se outra Chamada para o MoveNext, de onde quer que aconteça (enquanto espera), reavaliará toda a cadeia de estados novamente desde o início, para reavaliar resultados que, nesse meio tempo, já estão desatualizados?

fixagon
fonte
Mas por que ele iria querer começar do começo? Isso quase certamente não é o que você realmente gostaria que acontecesse - você desejaria uma exceção, porque nada mais deveria chamar MoveNext.
precisa