Paweł Wilkosz
Motorola Solutions Systems Polska Sp.z.o.o
Paweł WilkoszSenior Staff Engineer / Cloud Architect (Microsoft Azure) @ Motorola Solutions Systems Polska Sp.z.o.o

Czy C# nadaje się do rozwiązań real-time? Czyli o ważnym wyborze języka

Poznaj 4 złote zasady projektowania systemu real-time, niezależne od języka, który wybierzesz (na przykładzie C#).
23.02.20216 min
Czy C# nadaje się do rozwiązań real-time? Czyli o ważnym wyborze języka

W środowisku IT istnieje przekonanie, iż każdy z języków programowania jest zaprojektowany do rozwiązywania pewnej klasy problemów i każda próba wykorzystania takowego języka w innym, niż wskazany przez “stereotypy” celu, łączy się z falą krytyki. I tak na przykład utarło się przekonanie, że C++ znajdziemy wszędzie tam, gdzie szybkość i niemalże “zerowy” czas odpowiedzi jest istotny, podczas gdy Javie i C# przypisuje się rolę aplikacji desktopowych, czy też aplikacji serwerowych. 

Co jednak w przypadku, gdy zachodzi konieczność stworzenia systemu w języku, który nie pasuje do utartych schematów? Przypomnę tylko, iż wcale nie jest to takie nieprawdopodobne. Może się bowiem zdarzyć nagła potrzeba skalowania systemu, na którego przepisanie w jedynie słusznym języku po prostu nie ma czasu, bądź budżetu. Wymagania takie mogą iść bezpośrednio od klienta, bądź zatrudnienie specjalisty od danego języka jest w danym momencie zwyczajnie nieopłacalne, lub zbyt czasochłonne. 

Pomijając wszystkie powyższe argumenty, ja postulowałem za pragmatycznym podejściem do tworzenia oprogramowania, trzymając się zasady, iż dobre zrozumienie dziedziny problemu rozwiązuje 90% pozostałych problemów z implementacją i późniejszym wdrożeniem.

Wiele razy słyszałem, że C# jest zbyt wolny, aby pisać w nim systemy klasy real-time. Jeszcze więcej razy widziałem kody aplikacji, które niepoprawnie zaprojektowane względem wydajności właśnie przyczyniały się do stawiania tak krzywdzącej tezy. W tym artykule chciałbym przedstawić kilka złotych zasad dotyczących tego, w jaki sposób podejść do problemu projektowania systemu real-time niezależnie od tego, jaki główny język sobie wybierzemy. 

1. Na wydajność kodu aplikacji wpływa brak zrozumienia zasady działania funkcji, obiektów i bibliotek pomocniczych

Wiele razy widziałem taką sytuację, że kiedy do języka programowania wprowadzona zostaje nowość w składni, programiści prześcigają się w tym, który z nich użyje danej nowinki w sposób bardziej karkołomny. Tak, aby udowodnić, że jest się na topie i chyba troszeczkę pochwalić się przed kolegami juniorami, kto tu tak naprawdę rządzi. 

Najczęstsze sytuacje powodujące problemy, z jakimi się spotykałem, to m.in. nieoptymalne wykorzystanie mechanizmów Linq, “eleganckie”, ale jednocześnie żmudne mapowanie obiektowo-relacyjne i wykorzystanie bardzo wolnego klienta 3rd party w zakresie obsługi WebSocket. Ciężko w to uwierzyć, ale w systemie typu real time optymalizacja była znacząca również w chwili, gdy zdecydowaliśmy się zrezygnować z klas i zastąpić je… strukturami (tak znienawidzonymi i zapomnianymi przez programistów C#). 

Takich przykładów mógłbym podawać setki, szczególnie kiedy do projektu dołącza się biblioteki 3rd party. Niestety nadal większość z nas, zamiast czytać dokumentację, chce jak najszybciej rozpocząć pracę z nową biblioteką i bazując na tutorialu, doprowadzić do finalnej kompilacji i względnego działania. Z mojego własnego doświadczenia wynika, iż właśnie tego typu zabiegi wprowadzają luki w wydajności systemów, które niestety są bardzo trudne do wykrycia. Warto zatem poświęcić troszkę czasu na zapoznanie się z nową biblioteką, poeksperymentować z nią, a nawet dekompilować. Nigdy nie będzie to czas stracony. 

2. Ogranicz operacje zapisu/odczytu

Odczyt i zapis z/na dysk twardy niestety jest operacją niezwykle kosztowną, chociaż wg naszej ludzkiej percepcji, zajmuje to krócej niż mrugnięcie okiem. W momencie kiedy w programie wywołujemy komendę do odczytu danych z pliku, nasza aplikacja musi odczekać tzw. czas dostępowy. Składają się na niego następujące operacje:

  1. przetworzenie żądania danych z procesora
  2. pobranie wymaganych danych z urządzenia pamięci masowej
  3. ponieważ dyski twarde są mechaniczne, konieczne jest również poczekanie, aż zostanie wykonany obrót do wymaganego sektora dysku


Całość takiej operacji zajmuje rząd wielkości kilkudziesięciu milisekund, podczas gdy odczyt tej samej danej z pamięci RAM zajmuje kilkanaście nanosekund. Szybko, prawda? 1 nanosekunda równa się 10^-6 milisekundy. Zbliżony rząd wielkości otrzymalibyśmy, gdybyśmy porównali prędkość F16 do ślimaka winniczka. 

Warto zastanowić się zatem, czy na starcie aplikacji nie wczytać danych z pliku konfiguracyjnego do pamięci podręcznej, aby potem uniknąć zbędnego zaglądania do niego. Również w kwestii samego logowania wyników do zewnętrznego pliku rozsądnie jest zaimplementować “cache”, który będzie zbierał kilka “linijek” wpisów i uzupełniał już grupowo plik zgodnie z harmonogramem. Warto również zastanowić się nad zintegrowaniem naszych rozwiązań z silnikami, które są dostosowane do operowania na dużych zbiorach danych (a logi do takowych bez wątpienia należą). Wśród typowych rozwiązań można wskazać np. ELK stack.

3. Szukaj wąskiego gardła

Narzędzia, narzędzia i jeszcze raz narzędzia. Nie mam pojęcia, dlaczego programiści się ich tak boją i naprawdę na palcach jednej ręki mógłbym wyliczyć tych, którzy podczas programowania na bieżąco oceniają swój kod pod kątem różnych wymagań niefunkcjonalnych, w szczególności wydajnościowych. A przecież tego typu narzędzia podają nam jak na tacy komunikat dotyczący tego, gdzie popełniamy błąd albo gdzie szukać problemów. Poniżej przedstawię listę tych najpopularniejszych, z którymi warto być “za pan brat”, jeżeli chcesz pisać wydajny kod w C#:

  • ILSpy - open-source'owe narzędzie do dekompilowania i profilowania kodu w .NET. Świetnie nadaje się do sprawdzania, w jaki sposób nasz kod będzie wykonywany. Idealny do poszukiwania wąskich gardeł na poziomie kodu źródłowego
  • JetBrains DotMemory - analizator zużycia pamięci w m.in. .NET i .NET Core. Pozwala na śledzenie zużycia pamięci przez aplikację praktycznie w czasie rzeczywistym. Pozwala w sposób inteligentny wskazać obszary, które konsumują zbyt dużo RAM
  • Scitech .NET Memory Profiler - narzędzie pozwalające śledzić m.in. stany obiektów oraz powiązania między nimi
  • ADM CodeAnalyst Performance Analyzer - narzędzie tworzące statyczną i dynamiczną analizę kodu, pozwalające na bezpośrednie wskazanie, które metody wykonują się najdłużej i dlaczego

4. Dobierz odpowiednią architekturę do problemu, który rozwiązujesz

Real-time'owość w systemach oznacza, iż dana operacja powinna być wykonana w zaplanowanym przez m.in. kod momencie. Dobry architekt aplikacji w swoim kodzie poszuka zatem operacji, które sztucznie będą ten czas oddalać lub wydłużać. Najlepszym tego przykładem jest przetwarzanie asynchroniczne. 

Spotkałem się już z systemami, gdzie wąskim gardłem wydajności były właśnie operacje weryfikacji danych, bądź ich logowania do zewnętrznych baz (np. elasticsearch). W architekturze mikroserwisowej eliminacja tego typu wąskich gardeł może polegać na oddelegowaniu zadań, które sztucznie blokują wykonywanie właściwej logiki biznesowej do innych mikroserwisów. 

Gdy analiza tzw. flow aplikacyjnego nie jest rzeczą krytyczną do dalszego działania, zleć jej wykonanie w formie asynchronicznej innemu serwisowi. To samo dotyczy logowania/zapisu do bazy danych. Nie jest wymagane, żeby tego typu operacja blokowała działanie systemu. Umiejętne zaprojektowanie komunikatów pomiędzy mikroserwisami z pewnością pozwoli zaoszczędzić czas niezbędny do przetwarzania właściwych zadań.

W tym momencie chciałbym się jeszcze na chwilkę pochylić nad samą ideą architektury mikrousługowej. Moim zdaniem tego typu architektura działa najefektywniej w aplikacjach bezstanowych. Widziałem już tzw. “rozproszone monolity”, gdzie skomplikowany kod rozwiązywał problem przekazywania stanu danych obiektów pomiędzy węzłami, stając się wąskim gardłem dla logiki biznesowej aplikacji. W tym wypadku dobrze zaprojektowana warstwowa architektura działałaby zdecydowanie szybciej niż popularne mikroserwisy.

Podsumowanie

Czy zastanawialiście się, dlaczego język C zyskał miano najwydajniejszego? Oczywiście faktem jest, że język kompilowany, pracujący bardzo blisko procesora, będzie zawsze szybciej wykonywał swoje instrukcje niż każdy inny, mający po drodze do CPU pośrednika w formie “wirtualnej maszyny”. Jednakże zaryzykowałbym stwierdzenie, iż C ze względu na swoje ograniczenia (np. brak obiektowości, świadome zarządzanie pamięcią) wręcz wymuszał na programistach pisanie kodu wydajnego, bo innego wyjścia po prostu nie było. 

Nowoczesne języki prześcigają się we wprowadzaniu fasad ułatwiających pisanie eleganckiego i reużywalnego kodu, jednakże zapominając, że każda dodatkowa warstwa czy też delegat, po prostu wpływa na wydajność kodu. To z kolei pogłębia stereotypy, że niektóre klasy języków po prostu do real-time'owości się nie nadają. Zanim jednak poprzesz ten osąd, zachęcam Cię do wprowadzenia w życie tych 4 prostych zasad, które starałem się opisać w tym artykule. Może prawda okaże się być inna.

<p>Loading...</p>