C # 5 asynchroniczny CTP: dlaczego wewnętrzny „stan” jest ustawiony na 0 w wygenerowanym kodzie przed wywołaniem EndAwait?

194

Wczoraj mówiłem o nowej funkcji „asynchronicznej” w języku C #, w szczególności o tym, jak wyglądał wygenerowany kod i the GetAwaiter()/ BeginAwait()/ EndAwait()wywołania.

Przyjrzeliśmy się szczegółowo automatowi stanu wygenerowanemu przez kompilator C # i nie mogliśmy zrozumieć dwóch aspektów:

  • Dlaczego wygenerowana klasa zawiera Dispose()metodę i $__disposingzmienną, które nigdy nie są używane (a klasa nie jest implementowana IDisposable).
  • Dlaczego statezmienna wewnętrzna jest ustawiona na 0 przed jakimkolwiek wywołaniem funkcji EndAwait(), skoro 0 zwykle oznacza „to jest początkowy punkt wejścia”.

Podejrzewam, że na pierwszy punkt można odpowiedzieć, robiąc coś bardziej interesującego w ramach metody asynchronicznej, chociaż jeśli ktoś ma jakieś dodatkowe informacje, z przyjemnością je usłyszę. To pytanie dotyczy jednak bardziej drugiego punktu.

Oto bardzo prosty przykładowy kod:

using System.Threading.Tasks;

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

... a oto kod, który jest generowany dla MoveNext()metody implementującej maszynę stanową. To jest kopiowane bezpośrednio z Reflectora - nie naprawiłem niewypowiedzianych nazw zmiennych:

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);
    }
}

Jest długa, ale ważne linie na to pytanie są następujące:

// 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();

W obu przypadkach stan jest później ponownie zmieniany, zanim zostanie wyraźnie zaobserwowany ... więc po co w ogóle ustawiać go na 0? Gdyby MoveNext()został wywołany ponownie w tym momencie (bezpośrednio lub przez Dispose), skutecznie uruchomiłby ponownie metodę asynchroniczną, co byłoby całkowicie niewłaściwe, o ile mogę powiedzieć ... jeśli i MoveNext() nie zostanie wywołane, zmiana stanu jest nieistotna.

Czy jest to po prostu efekt uboczny ponownego wykorzystania przez kompilator kodu generowania bloków iteratora dla asynchronizacji, gdzie może mieć bardziej oczywiste wyjaśnienie?

Ważne zastrzeżenie

Oczywiście to tylko kompilator CTP. W pełni spodziewam się, że coś się zmieni przed ostatecznym wydaniem - a być może nawet przed kolejną wersją CTP. To pytanie w żaden sposób nie próbuje twierdzić, że jest to wada kompilatora C # lub coś podobnego. Próbuję tylko dowiedzieć się, czy istnieje subtelny powód, którego przegapiłem :)

Jon Skeet
źródło
7
Kompilator VB tworzy podobną maszynę stanu (nie wiem, czy jest to oczekiwane, czy nie, ale VB nie miał wcześniej bloków iteratora)
Damien_The_Unbeliever
1
@Rune: MoveNextDelegate to tylko pole delegata, które odwołuje się do MoveNext. Sądzę, że jest przechowywany w pamięci podręcznej, aby uniknąć tworzenia nowej akcji, która za każdym razem byłaby przekazywana oczekującemu.
Jon Skeet,
5
Myślę, że odpowiedź brzmi: to jest CTP. Najważniejszym zadaniem dla zespołu było wypuszczenie tego na rynek i zatwierdzenie projektu języka. I zrobili to niesamowicie szybko. Powinieneś oczekiwać, że dostarczona implementacja (kompilatorów, a nie MoveNext) będzie się znacznie różnić. Myślę, że Eric lub Lucian wrócą z odpowiedzią, że nie ma tu nic głębokiego, tylko zachowanie / błąd, który w większości przypadków nie ma znaczenia i nikt nie zauważył. Ponieważ jest to CTP.
Chris Burrows,
2
@Stilgar: Właśnie sprawdziłem za pomocą ildasm i naprawdę to robi.
Jon Skeet
3
@JonSkeet: Zwróć uwagę, że nikt nie głosuje za odpowiedziami. 99% z nas nie jest w stanie powiedzieć, czy odpowiedź brzmi właściwie.
the_drow

Odpowiedzi:

71

OK, w końcu mam prawdziwą odpowiedź. W pewnym sensie wypracowałem to sam, ale dopiero po tym, jak Lucian Wischik z części VB zespołu potwierdził, że naprawdę jest ku temu dobry powód. Wielkie dzięki dla niego - i zapraszam na jego blog , który rządzi.

Wartość 0 jest tutaj wyjątkowa tylko dlatego, że nie jest to prawidłowy stan, w którym możesz się znajdować tuż przed awaitw normalnym przypadku. W szczególności nie jest to stan, w przypadku którego automat stanowy może przeprowadzić testy w innym miejscu. Uważam, że użycie dowolnej wartości innej niż dodatnia działałoby równie dobrze: -1 nie jest do tego używane, ponieważ jest logicznie niepoprawne, ponieważ -1 zwykle oznacza „zakończone”. Mógłbym argumentować, że w tej chwili nadajemy dodatkowe znaczenie stanowi 0, ale ostatecznie nie ma to większego znaczenia. Celem tego pytania było ustalenie, dlaczego państwo w ogóle jest ustawiane.

Wartość ma znaczenie, jeśli await kończy się przechwyconym wyjątkiem. Możemy skończyć ponownie wraca do tego samego oczekują oświadczenia, ale nie muszą być w ten sposób stan „Jestem po prostu przyjść z powrotem, które czekają” jak inaczej wszystkie rodzaje kodu będzie pominięty. Najprościej jest to pokazać na przykładzie. Zauważ, że teraz używam drugiego CTP, więc wygenerowany kod różni się nieco od tego w pytaniu.

Oto metoda asynchroniczna:

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;
}

Koncepcyjnie SimpleAwaitablemoże to być dowolne oczekiwanie - może zadanie, może coś innego. Na potrzeby moich testów zawsze zwraca wartość false dla IsCompletedi zgłasza wyjątek GetResult.

Oto wygenerowany kod dla 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);
}

Musiałem się przenieść, Label_ContinuationPointaby był prawidłowym kodem - w przeciwnym razie nie wchodzi w zakres gotoinstrukcji - ale to nie wpływa na odpowiedź.

Pomyśl, co się dzieje, gdy GetResultzgłasza wyjątek. Przejdziemy przez blok catch, inkrementujemy i, a następnie ponownie zapętlamy (zakładając, że inadal jest mniej niż 3). Nadal jesteśmy w stanie, w jakim byliśmy przed GetResultwywołaniem ... ale kiedy wchodzimy do trybloku, musimy wypisać "W próbie" i zadzwonić GetAwaiterponownie ... i zrobimy to tylko wtedy, gdy stan nie będzie 1. Bez state = 0zadanie będzie wykorzystywać istniejące awaiter i pominąć Console.WriteLinewezwanie.

To dość skomplikowany fragment kodu do przepracowania, ale to tylko pokazuje, jakie rzeczy musi przemyśleć zespół. Cieszę się, że nie jestem odpowiedzialny za wdrożenie tego :)

Jon Skeet
źródło
8
@Shekhar_Pro: Tak, to goto. Powinieneś spodziewać się wielu instrukcji goto w automatycznie generowanych maszynach stanowych :)
Jon Skeet
12
@Shekhar_Pro: W ramach ręcznie napisanego kodu jest - ponieważ utrudnia to odczytanie i śledzenie kodu. Nikt nie czyta wygenerowany automatycznie kod jednak oprócz głupcy tacy jak ja, którzy dekompilować go :)
Jon Skeet
Więc co się dzieje, gdy znów czekamy na wyjątek? Zaczynamy od nowa?
konfigurator
1
@konfigurator: Wywołuje GetAwaiter na oczekiwanym, czego się spodziewałem.
Jon Skeet
Gotos nie zawsze sprawiają, że kod jest trudniejszy do odczytania. W rzeczywistości czasami nawet ich użycie ma sens (to świętokradztwo, wiem). Na przykład czasami może być konieczne przerwanie wielu zagnieżdżonych pętli. I rzadziej używaną funkcją goto (i brzydszym użyciem IMO) jest powodowanie kaskadowania instrukcji switch. Na marginesie, pamiętam dzień i wiek, kiedy gotos stanowiły podstawę niektórych języków programowania i dlatego w pełni zdaję sobie sprawę, dlaczego samo wspomnienie o goto przyprawia programistów o dreszcze. Niewłaściwie używane mogą sprawić, że rzeczy będą brzydkie.
Ben Lesh
5

jeśli zostanie utrzymany na 1 (pierwszy przypadek), otrzymasz połączenie EndAwaitbez połączenia do BeginAwait. Jeśli zostanie utrzymany na 2 (drugi przypadek), uzyskasz ten sam wynik tylko na drugim awaiterze.

Zgaduję, że wywołanie BeginAwait zwraca false, jeśli zostało już uruchomione (przypuszczenie z mojej strony) i zachowuje oryginalną wartość do zwrócenia w EndAwait. W takim przypadku będzie działać poprawnie, podczas gdy jeśli ustawisz ją na -1, możesz mieć niezainicjowany plik this.<1>t__$await1dla pierwszego przypadku.

To jednak zakłada, że ​​BeginAwaiter w rzeczywistości nie rozpocznie akcji dla żadnych wywołań po pierwszym i że w takich przypadkach zwróci false. Rozpoczęcie byłoby oczywiście niedopuszczalne, ponieważ mogłoby mieć efekt uboczny lub po prostu dać inny rezultat. Zakłada również, że EndAwaiter zawsze zwraca tę samą wartość bez względu na to, ile razy jest wywoływany i można to wywołać, gdy BeginAwait zwróci false (zgodnie z powyższym założeniem)

Wydawałoby się, że jest to strażnik przed warunkami wyścigu Jeśli wstawimy instrukcje, w których movenext jest wywoływane przez inny wątek po stanie = 0 w pytaniach, wyglądałoby to jak poniżej

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();

Jeśli powyższe założenia są poprawne, oznacza to, że wykonano niepotrzebną pracę, taką jak get sawiater i ponowne przypisanie tej samej wartości do <1> t __ $ await1. Gdyby stan został utrzymany na poziomie 1, ostatnią częścią byłoby zamiast tego:

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

dalej, gdyby była ustawiona na 2, automat stanowy założyłby, że uzyskał już wartość pierwszej akcji, która byłaby nieprawdziwa i (potencjalnie) nieprzypisana zmienna została użyta do obliczenia wyniku

Rune FS
źródło
Należy pamiętać, że stan nie jest faktycznie używany między przypisaniem do 0 a przypisaniem do bardziej znaczącej wartości. Jeśli ma to chronić przed warunkami wyścigu, spodziewałbym się innej wartości, aby to wskazać, np. -2, ze sprawdzeniem tego na początku MoveNext w celu wykrycia niewłaściwego użycia. Pamiętaj, że pojedyncza instancja i tak nigdy nie powinna być używana przez dwa wątki naraz - ma to na celu złudzenie pojedynczego wywołania metody synchronicznej, która co jakiś czas udaje się „wstrzymać”.
Jon Skeet
@Jon Zgadzam się, że nie powinien to być problem z warunkami wyścigu w przypadku asynchronicznego, ale może znajdować się w bloku iteracji i może pozostać
Rune FS
@Tony: Myślę, że poczekam, aż wyjdzie następny CTP lub beta, i sprawdzę to zachowanie.
Jon Skeet
1

Czy może to mieć coś wspólnego z połączonymi / zagnieżdżonymi wywołaniami asynchronicznymi?

to znaczy:

async Task m1()
{
    await m2;
}

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

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

Czy delegat movenext jest wywoływany wiele razy w tej sytuacji?

Naprawdę tylko łódka?

GaryMcAllister
źródło
W takim przypadku byłyby trzy różne wygenerowane klasy. MoveNext()zostałaby wezwana raz na każdego z nich.
Jon Skeet
0

Wyjaśnienie stanów faktycznych:

możliwe stany:

  • 0 Zainicjowano (tak mi się wydaje) lub czekam na zakończenie operacji
  • > 0 wywołane po prostu MoveNext, wybierając następny stan
  • -1 zakończone

Czy to możliwe, że ta implementacja chce tylko zapewnić, że jeśli kolejne wywołanie MoveNext z dowolnego miejsca (podczas oczekiwania) ponownie oszacuje cały łańcuch stanów od początku, aby ponownie ocenić wyniki, które w międzyczasie mogą być już nieaktualne?

fixagon
źródło
Ale dlaczego miałby chcieć zaczynać od początku? Prawie na pewno nie jest to to, co naprawdę chciałbyś, aby się wydarzyło - chciałbyś wyrzucić wyjątek, ponieważ nic innego nie powinno wywoływać MoveNext.
Jon Skeet