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$__disposing
zmienną, które nigdy nie są używane (a klasa nie jest implementowanaIDisposable
). - Dlaczego
state
zmienna wewnętrzna jest ustawiona na 0 przed jakimkolwiek wywołaniem funkcjiEndAwait()
, 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 :)
źródło
Odpowiedzi:
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
await
w 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
SimpleAwaitable
może to być dowolne oczekiwanie - może zadanie, może coś innego. Na potrzeby moich testów zawsze zwraca wartość false dlaIsCompleted
i zgłasza wyjątekGetResult
.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_ContinuationPoint
aby był prawidłowym kodem - w przeciwnym razie nie wchodzi w zakresgoto
instrukcji - ale to nie wpływa na odpowiedź.Pomyśl, co się dzieje, gdy
GetResult
zgłasza wyjątek. Przejdziemy przez blok catch, inkrementujemyi
, a następnie ponownie zapętlamy (zakładając, żei
nadal jest mniej niż 3). Nadal jesteśmy w stanie, w jakim byliśmy przedGetResult
wywołaniem ... ale kiedy wchodzimy dotry
bloku, musimy wypisać "W próbie" i zadzwonićGetAwaiter
ponownie ... i zrobimy to tylko wtedy, gdy stan nie będzie 1. Bezstate = 0
zadanie będzie wykorzystywać istniejące awaiter i pominąćConsole.WriteLine
wezwanie.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 :)
źródło
jeśli zostanie utrzymany na 1 (pierwszy przypadek), otrzymasz połączenie
EndAwait
bez połączenia doBeginAwait
. 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__$await1
dla 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
źródło
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?
źródło
MoveNext()
zostałaby wezwana raz na każdego z nich.Wyjaśnienie stanów faktycznych:
możliwe stany:
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?
źródło