devstyle.pl - Blog dla każdego programisty
devstyle.pl - Blog dla każdego programisty
0 8 minut

Niebezpieczne programowanie w .NET


26.03.2018

Jest takie słowo kluczowe, przed którym truchleje część programistów C#. Sama jego nazwa zaznacza, że opuszczamy bezpieczny świat zarządzany i wkraczamy w królestwo gwiazdek i operacji na byte’ach.

UWAGA: autorem tekstu jest Szymon Kulec. Dajcie znać jak się podoba, Szymon prawdopodobnie zagości na devstyle na dłużej ;).

Czy słowo to faktycznie przysparza samych problemów? A może istnieją przypadki w których warto je użyć? W tym artykule spojrzymy na unsafe z punktu widzenia tego, co możemy zrobić w niebezpiecznym kontekście i co nam on daje.

O mój Stosie!

Każdy kto pracuje w środowisku .NET świadomy jest zapewne dwóch obszarów pamięci z którymi pracujemy.

Pierwszym jest sterta (ang. heap), na której alokowane są instancje typów referencyjnych, czyli wszystkie, które zadeklarowano jako klasy. Trafiają tam też tablice.
Zależnie od rozmiaru, obiekt może być umieszczony na stercie małych obiektów (ang. Small Object Heap, SOH) lub stercie dużych obiektów (ang. Large Object Heap, LOH).

Drugim obszarem jest stos, czyli obszar roboczy wątku, który wykonuje nasz kod. To tam znajdą się wszystkie zmienne lokalne.

Wspomniana wcześniej tablica jest wyjątkowo niefortunnym przypadkiem. Tworzenie tablic możemy znaleźć w wielu miejscach. Szczególnie bolesne jest tworzenie tablic małych, których używamy do różnego rodzaju prostych obliczeń, transformacji i potem się ich pozbywamy.

Częste alokacje to jedna z częstych przyczyn powolnego działania aplikacji .NET. Czy istnieje zatem jakiś bardziej wydajny sposób, być może “niebezpieczny”, który pozwala efektywniej używać pamięci, niekoniecznie zmuszając mechanizm Garbage Collectora (GC) do zbierania pozostawionych obiektów?

Odpowiedzią jest słowo kluczowe stackalloc, które pozwala na tworzenie odpowiednika tablic na stosie. Spójrzmy na następujący przykład funkcji, która dla danego klucza pobiera dane i wylicza na nich jakiś hash. Widać, że dzięki posiadaniu informacji o maksymalnym rozmiarze danych, możemy utworzyć bufor na stosie i podać go jako parametr do wypełnienia. Potem, przekazać do funkcji hashującej i zwrócić wartość bez kosztownej alokacji tablicy.


public unsafe int HashValue (Guid key)
{
    const int size = 40;
    byte* data = stackalloc byte[size];
    var length = FillWithData (key, data);
    return Hash(data, length);
}

O ile taka operacja niekoniecznie będzie potrzebna w Twoim kontrolerze API, o tyle, przy częstym wykonywaniu jakiejś funkcji z logiki biznesowej (szczególnie algorytmu), może w znacznym stopniu zmniejszyć obciążenie związane z alokacjami na stercie, zmniejszając czas działania GC, a tym samym zwiększając przepustowość Twojej aplikacji.

Przypnij sobie uchwyt

W poprzednim przykładzie pojawiła się już magiczna gwiazdka, czyli nic innego jak stary dobry wskaźnik, którego na co dzień nie uświadczysz w kodzie pisanym w C#. Wskaźniki, w przeciwieństwie do referencji, nie są zarządzane przez CLR i to właśnie Ty musisz odpowiednio je obsłużyć.

Co natomiast, jeżeli w jakimś przypadku chcielibyśmy uzyskać wskaźnik, np. do tablicy, po to, aby wykonać na niej pewne operacje? Czy istnieje coś, co łączy świat obiektów alokowanych na stercie i świat wskaźników?

Rzeczą, która pomoże nam połączyć te dwa odległe światy, jest słowo kluczowe fixed. Pozwala ono na przypięcie (ang. pinning) obiektu tablicy w tym samym miejscu pamięci i otrzymanie wskaźnika do tak przypiętego (ang. pinned) obiektu. Poniższy przykład, z implementacji funkcji hashującej Murmur3 pokazuje, jak uzyskać dostęp do wskaźnika do danych. Zauważmy, że ze względu na to, iż wskaźnik nie posiada długości, dodatkowo do kolejnej metody podajemy liczbę byte’ów.


public static unsafe uint Hash32(byte[] buffer, uint seed)
{
    fixed (byte* bufPtr = buffer)
    {
        return Hash32(bufPtr, buffer.Length, seed);
    }
}
public static unsafe uint Hash32(byte* data, int length, uint seed)
{
    // very fast and very unsafe implementation here ;-)
}

Przypięcie obiektu przy użyciu słowa fixed trwa do momentu wyjścia z bloku tego słowa. Po wyjściu, obiekt może być przenoszony przez Garbage Collector, jeżeli ten wykonywał będzie zbieranie śmieci. W czasie gdy obiekt jest przypięty, nie może zostać poruszony przez mechanizm Garbage Collectora.

Przypinam was, dopóki koniec aplikacji nas nie rozłączy

Istnieją przypadki, w których przypięcie obiektów nie powinno być limitowane do jednego bloku. Typowym przykładem są tablice byte’ów, które chcemy używać podczas całego działania aplikacji i które chcemy używać zarówno ze świata zarządzalnego (operowanie na byte[]) jak i niezarządzanego (byte*). Szczególnie przydatne jest to w pisaniu niskopoziomowych bibliotek, takich jak Kestrel, czy innych, łączących się ściśle z systemem operacyjnym, siecią czy platformą.

Jak zatem uzyskać obiekt, który nie będzie się poruszał (będzie przypięty) przez dłuższy czas i w jaki sposób uzyskać do niego wskaźnik?

Okazuje się, że .NET dostarcza strukturę do tego potrzebną. Nazywa się GCHandle i pozwala na przypięcie obiektu w pamięci, dopóki nie zostanie wywołana odpowiednia metoda kończąca to przypięcie. Spójrzmy na przykład, który pozwala na używanie tablicy utworzonej na zarządzanej stercie, która może być także używana w kodzie używającym wskaźników:


var bigTableForProcessing = new byte [1024 * 1024];
var gc = GCHandle.Alloc(bigTableForProcessing, GCHandleType.Pinned);
byte* unsafeBigTableForProcessing = (byte*) gc.AddrOfPinnedObject();

// when this buffer is no longer needed
gc.Free(); // to unpin object

Nadpisywania, wycieki i inne problemy 1-ego świata

Z wszystkimi technikami opisanymi powyżej wiąże się kilka niebezpieczeństw.

Użycie wskaźników pozwala odczytać dane z innych obszarów aplikacji, nawet jeśli danych tego typu tam nie było. Podobnie z obszarem pamięci dostarczonym przez stackalloc – to po naszej stronie leży odpowiedzialność za sprawdzenie czy nie wychodzimy poza długość bloku pamięci tam dostarczonego.

Czy zatem warto używać tych mechanizmów? Tak, ale ostrożnie!

Jeżeli fragment Twojego kodu przelicza tony danych, jeżeli alokacje to faktyczny problem, jeżeli musisz porozumiewać się ze światem niezarządzanym, to powyższe konstrukcje jak najbardziej mogą przyczynić się do dużych wydajnościowych zysków.

Not so unsafe

Ponieważ alokacja na stosie, przy użyciu stackalloc, jest bardzo częstym mechanizmem do pozyskania małego obszaru roboczego pamięci, w najnowszej wersji języka C# i platformy .NET przesunięto go do bezpiecznej części języka. Możliwe to było dzięki nowej struktury danych, Span<T>. Struktura ta zachowuje się bardzo podobnie do tablicy, czy też wskaźnika, ale pozwala na konstrukcję jej w bezpiecznym kontekście.


Span<byte> bytes = stackalloc byte [32];

Przypuszczam, że Span<T> i związany z nim stackalloc będzie coraz częściej spotykanym tworem w naszym kodzie, pozwalając na wytwarzanie oprogramowania, które od samego począku, będzie niezwykle wydajne.

Podsumowanie

Słowa unsafe nie znajdziesz w kontrolerach API ani w mapowaniu encji EntityFramework. Obszary, gdzie jest to przydatne to funkcje, które są często wykonywane i alokują niepotrzebnie duże ilości obiektów albo elemety współpracujące z niskimi API systemu operacyjnego lub bibliotek napisanych w kodzie niezarządzalnym.

Będąc uzbrojonym w unsafe, stackalloc i GCHandle możesz śmiało wejść do Królestwa Niezarządzanego Performance’u.

Materiały dodatkowe:

  1. Span – świetny artykuł Adama Sitnika opisujący jak działa Span<T>
  2. Implementacja Span w CoreCLR
  3. Allocation is cheap… until it is not – dogłębny opis alokacji w .NET by Konrad Kokosa

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by ConvertKit
Powiadom o
Kendzior
Kendzior

TLDR – za długi / zakłócony work life balance.

Scooletz

Jest jakoś tak, żeby mniej alokować, trzeba więcej pisać ;-)

Piotr
Piotr

Sam jestem zdania, że takie podejście jest lepsze dla systemu, jednak jak często się zdarza. Nie każdy podziela takie zdanie i zostaniesz uznany za heretyka jak ja :D Powoli zaczynam zauważać trend „wrzucajmy jak najwięcej z języków funkcyjnych do C#”, fajnie się taki kod czyta i używa, jednak potem jest właśnie problem o którym wspomniałeś, system pluje nowymi instancjami jak z karabinu, gdy ktoś nie pomyśli, że będzie to używane wielokrotnie. Dla mnie osobiście takie mięso jak najbardziej na tak. Zresztą, po ostatnim Waszym tournee, zaczepił mnie jeden programista na innej konferencji, gdzie spierałem się z prelegentem nt. namiętnego używania… Czytaj więcej »

Scooletz

W nowym, lepszym C#, nie tylko funkcyjne smaki zostają dodane. Jest tam też dużo performance’owego Habanero: ValuteTuple, ValuteTask, możliwość zwracania referencji do struktury (ref T). Więc zgadzam się, że z jednej strony dostajemy więcej F# w C#, ale do programowania systemowego, czy po prostu, wydajnego, nadaje sie coraz bardziej.

> więc nie wszystkim jest znany fakt jak działa HEAP, STACK, GC i ogólnie pamięć

Jasne. Stąd meetupy i także DotNetos

Piotr
Piotr

Fakt, pół biedy jak dodaje to MS do C#, ponieważ w jakiś tam sposób jest to optymalizowane. Funkcyjność jest fajna, sam jej używam, jednak zawsze zapala mi się lampka, czy na pewno dobrze myślę. Gorzej jak ktoś na własną rękę próbuje przenieść funkcyjność F# do C# nie rozumiejąc jak to działa pod spodem m.in przez tworzenie niezmiennych wartości za pomocą klas, przykład z naszego podwórka. String posiada string pooling w miejscach, gdzie wartości są identyczne, więc scala instancje, więc jest to jakaś optymalizacja. Gorzej tak jak wyżej wspomniałeś, czy to nawet Adam Sitnik, kiedy musisz coś kopiować i tak naprawdę… Czytaj więcej »

Marcin Z
Marcin Z

Wg mnie troche powierzchownie potraktowany temat „unsafe”, szczegolnie jesli chodzi o ryzyko z nim zwiazane. Wlasnie korzystanie z unsafe tworzy ryzyko wystepowania w Twoim programie calej klasy problemow z kategorii bezpieczenstwa i nie tylko, z ktorych w innych przypadkach jestes kryty. Zeby daleko nie szukac w samym .NET frameworku zdarzaly sie powazne bugi (RCE) z powodu mieszania swiatow unsafe i managed, ktore trudno zauwazyc. Jest to szczegolnie niebezpieczne, gdy temat tak bardzo splycamy, szczegolnie dla osob ktore wiele lat spedzily w swiecie managed, zupelnie odciete od jakichkolwiek „wnetrznosci”. Mozna z pelnym powodzeniem wystarczajaco wydajnie korzystac ze swiata unmanaged nie schodzac… Czytaj więcej »

Scooletz

Oczywiście tematu w jednym artykule nie wyczerpałem, to tylko 800 słów:) Na temat runtime’u, optymalizacji, stosów i stert powstają książki.
Piszesz „Mozna z pelnym powodzeniem wystarczajaco wydajnie korzystac ze swiata unmanaged nie schodzac do unsafe” i na koniec artykułu to pokazuję. Ten ruch, stronę większej wydajności bez opuszczania wszystkich sprawdzeń ze świata zarządanego już widać i widać będzie jeszcze bardziej (jak chociażby w zbliżających się, niealokujących asyncach, itp. itd).

Dzięki za komentarz

Marek
Marek

„Częste alokacje to jedna z częstych przyczyn powolnego działania” – zastanawiam się nad tym stwierdzeniem. Nie znam się na szczegółach działania plarformy .NET – sam pochodzę ze środowiska Javy i zacząłem się zastanawiać jak to działa tutaj. Wedle mojej wiedzy tworzenie obiektów w Javie jest stosunkowo tanie. Java faktycznie ma już zaalokowaną pamięć, która jest jej potrzebna i udostępnienie miejsca na stworzenie nowego obiektu, to jedynie przesunięcie wskaźnika na nowe wolne miejsce w pamięci (dzięki użyciu tzw. TLAB unikamy również synchronizacji). Tworzenie wielu obiektów nie wpływa również na długość działania gc (ew. na jego częstotliwość), ponieważ w tym wypadku liczą… Czytaj więcej »

Piotr
Piotr

Problemem nie jest alokacja ponieważ jest ona bardzo szybka, problem zaczyna się w momencie de-alokacji obiektu kiedy GC musi zacząć sprzątać i idąc dalej najłatwiej GC’kowi czyścić Gen0 a potem pamięć Gen1, ponieważ wykonuje tylko i wyłącznie częściowe czyszczenie. Problem zaczyna się kiedy obiekt zostaje wypromowany do Gen2. Jest to największa pamięć z tych wszystkich, jednak w momencie kiedy zabraknie pamięci GC wykonuje pełne czyszczenie co powoduje usunięcie wszystkiego danych, samo odpalanie full-GC powoduje zastój systemu oraz potrzeba ładowania danych ponownie(wszystkie buffory, cache itd.). Idąc dalej, standardowo pamięć dzielimy na HEAP i STACK, jak mówimy o typach referencyjnych to HEAP… Czytaj więcej »

Scooletz

Amen :)

Marek
Marek

Podejrzewam, że dealokacja odbywa się na podobnej zasadzie jak w Javie (przepraszam wszystkich miłośników .NET, ale to środowisko znam najlepiej) – czyli dalej „boli” nas tylko ilość żywych obiektów. Skoro tworzymy ich dużo, to pewnie większość ginie również bardzo szybko – zgodnie z „weak generational hypothesis”, która jest zresztą argumentem za generacyjnym gc. Dalej nie do końca rozumiem tej niechęci odnośnie tworzenia obiektów. Przyznaję jednak, że nie robiłem nigdy żadnych porównań w tym temacie i uznaję stwierdzenie autora. Ostatecznie w C# nieco łatwiej o niezamierzone utrzymywanie obiektów w pamięci – co prowadzi do wydłużonego przetrzymywania i wpadania obiektów do starszych… Czytaj więcej »

Piotr
Piotr

Java i .NET działa na podobnych zasadach, jednak różni się w niektórych miejscach. Z tworzeniem nowych instancji bardziej bym był skłonny, aby przy zobaczeniu „new” zapaliła się lampka, niżeli „nie robimy wielu instancji, bo tak”. Jeśli w pewnych sytuacjach można wyeliminować tworzenie instancji lub zminimalizować ich ilość to powinno to się uwzględnić ze względu na to, że brak GC jest lepsze niż jakikolwiek GC. Złym przykładem tworzenia wielu instancji są wszystkiego rodzaju buffory, które dla każdej metody odpytują bazę danych, chociaż praktycznie można byłoby to zrobić za pomocą serwisu lub repozytorium razem z mechanizmem cache’owania. Problem jaki tutaj się pojawia… Czytaj więcej »

Scooletz

Nie wszystko co zwraca funkcja musi być obiektem. W świecie .NET mamy też struktury (struct, ValueType), które mogą być zwrócone z funkcji bez alokacji. Jeżeli chodzi o struktury, ale danych, to zestaw kolekcji immutable w .NET pozwala na alokowanie „możliwie mało”, tzn. po dodaniu jednego elementu do niezmiennej listy, zwróci ona obiekt, który będzie trzymał też referencje do starej listy, nie przepisując wszystkiego na na nowo. Ostatni aspekt, to samo programowanie funkcyjne, którego przedstawicielem na platformie .NET, jest F#. Z tego co wiem (nie używam go), posiada on kilka feature’ów, które pomagają w „niealokowaniu”, jak chociażby aliasy typów (przykład: https://fsharpforfunandprofit.com/posts/typesafe-performance-with-compiler-directives/#using-type-aliases… Czytaj więcej »

marbel82

Bardzo przyjemny post. Prosimy o więcej! :)
Może napiszesz coś na temat BenchmarkDotNet i dlaczego nie powinno się testować czasu pojedynczych wywołań (krótkich funkcji) za pomocą StopWatch.

Scooletz

Dziękuję!

Twój komentarz już jest fragmentem odpowiedzi! Nie zasypiam gruszek w popiele. Coś może;-) się jeszcze pojawi.

Moja książka „Zawód: Programista”

Facebook

Zobacz również