Jak rozumiem, yield
sł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 await
nie 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 awaits
metodę.
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 await
osią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 await
wywoł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 yield
zostanie osiągnięty, w jaki sposób środowisko wykonawcze śledzi punkt, w którym należy odebrać rzeczy? Jak zachowywany jest stan iteratora?
źródło
Odpowiedzi:
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.
await
jest generowany jako:W zasadzie to wszystko. Oczekiwanie to tylko fantazyjny powrót.
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.
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 .
Te wywołania metod powracają, więc ich rekordy aktywacji nie są już na stosie w punkcie oczekiwania.
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.
Ta sama droga. Stan miejscowych jest przenoszony na stertę, a liczba reprezentująca instrukcję, która
MoveNext
powinna 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.
źródło
yield
jest łatwiejszy z dwóch, więc przyjrzyjmy się temu.Powiedz, że mamy:
Kompilujemy to trochę tak, jakbyśmy napisali:
Więc nie jest tak skuteczny jak implementacji odręcznego z
IEnumerable<int>
iIEnumerator<int>
(na przykład nie byłoby prawdopodobnie odpady posiadające odrębną_state
,_i
a_current
w 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 skomplikowanymiyield
metodami.I oczywiście od tego czasu
Jest taki sam jak:
Następnie generowany
MoveNext()
jest wielokrotnie wywoływany.async
Sprawa 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:Tworzy kod taki jak:
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 czasawaiter.IsCompleted
jest zaznaczony, zwraca,true
ponieważ zadanieawait
ed 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
MoveNext
i albo będzie kontynuowane z następną częścią pracy (do następnejawait
), albo zakończy i zwróci, w którym to przypadkuTask
implementacja zostanie zakończona.źródło
yield
na 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.Jest tu już mnóstwo świetnych odpowiedzi; Podzielę się tylko kilkoma punktami widzenia, które mogą pomóc w stworzeniu modelu mentalnego.
Najpierw
async
metoda jest dzielona na kilka części przez kompilator; teawait
wyraż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,
await
jest 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óć”. (Weasync
wstępie używam bardzo podobnej terminologii ).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
await
przechwycony interfejs użytkownikaSynchronizationContext
i operacje we / wy zostały ukończone w puli wątków).To tylko oddzwonienia. Kiedy oczekiwane kończy się, wywołuje swoje wywołania zwrotne, a każda
async
metoda, która jużawait
ją 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ń.
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ć
async
metodę kończącą zadanie, która może wznowićasync
metodę, która kończy swoje zadanie itp.Tak więc, z synchronicznym kodu
A
wywołującegoB
powołanieC
, swoją callstack może wyglądać następująco:podczas gdy kod asynchroniczny wykorzystuje wywołania zwrotne (wskaźniki):
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 .
źródło
yield
iawait
chociaż oba dotyczą kontroli przepływu, są dwiema zupełnie różnymi rzeczami. Więc zajmę się nimi osobno.Celem
yield
jest ułatwienie tworzenia leniwych sekwencji. Kiedy piszesz pętlęyield
moduł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,MoveNext
gdy ją wywołasz , ponownie przechodzi przez tę pętlę. Więc kiedy robisz pętlę foreach taką jak ta:wygenerowany kod wygląda mniej więcej tak:
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.
async
aawait
z 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
async
iawait
ogólnie.źródło
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.
yield
jest starszą i prostszą instrukcją i jest cukrem syntaktycznym dla podstawowego automatu stanowego. Metoda powracającaIEnumerable<T>
lubIEnumerator<T>
może zawierać ayield
, 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 istniejeyield
wewnątrz. Powodem jest to, że kod, który piszesz, jest translokowany doIEnumerator<T>.MoveNext
metody, 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.
await
wymaga trochę wsparcia ze strony biblioteki typów i działa nieco inaczej. PotrzebaTask
lubTask<T>
argument , a następnie wartość, jeśli zadanie jest zakończone, lub rejestruje kontynuację za pośrednictwemTask.GetAwaiter().OnCompleted
. Pełne wdrożenieasync
/await
system 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
yield
iawait
muszą 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.
źródło
await
nie musisz wykonywać zadania . Wszystko coINotifyCompletion GetAwaiter()
jest wystarczające. Trochę podobnie jakforeach
nie potrzebaIEnumerable
, wszystko coIEnumerator GetEnumerator()
jest wystarczające.