TerminologiaNr.1 Znasz definicję i terminologię powiązane ze współbieżnością. Nie wiesz, jak to działa w procesorze? Spoko o to wpis dla Ciebie. 

Współbieżność jest przydatna w każdej aplikacji. Nie ważne czy pracujesz w aplikacjach desktopowych, czy w aplikacjach mobilnych. Mamy rok 202X i obecnie wielowątkowość stała się wymaganiem. 

Z obecnymi komputerami użytkowników i serwerów istniejmy w punkcie, w którym programowanie asynchroniczne jest zalecane. Całkiem nie dawno zrobiłem prezentację na żywo o tej tematyce. Jak się okazało, był to mój najlepszy i najbardziej popularny LIVE. Dlaczego? O ile istnieje tona artykułów o podstawach i zaawansowanych funkcjach danego języka programowania. To widać wyraźnie, że nie ma dużo informacji o wielowątkowości w C#.

Uzupełnijmy więc tę lukę. W tym wpisie omówimy w pigułce, o co chodzi z tą wielowątkowością.

Co omówimy w tym wpisie:

  • Terminologia
  • Co to jest wątek
  • Jak działa procesor
  • Podsumowanie

Współbieżność i terminologia

Współbieżność zawsze była możliwa, ale trudna do osiągnięcia. Współbieżne oprogramowanie było ciężkie do napisania, sprawdzenia i pielęgnowania. 

Dlatego przez lata wiele programistów wybierało łatwiejszą ścieżkę  i unikało współbieżności. 

Teraz z najnowszymi bibliotekami .NET programowanie współbieżne jest dużo łatwiejsze. Za chwilę nawet pokaże Ci, jak to wyglądało kiedyś (ale nie w tym wpisie).  Na razie musimy zdefiniować pewne pojęcia.

Terminologia

Terminologia, czyli co to jest współbieżność, wielowątkowość, asynchroniczne programowanie, parellel processing, programowanie reaktywne oraz "przyszłość", "obietnice" czy "Taski"

Jak widzisz, istnieje wiele definicji, które pojawią się za każdym razem, gdy omawiamy ten styl programowania. Czasem używamy, tych definicji zamienię. Pytanie, czy słusznie?

Współbieżność: Robienie więcej niż jedną rzecz na raz. 

Pomyśl, w jakich przypadkach aplikacja robi więcej niż jedna rzecz. Serwery używają współbieżności, aby odpowiadać na każde zapytanie HTTP użytkowników. Serwer może kończyć jedno zapytanie i być w trakcie odpowiadania na 3 pozostałe. 

Współbieżność jest Ci potrzebna za każdym razem, gdy chcesz, aby użytkownik mógł klikać w aplikację WPF, Windows Form, gdy w międzyczasie jest coś pobierane.

Prawie każda aplikacja na świecie może skorzystać ze współbieżności. Wiele programistów myśląc o współbieżności, myśli równocześnie o "wielowątkowości". Pora odróżnić tę definicję.

Wielowątkowość: Forma współbieżności, która używa mechanizmu wątków do wykonania zadania

Wielowątkowość referuję się do korzystania z wątków, aby uzyskać współbieżność. Co ciekawe "wielowątkowość" nie jest jedynym sposobem na osiągnięcie współbieżność.

Nie powiem, tutaj sprawa jest trochę skomplikowana, ale jest możliwe napisanie kodu asynchronicznie, który będzie asynchroniczny, ale nie stworzy nowego wątku.

Można więc się kłócić czy "każdy" kod współbieżny działa na wielu-wątkach, ponieważ procesor i system operacyjny dla trywialnych operacji jest, w stanie działać, asynchronicznie bez nowego wątku.

Ten przykład omówimy później i osobiście sam nie mogłem uwierzyć, że coś takiego jest możliwe. Dodatkowo warto zaznaczyć, że obecnie w .NET korzystamy async/await i Tasków. Te mechanizm oczywiście korzystają z wątków, ale nie zawsze i co warto zaznaczyć, za Ciebie robią, tę optymalizację. 

Co ma na myśli optymalizację? Powiedzmy, że każdy Task czy wyrażenie async, await w C# nie tworzy nowego wątku, być może inny wątek jest wykorzystywany ponownie bez twojej wiedzy.

Wątki w .NET nadal istnieją oczywiście i mechanizm kontrolowania istnieje w klasie ThreadPool.

Parallel processing: Dzielenie swojej pracy na pomiędzy wątkami tak, aby zadania wykonały się współbieżnie 

Masz więc pewne zadanie i chciałbyś je wykonać jak najlepiej, korzystając z tego, że masz wiele rdzeni w swoim procesorze. Fajnie by było rozdzielić to zadanie, na kilka wątków tak, aby każdy rdzeń w twoim procesorze mógł zostać wykorzystany na maksa. 

Asynchroniczne programowanie: Forma współbieżności, która używa obiektów "obietnic" i "przyszłości" tak, aby uniknąć niepotrzebnych wątków

"Przyszłość", "Obietnica", "Task" są to obiekty operacji, która zakończy się w przyszłości. W .NET jest tym właśnie klasy Task i Task<TResult>.

Poprzednie API w .NET opierało się na callback-ach i na zdarzeniach. Asynchroniczne operacje informują nas, że się zakończą gdzieś w przyszłości, ale nie blokują nam one wątku głównego. Gdy operacja się zakończy dostaniemy tę informację w obiekcie operacji w tym wypadku Tasku

Asynchroniczne programowanie to potężne narzędzie współbieżności tylko jeszcze całkiem nie dawno wymagało to kodu spaghetti. Potem pojawił się async i await, który sprawił, że programowanie asynchroniczne jest prawie tak samo łatwe co programowanie synchroniczne.

Reaktywne programowanie, czyli Reactive programming: Podtyp asynchronicznego programowania polegającym na reagowaniu na zdarzenia lub implementacji wzorca projektowego obserwator.

Jeżeli uznamy, że nasza aplikacja to wielka maszyna stanów to działanie naszej aplikacji może zostać opisane jako zbiór reakcji na pewne zdarzenia, które zmieniają pewny stan. 

Brzmi to dziwnie i abstrakcyjnie, ale wiele obecnych frameworków tak działa i ma to sens z punktu widzenia, tego, jak działa nasz prawdziwy świat. 

Co powinieneś wiedzieć: Jak działa procesor

Czy jest wątek? Wątek jest potrzebny do wykonywania kodu. Wątek to pojedyncza jednostka w twoim systemie operacyjnym używana przez rdzeń procesora do wykonywania kodu.

Wątki są planowane przez CPU/Procesor.  Warto pamiętać, że ten kod w C#

static void Main(string[] args)
{
    int a = 11;
    int b = 22;
    int c = 33;
    int d = 44;
    int f = 55;

    a = a + b;
    b = c + d;
    c = a + b;
    d = f + d;
    f = a + b + c + d / 4;
}

Dla procesora ostatecznie będzie wyglądać mniej więcej tak:

IL_0000: nop
IL_0001: ldc.i4.s 11
IL_0003: stloc.0
IL_0004: ldc.i4.s 22
IL_0006: stloc.1
IL_0007: ldc.i4.s 33
IL_0009: stloc.2
IL_000a: ldc.i4.s 44
IL_000c: stloc.3
IL_000d: ldc.i4.s 55
IL_000f: stloc.s 4
IL_0011: ldloc.0
IL_0012: ldloc.1
IL_0013: add
IL_0014: stloc.0
IL_0015: ldloc.2
IL_0016: ldloc.3
IL_0017: add
IL_0018: stloc.1
IL_0019: ldloc.0
IL_001a: ldloc.1
IL_001b: add
IL_001c: stloc.2
IL_001d: ldloc.s 4
IL_001f: ldloc.3
IL_0020: add
IL_0021: stloc.3
IL_0022: ldloc.0
IL_0023: ldloc.1
IL_0024: add
IL_0025: ldloc.2
IL_0026: add
IL_0027: ldloc.3
IL_0028: ldc.i4.4
IL_0029: div
IL_002a: add
IL_002b: stloc.s 4
IL_002d: ret

Ten skompilowany kod jest zrozumiany przez procesor i w swoim wątku wykona on te instrukcje.

Istnieje zależność pomiędzy wątkiem a sprzętem. O ile dziś możemy uznać, że każdy sprzęt ogarnie działanie wielowątkowe, to warto powiedzieć, jak to wygląda.

Po pierwsze warto zadać sobie pytanie, czy masz:

  • Jeden procesor
  • Wiele procesorów

Zapewne twój laptop i komputer ma tylko jeden procesor. Serwery mają wiele procesorów, wiec są one wręcz stworzone do ogarniania tysiąca zapytań HTTP.

0_04.png

Jeżeli masz jeden procesor, to warto zadać sobie pytanie, ile rdzeni ma twój procesor. Każdy rdzeń procesora umożliwia w pewnym absurdalnym momencie czasu przyjęcie jednego wątku i działania na nim.

0_06.png

Dodatkowo istnieje technologia: Hyper-Threading (Intela) i SMT (AMD). Jest to implementacja wielowątkowość współbieżności.

Jeśli przeczytasz wpis z Wikipedii na temat tych technologi, to się dowiesz, że ta technologia magicznie sprawia, że z jednego rdzenia tworzą się dwa wirtualne rdzenie.

Brzmi to, jak: bullshit co nie. To rozwiązanie działa tak:

Hyper Threading

Ciągle jeden wątek działa w pewnej absurdalnej małym momencie czasu. Jednakże istnieją momenty, w których ten wątek musi odczytać coś z pamięci. W tym momencie rdzeń procesora czeka i nic nie robi. 

Dlaczego więc tym momencie czeka nie wcisnąć drugi wątek? Brzmi genialnie. Problem leży w inteligentnym podziale kodu na te przerwy, gdy odczytujemy coś z pamięci.

Zakładając jednak, że "to" tak działa, to rzeczywiście można uznać, że podwoiłeś liczbę rdzeni w procesorze.

Co jeszcze powinni eś wiedzieć o wątkach? Procesor musi się pomiędzy nimi przełączać.

Context switching : wątki w kolejce

Wyobraź sobie, że cały system operacyjny ma dużą poczekalnię wątków. W procesorze z jednym rdzeniem wyglądałoby to tak. 

Context switching : kontekst wątku

Aby procesor się nie zgubił w tym przełączaniu wątków, tworzy on kontekst danego wątku. Znajdują się tam informację o nim jak: unikatowy identyfikator, priorytet, gdzie wykonywanie kodu trwa i gdzie się skończyło.

Context switching : przełączanie się pomiędzy wątkami

Po pewnym "kwancie czasu" (o czym później) wątek wraca do kolejki, a informacja o nim zostanie zapisana w pamięci. Tak na zmianę działa procesor.

Teraz omówmy pewne pojęcia, które tutaj się pojawiły.

Co to jest ten kwant czasu?

Procesor nie może cały czas przełączać się pomiędzy wątkami. Musi istnieć jakaś najmniejsza jednostka czasu, w którym dany wątek pracuje.

Tutaj wszystko zależy od systemu operacyjnego. Inaczej będzie działał system Linux inaczej Windows.

Jeżeli chodzi o system Windows.

Zaawansowane ustawienia systemu Windows i kwant czasu

W opcjach zaawansowanego systemu możesz nawet zmienić długość trwania tego kwantu czasu.

Domyślnie dla użytkowników Windows zaznaczona jest opcja "Program". Ma to sens. Pomyśl. Ciągle coś w swoich komputerze klikasz, piszesz, wyświetlasz. Ten "kwant czasu" musi być w takim przypadku bardzo mały, bo ważniejsze jest dla nas, aby kilka wątków działało równocześnie lub ciągle się przełączało.

Z drugiej strony, jeśli masz Serwer, który robi zadania w tle. To lepiej, aby ten kwant czasu był dłuższy.

Każde przełączenie wątku kosztuje procesor. Może to być nawet 4000 cykli.

Dlatego posiadanie zbyt wielu-wątków minęłoby się z celem.

Dodatkowo każdy wątek ma swój priorytet. Jest to liczba od 0 do 31. Nie do każdego priorytetu masz dostęp z poziomu kodu. Ma to sens, ponieważ zapewne twórca systemu operacyjnego nie chciałby, abyś mógł ustawić swój proces wyżej niż inne krytyczne proces działające dla systemu.

Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;
           
Thread t = Thread.CurrentThread;
t.Priority = ThreadPriority.Normal;

Warto też zaznaczyć, że ten kod zdziała tylko, gdy masz uprawienia administratora.

Podsumowanie wątków

Duże są koszty przełączania się pomiędzy wątkami. 

W idealnym świecie miałbyś tyle rdzeni procesora, ile masz wątków. Oczywiście tak nie jest, więc musi wykonywać się przełączanie. 

Fachowo mów się na to: "Context Switching"

Na początku wpisu mówiłem, że współbieżność jest przydatna w każdej aplikacji. Jednakże warto też pamiętać, że nawet jeśli coś działa wielowątkowo to wcale nieznaczny, że będzie działało to szybciej.

Warto pamiętać o tym, na jakim sprzęcie będzie uruchamiany kod.

Pamiętaj, jak w 2010 roku na swoim starym sprzęcie napisałem proste zapytanie LINQ, które wykonało się współbieżnie. Byłem w szoku, ale zapytanie współbieżnie działało wolniej prawdopobnie ze względu na koszt przełączania się pomiędzy wątkami.

Oczywiście teraz w roku 202X ma to małe znaczenie, bo możesz założyć, że będzie dobrze. Jednakże warto robić benchmarki swojego kodu.

Ostatecznie wielowątkowość sama w sobie nie powinna być celem.