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

Publikuję na Habr oryginał artykułu, którego tłumaczenie zostało umieszczone w firmowym blogu.

Konieczność robienia czegoś asynchronicznie, nie czekając na wyniki tu i teraz, czy dzielenie dużej pracy między kilka jednostek wykonawczych istniała już przed pojawieniem się komputerów. Z ich pojawieniem się ta potrzeba stała się bardzo odczuwalna. Obecnie, w 2019 roku, pisząc ten artykuł na laptopie z ośmiordzeniowym procesorem Intel Core, który równocześnie wykonuje nie jedną setkę procesów, a wątków nawet więcej. Obok leży już nieco zużyty, kupiony kilka lat temu telefon, który ma na pokładzie ośmiordzeniowy procesor. Na tematycznych zasobach jest mnóstwo artykułów i filmów, w których ich autorzy zachwycają się flagowymi smartfonami tego roku, które mają procesory szesnasto-rdzeniowe. MS Azure oferuje wirtualną maszynę z procesorem 128-rdzeniowym i 2 TB RAM za mniej niż 20$/godz. Na nieszczęście, nie można wydobyć maksimum i okiełznać tej mocy nie umiejąc zarządzać interakcją wątków.

Terminologia

Proces (Process) — obiekt OS, izolowana przestrzeń adresowa, zawiera wątki.
Wątek (Thread) — obiekt OS, najmniejsza jednostka wykonawcza, część procesu, wątki dzielą pamięć i inne zasoby między sobą w ramach procesu.
Wielozadaniowość — cecha OS, możliwość jednoczesnego wykonywania wielu procesów
Wielordzeniowość — cecha procesora, możliwość wykorzystania kilku rdzeni do przetwarzania danych
Wieloprocesorowość — cecha komputera, możliwość jednoczesnego działania z wieloma procesorami fizycznie
Wielowątkowość — cecha procesu, możliwość rozdzielania przetwarzania danych między wieloma wątkami.
Równoległość — wykonanie kilku działań fizycznie jednocześnie w jednostce czasu
Asynchroniczność — wykonanie operacji bez czekania na zakończenie przetwarzania, natomiast wynik wykonania może być przetworzony później.

Metafora

Nie wszystkie definicje są dobre i niektóre wymagają dodatkowego wyjaśnienia, dlatego do formalnie wprowadzonej terminologii dodam metaforę o przygotowywaniu śniadania. Przygotowanie śniadania w tej metaforze — process.

Przygotowując śniadanie rano ja (CPU) przychodzę do kuchni (Komputer). Mam 2 ręce (Rdzenie). W kuchni jest szereg 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, Non-Blocking-IO-Wait), wyciągam z lodówki jaja i wbijam je do miski, po czym ubijam jedną ręką (Wątek#1), a drugą (Wątek#2) trzymam miskę (Zasób współdzielony). Teraz byłoby jeszcze miło włączyć czajnik, ale rąk brakuje (Thread Starvation) W tym czasie rozgrzewa się patelnia (Przetwarzanie wyniku), na którą wylewam to, co ubite. Sięgam po czajnik i włączam go, a głupio patrzę, jak woda w nim się gotuje (Blocking-IO-Wait), chociaż mógłbym w tym czasie umyć miskę, w której ubijałem omlet.

Przygotowałem omlet używając tylko dwóch rąk, a nie mam więcej, ale w trakcie ubijania omletu miały miejsce jednocześnie 3 operacje: ubijanie omletu, trzymanie miski, rozgrzewanie patelni. CPU to najszybsza część komputera, IO to to, co często spowalnia, dlatego często efektywnym rozwiązaniem jest zajmowanie CPU podczas oczekiwania na dane z IO.

Kontynuując metaforę:

  • Gdybym podczas gotowania omleta jeszcze próbował się przebrać, byłby to przykład wielozadaniowości. Ważny szczegół: komputery radzą sobie z tym lepiej niż ludzie.
  • Kuchnia z wieloma kucharzami, na przykład w restauracji — to komputer wielordzeniowy.
  • Wiele restauracji na food courcie w centrum handlowym — to data center

Narzędzia .NET

W pracy z wątkami, tak jak w wielu innych aspektach, .NET jest dobry. Z każdą nową wersją wprowadza coraz więcej nowych narzędzi do pracy z nimi, nowe warstwy abstrakcji nad wątkami OS. W tworzeniu abstrakcji deweloperzy frameworka stosują podejście, które pozwala przy korzystaniu z wysokopoziomowej abstrakcji zejść na jeden lub kilka poziomów niżej. Najczęściej nie ma takiej potrzeby, wręcz przeciwnie, otwiera to możliwość strzału sobie w stopę z strzelby, ale czasami, w rzadkich przypadkach, może to okazać się jedynym sposobem na rozwiązanie problemu, który nie został rozwiązany na bieżącym poziomie abstrakcji.

Pod narzędziami mam na myśli zarówno interfejsy programowania aplikacji (API) dostarczane przez framework oraz zewnętrzne pakiety, jak i całe rozwiązania programowe ułatwiające poszukiwanie jakichkolwiek problemów związanych z kodem wielowątkowym.

Uruchomienie wątku

Klasa Thread, najprostsza w .NET do pracy z wątkami. Konstruktor przyjmuje jeden z dwóch delegatów:

  • ThreadStart — bez parametrów
  • ParametrizedThreadStart — z jednym parametrem typu object.

Delegat będzie wykonywany w nowoutworzonym wątku po wywołaniu metody Start. Jeśli do konstruktora został przekazany delegat typu ParametrizedThreadStart, to w metodzie Start należy przekazać obiekt. Mechanizm ten służy do przekazywania lokalnych informacji do wątku. Warto zauważyć, że tworzenie wątku jest operacją kosztowną, a sam wątek to ciężki obiekt, ponieważ przydzielana jest co najmniej 1 MB pamięci na stos i wymaga interakcji z API systemu operacyjnego.

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

Klasa ThreadPool reprezentuje koncepcję puli. W .NET pula wątków to dzieło inżynieryjnej sztuki, a inżynierowie z Microsoftu włożyli wiele wysiłku, aby działała optymalnie w różnych scenariuszach.

Ogólna koncepcja:

Od momentu uruchomienia aplikacja w tle tworzy kilka wątków na zapas i umożliwia ich wykorzystanie. Jeśli wątki są często i w dużej liczbie używane, pula rozszerza się, aby zaspokoić zapotrzebowanie wywołującego kodu. Gdy w danym momencie w puli nie ma wolnych wątków, albo czeka na zwrócenie jednego z wątków, albo stworzy nowy. Z tego wynika, że pula wątków doskonale nadaje się do krótkich działań, a źle do operacji działających jako usługi przez cały czas działania aplikacji.

Aby skorzystać z wątku z puli, istnieje metoda QueueUserWorkItem, która przyjmuje delegat typu WaitCallback, co pod względem sygnatury odpowiada ParametrizedThreadStart, a przekazany do niej parametr pełni tę samą funkcję.

ThreadPool.QueueUserWorkItem(...);

Mniej znana metoda puli wątków RegisterWaitForSingleObject służy do organizacji nieblokujących operacji IO. Delegat przekazany do tej metody zostanie wywołany, gdy WaitHandle przekazany do metody zostanie „uwolniony” (Released).

ThreadPool.RegisterWaitForSingleObject(...);

W .NET istnieje timer wątkowy, który różni się od timerów WinForms/WPF tym, że jego handler będzie wywoływany w wątku z puli.

System.Threading.Timer

Istnieje również dość egzotyczny sposób wysłania delegata do wykonania w wątku z puli — metoda BeginInvoke.

DelegateInstance.BeginInvoke

Chciałbym jeszcze zająć się funkcją, do której odnoszą się wiele z powyższych metod — CreateThread z Kernel32.dll Win32 API. Istnieje sposób, aby za pomocą mechanizmu metod zewnętrznych wywołać tę funkcję. Taki sposób widziałem tylko raz w przerażającym przykładzie przestarzałego kodu, a motywacja autora, który to zrobił, wciąż pozostaje dla mnie zagadką.

Kernel32.dll CreateThread

Przeglądanie i debugowanie wątków

Wątki utworzone przez Ciebie osobiście, wszystkie komponenty zewnętrzne oraz pula wątków .NET można przeglądać w oknie Wątki Visual Studio. To okno wyświetli informacje o wątkach tylko wtedy, gdy aplikacja jest w trybie debugowania i zatrzymania (Break mode). Tutaj można wygodnie przeglądać stosy, nazwy i priorytety każdego wątku, a także przełączać debugowanie na konkretny wątek. Właściwość Priority klasy Thread pozwala ustawić priorytet wątku, który system operacyjny i CLR będą traktować jako zalecenie przy podziale czasu procesora między wątkami.

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

Biblioteka zadań równoległych

Biblioteka zadań równoległych (TPL) pojawiła się w .NET 4.0. Obecnie jest to standard i główne narzędzie do pracy z asynchronicznością. Każdy kod używający starszych podejść jest uważany za przestarzały. Podstawową jednostką TPL jest klasa Task z przestrzeni nazw System.Threading.Tasks. Task jest abstrakcją nad wątkiem. W nowej wersji języka C# otrzymaliśmy elegancki sposób pracy z zadaniami — operatory async/await. Te koncepcje pozwoliły pisać kod asynchroniczny tak, jakby był prosty i synchroniczny, co umożliwiło nawet osobom słabo rozumiejącym wewnętrzne zagadnienia wątków pisanie aplikacji ich używających, aplikacji, które nie zawieszają się podczas wykonywania długich operacji. Użycie async/await to temat na jeden lub nawet kilka artykułów, ale spróbuję w kilku zdaniach wyjaśnić sedno:

  • async to modyfikator metody, która zwraca Task lub void
  • a await to operator niezablokowanego oczekiwania na Task.

Jeszcze raz: operator await w ogólnym przypadku (są wyjątki) puści bieżący wątek dalszego wykonywania, a gdy Task zakończy swoje działanie, a wątek (właściwie poprawniej powiedzieć kontekst, ale o tym później) będzie wolny, kontynuuje wykonywanie metody. W .NET ten mechanizm jest realizowany tak samo jak yield return, gdy napisany metod jest przekształcany w cały klasę, która jest maszyną stanów i może być wykonywana w osobnych kawałkach w zależności od tych stanów. Kto jest zainteresowany, może napisać dowolny nieskomplikowany kod z użyciem async/await, skompilować go i przejrzeć zestaw plików za pomocą JetBrains dotPeek z włączonym kodem generowanym przez kompilator.

Rozważmy opcje uruchamiania i używania Task’a. Na przykładzie kodu poniżej tworzymy nowy task, który nic nie robi pożytecznego (Thread.Sleep(10000)), ale w rzeczywistości powinien to być bardziej złożony proces zajmujący CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

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

    await Task.Factory.StartNew(
        // Kod działania będzie wykonywany w innym kontekście
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    // Kod po await zostanie wykonany w uchwyconym kontekście
}

Task jest tworzony z szeregiem opcji:

  • LongRunning — wskazówka, że zadanie nie zostanie wykonane szybko, co oznacza, że być może warto pomyśleć o niebraniu wątku z puli, a stworzeniu osobnego dla tego Task’a, aby nie zaszkodzić innym.
  • AttachedToParent — Task’y mogą być układane w hierarchii. Jeśli użyto tej opcji, to Task może znajdować się w stanie, w którym sam został wykonany i czeka na zakończenie zadań podrzędnych.
  • PreferFairness — oznacza, że dobrze byłoby najpierw wykonać Task’i wysłane do realizacji wcześniej, przed tymi, które zostały wysłane później. Ale to tylko zalecenie i wynik nie jest gwarantowany.

Drugim parametrem przekazanym do metody jest CancellationToken. Aby poprawnie obsługiwać anulowanie operacji po jej uruchomieniu, kod wykonywalny powinien być wzbogacony o sprawdzenia stanu CancellationToken. Jeśli takich sprawdzeń nie ma, to wywołanie metody Cancel na obiekcie CancellationTokenSource może zatrzymać wykonanie Task’a tylko przed jego uruchomieniem.

Ostatnim parametrem jest obiekt scheduler typu TaskScheduler. Ta klasa i jej podklasy są przeznaczone do zarządzania strategiami rozdzielania Tasków między wątkami. Domyślnie Task zostanie wykonany na losowym wątku z puli.

Do stworzonego Taska zastosowano operator await, co oznacza, że kod napisany po nim, jeśli taki istnieje, zostanie wykonany w tym samym kontekście (co często oznacza, że na tym samym wątku), co kod przed await.

Metoda jest oznaczona jako async void, co oznacza, że dopuszcza użycie operatora await, ale wywołujący kod nie będzie mógł poczekać na jej zakończenie. Jeśli taka możliwość jest potrzebna, metoda powinna zwracać Task. Metody oznaczone jako async void występują dość często: zazwyczaj są to obsługiwacze zdarzeń lub inne metody działające na zasadzie wykonaj i zapomnij (fire and forget). Jeśli konieczne jest nie tylko umożliwienie czekania na zakończenie wykonania, ale także zwrócenie wyniku, należy używać Task.

Na Tasku, który zwraca metoda StartNew, można również wywołać metodę ConfigureAwait z parametrem false, aby kontynuować wykonanie po await nie w przechwyconym kontekście, ale na dowolnym. Należy to robić zawsze, gdy kontekst wykonania nie jest kluczowy dla kodu po await. Jest to również zalecenie od MS przy pisaniu kodu, który będzie dostarczany w formie pakietu biblioteki.

Przyjrzyjmy się jeszcze, jak można czekać na zakończenie wykonania Tasków. Poniżej znajduje się przykład kodu z komentarzami, gdy oczekiwanie jest zrealizowane względnie dobrze i względnie źle.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); \/\/ dobrze

    result = AsyncMethod().Result; \/\/ źle

    AsyncMethod().Wait(); \/\/ źle

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

    await Task.WhenAll(tasks); \/\/ dobrze
    await Task.WhenAny(tasks); \/\/ dobrze

    Task.WaitAll(tasks.ToArray()); \/\/ źle
}

W pierwszym przykładzie czekamy na wykonanie Taska, nie blokując wywołującego wątku, do przetworzenia wyniku wrócimy dopiero, gdy będzie on dostępny; do tego momentu wywołujący wątek pozostaje sam.

W drugim wariancie blokujemy wątek wywołujący do momentu obliczenia wyniku metody. Jest to złe nie tylko dlatego, że zatrzymaliśmy wątek, który jest cennym zasobem programu, w prostym bezczynności, ale także dlatego, że jeśli w kodzie metody, którą wywołujemy, znajduje się await, a kontekst synchronizacji zakłada powrót do wątku wywołującego po await, to otrzymamy deadlock: wątek wywołujący czeka, aż wynik asynchronicznej metody zostanie obliczony, a asynchroniczna metoda bezskutecznie stara się kontynuować swoje wykonanie w wątku wywołującym.

Kolejną wadą takiego podejścia jest skomplikowana obsługa błędów. Problem polega na tym, że błędy w kodzie asynchronicznym przy użyciu async/await są bardzo łatwe do obsługi – zachowują się tak, jakby kod był synchroniczny. Podczas gdy gdy stosujemy synchroniczne oczekiwanie na Task, oryginalne wyjątki są opakowywane w AggregateException, co oznacza, że w celu obsługi wyjątku musimy badać typ InnerException i sami pisać łańcuch if wewnątrz jednego bloku catch lub użyć konstrukcji catch when, zamiast bardziej znajomej w świecie C# łańcucha bloków catch.

Trzeci i ostatni przykład także jest oznaczony jako zły z tej samej przyczyny i zawiera te same problemy.

Metody WhenAny i WhenAll są niezwykle wygodne do oczekiwania na grupę Task’ów, opakowując grupę Task’ów w jeden, który zadziała albo przy pierwszym zadziałaniu Task’a z grupy, albo gdy swoje wykonanie zakończą wszystkie.

Zatrzymywanie wątków

Z różnych powodów może pojawić się konieczność zatrzymania wątku po jego uruchomieniu. W tym celu istnieje kilka sposobów. Klasa Thread ma dwie metody o odpowiednich nazwach — to są Przerwij i Interrupt. Pierwsza jest zdecydowanie odradzana do użycia, ponieważ po jej wywołaniu w dowolnym przypadku, w trakcie przetwarzania jakiejkolwiek instrukcji, zostanie zgłoszony wyjątek ThreadAbortedException. Nie oczekujesz, że taki wyjątek wystąpi podczas inkrementacji jakiejkolwiek zmiennej całkowitej, prawda? A przy użyciu tej metody to całkiem realna sytuacja. W razie potrzeby, aby zabronić CLR generować taki wyjątek w określonym fragmencie kodu, można go owinąć w wywołania Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Takie wywołania obiega każdy kod napisany w bloku finally. Z tego powodu w głębi kodu frameworka można znaleźć bliki z pustym try, ale nie pustym finally. Microsoft tak bardzo odradza stosowanie tej metody, że nie włączył jej do .net core.

Metoda Interrupt działa w sposób bardziej przewidywalny. Może przerwać wątek wyjątkiem. ThreadInterruptedException tylko w tych momentach, gdy wątek jest w stanie oczekiwania. Taki stan osiąga, wieszcząc w oczekiwaniu WaitHandle, lock lub po wywołaniu Thread.Sleep.

Oba opisane powyżej warianty są złe z powodu swojej nieprzewidywalności. Wyjściem jest użycie struktury CancellationToken i klasy CancellationTokenSource. Istota jest następująca: tworzony jest egzemplarz klasy CancellationTokenSource i tylko ten, kto nim dysponuje, może zatrzymać operację, wywołując metodę Cancel. W samej operacji przekazywany jest tylko CancellationToken. Właściciele CancellationToken nie mogą sami anulować operacji, mogą jedynie sprawdzić, czy operacja nie została anulowana. W tym celu istnieje właściwość boolowska IsCancellationRequested i metodę ThrowIfCancelRequested. Ostatni wygeneruje wyjątek TaskCancelledException jeśli na powiązanym z CancellationToken egzemplarzu CancellationTokenSource została wywołana metoda Cancel. I to właśnie tę metodę zalecam używać. Jest to lepsza opcja niż poprzednie, dając pełną kontrolę nad tym, w jakich momentach operacja może zostać przerwana.

Najbardziej drastycznym sposobem zatrzymania wątku jest wywołanie funkcji Win32 API TerminateThread. Zachowanie CLR po wywołaniu tej funkcji może być nieprzewidywalne. Na MSDN napisano o tej funkcji: „TerminateThread to niebezpieczna funkcja, która powinna być używana tylko w skrajnych przypadkach.”

Przekształcenie legacy-API w Task Based z pomocą metody FromAsync.

Jeśli masz szczęście pracować nad projektem, który został rozpoczęty po wprowadzeniu zadań i przestał budzić cichy strach wśród większości deweloperów, nie będziesz musiał zmagać się z wieloma starymi interfejsami API, zarówno tych od zewnętrznych firm, jak i stworzonych przez twój zespół w przeszłości. Na szczęście zespół zajmujący się rozwojem .NET Framework zadbał o nas, chociaż być może ich celem było zadbanie o siebie. Tak czy inaczej, w .NET dostępnych jest wiele narzędzi do bezbolesnej konwersji kodu napisanego w starych podejściach programowania asynchronicznego na nowe. Jednym z nich jest metoda FromAsync klasy TaskFactory. W poniższym przykładzie kodu owijam stare asynchroniczne metody klasy WebRequest w zadań za pomocą tej metody.

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

To tylko przykład i prawdopodobnie nie będziesz musiał robić tego z wbudowanymi typami, ale każdy stary projekt po prostu roi się od metod BeginDoSomething zwracających IAsyncResult i metod EndDoSomething je przyjmujących.

Konwersja interfejsu API legacy na model oparty na zadaniach za pomocą klasy TaskCompletionSource

Kolejnym ważnym narzędziem do rozważenia jest klasa TaskCompletionSource. Pod względem funkcji, przeznaczenia i zasady działania, przypomina metodę RegisterWaitForSingleObject klasy ThreadPool, o której pisałem wcześniej. Dzięki tej klasie można łatwo i wygodnie owinąć stare asynchroniczne interfejsy API w zadania.

Możesz powiedzieć, że już mówiłem o metodzie FromAsync klasy TaskFactory, która jest przeznaczona do tych celów. Tutaj musimy przypomnieć sobie całą historię rozwoju modeli asynchronicznych w .NET, które Microsoft oferował przez ostatnie 15 lat: przed Task-Based Asynchronous Pattern (TAP) istniał Asynchronous Programming Pattern (APP), który dotyczył metod BeginDoSomething, zwracających IAsyncResult i metod EndDoSomething, które je przyjmowały, a dla legacy tego okresu doskonale pasuje metoda FromAsync, ale z czasem, w jej miejsce, przyszedł Event Based Asynchronous Pattern (EAP), który zakładał, że po zakończeniu wykonywania operacji asynchronicznej zostanie wywołane zdarzenie.

TaskCompletionSource jest doskonałym rozwiązaniem do opakowywania zadań w legacy-API opartych na modelu zdarzeniowym. Jego działanie jest następujące: obiekt tej klasy ma publiczną właściwość typu Task, której stan można kontrolować za pomocą metod SetResult, SetException itp. Klasy TaskCompletionSource. W miejscach, gdzie zastosowano operator await dla tego zadania, zostanie ono zakończone lub spowoduje wyjątek w zależności od zastosowanej metody TaskCompletionSource. Jeśli nadal nie jest to jasne, spójrzmy na przykład kodu, w którym stare API z czasów EAP jest owinięte w Task za pomocą TaskCompletionSource: po wyzwoleniu zdarzenia zadanie zostanie przeniesione do stanu Completed, a metoda, która zastosowała operator await dla tego zadania, wznowi swoje wykonywanie, otrzymując obiekt. result.

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

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

    return completionSource.Task;
}

Wskazówki i triki dotyczące TaskCompletionSource

Opakowanie starych API to nie wszystko, co można zrobić za pomocą TaskCompletionSource. Użycie tej klasy otwiera ciekawe możliwości projektowania różnych API opartych na zadaniach, które nie zajmują wątków. A wątek, jak pamiętamy, jest drogim zasobem, a ich liczba jest ograniczona (głównie przez ilość pamięci RAM). Ograniczenie to można łatwo osiągnąć, opracowując na przykład obciążoną aplikację webową złożoną z skomplikowanej logiki biznesowej. Przyjrzyjmy się możliwościom, o których mówię, implementując taki trik jak Long-Polling.

W skrócie, sedno triku polega na tym, że musisz uzyskiwać informacje z API o pewnych zdarzeniach zachodzących po jego stronie, przy czym API z jakiegoś powodu nie może powiadomić o zdarzeniu, a może jedynie zwrócić stan. Przykładem takich przypadków są wszystkie API zbudowane na bazie HTTP przed czasami WebSocket lub w sytuacjach, gdy z jakiegoś powodu nie można skorzystać z tej technologii. Klient może zapytać serwer HTTP. Serwer HTTP nie może sam zainicjować komunikacji z klientem. Proste rozwiązanie to okresowe zapytania do serwera, ale to generuje dodatkowe obciążenie dla serwera i dodatkowe opóźnienie średnio wynoszące TimerInterval / 2. W celu ominięcia tego wymyślono trik znany jako Long Polling, który zakłada opóźnienie odpowiedzi od serwera do momentu, aż upłynie Timeout lub nastąpi zdarzenie. Jeśli zdarzenie miało miejsce, to jest ono przetwarzane, a jeśli nie, to zapytanie jest wysyłane ponownie.

while(!eventOccures && !timeoutExceeded) {

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

Jednak takie rozwiązanie sprawdzi się tragicznie, gdy liczba klientów oczekujących na zdarzenie wzrośnie, ponieważ każdy taki klient zajmuje jeden wątek w oczekiwaniu na zdarzenie. Dodatkowo mamy opóźnienie wynoszące 1ms przy wystąpieniu zdarzenia, co najczęściej nie jest istotne, ale po co robić oprogramowanie gorszym niż może być? Jeśli usuniemy Thread.Sleep(1), obciążymy jedno rdzeń procesora na 100% w bezsensownym cyklu. Używając TaskCompletionSource, można łatwo przekształcić ten kod i rozwiązać wszystkie wskazane powyżej problemy:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource> tasks;

    public async Task AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource();
        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 użytkowania w produkcji, a jedynie demonstracyjny. Aby korzystać w rzeczywistych przypadkach, należy co najmniej obsłużyć sytuację, gdy wiadomość przychodzi w momencie, gdy nikt jej nie oczekuje: w takim przypadku metoda AcceptMessageAsync powinna zwrócić już zakończony Task. Jeśli jednak ten przypadek jest najczęstszy, warto rozważyć użycie ValueTask.

Gdy otrzymujemy żądanie wiadomości, tworzymy i umieszczamy w słowniku TaskCompletionSource, a następnie czekamy na to, co stanie się pierwsze: upłynie określony czas czy zostanie odebrana wiadomość.

ValueTask: po co i jak

Operatory async/await oraz operator yield return generują z metody maszynę stanów, co oznacza tworzenie nowego obiektu, co zazwyczaj jest mało istotne, ale w rzadkich przypadkach może stanowić problem. Takim przypadkiem może być metoda wywoływana bardzo często, chodzi o dziesiątki i setki tysięcy wywołań na sekundę. Jeśli taka metoda jest napisana tak, że w większości przypadków zwraca wynik omijając wszystkie metody await, .NET zapewnia narzędzie, aby to zoptymalizować — struktura ValueTask. Aby to wyjaśnić, rozważmy przykład jej użycia: jest cache, do którego często wchodzimy. Jakieś wartości w nim są, więc po prostu je zwracamy, jeśli ich nie ma, to idziemy do jakiegoś wolnego IO po nie. Ostatnie chcemy robić asynchronicznie, więc cała metoda staje się asynchroniczna. Tak więc oczywistą opcją napisania metody jest następująca:

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

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

Z powodu chęci optymalizacji oraz lekkiego strachu przed tym, co wygeneruje Roslyn kompilując ten kod, można ten przykład przepisać następująco:

public Task<string> GetById(int id) {

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

Optymalnym rozwiązaniem w tym przypadku będzie optymalizacja ścieżki hot-path, a mianowicie uzyskanie wartości ze słownika całkowicie bez zbędnych alokacji i obciążenia GC, podczas gdy w tych rzadkich przypadkach, gdy będziemy jednak musieli sięgać do IO po dane, wszystko pozostanie mniej więcej po staremu:

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: w przypadku, gdy w cache jest wartość, tworzymy strukturę, w przeciwnym razie rzeczywisty task zostanie opakowany w wartość. Kod wywołujący nie ma znaczenia, którą ścieżką wykonano ten kod: ValueTask z punktu widzenia składni C# będzie zachowywał się tak samo jak zwykły Task w tym przypadku.

TaskScheduler'y: zarządzanie strategiami uruchamiania Task'ów

Następne API, które chciałbym omówić, to klasa TaskScheduler i jego pochodne. Wspominałem wcześniej, że w TPL istnieje możliwość zarządzania strategiami rozdzielania zadań pomiędzy wątki. Takie strategie są definiowane w dziedziczących klasach TaskScheduler. Praktycznie każda strategia, która może być potrzebna, będzie dostępna w bibliotece ParallelExtensionsExtras, opracowanej przez Microsoft, ale nie będącej częścią .NET, dostarczanej w postaci pakietu NuGet. Krótko omówimy niektóre z nich:

na temat TaskScheduler w blogu Microsoft. artykuł Dla wygodnego debugowania wszystkiego, co związane z zadaniami w Visual Studio, istnieje okno Tasks. W tym oknie można zobaczyć aktualny stan zadania i przejść do wykonywanej w danym momencie linii kodu.

PLinq i klasa Parallel

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

PLinq i klasa Parallel

Oprócz Tasków i wszystkiego, co zostało powiedziane o .NET, istnieją jeszcze dwa interesujące narzędzia: PLinq (Linq2Parallel) oraz klasa Parallel. Pierwsza z nich obiecuje równoległe wykonywanie wszystkich operacji Linq na kilku wątkach. Liczbę wątków można skonfigurować za pomocą metody rozszerzającej WithDegreeOfParallelism. Niestety, najczęściej PLinq w trybie pracy domyślnej nie ma wystarczających informacji o wewnętrznych aspektach źródła danych, aby zapewnić znaczny wzrost wydajności, z drugiej strony koszt próby jest bardzo niski: wystarczy tylko wywołać metodę AsParallel przed łańcuchem metod Linq i przeprowadzić testy wydajności. Co więcej, istnieje możliwość przekazania do PLinq dodatkowych informacji o charakterystyce źródła danych za pomocą mechanizmu Partitions. Więcej można poczytać tutaj i tutaj.

Statyczna klasa Parallel udostępnia metody do równoległej iteracji przez kolekcje z użyciem Foreach, wykonywania pętli For oraz równoległego wywoływania kilku delegatów. Wykonanie bieżącego wątku zostanie wstrzymane do zakończenia obliczeń. Liczbę wątków można skonfigurować, przekazując ParallelOptions jako ostatni argument. Za pomocą opcji można również określić TaskScheduler i CancellationToken.

Wnioski

Kiedy zaczynałem pisać ten artykuł w oparciu o materiały mojego wystąpienia oraz informacje, które zebrałem po nim, nie spodziewałem się, że powstanie go tak wiele. Teraz, gdy edytor tekstu, w którym piszę ten artykuł, bezlitośnie informuje mnie, że zaczęła się 15. strona, podsumuję wyniki pośrednie. Inne sztuczki, API, narzędzia wizualne i pułapki zostaną omówione w następnym artykule.

Wnioski:

  • Należy znać narzędzia do pracy z wątkami, asynchronicznością i równoległością, aby wykorzystać zasoby nowoczesnych komputerów osobistych.
  • .NET oferuje wiele różnych narzędzi do tych celów.
  • Nie wszystkie z nich zostały wprowadzone od razu, dlatego często można spotkać legacy, chociaż istnieją sposoby na przekształcenie starych API bez większych trudności.
  • Praca z wątkami w .NET jest reprezentowana przez klasy Thread i ThreadPool.
  • Metody Thread.Abort, Thread.Interrupt oraz funkcja Win32 API TerminateThread są niebezpieczne i niezalecane do użycia. Zamiast nich lepiej korzystać z mechanizmu CancellationTokenów.
  • Strumień to cenny zasób, którego liczba jest ograniczona. Należy unikać sytuacji, w których strumienie są zajęte oczekiwaniem na zdarzenia. W tym celu wygodnie jest używać klasy TaskCompletionSource.
  • Najpotężniejszym i najbardziej zaawansowanym narzędziem .NET do pracy z równoległością i asynchronicznością są Taski.
  • Operatory C# async/await realizują koncepcję nieblokującego oczekiwania.
  • Zarządzanie dystrybucją Tasków wątkach można realizować za pomocą klas pochodnych od TaskScheduler.
  • Struktura ValueTask może być przydatna w optymalizacji hot-paths i memory-traffic.
  • Okna Tasks i Threads w Visual Studio dostarczają wielu przydatnych informacji do debugowania wielowątkowego lub asynchronicznego kodu.
  • PLinq to świetne narzędzie, ale może nie mieć wystarczających informacji o twoim źródle danych; można to jednak naprawić za pomocą mechanizmu partycjonowania.
  • Ciąg dalszy nastąpi…

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster