Jak uzyskać i czekać na wdrożenie przepływu kontroli w .NET?

105

Jak rozumiem, yieldsłowo kluczowe, jeśli jest używane z wnętrza bloku iteratora, zwraca przepływ sterowania do kodu wywołującego, a gdy iterator jest wywoływany ponownie, rozpoczyna od miejsca, w którym został przerwany.

Ponadto awaitnie tylko czeka na wywoływany, ale zwraca kontrolę do wywołującego, tylko po to, aby odebrać miejsce, w którym zostało przerwane, gdy wywołujący awaitsmetodę.

Innymi słowy - nie ma wątku , a „współbieżność” asynchronii i await jest iluzją spowodowaną sprytnym przepływem kontroli, którego szczegóły są ukryte w składni.

Teraz jestem byłym programistą asemblera i bardzo dobrze znam wskaźniki do instrukcji, stosy itp. I rozumiem, jak działają normalne przepływy sterowania (podprogram, rekurencja, pętle, gałęzie). Ale te nowe konstrukcje ... Nie rozumiem ich.

Po awaitosiągnięciu wartości, skąd środowisko wykonawcze wie, który fragment kodu powinien zostać wykonany jako następny? Skąd wie, kiedy może wznowić od miejsca, w którym zostało przerwane, i jak zapamiętuje gdzie? Co dzieje się z aktualnym stosem wywołań, czy zostaje on w jakiś sposób zapisany? Co się stanie, jeśli metoda wywołująca awaitwywoła inne metody, zanim s - dlaczego stos nie zostanie nadpisany? I jak, u licha, środowisko wykonawcze przeszłoby przez to wszystko w przypadku wyjątku i rozwinięcia stosu?

Kiedy yieldzostanie osiągnięty, w jaki sposób środowisko wykonawcze śledzi punkt, w którym należy odebrać rzeczy? Jak zachowywany jest stan iteratora?

John Wu
źródło
4
Możesz rzucić okiem na wygenerowany kod w kompilatorze TryRoslyn online
xanatos
1
Możesz sprawdzić serię artykułów Eduasync autorstwa Jona Skeeta.
Leonid Vasilev
Powiązane interesujące lektury: stackoverflow.com/questions/37419572/…
Jason C

Odpowiedzi:

115

Odpowiem na Twoje konkretne pytania poniżej, ale prawdopodobnie po prostu przeczytasz moje obszerne artykuły o tym, jak zaprojektowaliśmy wydajność i czekanie.

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/

Niektóre z tych artykułów są obecnie nieaktualne; wygenerowany kod różni się pod wieloma względami. Ale to z pewnością da ci wyobrażenie, jak to działa.

Ponadto, jeśli nie rozumiesz, w jaki sposób lambdy są generowane jako klasy zamknięcia, zrozum to najpierw . Nie zrobisz asynchronicznych głów ani ogonów, jeśli nie masz wyłączonych lambd.

Po osiągnięciu czasu oczekiwania, skąd środowisko wykonawcze wie, który fragment kodu powinien zostać wykonany jako następny?

await jest generowany jako:

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

W zasadzie to wszystko. Oczekiwanie to tylko fantazyjny powrót.

Skąd wie, kiedy może wznowić od miejsca, w którym zostało przerwane, i jak zapamiętuje gdzie?

Jak to zrobić bez czekania? Kiedy metoda foo wywołuje metodę bar, w jakiś sposób pamiętamy, jak wrócić do środka foo, z nienaruszonymi wszystkimi lokalnymi lokalizacjami aktywacji foo, bez względu na to, co robi bar.

Wiesz, jak to się robi w asemblerze. Rekord aktywacji dla foo jest umieszczany na stosie; zawiera wartości miejscowych. W momencie wywołania adres zwrotny w foo jest umieszczany na stosie. Kiedy pasek jest gotowy, wskaźnik stosu i wskaźnik instrukcji są resetowane do miejsca, w którym powinny być, a foo kontynuuje pracę od miejsca, w którym zostało przerwane.

Kontynuacja await jest dokładnie taka sama, z wyjątkiem tego, że rekord jest umieszczany na stercie z oczywistego powodu, że sekwencja aktywacji nie tworzy stosu .

Delegat, który await podaje jako kontynuację zadania, zawiera (1) liczbę będącą danymi wejściowymi do tabeli przeglądowej, która zawiera wskaźnik instrukcji, którą należy wykonać w następnej kolejności, oraz (2) wszystkie wartości lokalnych i tymczasowych.

Jest tam trochę dodatkowego sprzętu; na przykład w .NET niedozwolone jest rozgałęzianie się w środku bloku try, więc nie można po prostu wstawić adresu kodu wewnątrz bloku try do tabeli. Ale to są szczegóły księgowe. Koncepcyjnie rekord aktywacji jest po prostu przenoszony na stos.

Co dzieje się z aktualnym stosem wywołań, czy zostaje on w jakiś sposób zapisany?

Odpowiednie informacje w aktualnym rekordzie aktywacji nigdy nie są umieszczane na stosie w pierwszej kolejności; jest on przydzielany na stosie od samego początku. (Cóż, parametry formalne są normalnie przekazywane na stos lub w rejestrach, a następnie kopiowane do lokalizacji sterty, gdy rozpoczyna się metoda).

Rekordy aktywacji dzwoniących nie są przechowywane; pamiętaj, że oczekiwanie prawdopodobnie do nich wróci, więc będą normalnie traktowani.

Zauważ, że jest to zasadnicza różnica między uproszczonym stylem przechodzenia kontynuacji await a strukturami prawdziwego wezwania z bieżącą kontynuacją, które widzisz w językach takich jak Scheme. W tych językach cała kontynuacja, w tym kontynuacja z powrotem do dzwoniących, jest przechwytywana przez call-cc .

Co się stanie, jeśli metoda wywołująca wywoła inne metody, zanim zaczeka - dlaczego stos nie zostanie nadpisany?

Te wywołania metod powracają, więc ich rekordy aktywacji nie są już na stosie w punkcie oczekiwania.

I jak, u licha, środowisko wykonawcze przeszłoby przez to wszystko w przypadku wyjątku i rozwinięcia stosu?

W przypadku nieprzechwyconego wyjątku wyjątek jest przechwytywany, zapisywany w zadaniu i ponownie generowany po pobraniu wyniku zadania.

Pamiętasz całą księgowość, o której wspomniałem wcześniej? Pozwólcie, że powiem wam, że uzyskanie prawidłowej semantyki wyjątków było ogromnym problemem.

Po osiągnięciu plonu, w jaki sposób środowisko wykonawcze śledzi punkt, w którym należy zebrać rzeczy? Jak zachowywany jest stan iteratora?

Ta sama droga. Stan miejscowych jest przenoszony na stertę, a liczba reprezentująca instrukcję, która MoveNextpowinna zostać wznowiona przy następnym wywołaniu, jest przechowywana razem z lokalnymi.

I znowu, w bloku iteratora znajduje się kilka narzędzi, aby upewnić się, że wyjątki są obsługiwane poprawnie.

Eric Lippert
źródło
1
ze względu na pochodzenie autorów pytań (assembler i wsp.), warto chyba wspomnieć, że obie te konstrukcje nie są możliwe bez pamięci zarządzanej. bez pamięci zarządzanej próba skoordynowania czasu życia zamknięcia z pewnością spowodowałaby potknięcie się o bootstrapy.
Jim
Nie znaleziono linku do wszystkich stron (404)
Digital3D
Wszystkie Twoje artykuły są teraz niedostępne. Czy możesz je ponownie opublikować?
Michał Turczyn
1
@ MichałTurczyn: Nadal są w internecie; Microsoft ciągle przemieszcza się tam, gdzie jest archiwum blogów. Stopniowo będę je przenosić do mojej osobistej witryny i spróbuję zaktualizować te linki, gdy będę miał czas.
Eric Lippert
38

yield jest łatwiejszy z dwóch, więc przyjrzyjmy się temu.

Powiedz, że mamy:

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

Kompilujemy to trochę tak, jakbyśmy napisali:

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

Więc nie jest tak skuteczny jak implementacji odręcznego z IEnumerable<int>i IEnumerator<int>(na przykład nie byłoby prawdopodobnie odpady posiadające odrębną _state, _ia _currentw tym przypadku), ale nie jest źle (trick ponownego wykorzystania się gdy jest to bezpieczne, zamiast tworzenia nowej obiekt jest dobry) i rozszerzalny, aby radzić sobie z bardzo skomplikowanymi yieldmetodami.

I oczywiście od tego czasu

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

Jest taki sam jak:

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

Następnie generowany MoveNext()jest wielokrotnie wywoływany.

asyncSprawa jest prawie taka sama zasada, ale z odrobiną dodatkowej złożoności. Aby ponownie użyć przykładu z innej odpowiedzi Kod, taki jak:

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

Tworzy kod taki jak:

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

Jest to bardziej skomplikowane, ale ma bardzo podobną podstawową zasadę. Główną dodatkową komplikacją jest to, że teraz GetAwaiter()jest używany. Jeśli którykolwiek czas awaiter.IsCompletedjest zaznaczony, zwraca, trueponieważ zadanie awaited jest już zakończone (np. Przypadki, w których może powrócić synchronicznie), to metoda przechodzi przez stany, ale w przeciwnym razie ustawia się jako wywołanie zwrotne do awaitera.

To, co się z tym dzieje, zależy od awaitera, pod względem tego, co wyzwala wywołanie zwrotne (np. Asynchroniczne zakończenie operacji we / wy, zadanie uruchomione w wątku) i jakie są wymagania dotyczące kierowania do określonego wątku lub uruchamiania wątku w puli wątków jaki kontekst z pierwotnego wezwania może być potrzebny lub nie, i tak dalej. Cokolwiek to jest, coś w tym awaiterze wywoła MoveNexti albo będzie kontynuowane z następną częścią pracy (do następnej await), albo zakończy i zwróci, w którym to przypadku Taskimplementacja zostanie zakończona.

Jon Hanna
źródło
Czy poświęciłeś trochę czasu na zrobienie własnego tłumaczenia? O_O uao.
CoffeDeveloper
4
@DarioOO Pierwsze, które mogę zrobić dość szybko, wykonałem wiele tłumaczeń z yieldna ręcznie przewijane, gdy jest to korzystne (ogólnie jako optymalizacja, ale chcę się upewnić, że punkt początkowy jest blisko wygenerowanego przez kompilator więc nic nie ulega dezoptymalizacji w wyniku złych założeń). Drugi został użyty po raz pierwszy w innej odpowiedzi i wtedy było kilka luk w mojej własnej wiedzy, więc skorzystałem z ich wypełnienia, jednocześnie udzielając odpowiedzi poprzez ręczną dekompilację kodu.
Jon Hanna,
13

Jest tu już mnóstwo świetnych odpowiedzi; Podzielę się tylko kilkoma punktami widzenia, które mogą pomóc w stworzeniu modelu mentalnego.

Najpierw asyncmetoda jest dzielona na kilka części przez kompilator; te awaitwyrażenia są punkty złamania. (Łatwo to sobie wyobrazić w przypadku prostych metod; bardziej złożone metody z pętlami i obsługą wyjątków również są przerywane, po dodaniu bardziej złożonej maszyny stanów).

Po drugie, awaitjest tłumaczone na dość prostą sekwencję; Podoba mi się opis Luciana , który w słowach brzmi: „jeśli oczekiwanie jest już zakończone, uzyskaj wynik i kontynuuj wykonywanie tej metody; w przeciwnym razie zapisz stan tej metody i wróć”. (We asyncwstępie używam bardzo podobnej terminologii ).

Po osiągnięciu czasu oczekiwania, skąd środowisko wykonawcze wie, który fragment kodu powinien zostać wykonany jako następny?

Pozostała część metody istnieje jako wywołanie zwrotne dla tego oczekiwanego (w przypadku zadań te wywołania zwrotne są kontynuacjami). Kiedy oczekiwane zostanie zakończone, wywołuje swoje wywołania zwrotne.

Zwróć uwagę, że stos wywołań nie jest zapisywany ani przywracany; wywołania zwrotne są wywoływane bezpośrednio. W przypadku nakładających się operacji we / wy są wywoływane bezpośrednio z puli wątków.

Te wywołania zwrotne mogą kontynuować wykonywanie metody bezpośrednio lub mogą zaplanować jej uruchomienie w innym miejscu (np. Jeśli awaitprzechwycony interfejs użytkownika SynchronizationContexti operacje we / wy zostały ukończone w puli wątków).

Skąd wie, kiedy może wznowić od miejsca, w którym zostało przerwane, i jak zapamiętuje gdzie?

To tylko oddzwonienia. Kiedy oczekiwane kończy się, wywołuje swoje wywołania zwrotne, a każda asyncmetoda, która już awaitją zmodyfikowała, zostaje wznowiona. Wywołanie zwrotne wskakuje do środka tej metody i ma swoje zmienne lokalne w zakresie.

Wywołania zwrotne nie są uruchamiane w określonym wątku i nie mają przywróconego stosu wywołań.

Co dzieje się z aktualnym stosem wywołań, czy zostaje on w jakiś sposób zapisany? Co się stanie, jeśli metoda wywołująca wywoła inne metody, zanim zaczeka - dlaczego stos nie zostanie nadpisany? I jak, u licha, środowisko wykonawcze przeszłoby przez to wszystko w przypadku wyjątku i rozwinięcia stosu?

Stos wywołań nie jest zapisywany w pierwszej kolejności; nie jest to konieczne.

Dzięki kodowi synchronicznemu możesz otrzymać stos wywołań zawierający wszystkich wywołujących, a środowisko wykonawcze wie, gdzie powrócić, korzystając z tego.

Dzięki kodowi asynchronicznemu możesz skończyć z wieloma wskaźnikami wywołania zwrotnego - zakorzenionymi w pewnej operacji we / wy kończącej swoje zadanie, która może wznowić asyncmetodę kończącą zadanie, która może wznowić asyncmetodę, która kończy swoje zadanie itp.

Tak więc, z synchronicznym kodu Awywołującego Bpowołanie C, swoją callstack może wyglądać następująco:

A:B:C

podczas gdy kod asynchroniczny wykorzystuje wywołania zwrotne (wskaźniki):

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

Po osiągnięciu plonu, w jaki sposób środowisko wykonawcze śledzi punkt, w którym należy zebrać rzeczy? Jak zachowywany jest stan iteratora?

Obecnie raczej nieefektywnie. :)

Działa jak każda inna lambda - okresy życia zmiennych są wydłużane, a referencje są umieszczane w obiekcie stanu, który żyje na stosie. Najlepszym źródłem wszystkich szczegółowych informacji jest seria EduAsync Jona Skeeta .

Stephen Cleary
źródło
7

yieldi awaitchociaż oba dotyczą kontroli przepływu, są dwiema zupełnie różnymi rzeczami. Więc zajmę się nimi osobno.

Celem yieldjest ułatwienie tworzenia leniwych sekwencji. Kiedy piszesz pętlę yieldmodułu wyliczającego zawierającą instrukcję, kompilator generuje mnóstwo nowego kodu, którego nie widzisz. Pod maską faktycznie generuje zupełnie nową klasę. Klasa zawiera elementy członkowskie, które śledzą stan pętli, i implementację IEnumerable, dzięki czemu za każdym razem, MoveNextgdy ją wywołasz , ponownie przechodzi przez tę pętlę. Więc kiedy robisz pętlę foreach taką jak ta:

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

wygenerowany kod wygląda mniej więcej tak:

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

W implementacji mything.items () znajduje się zbiór maszynowego kodu stanu, który wykona jeden "krok" pętli, a następnie zwróci. Więc kiedy piszesz to w źródle jak prostą pętlę, pod maską nie jest to zwykła pętla. Więc oszustwo kompilatora. Jeśli chcesz zobaczyć siebie, wyciągnij ILDASM lub ILSpy lub podobne narzędzia i zobacz, jak wygląda wygenerowany IL. To powinno być pouczające.

asynca awaitz drugiej strony są zupełnie innym kotłem ryb. Await to w skrócie prymityw synchronizacji. Jest to sposób na powiedzenie systemowi „Nie mogę kontynuować, dopóki to nie zostanie zrobione”. Ale, jak zauważyłeś, nie zawsze jest w to zaangażowany wątek.

Co jest zaangażowany jest coś, co nazywa kontekst synchronizacji. Zawsze jest ktoś w pobliżu. Zadaniem kontekstu synchronizacji jest planowanie zadań, na które są oczekiwane, oraz ich kontynuacji.

Kiedy mówisz await thisThing(), dzieje się kilka rzeczy. W metodzie asynchronicznej kompilator faktycznie dzieli metodę na mniejsze fragmenty, z których każdy jest sekcją „przed await” i „po await” (lub kontynuacją). Po wykonaniu await zadanie, którego oczekuje się, a następnie kontynuacja - innymi słowy reszta funkcji - jest przekazywana do kontekstu synchronizacji. Kontekst zajmuje się planowaniem zadania, a po zakończeniu kontekst uruchamia kontynuację, przekazując dowolną żądaną wartość zwracaną.

Kontekst synchronizacji może robić wszystko, co chce, o ile planuje. Może korzystać z puli wątków. Może utworzyć wątek na zadanie. Może je uruchamiać synchronicznie. Różne środowiska (ASP.NET i WPF) zapewniają różne implementacje kontekstu synchronizacji, które wykonują różne rzeczy w zależności od tego, co jest najlepsze dla ich środowisk.

(Bonus: kiedykolwiek zastanawiałeś się, co to .ConfigurateAwait(false)robi? Mówi systemowi, aby nie używał bieżącego kontekstu synchronizacji (zwykle opartego na typie projektu - na przykład WPF vs ASP.NET), a zamiast tego używał domyślnego, który używa puli wątków).

Więc znowu, jest to wiele sztuczek kompilatora. Jeśli spojrzysz na wygenerowany kod, jest skomplikowany, ale powinieneś być w stanie zobaczyć, co robi. Tego typu transformacje są trudne, ale deterministyczne i matematyczne, dlatego świetnie, że kompilator robi je za nas.

PS Jest jeden wyjątek od istnienia domyślnych kontekstów synchronizacji - aplikacje konsolowe nie mają domyślnego kontekstu synchronizacji. Sprawdzić blogu Stephena Toub jest za wiele innych informacji. To świetne miejsce do szukania informacji na temat asynci awaitogólnie.

Chris Tavares
źródło
1
„Mówi systemowi, aby nie używał domyślnego kontekstu synchronizacji, a zamiast tego używał domyślnego, który korzysta z puli wątków”, czy możesz wyjaśnić, co masz na myśli? „nie używaj domyślnych, używaj domyślnych”
Kroltan
3
Przepraszam, pomieszałem moją terminologię, naprawię post. Zasadniczo nie używaj domyślnego dla środowiska, w którym się znajdujesz, użyj domyślnego dla .NET (tj. Puli wątków).
Chris Tavares
bardzo proste, zrozumiałem, dostałeś mój głos :)
Ehsan Sajjad
4

Zwykle radziłbym spojrzeć na CIL, ale w przypadku tych jest bałagan.

Te dwie konstrukcje językowe są podobne w działaniu, ale zaimplementowane nieco inaczej. Zasadniczo to tylko cukier syntaktyczny dla magii kompilatora, nie ma nic szalonego / niebezpiecznego na poziomie asemblera. Przyjrzyjmy się im pokrótce.

yieldjest starszą i prostszą instrukcją i jest cukrem syntaktycznym dla podstawowego automatu stanowego. Metoda powracającaIEnumerable<T> lub IEnumerator<T>może zawierać a yield, która następnie przekształca metodę na fabrykę maszyn stanowych. Jedną rzeczą, na którą należy zwrócić uwagę, jest to, że żaden kod w metodzie nie jest uruchamiany w momencie jej wywołania, jeśli istnieje yieldwewnątrz. Powodem jest to, że kod, który piszesz, jest translokowany do IEnumerator<T>.MoveNextmetody, która sprawdza stan, w jakim się znajduje i uruchamia odpowiednią część kodu. yield return x;jest następnie przekształcany w coś podobnegothis.Current = x; return true;

Jeśli zastanowisz się, możesz łatwo sprawdzić skonstruowaną maszynę stanu i jej pola (przynajmniej jedno dla stanu i dla lokalnych). Możesz go nawet zresetować, jeśli zmienisz pola.

awaitwymaga trochę wsparcia ze strony biblioteki typów i działa nieco inaczej. Potrzeba TasklubTask<T> argument , a następnie wartość, jeśli zadanie jest zakończone, lub rejestruje kontynuację za pośrednictwemTask.GetAwaiter().OnCompleted . Pełne wdrożenie async/ awaitsystem zajęłoby zbyt dużo czasu, aby wyjaśnić, ale też nie jest to takie mistyczne. Tworzy również maszynę stanu i przekazuje ją dalej do OnCompleted . Jeśli zadanie jest zakończone, wykorzystuje jego wynik w kontynuacji. Implementacja awaitera decyduje o sposobie wywołania kontynuacji. Zwykle używa kontekstu synchronizacji wątku wywołującego.

Obie yieldi awaitmuszą podzielić metodę na podstawie ich wystąpienia, aby utworzyć maszynę stanową, w której każda gałąź maszyny reprezentuje każdą część metody.

Nie powinieneś myśleć o tych pojęciach w terminach „niższego poziomu”, takich jak stosy, wątki itp. Są to abstrakcje, a ich wewnętrzne działanie nie wymaga żadnego wsparcia ze strony CLR, tylko kompilator robi magię. To jest szalenie różne od współprogram Lua, który mam za wsparcie środowiska wykonawczego lub C za longjmp , który jest po prostu czarna magia.

IllidanS4 chce powrotu Moniki
źródło
5
Uwaga dodatkowa : awaitnie musisz wykonywać zadania . Wszystko co INotifyCompletion GetAwaiter()jest wystarczające. Trochę podobnie jak foreachnie potrzeba IEnumerable, wszystko co IEnumerator GetEnumerator()jest wystarczające.
IllidanS4 chce, aby Monica wróciła