Nastoletni
Programiści

Logo Nastoletnich Programistów

Synchronizacja wątków w C# i C++ (Spinlock)

Wielowątkowość, synchronizacja… Ale po co to komu?

Przytoczę pewne bardzo obrazowe porównanie. Wyobraź sobie, że jesteś w restauracji. Podnosisz menu, wybierasz potrawę i czekasz na kelnera. Widzisz, że tymczasowo obsługuje innego klienta. Mija kilka, kilkanaście minut, a kelner wciąż stoi przy stoliku innego klienta, obsługuje tylko jego. Dopiero, gdy klient płaci i wychodzi z restauracji, kelner podchodzi do Ciebie i zaczyna cię obsługiwać. W tym czasie przychodzi inny klient, ale kelner jest zajęty tylko tobą. Mniej więcej tak działa aplikacja jednowątkowa (nasz kelner). W oczywisty sposób jest to nieefektywne.

Wtedy niczym superbohater, przybywa wielowątkowość.

Super! A więc zyskujemy trochę wydajności. Jednak rozważmy ten kod w języku C# i przeanalizujmy go.

Co tu się dzieje? Program sprawdza czy plik examplefile.txt istnieje, jeżeli nie, tworzy go. Teraz przejdźmy do tego, dlaczego kod ten nie jest bezpieczny. Jako, że dziś mamy do czynienia z wieloma procesorami (rdzeniami), to w tle mogą działać równocześnie różne programy. Nie ma więc żadnej gwarancji, że jeden z nich nie utworzy pliku o nazwie examplefile.txt właśnie w tym katalogu. Rozchodzi się więc o moment pomiędzy otrzymaniem informacji, że plik nie istnieje, a utworzeniem go. Klasa tego błędu to tzw. Race Condition. Aby uniknąć takich sytuacji, wymagana jest synchronizacja.

Najprostszym używanym niskopoziomowym mechanizmem do synchronizacji są tzw. Spinlocki (w luźnym tłumaczeniu – kręcące się blokady). Jest on w zasadzie aktywną pętlą, która czeka, aż dany obiekt (blokada) zostanie zwolniony. Dziś w zasadzie się z nich nie korzysta, gdyż nowoczesne języki takie jak C# mają wbudowane mechanizmy synchronizujące, takie jak lock. Lock działa w ten sposób:

i jest równoważny temu:

gdyż lock jest w zasadzie aliasem tego drugiego. Teraz, zanim przejdę do spinlocków, wytłumaczę z pomocą Wikipedii czym jest owa sekcja krytyczna.

Sekcja krytyczna – fragment kodu programu, w którym korzysta się z zasobu dzielonego, a co za tym idzie w danej chwili może być wykorzystywany przez co najwyżej jeden wątek.

Myślę, że ta definicja spokojnie wystarczy. Przejdźmy teraz do Spinlocków.

W C++ można podejść do tego na prawdę prosto, jest to zwyczajnie (jak już pisałem) aktywna pętla. Wygląda to mniej więcej tak (korzystając z biblioteki <atomic> służącej właśnie do synchronizacji, i jak sama nazwa mówi do operacji atomowych):

Dzięki Spinlockom możemy także zsynchronizować uruchomienie wątków, aby wystartowały w mniej więcej tym samym czasie, co można zaimplementować bardzo prosto:

Wówczas nasza blokada może blokować(?) wykonanie wątku tak długo, jak programista sobie zażyczy. Słówka volatile użyłem, gdyż nie chce, by kompilator potraktował tę pętle tak:
if (slock != THREAD_COUNT) while(1); a tego oczywiście nie chcemy.

C# również udostępnia nam mechanizm spinlocków, ba, w zasadzie całą strukturę! Napisałem tu krótki program implementujący Spinlock:

Przeanalizujmy, co się w nim dzieje.


Zacznijmy tuż przed punktem wejścia do programu, tj. static void Main(string[] args). Tworzymy w nim instancję struktury SpinLock, w bardzo standardowy sposób, oraz prywatne pole int o nazwie _count, którego zadaniem będzie przechowywanie pewnej wartości. Wewnątrz funkcji głównej tworzę prostą pętlę, w niej tworze natomiast nowy obiekt Task, który będzie asynchronicznie wykonywał naszą funkcję vInc(). Tam widzimy standardowy blok try finally,  w którym to rozpoczynamy spin SpinLocka. Zauważ, że zanim wszedłem w aktywną pętlę, flagę lockTaken ustawiłem na false i jest przekazywana przez referencję, nie wartość.

To by było na tyle, jeżeli czegoś wystarczająco nie rozwinąłem, albo macie jakieś pytania, zachęcam do komentowania!


Źródła

 

camed

16-latek uwielbiający C#, Security IT, algorytmikę, TF2, piłkę siatkową i nożną. Będzie próbować swoich sił w OI. Dodatkowo amator książek fantasy.

Zobacz wszystkie posty tego autora →

Komentarze

  • „Przytoczę pewne bardzo obrazowe porównanie. Wyobraź sobie, że jesteś w restauracji. Podnosisz menu, wybierasz potrawę i czekasz na kelnera. Widzisz, że tymczasowo obsługuje innego klienta. Mija kilka, kilkanaście minut, a kelner wciąż stoi przy stoliku innego klienta, obsługuje tylko jego. Dopiero, gdy klient płaci i wychodzi z restauracji, kelner podchodzi do Ciebie i zaczyna cię obsługiwać. W tym czasie przychodzi inny klient, ale kelner jest zajęty tylko tobą. Mniej więcej tak działa aplikacja jednowątkowa (nasz kelner).”
    W życiu nie widziałem gorszej próby zobrazowania jednowątkowości. Opisaną sytuację da się dobrze ogarnąć jednym wątkiem z wykorzystaniem prostego systemu zdarzeń. Wielowątkowość tutaj polegałaby na dodaniu kolejnych kelnerów i zadbaniu o to by nie wpadali sobie pod nogi próbując obsłużyć tego samego gościa. O tym zresztą jest cała reszta twojego wpisu.

    „Słówka volatile użyłem”
    Nie użyłeś.

    „gdyż nie chce, by kompilator potraktował tę pętle tak: if (slock != THREAD_COUNT) while(1); a tego oczywiście nie chcemy”
    1. Kto nie chce?
    2. I tak by tego nie zrobił.

    „lock.clear();”
    Zły std::memory_order.

    „Sekcja krytyczna – fragment kodu programu, w którym korzysta się z zasobu dzielonego, a co za tym idzie w danej chwili może być wykorzystywany przez co najwyżej jeden wątek.”
    Definicja sekcji krytycznej na polskiej wikipedii jest błędna, zwłaszcza ten fragment. Odsyłam tu: https://en.wikipedia.org/wiki/Critical_section

    • Gabriel Chomiczewski

      Odnosząc się do 1.:
      Kwestia gustu, mi te porównanie na prawdę zapadło w pamięć i uważam je, za bardzo dobre, dlatego też się nim podzieliłem.

      Do 2.:
      Użyłem, mogłem zrobić to pole w sumie statycznym nawet. Nie wiem o Ci chodzi.

      Do 3.:
      Przepraszam, sprawdzałem tekst, ale nie wszystkie ‚ę’, ‚ą’ etc. złapałem. Nie bądźmy tacy hmm drobiazgowi.

      Do 4.:
      Mógłby to zrobić, różne kompilatory mają różne optymalizacje. Nie tylko gcc istnieje. Przezorny zawsze ubezpieczony, chyba jakoś tak się mówi 🙂

      Do 5.:
      Dlaczego? Rozwiń.

      Do 6.:
      Nie zgodziłbym się z tym, że ta definicja jest zła. Nie jest książkowa oraz nie jest kompletna, ale spokojnie wystarczy, aby zrozumieć jej cel. Też uważam, że na angielskiej jest lepsza, ale niemal wszystko na angielskiej Wikipedii jest napisane bardziej hmm jakościowo(?).

      Pozdrawiam!

      • 1. Ale jest błędna, bo źle opisuje sytuację. Jeśli znasz już wielowątkowość to spoko, może ty załapiesz, ale nijak nie ma się to tak naprawdę do tematu wątków.
        2. Sorka, źle popatrzyłem. ;-;
        3. Shhh, mi się to rzuca w oczy.
        4. To zdefiniowane przez standard, ta metoda zadziała zawsze i żaden kompilator nie ma tam prawa przeprowadzić takiej optymalizacji.
        5. Ponieważ tak zaprojektowany jest atomic_flag, domyślny memory_order jest tam dla kompatybilności (std::memory_order_seq_cst), ale dużo lepiej używać std::memory_order_release, ponieważ… cóż, użycie go tu jest jednym z powodów dla których powstał, głównie chodzi o wydajność.
        6. Jest zła bo zakłada, że nigdy nie można używać tego samego zasobu z dwóch (lub więcej) wątków co generalnie nie jest prawdą, gdyby tak było zysk z wielowątkowości zwykle byłby znikomy i takie podejście nie prowadzi do niczego dobrego. Nawet klasyka w stylu std::shared_ptr jest bezpieczna przy odczycie z wielu wątków, ale nie zapisie.

        • Gabriel Chomiczewski

          Częściowo się z tobą zgadzam, a częściowo nie. Według mnie nie ma co już tej sprawy roztrząsać, mamy swoje zdania. Co nie oznacza, że twoje są błędne, to samo z moimi.