.NET: Narzędzia do pracy z wielowątkowością i asynchronią. Część 1

Publikuję oryginalny artykuł na Habr, którego tłumaczenie zamieszczono w korporacji post na blogu.

Konieczność wykonania czegoś asynchronicznie, bez czekania na wynik tu i teraz, czy też rozdzielenia dużej pracy pomiędzy kilka wykonujących ją jednostek, istniała już przed pojawieniem się komputerów. Wraz z ich pojawieniem się potrzeba ta stała się bardzo namacalna. Teraz, w 2019 roku, piszę ten artykuł na laptopie z 8-rdzeniowym procesorem Intel Core, na którym równolegle pracuje ponad sto procesów i jeszcze więcej wątków. Nieopodal stoi nieco podniszczony telefon, kupiony kilka lat temu, ma na pokładzie 8-rdzeniowy procesor. Zasoby tematyczne pełne są artykułów i filmów, w których ich autorzy podziwiają tegoroczne flagowe smartfony wyposażone w 16-rdzeniowe procesory. MS Azure udostępnia maszynę wirtualną ze 20-rdzeniowym procesorem i 128 TB RAM za niecałe 2 dolarów za godzinę. Niestety nie da się wydobyć maksimum i ujarzmić tej mocy bez możliwości zarządzania interakcją wątków.

terminologia

Proces - Obiekt systemu operacyjnego, izolowana przestrzeń adresowa, zawiera wątki.
Nitka - obiekt systemu operacyjnego, najmniejsza jednostka wykonania, część procesu, wątki współdzielą między sobą pamięć i inne zasoby w ramach procesu.
Wielozadaniowość - Właściwość systemu operacyjnego, możliwość jednoczesnego uruchomienia kilku procesów
Wielordzeniowy - właściwość procesora, możliwość wykorzystania kilku rdzeni do przetwarzania danych
Przetwarzanie wieloprocesowe - właściwość komputera, możliwość jednoczesnej fizycznej pracy z kilkoma procesorami
Wielowątkowość — właściwość procesu, zdolność do rozłożenia przetwarzania danych na kilka wątków.
Równoległość - wykonywanie kilku czynności fizycznie jednocześnie w jednostce czasu
Asynchronia — wykonanie operacji bez oczekiwania na zakończenie tego przetwarzania; wynik wykonania można przetworzyć później.

Metafora

Nie wszystkie definicje są dobre i niektóre wymagają dodatkowego wyjaśnienia, dlatego do formalnie wprowadzonej terminologii dodam metaforę dotyczącą gotowania śniadania. Gotowanie śniadania w tej metaforze to proces.

Przygotowując poranne śniadanie ja (CPU) Przychodzę do kuchni (Komputer). Mam 2 ręce (Rdzenie). W kuchni znajduje się wiele urządzeń (IO): piekarnik, czajnik, toster, lodówka. Włączam gaz, stawiam na nim patelnię i wlewam do niej olej, nie czekając, aż się rozgrzeje (asynchronicznie, nieblokujące-IO-Wait), wyjmuję jajka z lodówki i rozbijam je na talerz, a następnie ubijam jedną ręką (Wątek nr 1), i drugi (Wątek nr 2) trzymający talerz (Zasób wspólny). Teraz chciałbym włączyć czajnik, ale nie mam dość rąk (Głód wątku) W tym czasie nagrzewa się patelnia (obróbka wyniku) na którą wlewam to co ubite. Sięgam po czajnik, włączam go i głupio patrzę, jak wrze w nim woda (Blokowanie-IO-Czekaj), chociaż w tym czasie mógł umyć talerz, na którym ubijał omlet.

Ugotowałam omlet tylko 2 rękami, więcej nie mam, ale jednocześnie w momencie ubijania omletu miały miejsce 3 operacje na raz: ubijanie omletu, trzymanie talerza, podgrzewanie patelni Procesor jest najszybszą częścią komputera, IO jest tym, co najczęściej wszystko spowalnia, dlatego często skutecznym rozwiązaniem jest zajęcie czymś procesora podczas odbierania danych z IO.

Kontynuując metaforę:

  • Gdybym w trakcie przygotowywania omletu próbowała także zmienić ubranie, byłby to przykład wielozadaniowości. Ważny niuans: komputery są w tym znacznie lepsze niż ludzie.
  • Kuchnia z kilkoma szefami kuchni, np. w restauracji – komputer wielordzeniowy.
  • Wiele restauracji w strefie gastronomicznej w centrum handlowym - centrum danych

Narzędzia .NET

.NET dobrze radzi sobie z wątkami, podobnie jak z wieloma innymi rzeczami. Z każdą nową wersją wprowadza coraz to nowe narzędzia do pracy z nimi, nowe warstwy abstrakcji nad wątkami systemu operacyjnego. Pracując nad konstrukcją abstrakcji, twórcy frameworków stosują podejście, które pozostawia możliwość, w przypadku korzystania z abstrakcji wysokiego poziomu, zejścia o jeden lub więcej poziomów poniżej. Najczęściej nie jest to konieczne, wręcz otwiera drzwi do strzelenia sobie w stopę ze strzelby, ale czasami, w rzadkich przypadkach, może to być jedyny sposób na rozwiązanie problemu, którego na obecnym poziomie abstrakcji nie da się rozwiązać .

Przez narzędzia mam na myśli zarówno interfejsy programowania aplikacji (API) dostarczane przez framework i pakiety firm trzecich, jak i całe rozwiązania programowe, które ułatwiają wyszukiwanie wszelkich problemów związanych z kodem wielowątkowym.

Rozpoczęcie wątku

Klasa Thread jest najbardziej podstawową klasą w platformie .NET do pracy z wątkami. Konstruktor akceptuje jednego z dwóch delegatów:

  • ThreadStart — Brak parametrów
  • ParametrizedThreadStart - z jednym parametrem typu obiekt.

Delegat zostanie wykonany w nowo utworzonym wątku po wywołaniu metody Start.Jeżeli do konstruktora został przekazany delegat typu ParametrizedThreadStart to do metody Start należy przekazać obiekt. Mechanizm ten jest niezbędny do przesyłania wszelkich informacji lokalnych do strumienia. Warto zaznaczyć, że utworzenie wątku jest kosztowną operacją, a sam wątek jest ciężkim obiektem, przynajmniej dlatego, że alokuje 1MB pamięci na stosie i wymaga interakcji z API systemu operacyjnego.

new Thread(...).Start(...);

Klasa ThreadPool reprezentuje koncepcję puli. W platformie .NET pula wątków jest dziełem inżynierii, a programiści w firmie Microsoft włożyli wiele wysiłku w zapewnienie jej optymalnego działania w wielu różnych scenariuszach.

Ogólna koncepcja:

Od momentu uruchomienia aplikacja tworzy w tle kilka wątków rezerwowych i udostępnia możliwość ich wykorzystania. Jeśli wątki są używane często i w dużych ilościach, pula rozszerza się w celu zaspokojenia potrzeb osoby wywołującej. Gdy w puli w odpowiednim momencie nie będzie wolnych wątków, albo zaczeka na powrót jednego z wątków, albo utworzy nowy. Wynika z tego, że pula wątków świetnie nadaje się do niektórych krótkotrwałych działań i słabo nadaje się do operacji, które działają jako usługi przez cały czas działania aplikacji.

Aby użyć wątku z puli, istnieje metoda QueueUserWorkItem, która akceptuje delegata typu WaitCallback, który ma taki sam podpis jak ParametrizedThreadStart, a przekazany do niego parametr pełni tę samą funkcję.

ThreadPool.QueueUserWorkItem(...);

Mniej znana metoda puli wątków RegisterWaitForSingleObject służy do organizowania nieblokujących operacji IO. Delegat przekazany do tej metody zostanie wywołany, gdy WaitHandle przekazany do metody ma wartość „Zwolniony”.

ThreadPool.RegisterWaitForSingleObject(...)

.NET ma licznik czasu wątku i różni się od liczników czasu WinForms/WPF tym, że jego procedura obsługi zostanie wywołana w wątku pobranym z puli.

System.Threading.Timer

Istnieje także dość egzotyczny sposób wysłania delegata do wykonania do wątku z puli – metoda BeginInvoke.

DelegateInstance.BeginInvoke

Chciałbym pokrótce zatrzymać się nad funkcją, do której można wywołać wiele z powyższych metod - CreateThread z Kernel32.dll Win32 API. Istnieje możliwość, dzięki mechanizmowi metod zewnętrznych, wywołania tej funkcji. Takie wywołanie widziałem tylko raz w okropnym przykładzie starszego kodu, a motywacja autora, który dokładnie to zrobił, nadal pozostaje dla mnie zagadką.

Kernel32.dll CreateThread

Przeglądanie i debugowanie wątków

Wątki utworzone przez Ciebie, wszystkie komponenty innych firm i pulę .NET można przeglądać w oknie wątki programu Visual Studio. To okno będzie wyświetlać informacje o wątku tylko wtedy, gdy aplikacja jest w trakcie debugowania i w trybie przerwania. Tutaj możesz wygodnie przeglądać nazwy stosów i priorytety każdego wątku oraz przełączyć debugowanie na konkretny wątek. Korzystając z właściwości Priority klasy Thread, możesz ustawić priorytet wątku, który OC i CLR uznają za zalecenie podczas dzielenia czasu procesora pomiędzy wątki.

.NET: Narzędzia do pracy z wielowątkowością i asynchronią. Część 1

Biblioteka równoległa zadań

Biblioteka zadań równoległych (TPL) została wprowadzona w platformie .NET 4.0. Teraz jest to standard i główne narzędzie do pracy z asynchronią. Każdy kod korzystający ze starszego podejścia jest uważany za starszy. Podstawową jednostką TPL jest klasa Task z przestrzeni nazw System.Threading.Tasks. Zadanie to abstrakcja na wątku. Dzięki nowej wersji języka C# otrzymaliśmy elegancki sposób pracy z zadaniami - operatory asynchroniczne/await. Koncepcje te umożliwiły pisanie kodu asynchronicznego tak, jakby był prosty i synchroniczny, co umożliwiło nawet osobom z niewielkim zrozumieniem wewnętrznego działania wątków pisanie aplikacji, które z nich korzystają, aplikacji, które nie zawieszają się podczas wykonywania długich operacji. Używanie async/await to temat na jeden lub nawet kilka artykułów, ale postaram się ująć jego istotę w kilku zdaniach:

  • async jest modyfikatorem metody zwracającej Task lub void
  • i Wait jest nieblokującym operatorem oczekującym na zadanie.

Jeszcze raz: operator oczekujący w ogólnym przypadku (są wyjątki) zwolni dalej bieżący wątek wykonania, a gdy Zadanie zakończy jego wykonywanie, i wątek (właściwie wypadałoby powiedzieć kontekst , ale o tym później) będzie kontynuować wykonywanie metody. Wewnątrz .NET mechanizm ten jest zaimplementowany w taki sam sposób jak return return, gdy napisana metoda zamienia się w całą klasę, która jest maszyną stanów i może być wykonywana w oddzielnych fragmentach w zależności od tych stanów. Każdy zainteresowany może napisać dowolny prosty kod przy użyciu asynс/await, skompilować i wyświetlić złożenie za pomocą JetBrains dotPeek z włączoną funkcją Compiler Generated Code.

Przyjrzyjmy się opcjom uruchamiania i używania zadania. W poniższym przykładzie kodu tworzymy nowe zadanie, które nie robi nic przydatnego (Wątek. Uśpienie (10000)), ale w prawdziwym życiu powinna to być skomplikowana praca intensywnie obciążająca procesor.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Tworzone jest zadanie z wieloma opcjami:

  • LongRunning jest wskazówką, że zadanie nie zostanie szybko wykonane, dlatego może warto rozważyć nie branie wątku z puli, ale utworzenie osobnego dla tego Zadania, aby nie szkodzić innym.
  • ApplyedToParent — zadania można układać w hierarchię. Jeżeli użyto tej opcji, to Zadanie może znajdować się w stanie, w którym samo się zakończyło i oczekuje na wykonanie swoich dzieci.
  • PreferFairness - oznacza, że ​​lepiej będzie wykonać Zadania wysłane do realizacji wcześniej niż te wysłane później. Jest to jednak tylko zalecenie, a wyniki nie są gwarantowane.

Drugim parametrem przekazywanym do metody jest CancellationToken. Aby poprawnie obsłużyć anulowanie operacji po jej rozpoczęciu, wykonywany kod musi zostać wypełniony sprawdzeniem stanu CancellationToken. Jeżeli nie ma żadnych kontroli, to metoda Cancel wywołana na obiekcie CancellationTokenSource będzie mogła zatrzymać wykonanie zadania dopiero przed jego rozpoczęciem.

Ostatnim parametrem jest obiekt harmonogramu typu TaskScheduler. Ta klasa i jej potomkowie służą do kontrolowania strategii dystrybucji zadań między wątkami; domyślnie zadanie zostanie wykonane w losowym wątku z puli.

Operator Wait jest stosowany do utworzonego zadania, co oznacza, że ​​kod zapisany po nim, jeśli taki istnieje, zostanie wykonany w tym samym kontekście (często oznacza to w tym samym wątku), co kod przed oczekiwaniem.

Metoda jest oznaczona jako async void, co oznacza, że ​​może skorzystać z operatora Wait, ale kod wywołujący nie będzie mógł czekać na wykonanie. Jeśli taka funkcja jest konieczna, to metoda musi zwrócić Task. Metody oznaczone jako async void są dość powszechne: z reguły są to procedury obsługi zdarzeń lub inne metody działające na zasadzie „odpal i zapomnij”. Jeśli chcesz nie tylko dać możliwość poczekania do końca wykonania, ale także zwrócić wynik, musisz użyć Zadania.

Na zadaniu, które zwróciła metoda StartNew, jak i na każdym innym, możesz wywołać metodę ConfigureAwait z parametrem false, wówczas wykonanie po oczekiwaniu będzie kontynuowane nie na przechwyconym kontekście, ale na dowolnym. Należy to zawsze robić, gdy kontekst wykonania nie jest ważny dla kodu po oczekiwaniu. Jest to również zalecenie MS dotyczące pisania kodu, który będzie dostarczany w formie spakowanej w bibliotece.

Zatrzymajmy się trochę więcej na temat tego, jak możesz poczekać na zakończenie zadania. Poniżej znajduje się przykład kodu z komentarzami dotyczącymi tego, kiedy oczekiwanie zostało zrealizowane warunkowo dobrze, a kiedy zostało zrealizowane warunkowo słabo.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

W pierwszym przykładzie czekamy na zakończenie Zadania bez blokowania wątku wywołującego; do przetwarzania wyniku wrócimy dopiero wtedy, gdy już on będzie; do tego czasu wątek wywołujący pozostawiony jest własnym urządzeniom.

W drugiej opcji blokujemy wątek wywołujący do czasu obliczenia wyniku metody. Jest to złe nie tylko dlatego, że zajęliśmy wątek, tak cenny zasób programu, zwykłą bezczynnością, ale także dlatego, że jeśli kod wywoływanej przez nas metody zawiera Wait, a kontekst synchronizacji wymaga powrotu po czekaj, wtedy otrzymamy zakleszczenie: Wątek wywołujący czeka na obliczenie wyniku metody asynchronicznej, metoda asynchroniczna na próżno próbuje kontynuować wykonywanie w wątku wywołującym.

Kolejną wadą tego podejścia jest skomplikowana obsługa błędów. Faktem jest, że błędy w kodzie asynchronicznym podczas używania async/await są bardzo łatwe do naprawienia - zachowują się tak samo, jakby kod był synchroniczny. Jeśli zastosujemy synchroniczny egzorcyzm oczekiwania do zadania, oryginalny wyjątek zamienia się w wyjątek AggregateException, tj. Aby obsłużyć wyjątek, będziesz musiał sprawdzić typ InnerException i napisać łańcuch if w jednym bloku catch lub użyć catch podczas konstruowania zamiast łańcucha bloków catch, który jest bardziej znany w świecie C#.

Trzeci i ostatni przykład również został oznaczony jako zły z tego samego powodu i zawierał te same problemy.

Metody WhenAny i WhenAll są niezwykle wygodne do oczekiwania na grupę Zadań; łączą grupę Zadań w jedno, które zostanie uruchomione albo po pierwszym uruchomieniu zadania z grupy, albo po zakończeniu wykonywania wszystkich zadań.

Zatrzymywanie wątków

Z różnych powodów może zaistnieć konieczność zatrzymania przepływu po jego rozpoczęciu. Można to zrobić na wiele sposobów. Klasa Thread posiada dwie odpowiednio nazwane metody: poronienie и Przerwać. Tego pierwszego zdecydowanie nie zaleca się stosować, gdyż po wywołaniu jej w dowolnym losowym momencie, podczas przetwarzania dowolnej instrukcji zostanie zgłoszony wyjątek Wyjątek ThreadAborted. Nie spodziewasz się, że taki wyjątek zostanie zgłoszony podczas zwiększania dowolnej zmiennej całkowitej, prawda? A przy stosowaniu tej metody jest to bardzo realna sytuacja. Jeśli chcesz uniemożliwić środowisku CLR generowanie takiego wyjątku w określonej sekcji kodu, możesz zawinąć go w wywołania Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Dowolny kod zapisany w bloku final jest opakowany w takie wywołania. Z tego powodu w głębi kodu frameworka można znaleźć bloki z pustą próbą, ale nie pustą finalnie. Microsoft tak bardzo odradza tę metodę, że nie uwzględnił jej w .net core.

Metoda przerwania działa bardziej przewidywalnie. Może przerwać wątek z wyjątkiem Wyjątek ThreadInterrupted tylko w tych momentach, gdy wątek jest w stanie oczekiwania. Przechodzi w ten stan podczas oczekiwania na WaitHandle, zablokowanie lub po wywołaniu Thread.Sleep.

Obie opcje opisane powyżej są złe ze względu na swoją nieprzewidywalność. Rozwiązaniem jest użycie struktury Token anulowania i klasa Źródło tokenu anulowania. Rzecz w tym, że tworzona jest instancja klasy CancellationTokenSource i tylko jej właściciel może przerwać operację wywołując metodę Anuluj. Do samej operacji przekazywany jest tylko token anulowania. Właściciele CancellationToken nie mogą sami anulować operacji, mogą jedynie sprawdzić, czy operacja została anulowana. Istnieje do tego właściwość Boolean Jest żądanie anulowania i metoda Żądanie ThrowIfCancel. Ten ostatni zgłosi wyjątek ZadanieAnulowaneWyjątek jeśli metoda Cancel została wywołana w papugowanej instancji CancellationToken. I to jest metoda, którą polecam stosować. Jest to ulepszenie w stosunku do poprzednich opcji, polegające na uzyskaniu pełnej kontroli nad tym, w którym momencie można przerwać operację wyjątku.

Najbardziej brutalną opcją zatrzymania wątku jest wywołanie funkcji Win32 API TerminateThread. Zachowanie środowiska CLR po wywołaniu tej funkcji może być nieprzewidywalne. W MSDN napisano o tej funkcji co następuje: „TerminateThread to niebezpieczna funkcja, której należy używać tylko w najbardziej ekstremalnych przypadkach. „

Konwertowanie starszego interfejsu API na oparty na zadaniach przy użyciu metody FromAsync

Jeśli będziesz miał szczęście pracować nad projektem, który został rozpoczęty po wprowadzeniu Zadań i przestał powodować cichą grozę dla większości programistów, nie będziesz musiał zajmować się wieloma starymi interfejsami API, zarówno zewnętrznymi, jak i tymi, które tworzy Twój zespół torturował w przeszłości. Na szczęście zaopiekował się nami zespół .NET Framework, chociaż być może celem było zadbanie o nas samych. Tak czy inaczej, .NET posiada szereg narzędzi do bezbolesnej konwersji kodu napisanego w starych podejściach do programowania asynchronicznego na nowy. Jedną z nich jest metoda FromAsync klasy TaskFactory. W poniższym przykładzie kodu zawijam stare metody asynchroniczne klasy WebRequest w zadanie przy użyciu tej metody.

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

To tylko przykład i jest mało prawdopodobne, że będziesz musiał to robić w przypadku typów wbudowanych, ale w każdym starym projekcie po prostu roi się od metod BeginDoSomething, które zwracają metody IAsyncResult i EndDoSomething, które je odbierają.

Konwertuj starszy interfejs API na oparty na zadaniach przy użyciu klasy TaskCompletionSource

Kolejnym ważnym narzędziem, które należy wziąć pod uwagę, jest klasa Źródło zakończenia zadania. Pod względem funkcji, przeznaczenia i zasady działania może przypominać nieco metodę RegisterWaitForSingleObject klasy ThreadPool, o której pisałem powyżej. Korzystając z tej klasy, możesz łatwo i wygodnie zawijać stare asynchroniczne interfejsy API w zadaniach.

Powiesz, że mówiłem już o przeznaczonej do tego celu metodzie FromAsync klasy TaskFactory. W tym miejscu będziemy musieli przypomnieć sobie całą historię rozwoju modeli asynchronicznych w .net, którą Microsoft oferował przez ostatnie 15 lat: przed wzorcem asynchronicznym opartym na zadaniach (TAP) istniał wzorzec programowania asynchronicznego (APP), który chodziło o metody RozpocząćZrób coś, co wróci Wynik IAsync i metody KoniecDoSomething, który to akceptuje, a na dziedzictwo tych lat metoda FromAsync jest po prostu idealna, ale z biegiem czasu została zastąpiona wzorcem asynchronicznym opartym na zdarzeniach (EAP), który zakładał, że zdarzenie zostanie zgłoszone po zakończeniu operacji asynchronicznej.

TaskCompletionSource doskonale nadaje się do opakowywania zadań i starszych interfejsów API zbudowanych wokół modelu zdarzeń. Istota jego działania jest następująca: obiekt tej klasy posiada publiczną właściwość typu Task, której stan można kontrolować za pomocą metod SetResult, SetException itp. klasy TaskCompletionSource. W miejscach, w których zastosowano operator oczekujący do tego zadania, zostanie on wykonany lub zakończy się niepowodzeniem z wyjątkiem, w zależności od metody zastosowanej do źródła TaskCompletionSource. Jeśli nadal nie jest to jasne, spójrzmy na przykładowy kod, w którym niektóre stare interfejsy API EAP są opakowane w zadanie przy użyciu elementu TaskCompletionSource: po uruchomieniu zdarzenia zadanie zostanie przeniesione do stanu Zakończono, a metoda, w której zastosowano operator Wait do tego Zadania wznowi swoje wykonanie po otrzymaniu obiektu dalsze.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Zakończenie zadaniaŹródło porad i wskazówek

Zawijanie starych interfejsów API to nie wszystko, co można zrobić za pomocą TaskCompletionSource. Użycie tej klasy otwiera interesującą możliwość projektowania różnych API na Zadaniach, które nie zajmują wątków. A strumień, jak pamiętamy, jest drogim zasobem, a ich liczba jest ograniczona (głównie ilością pamięci RAM). To ograniczenie można łatwo osiągnąć tworząc np. załadowaną aplikację webową ze złożoną logiką biznesową. Rozważmy możliwości, o których mówię, wdrażając taką sztuczkę jak Long-Polling.

Krótko mówiąc, istota sztuczki jest taka: musisz otrzymać informację od API o jakichś zdarzeniach zachodzących po jego stronie, podczas gdy API z jakiegoś powodu nie może zgłosić zdarzenia, a jedynie zwrócić stan. Przykładem są wszystkie interfejsy API zbudowane na bazie protokołu HTTP przed czasami WebSocket lub gdy z jakiegoś powodu nie było możliwe użycie tej technologii. Klient może zadać pytanie serwerowi HTTP. Serwer HTTP nie może sam zainicjować komunikacji z klientem. Prostym rozwiązaniem jest odpytywanie serwera za pomocą timera, ale powoduje to dodatkowe obciążenie serwera i dodatkowe opóźnienie średnio TimerInterval / 2. Aby obejść ten problem, wymyślono sztuczkę o nazwie Long Polling, która polega na opóźnianiu odpowiedzi z serwerze do momentu upłynięcia limitu czasu lub wystąpienia zdarzenia. Jeżeli zdarzenie wystąpiło to zostaje ono zrealizowane, jeżeli nie to żądanie zostaje wysłane ponownie.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Ale takie rozwiązanie okaże się fatalne, gdy tylko zwiększy się liczba klientów oczekujących na wydarzenie, bo... Każdy taki klient zajmuje cały wątek oczekując na zdarzenie. Tak i dostajemy dodatkowe opóźnienie 1ms w momencie wywołania zdarzenia, najczęściej nie jest to istotne, ale po co robić oprogramowanie gorsze niż to możliwe? Jeśli usuniemy Thread.Sleep(1), to na próżno będziemy ładować jeden rdzeń procesora w 100% bezczynny, obracając się w bezużytecznym cyklu. Używając TaskCompletionSource, możesz łatwo przerobić ten kod i rozwiązać wszystkie problemy zidentyfikowane powyżej:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Ten kod nie jest gotowy do wersji produkcyjnej, a jedynie wersję demonstracyjną. Aby móc z niej skorzystać w rzeczywistych przypadkach, trzeba także przynajmniej poradzić sobie z sytuacją, gdy wiadomość dotrze w momencie, gdy nikt się jej nie spodziewa: w tym przypadku metoda AsseptMessageAsync powinna zwrócić już ukończone zadanie. Jeśli jest to najczęstszy przypadek, możesz pomyśleć o użyciu ValueTask.

Gdy otrzymamy żądanie wiadomości, tworzymy i umieszczamy w słowniku element TaskCompletionSource, a następnie czekamy, co stanie się najpierw: upłynie określony przedział czasu lub zostanie odebrana wiadomość.

WartośćZadanie: dlaczego i jak

Operatory async/await, podobnie jak operator zwrotu plonu, generują maszynę stanu na podstawie metody i jest to utworzenie nowego obiektu, co prawie zawsze nie jest ważne, ale w rzadkich przypadkach może powodować problem. W tym przypadku może to być metoda wywoływana naprawdę często, mówimy o dziesiątkach i setkach tysięcy wywołań na sekundę. Jeżeli taka metoda jest napisana w taki sposób, że w większości przypadków zwraca wynik z pominięciem wszystkich metod oczekujących, to .NET udostępnia narzędzie pozwalające to zoptymalizować – strukturę ValueTask. Żeby było jasne, spójrzmy na przykład jego użycia: istnieje skrytka, do której bardzo często zaglądamy. Są w nim pewne wartości i wtedy je po prostu zwracamy; jeśli nie, to idziemy do jakiegoś powolnego IO, żeby je zdobyć. Chcę to zrobić asynchronicznie, co oznacza, że ​​cała metoda okazuje się asynchroniczna. Zatem oczywisty sposób napisania metody jest następujący:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Ze względu na chęć niewielkiej optymalizacji i lekką obawę przed tym, co wygeneruje Roslyn podczas kompilacji tego kodu, możesz przepisać ten przykład w następujący sposób:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Rzeczywiście optymalnym rozwiązaniem w tym przypadku byłaby optymalizacja gorącej ścieżki, a mianowicie uzyskanie wartości ze słownika bez zbędnych alokacji i obciążenia GC, natomiast w tych rzadkich przypadkach, gdy nadal musimy udać się do IO po dane , wszystko pozostanie plus/minus w starym stylu:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Przyjrzyjmy się bliżej temu fragmentowi kodu: jeśli w pamięci podręcznej znajduje się wartość, tworzymy strukturę, w przeciwnym razie prawdziwe zadanie zostanie opakowane w sensowną. Kod wywołujący nie ma znaczenia, w której ścieżce ten kod został wykonany: ValueTask, z punktu widzenia składni języka C#, będzie w tym przypadku zachowywał się tak samo jak zwykłe zadanie.

TaskSchedulers: zarządzanie strategiami uruchamiania zadań

Kolejnym interfejsem API, który chciałbym rozważyć, jest klasa Harmonogram zadań i jego pochodne. Wspomniałem już powyżej, że TPL ma możliwość zarządzania strategiami dystrybucji Zadań pomiędzy wątkami. Takie strategie są zdefiniowane w potomkach klasy TaskScheduler. Prawie każdą strategię, której możesz potrzebować, można znaleźć w bibliotece. Dodatki dotyczące rozszerzeń równoległych, opracowany przez firmę Microsoft, ale nie będący częścią .NET, ale dostarczany jako pakiet Nuget. Przyjrzyjmy się pokrótce niektórym z nich:

  • Harmonogram zadań CurrentThreadTask — wykonuje zadania w bieżącym wątku
  • Harmonogram zadań LimitedConcurrencyLevelTask — ogranicza liczbę Zadań wykonywanych jednocześnie przez parametr N, który jest akceptowany w konstruktorze
  • Zamówiony harmonogram zadań — jest zdefiniowany jako LimitedConcurrencyLevelTaskScheduler(1), więc zadania będą wykonywane sekwencyjnie.
  • Harmonogram zadań kradzieży pracy - narzędzia kradzież pracy podejście do podziału zadań. Zasadniczo jest to oddzielny ThreadPool. Rozwiązuje problem polegający na tym, że w .NET ThreadPool jest to klasa statyczna, jedna dla wszystkich aplikacji, co oznacza, że ​​jej przeciążenie lub nieprawidłowe użycie w jednej części programu może prowadzić do skutków ubocznych w innej. Co więcej, niezwykle trudno jest zrozumieć przyczynę takich wad. To. Może zaistnieć potrzeba użycia oddzielnych obiektów WorkStealingTaskSchedulers w częściach programu, w których użycie ThreadPool może być agresywne i nieprzewidywalne.
  • Harmonogram zadań w kolejce — umożliwia wykonywanie zadań zgodnie z regułami kolejki priorytetowej
  • Harmonogram ThreadPerTask — tworzy oddzielny wątek dla każdego zadania, które jest w nim wykonywane. Może być przydatny w przypadku zadań, których wykonanie zajmuje nieprzewidywalnie dużo czasu.

Jest dobry szczegółowy artykuł o TaskSchedulers na blogu Microsoft.

W celu wygodnego debugowania wszystkiego, co jest związane z zadaniami, w programie Visual Studio dostępne jest okno Zadania. W tym oknie możesz zobaczyć aktualny stan zadania i przejść do aktualnie wykonywanej linijki kodu.

.NET: Narzędzia do pracy z wielowątkowością i asynchronią. Część 1

PLinq i klasa Parallel

Oprócz zadań i wszystkiego, co o nich powiedziano, w .NET znajdują się jeszcze dwa interesujące narzędzia: PLinq (Linq2Parallel) i klasa Parallel. Pierwsza obiecuje równoległe wykonywanie wszystkich operacji Linq na wielu wątkach. Liczbę wątków można skonfigurować przy użyciu metody rozszerzenia WithDegreeOfParallelism. Niestety, najczęściej PLinq w trybie domyślnym nie ma wystarczających informacji o wewnętrznych elementach źródła danych, aby zapewnić znaczny przyrost prędkości, z drugiej strony koszt wypróbowania jest bardzo niski: wystarczy wcześniej wywołać metodę AsParallel łańcuch metod Linq i uruchamiaj testy wydajnościowe. Co więcej, istnieje możliwość przekazania do PLinq dodatkowych informacji o charakterze Twojego źródła danych za pomocą mechanizmu Partycji. Możesz przeczytać więcej tutaj и tutaj.

Klasa statyczna Parallel udostępnia metody równoległego wykonywania iteracji w kolekcji Foreach, wykonywania pętli For i wykonywania wielu delegatów w trybie równoległym Invoke. Wykonywanie bieżącego wątku zostanie zatrzymane do czasu zakończenia obliczeń. Liczbę wątków można skonfigurować, przekazując ParallelOptions jako ostatni argument. Możesz także określić TaskScheduler i CancellationToken za pomocą opcji.

odkrycia

Kiedy zaczynałem pisać ten artykuł w oparciu o materiały mojego reportażu i informacje, które zebrałem w trakcie pracy po nim, nie spodziewałem się, że będzie tego tak dużo. Teraz, gdy edytor tekstu, w którym piszę ten artykuł, z wyrzutem powie mi, że strona 15 zniknęła, podsumuję tymczasowe wyniki. Inne triki, interfejsy API, narzędzia wizualne i pułapki zostaną omówione w następnym artykule.

Wnioski:

  • Aby móc korzystać z zasobów współczesnych komputerów PC, trzeba znać narzędzia do pracy z wątkami, asynchronią i równoległością.
  • .NET ma wiele różnych narzędzi do tych celów
  • Nie wszystkie pojawiły się od razu, więc często można znaleźć starsze, jednak istnieją sposoby na konwersję starych API bez większego wysiłku.
  • Praca z wątkami w .NET jest reprezentowana przez klasy Thread i ThreadPool
  • Metody Thread.Abort, Thread.Interrupt i Win32 API TerminateThread są niebezpieczne i nie zaleca się ich używania. Zamiast tego lepiej skorzystać z mechanizmu CancellationToken
  • Przepływ jest cennym zasobem, a jego podaż jest ograniczona. Należy unikać sytuacji, w których wątki są zajęte oczekiwaniem na zdarzenia. W tym celu wygodnie jest użyć klasy TaskCompletionSource
  • Najpotężniejszymi i najbardziej zaawansowanymi narzędziami .NET do pracy z równoległością i asynchronią są Zadania.
  • Operatory asynchroniczne/await w języku c# implementują koncepcję nieblokującego oczekiwania
  • Można kontrolować dystrybucję zadań między wątkami przy użyciu klas pochodnych TaskScheduler
  • Struktura ValueTask może być przydatna w optymalizacji gorących ścieżek i ruchu pamięci
  • Okna Zadania i wątki programu Visual Studio zawierają wiele informacji przydatnych do debugowania kodu wielowątkowego lub asynchronicznego
  • PLinq to fajne narzędzie, ale może nie zawierać wystarczających informacji o źródle danych, ale można to naprawić za pomocą mechanizmu partycjonowania
  • To be continued ...

Źródło: www.habr.com

Dodaj komentarz