Przejdź do treści

DevStyle - Strona Główna
Niebezpieczne programowanie w .NET

Niebezpieczne programowanie w .NET

Szymon Kulec

26 marca 2018

Backend

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

Zobacz również