Async in depth (Asynchroniczne w głębi programu)

Pisanie kodu asynchronicznego związanego z operacjami we/wy i procesorem CPU jest proste przy użyciu modelu asynchronicznego opartego na zadaniach .NET. Model jest ujmowany przez typy i oraz słowa kluczowe i w Task Task<T> async await języku C# i Visual Basic. (Zasoby specyficzne dla języka znajdują się w sekcji Zobacz również). W tym artykule wyjaśniono, jak używać asynchronicznych platform .NET i przedstawiono w nich informacje o platformie asynchronicznej używanej w ramach okładki.

Zadanie i zadanie<T>

Zadania to konstrukcje służące do implementowania tzw. modelu obietnic współbieżności. Krótko mówiąc, oferują one "obietnicę", że praca zostanie ukończona w późniejszym momencie, co pozwoli Ci skoordynować obietnicę z czystym interfejsem API.

  • Task reprezentuje pojedynczą operację, która nie zwraca wartości.
  • Task<T> reprezentuje pojedynczą operację, która zwraca wartość typu T .

Ważne jest, aby pamiętać o zadaniach jako abstrakcjach pracy wykonywanej asynchronicznie, a nie jako abstrakcji nad wątkami. Domyślnie zadania są wykonywane w bieżącym wątku i delegować pracę do systemu operacyjnego, zgodnie z potrzebami. Opcjonalnie można jawnie zażądać uruchomienia zadań w osobnym wątku za pośrednictwem interfejsu Task.Run API.

Zadania uwidoczniają protokół interfejsu API do monitorowania, oczekiwania i uzyskiwania dostępu do wartości wyniku (w przypadku Task<T> ) zadania. Integracja języka ze słowem kluczowym zapewnia abstrakcję wyższego await poziomu do używania zadań.

Użycie funkcji umożliwia aplikacji lub usłudze wykonywanie przydatnej pracy, gdy zadanie jest uruchomione, przez nadanie kontrolce funkcji wywołującej do await momentu wykonania zadania. Kod nie musi polegać na wywołaniach zwrotnych ani zdarzeniach, aby kontynuować wykonywanie po zakończeniu zadania. Integracja interfejsu API języka i zadań robi to za Ciebie. Jeśli używasz funkcji , słowo kluczowe Task<T> dodatkowo "odpakuje" wartość zwróconą await po zakończeniu zadania. Szczegółowe informacje o tym, jak to działa, zostały wyjaśnione poniżej.

Więcej informacji o zadaniach i różnych sposobach interakcji z nimi można znaleźć w temacie Wzorzec asynchroniczny oparty na zadaniach (TAP).

Deeper Dive into Tasks for an I/O-Bound Operation

W poniższej sekcji opisano 10 000 stop widoku, co się dzieje w przypadku typowego asynchronicznego wywołania we/wy. Zacznijmy od kilku przykładów z poniższej klasy.

Pierwsza przykładowa metoda GetHtmlAsync() wywołuje metodę asynchroniczną i zwraca aktywne zadanie, prawdopodobnie jeszcze do ukończenia. Druga przykładowa GetFirstCharactersCountAsync() metoda dodaje użycie słów kluczowych i do wykonania na async await zadaniu.

class DotNetFoundationClient
{
    // HttpClient is intended to be instantiated once per application, rather than per-use.
    private static readonly HttpClient s_client = new HttpClient();

    public Task<string> GetHtmlAsync()
    {
        // Execution is synchronous here
        var uri = new Uri("https://www.dotnetfoundation.org");

        return s_client.GetStringAsync(uri);
    }

    public async Task<string> GetFirstCharactersCountAsync(int count)
    {
        // Execution is synchronous here
        var uri = new Uri("https://www.dotnetfoundation.org");

        // Execution of GetFirstCharactersCountAsync() is yielded to the caller here
        // GetStringAsync returns a Task<string>, which is *awaited*
        var page = await s_client.GetStringAsync(uri);

        // Execution resumes when the client.GetStringAsync task completes,
        // becoming synchronous again.

        if (count > page.Length)
        {
            return page;
        }
        else
        {
            return page.Substring(0, count);
        }
    }
}

Wywołanie wywołania za pośrednictwem bibliotek platformy .NET niższego poziomu (np. wywoływanie innych metod asynchronicznych), dopóki nie osiągnie wywołania międzyoptymowego P/Invoke do natywnej GetStringAsync() biblioteki sieciowej. Biblioteka natywna może następnie wywołać wywołanie interfejsu API systemu (na przykład write() do gniazda w systemie Linux). Obiekt zadania zostanie utworzony w granicach natywnych/zarządzanych, prawdopodobnie przy użyciu obiektu TaskCompletionSource. Obiekt zadania zostanie przekazany przez warstwy, prawdopodobnie obsługiwane lub bezpośrednio zwracane, ostatecznie zwrócony do początkowego obiektu wywołującego.

W drugiej przykładowej GetFirstCharactersCountAsync() metodzie powyżej obiekt zostanie Task<T> zwrócony z metody GetStringAsync . Użycie słowa await kluczowego powoduje, że metoda zwraca nowo utworzony obiekt zadania. Kontrolka wraca do wywołującego z tej lokalizacji w GetFirstCharactersCountAsync metodzie . Metody i właściwości obiektu < T > zadania umożliwiają wywołującym monitorowanie postępu zadania, które zakończy się po wykonaniu pozostałego kodu w obiekcie GetFirstCharactersCountAsync.

Po wywołaniu interfejsu API systemu żądanie znajduje się teraz w przestrzeni jądra, co umożliwia dostęp do podsystemu sieciowego systemu operacyjnego (na przykład w jądrze /net systemu Linux). W tym miejscu system operacyjny będzie obsługiwać żądanie sieci asynchronicznie. Szczegóły mogą się różnić w zależności od używanego systemu operacyjnego (wywołanie sterownika urządzenia może być zaplanowane jako sygnał wysyłany z powrotem do środowiska uruchomieniowego lub może zostać wykonane wywołanie sterownika urządzenia, a następnie sygnał wysłany z powrotem), ale ostatecznie środowisko uruchomieniowe zostanie powiadomione, że żądanie sieciowe jest w toku. W tej chwili praca dla sterownika urządzenia będzie zaplanowana, w toku lub już zakończona (żądanie jest już gotowe "za pośrednictwem sieci") — ale ponieważ to wszystko odbywa się asynchronicznie, sterownik urządzenia może natychmiast obsłużyć coś innego!

Na przykład w Windows systemu operacyjnego wykonuje wywołanie do sterownika urządzenia sieciowego i prosi go o wykonanie operacji sieciowej za pośrednictwem pakietu żądań przerwań (IRP), który reprezentuje operację. Sterownik urządzenia odbiera IRP, wykonuje wywołanie do sieci, oznacza IRP jako "oczekujące" i wraca do systemu operacyjnego. Ponieważ wątek systemu operacyjnego wie teraz, że proces IRP jest "oczekujący", nie ma więcej pracy do wykonania dla tego zadania i "zwraca" z powrotem, aby można go było użyć do wykonania innej pracy.

Gdy żądanie zostanie spełnione, a dane zostaną ponownie odebrane za pośrednictwem sterownika urządzenia, powiadamia procesor o nowych danych odebranych za pośrednictwem przerwania. Sposób obsługi tego przerwania różni się w zależności od systemu operacyjnego, ale ostatecznie dane będą przekazywane przez system operacyjny do momentu osiągnięcia wywołania międzyopmięciowego systemu (na przykład w systemie Linux procedura obsługi przerwań zaplanuje dolną połowę irQ, aby dane przekazywane asynchronicznie przez system operacyjny). Dzieje się to również asynchronicznie! Wynik jest kolejkowany do momentu, gdy następny dostępny wątek będzie mógł wykonać metodę asynchroniczną i "odpakować" wynik ukończonego zadania.

W całym procesie kluczowe jest to, że żaden wątek nie jest przeznaczony do uruchamiania zadania. Mimo że praca jest wykonywana w pewnym kontekście (oznacza to, że system operacyjny musi przekazywać dane do sterownika urządzenia i odpowiadać na przerwanie), nie ma wątku przeznaczonego do oczekiwania na dane z żądania powrotu. Dzięki temu system może obsłużyć znacznie większą ilość pracy, zamiast czekać na zakończenie niektórych wywołań we/wy.

Chociaż może to wydawać się dużo pracy do wykonanej, mierzone w zakresie zegara zegarowego, jest to minuscule w porównaniu do czasu, jaki zajmuje do pracy we/wy. Chociaż nie jest to w ogóle dokładne, potencjalna oś czasu dla takiego wywołania wyglądałaby tak:

0-1————————————————————————————————————————————————–2-3

  • Czas spędzony od punktów do to wszystko do momentu, gdy metoda 0 1 asynchroniczna daje kontrolę elementowi wywołującemu.
  • Czas spędzony z punktów do to czas spędzony na 1 2 we/wy bez kosztu procesora CPU.
  • Na koniec czas spędzony od punktów do jest przekazywaniem kontroli z powrotem (i potencjalnie wartością) do metody asynchronicznej, w którym jest ona 2 3 ponownie wykonywana.

Co to oznacza dla scenariusza serwera?

Ten model dobrze sprawdza się w przypadku typowego obciążenia scenariusza serwera. Ponieważ nie ma wątków przeznaczonych do blokowania dla zadań niedokończonych, pula wątków serwera może wykonywać znacznie większą ilość żądań internetowych.

Rozważ dwa serwery: jeden, który uruchamia kod asynchroniczny, i jeden, który nie. W tym przykładzie każdy serwer ma tylko pięć wątków dostępnych dla żądań obsługi. Ta liczba jest nierealistycznie mała i służy tylko w kontekście demonstracyjnym.

Załóżmy, że oba serwery odbierają sześć równoczesnych żądań. Każde żądanie wykonuje operację We/Wy. Serwer bez kodu asynchronicznego musi kolejkować szóste żądanie, dopóki jeden z pięciu wątków nie zakończy pracy związanej z we/wy i nie napisano odpowiedzi. W momencie, gdy pojawia się 20. żądanie, serwer może zacząć spowalniać, ponieważ kolejka jest zbyt długa.

Serwer z uruchomionym kodem asynchronicznym nadal kolejkuje szóste żądanie, ale ponieważ używa i , każdy z jego wątków jest wolny po uruchomieniu pracy powiązanej z async we/wy, a nie po await zakończeniu. Do czasu, gdy pojawi się 20. żądanie, kolejka dla żądań przychodzących będzie znacznie mniejsza (jeśli w ogóle ma coś w nim), a serwer nie zwolni tempa.

Chociaż jest to ciągły przykład, działa on w podobny sposób w świecie rzeczywistym. W rzeczywistości można oczekiwać, że serwer będzie mógł obsłużyć o rząd wielkości więcej żądań przy użyciu i niż gdyby dedykował wątek dla każdego async await odbieranych żądań.

Co to oznacza dla scenariusza klienta?

Największy zysk z używania i async await dla aplikacji klienckiej to zwiększenie czasu odpowiedzi. Mimo że aplikację można reagować przez ręczne zduplikowanie wątków, zduplikowanie wątku jest kosztowną operacją w porównaniu z używaniem tylko i async await . Szczególnie w przypadku czegoś takiego jak gra mobilna kluczowe znaczenie ma wpływ na wątek interfejsu użytkownika tak bardzo, jak to możliwe, gdy dotyczy to we/wy.

Co ważniejsze, ponieważ praca związana z we/wy nie poświęca praktycznie żadnego czasu na procesor CPU, poświęcanie całego wątku procesora CPU na wykonanie praktycznie dowolnej użytecznej pracy byłoby złym użyciem zasobów.

Ponadto wysyłanie pracy do wątku interfejsu użytkownika (takiego jak aktualizowanie interfejsu użytkownika) jest proste przy użyciu metod i nie wymaga dodatkowej pracy (takiej jak wywoływanie delegata bezpiecznego async wątkowo).

Deeper Dive into Task and Task for a CPU-Bound Operation (Bardziej zagłębiają się w zadania <T> i zadania dla CPU-Bound operacji)

Kod powiązany z procesorem CPU jest nieco inny niż kod powiązany async z we/wy. async Ponieważ praca jest wykonywana na procesorze CPU, nie ma możliwości pominiania poświęcania wątku na obliczenia. Funkcje i zapewniają czysty sposób interakcji z wątkiem w tle i zapewniają dynamiczne reagowanie wywołującej async await metodę asynchroniczną. Należy pamiętać, że nie zapewnia to żadnej ochrony danych udostępnionych. Jeśli używasz danych udostępnionych, nadal musisz zastosować odpowiednią strategię synchronizacji.

Oto 10 000 stop widoku asynchronicznego wywołania asynchronicznego powiązanego z procesorem CPU:

public async Task<int> CalculateResult(InputData data)
{
    // This queues up the work on the threadpool.
    var expensiveResultTask = Task.Run(() => DoExpensiveCalculation(data));

    // Note that at this point, you can do some other work concurrently,
    // as CalculateResult() is still executing!

    // Execution of CalculateResult is yielded here!
    var result = await expensiveResultTask;

    return result;
}

CalculateResult() Wykonuje polecenie w wątku, na który został wywołany. Gdy wywołuje on operację , kolejkuje kosztowną operację powiązaną z procesorem CPU w puli wątków i Task.Run DoExpensiveCalculation() odbiera Task<int> dojście. DoExpensiveCalculation() Jest ostatecznie uruchamiany współbieżnie w następnym dostępnym wątku, prawdopodobnie na innym rdzeniu procesora CPU. Istnieje możliwość wykonywania równoczesnej pracy, gdy jest ona zajęta w innym wątku, ponieważ wątek, który jest DoExpensiveCalculation() CalculateResult() nadal wykonywany.

Po napotkaniu wykonanie jest przywoływane do jego wywołującego, co umożliwia wykonywanie innych zadań przy użyciu bieżącego wątku, podczas gdy await CalculateResult() DoExpensiveCalculation() rezygnacja z wyniku. Po zakończeniu wynik jest do kolejki w celu uruchomienia w wątku głównym. Po pewnym czasie wątek główny wróci do wykonania , w którym to momencie będzie CalculateResult() mieć wynik DoExpensiveCalculation() .

Dlaczego asynchroniczna pomoc w tym przypadku?

async i await są najlepszym rozwiązaniem do zarządzania pracą związaną z procesorem CPU, gdy potrzebujesz czasu odpowiedzi. Istnieje wiele wzorców używania asynchroniczności z pracą powiązaną z procesorem CPU. Należy pamiętać, że korzystanie z asynchronicznej aplikacji jest niewielkie i nie jest zalecane w przypadku wąskich pętli. To Ty decydujesz, jak napisać kod wokół tej nowej funkcji.

Zobacz też