Artykuły | 29 kwiecień, 2020

Pułapki współbieżności. Narzut synchronizacji wątków

W powszechnym mniemaniu programowanie współbieżne zapewnia przyspieszenie działania każdej aplikacji. Entuzjaści nowinek technologicznych, którzy nie wgłębiają się w szczegóły techniczne wprowadzanych innowacji, chętnie pokuszą się też o wykorzystanie biblioteki TPL w swoim kodzie. Ci, którzy zmierzą wyniki swoich „optymalizacji”, ze zdziwieniem zauważą, że nie zawsze i nie każde użycie takich rozwiązań skutkuje przyspieszeniem działania kodu, a w wielu przypadkach może spowodować wręcz jego spowolnienie. Skąd wynika taka sytuacja?

Pułapki współbieżności. Narzut synchronizacji wątków

Punkt wyjścia

Programowanie współbieżne jest tematem bardzo rozległym i zawiera w sobie wiele elementów, o których trzeba pamiętać, aby dostosować sposób jego użycia do potrzeb danego scenariusza i by jego użycie nie przyniosło rezultatów odwrotnych do zamierzonych. Już sama historia ewolucji API .NETa (mechanizmy takie, jak instrukcja lock, semafory, interfejs IAsyncResult, zdarzenia, biblioteka TPL aż po klasę ValueTask) pokazuje, że temat ten nie jest łatwy nawet dla inżynierów Microsoftu. Świadomość niskopoziomowych mechanizmów działających pod tą technologią ułatwia prawidłowe zastosowanie tych narzędzi. W tym artykule zostanie poruszony tylko niewielki wycinek tego obszaru wiedzy.

Aby zobrazować pewne aspekty użycia programowania współbieżnego, wziąłem za przykład scenariusz, w którym chcę wyliczyć liczby pierwsze w zadanym zakresie liczb naturalnych. Aby sprawdzić, czy dana liczba jest liczbą pierwszą, posłużyłem się przykładowym kodem znalezionym na StackOverflow:

Współbieżność w aplikacji

Rozpocząłem od przetwarzania sekwencyjnego, by sprawdzić, ile czasu zajmuje wyliczenie liczb pierwszych w zakresie od 1 do 100. Aby zmierzyć ten czas, wykorzystałem bibliotekę BenchmarkDotNet.

W tym celu:

  1. Stworzyłem program konsolowy (w moim przypadku wykorzystam .NET Core 3.0),
  2. Dodałem paczkę nuget BenchmarkDotNet (w moim przypadku jest to wersja 0.11.5),
  3. Dodałem klasę BenchmarkTest
  4. Do klasy BenchmarkTest dodałem wylistowaną wyżej metodę IsPrime,
  5. Do klasy BenchmarkTest dodałem metodę wyliczania sekwencyjnego liczb pierwszych oraz opatrzyłem tę metodę atrybutem [Benchmark] z przestrzeni nazw BenchmarkDotNet.Attributes:
 class=

Program musiał być zbudowany w trybie RELEASE, aby dać wymierne wyniki. Aby więc uniknąć usunięcia powyższej metody podczas procesu optymalizacji, powinienem był zadbać, aby jej typ zwracany nie był deklarowany jako void. Dlatego zdecydowałem, by zwracała wyliczony wynik jako listę liczb pierwszych.

  1. Do metody statycznej Program.Main dodałem wywołanie mechanizmu mierzenia wydajności testowanej metody:
 class=
  1. Zmieniłem konfigurację projektu na Release.

W dalszym kroku mogłem już uruchomić projekt, ale – uwaga! – aby zmniejszyć wpływ innych procesów na wyniki testu (np. trybu debuggowania z Visual Studio), zrobiłem to z linii poleceń.

W wyniku uruchomienia zostały wykonane wielokrotne testy, a na końcu otrzymałem tabelkę z takimi wynikami:

Tabela - wpółbieżność

Pułapka naiwnej optymalizacji

Następnie chciałem zmierzyć czas potrzebny na wygenerowanie tej samej listy liczb pierwszych w sposób równoległy. W tym celu do klasy BenchmarkTest dodałem odpowiednio zmodyfikowaną metodę. Zamiast klasy Enumerable wykorzystałem ParallelEnumerable, którego metoda Range generuje równoległą sekwencję liczb (ang. parallel sequence of integral numbers):

Współbieżność w aplikacji

Po uruchomieniu programu narzędzie BenchmarkDotNet zmierzyło średni czas wykonania dla każdej z obu metod.

Wyniki były następujące:

Współbieżność w aplikacji

Jak widać, zrównoleglone zadanie zostało wykonane w czasie ok. 8 razy dłuższym niż zadanie wykonane sekwencyjnie. Skąd to może wynikać?

Meandry równoległości

Microsoft na stronie Understanding Speedup in PLINQ wyjaśnia, że jedną z przyczyn niepowodzenia optymalizacji przez zrównoleglenie jest zjawisko występowania pewnego narzutu czasu (ang. overhead) potrzebnego do synchronizacji zadań pomiędzy wątkami.

To zjawisko można zaobserwować, analizując zapis zdarzeń systemowych, które w systemie Windows funkcjonują pod nazwą Event Tracing for Windows. Aby zebrać i przeanalizować te zdarzenia, użyłem narzędzia Concurrency Visualizer for Visual Studio 2017, które jest dodatkiem do Visual Studio 2017 (w momencie pisania tego artykułu nie istniał dodatek dla Visual Studio 2019).

Aby ułatwić analizę zdarzeń generowanych przez Concurrency Visualizer, chwilowo zrezygnowałem z uruchamiania analizowanego kodu przez BenchmarkDotNet, a w zamian uruchomiłem tylko raz jedną z testowanych metod – najpierw wersję sekwencyjną, potem wielowątkową. W tym celu zmodyfikowałem metodę Program.Main do poniższej postaci:

Współbieżność w aplikacji

Dodatkowo, aby pozwolić narzędziu na odczytanie danych diagnostycznych z uruchamianego procesu, zmodyfikowałem plik csproj projektu, dodając w nim do elementów PropertyGroup poniższy fragment XML: 

Współbieżność w aplikacji

Po przebudowaniu aplikacji uruchomiłem Visual Studio 2017 i z menu Analyze / Concurrency Visualizer wybrałem komendę Launch New Process. W okienku z parametrami wskazałem ścieżkę do pliku exe mojej aplikacji:

Pułapki współbieżności

Po kliknięciu przycisku Start został uruchomiony mój program, a następnie zostały wczytane dane dotyczące zdarzeń zarejestrowanych podczas jego uruchomienia.

Przed przeprowadzeniem tego procesu istotne jest, aby zamykać wszystkie zbędne aplikacje, by ich działanie nie wpłynęło na wynik analizy. Dodatkowo warto tę analizę powtórzyć kilka razy, aby sprawdzić, czy wyniki są do siebie wystarczająco zbliżone, i w ten sposób upewnić się co do rzetelności wyników. Ja na potrzeby tego artykułu każdą analizę wykonałem po trzy razy i – porównując wyniki między sobą – uznałem tę liczbę powtórzeń za wystarczającą.

Po zakończeniu ładowania zobaczyłem taki wykres:

Pułapki współbieżności

Przedstawia on stopień wykorzystania rdzeni logicznych przez uruchomioną aplikację (kolor zielony) w czasie (oś pozioma) analizy. Za pomocą kolorów szarych przedstawiony jest stopień wykorzystania rdzeni logicznych przez pozostałe procesy. Kolor biały przedstawia niewykorzystane zasoby procesora.

Z punktu widzenia tej analizy istotniejsze dane kryją się jednak pod elementem „Threads”. Po jego kliknięciu ukazał się taki widok:

Pułapki współbieżności

W tym widoku u góry znajduje się pasek, który znamy już z widoku „Utilization”, a który tutaj pełni funkcję osi czasu. Za pomocą czerwonej ramki ograniczonej po bokach uchwytami (czerwonymi kwadratami) zaznaczony jest obszar, dla którego w dolnej części przedstawiono dane. Przesuwając uchwyty, można ograniczyć ten zakres do określonego odcinka czasu i tym samym zobaczyć bardziej szczegółowe dane.

Poniżej paska osi czasu znajduje się wykres obrazujący stan wątków procesu (reprezentowanych na osi pionowej) w kolejnych momentach działania procesu (oś pozioma). W dolnej części widoku znajduje się legenda przedstawiająca znaczenie użytych kolorów, np. zielonym oznaczono stan działania (executing) wątku, a czerwonym stan synchronizacji. Na legendzie widnieją też określone w procentach udziały tych stanów w zaznaczonym obrębie czasu.

W przypadku uruchomienia eksperymentalnej metody, można zauważyć, że proces działa w więcej, niż 1 wątku, oraz że 58% czasu zostało poświęcone na synchronizację wątków, czyli operację kopiowania danych z obszarów pamięci zarezerwowanych dla poszczególnych wątków. Po zakończeniu pracy poszczególnych roboczych wątków następuje zebranie wyników (ewentualne dodatkowe operacje na tych wynikach). Proces ten nazywamy synchronizacją wątków. Następuje ona po zakończeniu pracy wszystkich wątków, aby zapewnić, że operacje wykonane w jednym wątku nie wpłyną w sposób niekontrolowany na dane przydzielone do innych wątków.

Pomimo że zastosowany sposób wyliczania liczb pierwszych był sekwencyjny, proces posiadał wiele wątków. Jest to cecha każdego procesu uruchamianego w systemie Windows – po załadowaniu aplikacji (co na wykresie jest widoczne w postaci fioletowego paska w lewej części wykresu, oznaczonego w legendzie jako proces I/O) tworzonych jest kilka wątków roboczych. Więcej informacji na ten temat można znaleźć w książce „Windows Internals. Seventh Edition. Part 1” (Pavel Yosifovich et al.) oraz – odnośnie platformy .NET – w rozdziale Threads dokumentacji The Book of the Runtime.

W kolejnym kroku uruchomiłem analizę dla wersji równoległej. Zmieniłem więc odpowiednio metodę Main:

Współbieżność w aplikacji

i ponownie uruchomiłem narzędzie Concurrency Visualizer. Po przełączeniu się na zakładkę Threads mój wykres wyglądał w ten sposób:

Pułapki współbieżności

Z wykresu wynika, że w tym przypadku synchronizacja wątków zajęła 63% czasu.

Różnica pomiędzy 58% a 63% nie wydaje się być wielka, ale – jak widać po wykresie z przypadku działania sekwencyjnego – synchronizacja odbywa się nie tylko wtedy, gdy wykonywane są obliczenia wielowątkowe, ale synchronizacja jest jednym z podstawowych działań, które odbywają się w procesach. Poza tym powyższe udziały są obliczone względem czasu trwania całego procesu, który obejmuje też czas związany z ładowaniem i uruchamianiem aplikacji, zanim aplikacja rozpocznie uruchamianie kodu, który zaimplementowaliśmy (dokona obliczeń czasu liczb pierwszych). Narzędzie BenchmarkDotNet uwzględnia te czynniki podczas mierzenia wydajności.

Działanie i synchronizację wątków utworzonych przez Parallel.Range() można zaobserwować na podstawie zakresu działania oznaczonego ciemnofioletowym paskiem oznaczonym napisem „ParallelQueryBegin”. Aby zaobserwować działania przeprowadzane na poszczególnych wątkach i synchronizację pomiędzy nimi, na osi czasu (na wykresie na samej górze okna) zawęziłem zakres wyznaczony przez ten pasek do zakresu oznaczonego ciemnofioletowym paskiem. Na ekranie zobaczyłem wtedy taki widok:

Pułapki współbieżności

Kiedy zrównoleglać obliczenia?

Kiedy w takim razie warto zrównoleglać obliczenia? We wspomnianym artykule Understanding Speedup in PLINQ Microsoft wyjaśnia, że musi być spełniony warunek: koszt operacji obliczeniowych powinien przewyższać narzut czasu związany z synchronizacją wątków związany z tymi obliczeniami.

Obliczanie liczby pierwszej, według wykorzystanego w tym przykładzie algorytmu, będzie przebiegało szybciej dla małych liczb niż dla liczb większych. Na kolejnym etapie testów wróciłem do przeprowadzania benchmarków i sprawdzania średniego czasu wykonywania obliczeń dla zakresów: od 1 do 100, od 1 do 10’000 i od 1 do 1’000’000.

BenchmarkDotNet wspomaga parametryzację przeprowadzanych testów za pomocą atrybutu ParamsAttribute z przestrzeni nazw BenchmarkDotNet.Attributes. Ustawia się go na publicznej właściwości z publicznym setterem, a opatrzonej nim właściwości używa się w testowanej metodzie:

Współbieżność w aplikacji

Po uruchomieniu testów do tabeli wyników została dodana kolumna z wartością wykorzystanego parametru:

Współbieżność w aplikacji

Jak widać na wynikach: im większy zakres pętli, tym bardziej korzystne okazuje się rozwiązanie wielowątkowe.

Podsumowanie: warto robić pomiary

Temat wprowadzania współbieżności do aplikacji nie jest tematem trywialnym. W zrozumieniu tematu pomaga nie tylko znajomość dokumentacji, ale też mechanizmów systemowych działających na niskim poziomie – systemu lub nawet pamięci i procesora. Dlatego, aby upewnić się, że wprowadzenie wielowątkowości do aplikacji łączy się z realną korzyścią, warto przeprowadzać testy wydajności.

Przeczytaj także: Test-Driven Development na co dzień

Wyznawca stosowania dobrych praktyk programistycznych i pisania testów automatycznych. Po godzinach triathlonista i członek wspólnoty ewangelizacyjnej.

Zapisz się do newslettera, ekskluzywna zawartość czeka

Bądź na bieżąco z najnowszymi artykułami i wydarzeniami IT

Informacje dotyczące przetwarzania danych osobowych

Zapisz się do newslettera, ekskluzywna zawartość czeka

Bądź na bieżąco z najnowszymi artykułami i wydarzeniami IT

Informacje dotyczące przetwarzania danych osobowych

Zapisz się do newslettera, aby pobrać plik

Bądź na bieżąco z najnowszymi artykułami i wydarzeniami IT

Informacje dotyczące przetwarzania danych osobowych

Dziękujemy za zapis na newsletter — został ostatni krok do aktywacji

Potwierdź poprawność adresu e-mail klikając link wiadomości, która została do Ciebie wysłana w tej chwili.

 

Jeśli w czasie do 5 minut w Twojej skrzynce odbiorczej nie będzie wiadomości to sprawdź również folder *spam*.

Twój adres e-mail znajduje się już na liście odbiorców newslettera

Wystąpił nieoczekiwany błąd

Spróbuj ponownie za chwilę.

    Get notified about new articles

    Be a part of something more than just newsletter

    I hereby agree that Inetum Polska Sp. z o.o. shall process my personal data (hereinafter ‘personal data’), such as: my full name, e-mail address, telephone number and Skype ID/name for commercial purposes.

    I hereby agree that Inetum Polska Sp. z o.o. shall process my personal data (hereinafter ‘personal data’), such as: my full name, e-mail address and telephone number for marketing purposes.

    Read more

    Just one click away!

    We've sent you an email containing a confirmation link. Please open your inbox and finalize your subscription there to receive your e-book copy.

    Note: If you don't see that email in your inbox shortly, check your spam folder.