Jeśli async-oczekuje nie tworzy żadnych dodatkowych wątków, to w jaki sposób sprawia, że ​​aplikacje reagują?

242

Raz po raz widzę, że mówienie, że używanie async- awaitnie tworzy żadnych dodatkowych wątków. To nie ma sensu, ponieważ jedynym sposobem, w jaki komputer wydaje się robić więcej niż jedną rzecz na raz, jest

  • Właściwie robienie więcej niż 1 rzeczy na raz (wykonywanie równoległe, korzystanie z wielu procesorów)
  • Symulowanie przez planowanie zadań i przełączanie się między nimi (zrób trochę A, trochę B, trochę A, itp.)

Jeśli więc async- awaitżaden z nich nie działa, to w jaki sposób może sprawić, że aplikacja będzie reagować? Jeśli jest tylko jeden wątek, wówczas wywołanie dowolnej metody oznacza oczekiwanie na zakończenie metody przed wykonaniem czegokolwiek innego, a metody wewnątrz tej metody muszą czekać na wynik przed kontynuowaniem itd.

Pani Corlib
źródło
17
Zadania IO nie są powiązane z procesorem, a zatem nie wymagają wątku. Głównym celem asynchronizacji jest nie blokowanie wątków podczas zadań związanych z operacjami we / wy.
juharr
24
@jdweng: Nie, wcale nie. Nawet jeśli stworzył nowe wątki , to bardzo różni się od tworzenia nowego procesu.
Jon Skeet
8
Jeśli rozumiesz programowanie asynchroniczne oparte na wywołaniu zwrotnym, rozumiesz, jak await/ asyncdziała bez tworzenia wątków.
user253751
6
Nie powoduje to, że aplikacja jest bardziej responsywna, ale zniechęca do blokowania wątków, co jest częstą przyczyną niereagujących aplikacji.
Owen
6
@RubberDuck: Tak, do kontynuacji może używać wątku z puli wątków. Ale to nie zaczyna wątku w sposób, jaki wyobraża sobie tutaj OP - to nie tak, że mówi: „Weź tę zwykłą metodę, teraz uruchom ją w osobnym wątku - to jest asynchroniczne”. To jest znacznie subtelniejsze.
Jon Skeet

Odpowiedzi:

299

W rzeczywistości asynchronizacja / oczekiwanie nie jest aż tak magiczne. Cały temat jest dość obszerny, ale myślę, że poradzimy sobie z szybką, ale wystarczająco kompletną odpowiedzią na twoje pytanie.

Zajmijmy się prostym zdarzeniem kliknięcia przycisku w aplikacji Windows Forms:

public async void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before awaiting");
    await GetSomethingAsync();
    Console.WriteLine("after awaiting");
}

Idę wyraźnie nie mówić o cokolwiek to jest GetSomethingAsyncpowraca do teraz. Powiedzmy, że jest to coś, co zakończy się, powiedzmy, po 2 sekundach.

W tradycyjnym, niesynchronicznym świecie moduł obsługi zdarzeń kliknięcia przycisku wyglądałby mniej więcej tak:

public void button1_Click(object sender, EventArgs e)
{
    Console.WriteLine("before waiting");
    DoSomethingThatTakes2Seconds();
    Console.WriteLine("after waiting");
}

Po kliknięciu przycisku w formularzu aplikacja będzie się zawieszać przez około 2 sekundy, podczas gdy my czekamy na zakończenie tej metody. Dzieje się tak, że „pompa komunikatów”, w zasadzie pętla, jest zablokowana.

Ta pętla nieustannie pyta okna: „Czy ktoś coś zrobił, na przykład przesunął mysz, kliknął coś? Czy muszę coś odmalować? Jeśli tak, powiedz mi!” a następnie przetwarza to „coś”. Ta pętla otrzymała komunikat, że użytkownik kliknął przycisk „button1” (lub równoważny typ wiadomości z systemu Windows) i ostatecznie wywołał naszą button1_Clickmetodę powyżej. Dopóki ta metoda nie powróci, ta pętla utknęła w oczekiwaniu. Zajmuje to 2 sekundy i podczas tego procesu nie są przetwarzane żadne wiadomości.

Większość czynności związanych z oknami odbywa się za pomocą wiadomości, co oznacza, że ​​jeśli pętla wiadomości przestanie pompować wiadomości, nawet na sekundę, użytkownik szybko to zauważy. Na przykład, jeśli umieścisz notatnik lub inny program na swoim własnym programie, a następnie ponownie, do twojego programu wysyłana jest seria komunikatów o farbie wskazujących, który region okna, który teraz nagle znów stał się widoczny. Jeśli pętla wiadomości, która przetwarza te wiadomości, czeka na coś zablokowana, wówczas nie jest wykonywane malowanie.

Więc jeśli w pierwszym przykładzie async/awaitnie tworzy nowych wątków, jak to robi?

Cóż, dzieje się tak, że twoja metoda jest podzielona na dwie części. Jest to jeden z tych szerokich tematów, więc nie będę wchodził w zbyt szczegółowe szczegóły, ale wystarczy powiedzieć, że metoda jest podzielona na dwie rzeczy:

  1. Cały kod prowadzący do await, w tym połączenie zGetSomethingAsync
  2. Cały następujący kod await

Ilustracja:

code... code... code... await X(); ... code... code... code...

Przeorganizowano:

code... code... code... var x = X(); await X; code... code... code...
^                                  ^          ^                     ^
+---- portion 1 -------------------+          +---- portion 2 ------+

Zasadniczo metoda działa w następujący sposób:

  1. Wykonuje wszystko do await
  2. Wywołuje GetSomethingAsyncmetodę, która działa, i zwraca coś, co zakończy się w 2 sekundy w przyszłości

    Do tej pory nadal znajdujemy się w pierwotnym wywołaniu button1_Click, które dzieje się w głównym wątku wywoływanym z pętli wiadomości. Jeśli kod prowadzący do tego awaitzajmie dużo czasu, interfejs użytkownika nadal się zawiesza. W naszym przykładzie nie tak bardzo

  3. To awaitsłowo kluczowe, wraz z jakąś sprytną magią kompilatora, polega na tym, że w zasadzie jest to coś w stylu „Ok, wiesz co, po prostu wrócę tutaj z funkcji obsługi zdarzenia kliknięcia przycisku. czekam na) przejdź do zakończenia, daj mi znać, ponieważ wciąż mam trochę kodu do wykonania ".

    W rzeczywistości powiadomi klasę SynchronizationContext , że jest to zrobione, co w zależności od aktualnego kontekstu synchronizacji, który jest obecnie w grze, ustawi się w kolejce do wykonania. Klasa kontekstu używana w programie Windows Forms umieści ją w kolejce, używając kolejki, którą pompuje pętla komunikatów.

  4. Powraca więc do pętli komunikatów, która może swobodnie pompować wiadomości, takie jak przesuwanie okna, zmiana jego rozmiaru lub klikanie innych przycisków.

    Dla użytkownika interfejs użytkownika jest teraz ponownie responsywny, przetwarza inne kliknięcia przycisków, zmienia rozmiar i, co najważniejsze, przerysowuje , więc wydaje się, że nie zawiesza się.

  5. 2 sekundy później to, na co czekamy, kończy się i teraz dzieje się tak, że (cóż, kontekst synchronizacji) umieszcza komunikat w kolejce, na którą patrzy pętla komunikatów, mówiąc: „Hej, mam trochę więcej kodu you to execute ”, a ten kod to cały kod po oczekiwaniu.
  6. Gdy pętla komunikatów dotrze do tej wiadomości, po prostu „ponownie wejdzie” do tej metody, w której została przerwana, zaraz po niej awaiti kontynuuje wykonywanie pozostałej części metody. Zauważ, że ten kod jest ponownie wywoływany z pętli komunikatów, więc jeśli ten kod zrobi coś długiego bez async/awaitprawidłowego użycia , ponownie zablokuje pętlę komunikatów

Pod maską znajduje się wiele ruchomych części, więc oto kilka linków do dodatkowych informacji, chciałem powiedzieć „jeśli będziesz tego potrzebować”, ale ten temat jest dość szeroki i dość ważne jest, aby znać niektóre z tych ruchomych części . Niezmiennie zrozumiesz, że asynchronizacja / oczekiwanie to wciąż nieszczelna koncepcja. Niektóre z podstawowych ograniczeń i problemów wciąż wyciekują do otaczającego kodu, a jeśli nie, zwykle trzeba debugować aplikację, która pęka losowo z pozornie nieuzasadnionego powodu.


OK, a co, jeśli GetSomethingAsyncrozwinie wątek, który zakończy się za 2 sekundy? Tak, to oczywiście jest nowy wątek w grze. Ten wątek nie jest jednak spowodowany asynchronicznością tej metody, lecz dlatego, że programista tej metody wybrał wątek do implementacji kodu asynchronicznego. Prawie wszystkie asynchroniczne operacje we / wy nie używają wątku, używają różnych rzeczy. async/await same w sobie nie rozwijają nowych wątków, ale oczywiście „rzeczy, na które czekamy” można zaimplementować za pomocą wątków.

W .NET jest wiele rzeczy, które niekoniecznie same rozwijają wątek, ale nadal są asynchroniczne:

  • Żądania sieciowe (i wiele innych rzeczy związanych z siecią, które wymagają czasu)
  • Asynchroniczne odczytywanie i zapisywanie plików
  • i wiele innych, jest to dobry znak, jeśli klasa / interfejs w pytaniu został nazwany metody SomethingSomethingAsynclub BeginSomethinga EndSomethingi nie jest to IAsyncResultzaangażować.

Zwykle te rzeczy nie używają nici pod maską.


OK, więc chcesz trochę „szerokich tematów”?

Cóż, zapytajmy Wypróbuj Roslyn o nasze kliknięcie przycisku:

Spróbuj Roslyn

Nie będę tutaj linkować w pełni wygenerowanej klasie, ale to dość krwawe rzeczy.

Lasse V. Karlsen
źródło
11
Więc to w zasadzie to, co OP opisał jako „ Symulowanie równoległego wykonywania poprzez planowanie zadań i przełączanie się między nimi ”, prawda?
Bergi
4
@Bergi Niezupełnie. Wykonywanie jest naprawdę równoległe - asynchroniczne zadanie we / wy jest w toku i nie wymaga kontynuowania wątków (jest to coś, co było używane na długo przed pojawieniem się systemu Windows - MS DOS również używał asynchronicznych we / wy, nawet jeśli nie mieć wiele wątków!). Oczywiście await można go również używać w sposób, w jaki go opisujesz, ale ogólnie nie jest. Planowane są tylko połączenia zwrotne (w puli wątków) - między wywołaniem zwrotnym a żądaniem nie jest potrzebny żaden wątek.
Luaan
3
Właśnie dlatego chciałem wyraźnie unikać zbytniego mówienia o tym, co zrobiła ta metoda, ponieważ pytanie dotyczyło konkretnie asynchronizacji / czekania, która nie tworzy własnych wątków. Oczywiście można ich użyć do oczekiwania na zakończenie wątków.
Lasse V. Karlsen
6
@ LasseV.Karlsen - Przyjmuję twoją świetną odpowiedź, ale wciąż nie rozumiem jednego szczegółu. Rozumiem, że moduł obsługi zdarzeń istnieje, jak w kroku 4, który pozwala pompie komunikatów kontynuować pompowanie, ale kiedy i gdzie „rzecz, która zajmuje dwie sekundy”, kontynuuje wykonywanie, jeśli nie jest w osobnym wątku? Gdyby miał zostać uruchomiony w wątku interfejsu użytkownika, i tak zablokowałby pompę komunikatów podczas wykonywania, ponieważ musiałby wykonać jakiś czas w tym samym wątku .. [ciąg dalszy] ...
rory.ap
3
Podoba mi się twoje wyjaśnienie z pompą wiadomości. Czym różni się twoje wyjaśnienie, gdy nie ma pompy komunikatów, takiej jak w aplikacji konsoli lub serwerze WWW? Jak osiąga się ponowne umieszczenie metody?
Puchacz
95

Wyjaśniam to w całości w moim poście na blogu There Is No Thread .

Podsumowując, nowoczesne systemy I / O intensywnie wykorzystują DMA (bezpośredni dostęp do pamięci). Istnieją specjalne, dedykowane procesory na kartach sieciowych, kartach graficznych, kontrolerach dysków twardych, portach szeregowych / równoległych itp. Procesory te mają bezpośredni dostęp do magistrali pamięci i obsługują odczyt / zapis całkowicie niezależnie od procesora. Procesor musi tylko powiadomić urządzenie o lokalizacji w pamięci zawierającej dane, a następnie może zrobić to samo, dopóki urządzenie nie zgłosi przerwania powiadamiającego procesor o zakończeniu odczytu / zapisu.

Gdy operacja jest w locie, procesor nie wykonuje żadnej pracy, a zatem nie ma wątku.

Stephen Cleary
źródło
Żeby było jasne… Rozumiem wysoki poziom tego, co dzieje się podczas używania asynchronicznego oczekiwania. Jeśli chodzi o tworzenie bez wątku - nie ma wątku tylko w żądaniach We / Wy do urządzeń, które, jak powiedziałeś, mają własne procesory, które obsługują samo żądanie? Czy możemy założyć, że WSZYSTKIE żądania we / wy są przetwarzane na takich niezależnych procesorach, co oznacza, że ​​używaj Task.Run TYLKO na akcjach związanych z procesorem?
Yonatan Nir,
@YonatanNir: Nie chodzi tylko o osobne procesory; jakakolwiek reakcja na zdarzenie jest naturalnie asynchroniczna. Task.Runjest najbardziej odpowiedni dla akcji związanych z procesorem , ale ma także kilka innych zastosowań.
Stephen Cleary,
1
Skończyłem czytać twój artykuł i wciąż jest coś podstawowego, czego nie rozumiem, ponieważ tak naprawdę nie jestem zaznajomiony z implementacją systemu operacyjnego na niższym poziomie. Mam to, co napisałeś do miejsca, w którym napisałeś: „Operacja zapisu jest teraz„ w locie ”. Ile wątków ją przetwarza? Żadnych”. . Więc jeśli nie ma wątków, to jak sama operacja jest wykonywana, jeśli nie ma wątku?
Yonatan Nir,
6
To brakujący fragment w tysiącach wyjaśnień !!! W rzeczywistości ktoś wykonuje pracę w tle przy operacjach wejścia / wyjścia. To nie jest wątek, ale inny dedykowany komponent sprzętowy, który wykonuje swoją pracę!
the_dark_destructor
2
@PrabuWeerasinghe: Kompilator tworzy strukturę, która przechowuje zmienne stanu i lokalne. Jeśli oczekiwanie musi ustąpić (tzn. Wrócić do swojego wywołującego), struktura ta jest zapakowana i żyje na stosie.
Stephen Cleary
87

jedynym sposobem, w jaki komputer może sprawiać wrażenie, że robi więcej niż jedną rzecz naraz, jest (1) W rzeczywistości wykonywanie więcej niż jednej rzeczy naraz, (2) symulowanie jej przez planowanie zadań i przełączanie się między nimi. Więc jeśli async-czekaj nie spełnia żadnej z nich

To nie tak, że czekają, ani jedno z nich. Pamiętaj, że celem tego awaitnie jest uczynienie kodu synchronicznego magicznie asynchronicznym . Ma umożliwić korzystanie z tych samych technik, których używamy do pisania kodu synchronicznego podczas wywoływania kodu asynchronicznego . Oczekiwanie polega na tym, aby kod korzystający z operacji o wysokim opóźnieniu wyglądał jak kod korzystający z operacji o niskim opóźnieniu . Te operacje o dużym opóźnieniu mogą dotyczyć wątków, mogą być na sprzęcie specjalnego przeznaczenia, mogą rozrywać swoją pracę na małe kawałki i umieszczać ją w kolejce komunikatów do przetworzenia przez wątek interfejsu użytkownika później. Robią coś, aby osiągnąć asynchronię, ale onito oni to robią. Oczekiwanie pozwala tylko skorzystać z tej asynchronii.

Myślę też, że brakuje ci trzeciej opcji. My, starzy ludzie - dziś dzieci z muzyką rapową powinniśmy zejść z trawnika itp. - Pamiętamy świat Windows na początku lat 90. Nie było maszyn wieloprocesorowych i harmonogramów wątków. Chciałeś uruchomić dwie aplikacje systemu Windows jednocześnie, musiałeś ustąpić . Wielozadaniowość była kooperatywna . System operacyjny informuje proces, który ma zostać uruchomiony, a jeśli jest źle wychowany, głosi wszystkie pozostałe procesy. Działa, dopóki się nie poddaje, i jakoś musi wiedzieć, jak podnieść to, co zostało przerwane, gdy następnym razem system operacyjny kontroluje go z powrotem. Jednowątkowy kod asynchroniczny jest podobny, z „oczekuj” zamiast „wydajnością”. Oczekiwanie oznacza „Będę pamiętać, gdzie tu skończyłem, i pozwolę komuś innemu uciec na chwilę; oddzwoń, gdy zadanie, na które czekam, jest zakończone, a ja wybiorę to, co przerwałem”. Myślę, że widać, jak to sprawia, że ​​aplikacje są bardziej responsywne, tak jak w Windows 3 dni.

wywołanie dowolnej metody oznacza oczekiwanie na jej zakończenie

Istnieje klucz, którego brakuje. Metoda może powrócić przed zakończeniem pracy . To jest właśnie istota asynchronii. Metoda powraca, zwraca zadanie, które oznacza „ta praca jest w toku; powiedz mi, co mam zrobić po jej zakończeniu”. Praca metody nie jest wykonywana, nawet jeśli powróciła .

Przed operatorem oczekującym trzeba było napisać kod, który wyglądał jak spaghetti przepleciony szwajcarskim serem, aby poradzić sobie z faktem, że mamy pracę do wykonania po zakończeniu, ale zwrot i zakończenie są zsynchronizowane . Oczekiwanie pozwala napisać kod, który wygląda jak zwrot i zakończenie są zsynchronizowane, bez faktycznej synchronizacji.

Eric Lippert
źródło
Inne współczesne języki wysokiego poziomu obsługują również podobne jawnie kooperacyjne zachowanie (tj. Funkcja robi pewne rzeczy, daje [możliwe przesłanie pewnej wartości / obiektu do osoby wywołującej], kontynuuje pracę tam, gdzie została przerwana, gdy sterowanie jest przekazywane z powrotem [możliwe z dostarczeniem dodatkowych danych wejściowych] ). Generatory są dość duże w Pythonie.
JAB
2
@JAB: Oczywiście. Generatory nazywane są „blokami iteracyjnymi” w języku C # i używają yieldsłowa kluczowego. Zarówno asyncmetody, jak i iteratory w języku C # są formą coroutine , która jest ogólnym terminem na funkcję, która wie, jak zawiesić bieżącą operację w celu jej późniejszego wznowienia. Wiele języków ma dziś koruptyny lub korupcyjne przepływy kontrolne.
Eric Lippert,
1
Analogia do wydajności jest dobra - to wielozadaniowość kooperacyjna w ramach jednego procesu. (unikając w ten sposób problemów ze stabilnością systemu w ramach wielozadaniowości kooperacyjnej w całym systemie)
user253751
3
Myślę, że koncepcja „przerwań procesora” wykorzystywana do operacji wejścia / wyjścia nie jest znana na temat wielu „programistów” modemów, dlatego uważają, że wątek musi czekać na każdy bit operacji wejścia / wyjścia.
Ian Ringrose,
@EricLippert Metoda asynchroniczna WebClient faktycznie tworzy dodatkowy wątek, patrz tutaj stackoverflow.com/questions/48366871/…
KevinBui
28

Bardzo się cieszę, że ktoś zadał to pytanie, ponieważ przez najdłuższy czas wierzyłem również, że wątki są niezbędne do współbieżności. Kiedy po raz pierwszy zobaczyłem pętle zdarzeń , myślałem, że to kłamstwo. Pomyślałem sobie: „nie ma możliwości, aby ten kod był współbieżny, jeśli działa w jednym wątku”. Pamiętaj, że dzieje się to po tym, jak już przeszedłem walkę o zrozumienie różnicy między współbieżnością a równoległością.

Po badaniach własną rękę, w końcu znalazłem brakujący kawałek: select(). Konkretnie, IO multipleksowanie, realizowane przez różnych jąder pod różnymi nazwami: select(), poll(), epoll(), kqueue(). Są to wywołania systemowe, które mimo różnych szczegółów implementacji, umożliwiają przekazanie zestawu deskryptorów plików do obejrzenia. Następnie możesz wykonać kolejne wywołanie, które blokuje się, dopóki nie zmieni się jeden z obserwowanych deskryptorów plików.

Zatem można poczekać na zestaw zdarzeń we / wy (główna pętla zdarzeń), obsłużyć pierwsze zdarzenie, które się zakończy, a następnie przywrócić kontrolę nad pętlą zdarzeń. Wypłukać i powtórzyć.

Jak to działa? Krótka odpowiedź brzmi: to magia jądra i sprzętu. Oprócz procesora w komputerze znajduje się wiele komponentów, które mogą działać równolegle. Jądro może kontrolować te urządzenia i komunikować się bezpośrednio z nimi, aby odbierać określone sygnały.

Te wywołania systemowe multipleksowania IO są podstawowym elementem budującym jednowątkowe pętle zdarzeń, takie jak node.js lub Tornado. Gdy jesteś awaitfunkcją, obserwujesz określone zdarzenie (zakończenie tej funkcji), a następnie dajesz kontrolę z powrotem do głównej pętli zdarzeń. Po zakończeniu wydarzenia, które oglądasz, funkcja (ostatecznie) rozpoczyna się od miejsca, w którym została przerwana. Funkcje umożliwiające zawieszanie i wznawianie obliczeń w ten sposób nazywane są coroutines .

ogrodnik
źródło
25

awaiti asyncużywaj Zadań, a nie Wątków.

Framework ma pulę wątków gotowych do wykonania niektórych prac w postaci obiektów Task ; przesłanie Zadania do puli oznacza wybranie wolnego, już istniejącego wątku 1 , aby wywołać metodę działania zadania.
Utworzenie zadania polega na utworzeniu nowego obiektu, znacznie szybciej niż utworzenie nowego wątku.

Biorąc pod uwagę zadania można dołączyć Kontynuacja do niego, że jest to nowy Task obiekt do być wykonywane raz końce nici.

Od czasu async/awaitużycia Zadań nie tworzą nowego wątku.


Chociaż technika programowania przerwań jest szeroko stosowana w każdym nowoczesnym systemie operacyjnym, nie sądzę, aby były one tutaj istotne.
Możesz mieć dwa zadania związane z CPU wykonywane równolegle (właściwie przeplecione) w jednym procesorze aysnc/await.
Nie można tego wyjaśnić po prostu faktem, że system operacyjny obsługuje kolejkowanie IORP .


Ostatnim razem, gdy sprawdziłem metody kompilatora przekształcone asyncw DFA , praca jest podzielona na etapy, z których każdy kończy się awaitinstrukcją. Rozpoczyna swoje zadanie i dołączyć go do kontynuacji, aby wykonać następny krok.
await

Jako przykład koncepcji, oto przykład pseudokodu.
Upraszcza się rzeczy, ponieważ nie pamiętam dokładnie wszystkich szczegółów.

method:
   instr1                  
   instr2
   await task1
   instr3
   instr4
   await task2
   instr5
   return value

Przekształca się w coś takiego

int state = 0;

Task nextStep()
{
  switch (state)
  {
     case 0:
        instr1;
        instr2;
        state = 1;

        task1.addContinuation(nextStep());
        task1.start();

        return task1;

     case 1:
        instr3;
        instr4;
        state = 2;

        task2.addContinuation(nextStep());
        task2.start();

        return task2;

     case 2:
        instr5;
        state = 0;

        task3 = new Task();
        task3.setResult(value);
        task3.setCompleted();

        return task3;
   }
}

method:
   nextStep();

1 W rzeczywistości pula może mieć zasady tworzenia zadań.

Margaret Bloom
źródło
16

Nie zamierzam konkurować z Erikiem Lippertem ani Lasse V. Karlsenem i innymi, chciałbym tylko zwrócić uwagę na inny aspekt tego pytania, który moim zdaniem nie został wyraźnie wymieniony.

Korzystanie awaitz niej samodzielnie nie powoduje magicznej reakcji aplikacji. Jeśli cokolwiek zrobisz w metodzie, na którą czekasz z bloków wątków interfejsu użytkownika, nadal będzie blokować twój interfejs użytkownika w taki sam sposób, jak zrobiłaby to wersja nieoczekiwana .

Musisz napisać swoją oczekiwaną metodę specjalnie, aby albo odrodził się nowy wątek, albo użył czegoś takiego jak port zakończenia (który zwróci wykonanie w bieżącym wątku i wezwie coś innego do kontynuacji za każdym razem, gdy port zakończenia zostanie zgłoszony). Ale tę część dobrze wyjaśniono w innych odpowiedziach.

Andrew Savinykh
źródło
3
Przede wszystkim nie jest to konkurs; to współpraca!
Eric Lippert
16

Oto, jak to wszystko widzę, może nie być super technicznie dokładne, ale pomaga mi przynajmniej :).

Istnieją dwa typy przetwarzania (obliczenia), które mają miejsce na komputerze:

  • przetwarzanie, które dzieje się na procesorze
  • przetwarzanie, które dzieje się na innych procesorach (GPU, karta sieciowa itp.), nazwijmy je IO.

Kiedy więc piszemy fragment kodu źródłowego, po kompilacji, w zależności od używanego obiektu (i to jest bardzo ważne), przetwarzanie będzie powiązane z procesorem lub we / wy , a w rzeczywistości może być powiązane z kombinacją obie.

Kilka przykładów:

  • jeśli użyję metody Write FileStreamobiektu (który jest Strumieniem), przetwarzanie będzie powiedziane, 1% związane z procesorem i 99% związane z IO.
  • jeśli użyję metody Write NetworkStreamobiektu (który jest Strumieniem), przetwarzanie będzie powiedziane, 1% związane z procesorem i 99% związane z IO.
  • jeśli użyję metody Write Memorystreamobiektu (którym jest Stream), przetwarzanie będzie w 100% związane z procesorem.

Tak więc, jak widzisz, z punktu widzenia programisty obiektowego, chociaż zawsze uzyskuję dostęp do Streamobiektu, to, co dzieje się poniżej, może w dużym stopniu zależeć od ostatecznego typu obiektu.

Teraz, aby zoptymalizować rzeczy, czasem warto uruchomić równolegle kod (uwaga: nie używam słowa asynchronicznego), jeśli jest to możliwe i / lub konieczne.

Kilka przykładów:

  • W aplikacji komputerowej chcę wydrukować dokument, ale nie chcę na niego czekać.
  • Mój serwer WWW obsługuje wielu klientów jednocześnie, z których każdy otrzymuje swoje strony równolegle (nie serializowane).

Przed asynchronizacją / oczekiwaniem mieliśmy zasadniczo dwa rozwiązania tego problemu:

  • Nici . Był stosunkowo łatwy w użyciu, dzięki klasom Thread i ThreadPool. Wątki są związane tylko z procesorem .
  • „Stary” asynchroniczny model programowania Begin / End / AsyncCallback . To tylko model, nie mówi ci, czy będziesz związany z procesorem, czy we / wy. Jeśli spojrzysz na klasy Socket lub FileStream, jest to związane z IO, co jest fajne, ale rzadko go używamy.

Async / await to tylko wspólny model programowania, oparty na koncepcji Zadania . Jest nieco łatwiejszy w użyciu niż wątki lub pule wątków do zadań związanych z procesorem i znacznie łatwiejszy w użyciu niż stary model Begin / End. Pod przykrywką jest to jednak „po prostu” bardzo wyrafinowane opakowanie pełne funkcji na obu.

Tak więc, prawdziwa wygrana dotyczy głównie zadań związanych z IO , zadań, które nie wykorzystują procesora, ale async / Oczekiwanie jest nadal tylko modelem programowania, nie pomaga ci określić, w jaki sposób / gdzie przetwarzanie nastąpi w końcu.

Oznacza to, że nie dzieje się tak dlatego, że klasa ma metodę „DoSomethingAsync” zwracającą obiekt Task, który można założyć, że będzie związany z procesorem (co oznacza, że ​​może być całkiem bezużyteczny , szczególnie jeśli nie ma parametru tokenu anulowania), ani powiązania IO (co oznacza, że ​​prawdopodobnie jest to konieczne ) lub kombinacja obu (ponieważ model jest dość wirusowy, wiązanie i potencjalne korzyści mogą być ostatecznie bardzo mieszane i nie tak oczywiste).

Tak więc, wracając do moich przykładów, wykonywanie operacji zapisu przy użyciu asynchronizacji / oczekiwania na MemoryStream pozostanie związane z procesorem (prawdopodobnie nie skorzystam z niego), chociaż na pewno skorzystam z niego z plikami i strumieniami sieciowymi.

Simon Mourier
źródło
1
Jest to dość dobra odpowiedź przy użyciu theadpool do pracy związanej z procesorem jest kiepska w tym sensie, że wątki TP powinny być użyte do odciążenia operacji IO. Imo związane z procesorem powinno oczywiście blokować z pewnymi zastrzeżeniami i nic nie wyklucza użycia wielu wątków.
davidcarr,
3

Podsumowując inne odpowiedzi:

Asynchronizacja / oczekiwanie jest tworzona przede wszystkim dla zadań powiązanych z IO, ponieważ dzięki ich użyciu można uniknąć blokowania wątku wywołującego. Ich głównym zastosowaniem są wątki interfejsu użytkownika, w których nie jest pożądane blokowanie wątku podczas operacji związanej z IO.

Async nie tworzy własnego wątku. Wątek metody wywołującej służy do wykonywania metody asynchronicznej, dopóki nie znajdzie oczekiwanego. Ten sam wątek kontynuuje wykonywanie reszty metody wywołującej poza wywołaniem metody asynchronicznej. W ramach wywoływanej metody asynchronicznej po powrocie z tego, co oczekiwane, kontynuację można wykonać na wątku z puli wątków - jedyne miejsce, w którym pojawia się osobny wątek.

Vaibhav Kumar
źródło
Dobre podsumowanie, ale myślę, że powinno odpowiedzieć na 2 dodatkowe pytania, aby uzyskać pełny obraz: 1. W jakim wątku jest wykonywany oczekiwany kod? 2. Kto kontroluje / konfiguruje wspomnianą pulę wątków - programista lub środowisko wykonawcze?
stojke
1. W tym przypadku najczęściej oczekiwany kod jest operacją związaną z We / Wy, która nie używałaby wątków procesora. Jeśli pożądane jest użycie funkcji oczekiwania na operację związaną z procesorem, można utworzyć osobne zadanie. 2. Wątkiem w puli wątków zarządza harmonogram zadań, który jest częścią frameworku TPL.
vaibhav kumar
2

Próbuję to wyjaśnić oddolnie. Może ktoś uzna to za pomocne. Byłem tam, zrobiłem to, wymyśliłem na nowo, kiedy tworzyłem proste gry w DOS-ie w Pascalu (stare dobre czasy ...)

Więc ... W aplikacji sterowanej każdym zdarzeniem znajduje się pętla zdarzeń, która wygląda mniej więcej tak:

while (getMessage(out message)) // pseudo-code
{
   dispatchMessage(message); // pseudo-code
}

Ramy zwykle ukrywają przed tobą ten szczegół, ale on tam jest. Funkcja getMessage odczytuje następne zdarzenie z kolejki zdarzeń lub czeka, aż zdarzenie się wydarzy: ruch myszy, klawisz dostępu, keyup, kliknięcie itp. A następnie dispatchMessage wysyła zdarzenie do odpowiedniej procedury obsługi zdarzenia. Następnie czeka na następne zdarzenie i tak dalej, aż nadejdzie zdarzenie zakończenia, które opuści pętle i zakończy aplikację.

Procedury obsługi zdarzeń powinny działać szybko, aby pętla zdarzeń mogła sondować więcej zdarzeń, a interfejs użytkownika pozostaje responsywny. Co się stanie, jeśli kliknięcie przycisku wywoła taką kosztowną operację?

void expensiveOperation()
{
    for (int i = 0; i < 1000; i++)
    {
        Thread.Sleep(10);
    }
}

Interfejs użytkownika przestaje odpowiadać do czasu zakończenia 10-sekundowej operacji, gdy kontrola pozostaje w obrębie funkcji. Aby rozwiązać ten problem, musisz podzielić zadanie na małe części, które można szybko wykonać. Oznacza to, że nie możesz obsłużyć całości w jednym wydarzeniu. Musisz wykonać niewielką część pracy, a następnie opublikować kolejne wydarzenie w kolejce zdarzeń, aby poprosić o kontynuację.

Więc zmień to na:

void expensiveOperation()
{
    doIteration(0);
}

void doIteration(int i)
{
    if (i >= 1000) return;
    Thread.Sleep(10); // Do a piece of work.
    postFunctionCallMessage(() => {doIteration(i + 1);}); // Pseudo code. 
}

W takim przypadku uruchamia się tylko pierwsza iteracja, a następnie wysyła komunikat do kolejki zdarzeń, aby uruchomić następną iterację i zwraca. Nasza przykładowa postFunctionCallMessagepseudo funkcja umieszcza w kolejce zdarzenie „wywołaj tę funkcję”, więc dyspozytor zdarzeń wywoła je, gdy do niego dotrze. Pozwala to na przetwarzanie wszystkich innych zdarzeń GUI podczas ciągłego uruchamiania również fragmentów pracy o długim czasie pracy.

Tak długo, jak długo działa to długo działające zadanie, jego kontynuacja jest zawsze w kolejce zdarzeń. Więc w zasadzie wymyśliłeś swój własny harmonogram zadań. Gdzie zdarzeniami kontynuującymi w kolejce są uruchomione „procesy”. W rzeczywistości robią to systemy operacyjne, z tym wyjątkiem, że wysyłanie zdarzeń kontynuacji i powrót do pętli harmonogramu odbywa się za pośrednictwem przerwania czasomierza CPU, w którym system operacyjny zarejestrował kod przełączania kontekstu, więc nie musisz się tym przejmować. Ale tutaj piszesz swój harmonogram, więc musisz się tym zająć - do tej pory.

Możemy więc uruchamiać długo działające zadania w jednym wątku równolegle z GUI, dzieląc je na małe części i wysyłając zdarzenia kontynuacji. To jest ogólna idea Taskklasy. Reprezentuje kawałek pracy, a kiedy go wywołujesz .ContinueWith, określasz, jaką funkcję wywołać jako następny kawałek, gdy bieżący kawałek się zakończy (a jego wartość zwracana jest przekazywana do kontynuacji). TaskKlasa korzysta z puli wątków, w których występuje pętla zdarzenie w każdym wątku czeka robić kawałki podobne do pracy chcą pokazałem na początku. W ten sposób możesz równolegle wykonywać miliony zadań, ale tylko kilka wątków do ich uruchomienia. Ale działałoby to równie dobrze z jednym wątkiem - pod warunkiem, że twoje zadania są odpowiednio podzielone na małe kawałki, każdy z nich wydaje się działać w trybie parellel.

Ale ręczne wykonywanie całego łańcucha dzielenia pracy na małe części jest uciążliwe i całkowicie psuje układ logiki, ponieważ cały kod zadania w tle jest w zasadzie .ContinueWithbałaganem. Tutaj kompilator pomaga. Wszystko to łączy i kontynuuje dla Ciebie w tle. Kiedy mówisz await, że mówisz kompilatorowi, że „zatrzymaj się tutaj, dodaj resztę funkcji jako zadanie kontynuacji”. Kompilator zajmie się resztą, więc nie musisz.

Calmarius
źródło
0

W rzeczywistości async awaitłańcuchy są maszyną stanu generowaną przez kompilator CLR.

async await używa jednak wątków, które TPL używa puli wątków do wykonywania zadań.

Powodem, dla którego aplikacja nie jest blokowana, jest to, że maszyna stanu może zdecydować, która wspólna procedura ma zostać wykonana, powtórzyć, sprawdzić i ponownie podjąć decyzję.

Dalsza lektura:

Co generuje asynchronizacja i oczekiwanie?

Async Await and the Generated StateMachine

Asynchroniczne C # i F # (III.): Jak to działa? - Tomas Petricek

Edytuj :

W porządku. Wygląda na to, że moje opracowanie jest nieprawidłowe. Muszę jednak zaznaczyć, że maszyny stanowe są ważnymi zasobami dla async awaits. Nawet jeśli weźmiesz asynchroniczne operacje we / wy, nadal potrzebujesz pomocnika, aby sprawdzić, czy operacja jest zakończona, dlatego nadal potrzebujemy maszyny stanów i ustalimy, która procedura może zostać wykonana asychronicznie razem.

Steve Fan
źródło
0

To nie jest bezpośrednia odpowiedź na pytanie, ale myślę, że jest to interesująca dodatkowa informacja:

Asynchronizacja i oczekiwanie same w sobie nie tworzą nowych wątków. ALE w zależności od tego, gdzie używasz asynchronicznego oczekiwania, część synchroniczna PRZED oczekiwaniem może działać w innym wątku niż część synchroniczna PO Oczekiwaniu (na przykład ASP.NET i rdzeń ASP.NET zachowują się inaczej).

W aplikacjach opartych na wątkach interfejsu użytkownika (WinForms, WPF) będziesz przed i po tym samym wątku. Ale gdy używasz asynchronizacji w wątku puli wątków, wątek przed i po oczekiwaniu może nie być taki sam.

Świetne wideo na ten temat

Blechdose
źródło