fbpx
devstyle.pl - Blog dla każdego programisty
devstyle.pl - Blog dla każdego programisty
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
0 0 votes
Article Rating
15 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Kendzior
Kendzior
6 years ago

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

Scooletz
6 years ago
Reply to  Kendzior

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

Piotr
6 years ago

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 podejścia funkcyjnego w C# i ogólnie “new”, gdzie popadnie i mówi. ” Wczoraj byli chłopacy z DotNetos w Gdańsku i opowiadali co się dzieje jak wywołujesz “new”, ile to faktycznie jest roboty przy alokacji i co tam się dzieje, widzę nie tylko oni mają takie podejście”, więc nie wszystkim jest znany fakt jak działa HEAP, STACK, GC i ogólnie pamięć :-)

Scooletz
6 years ago
Reply to  Piotr

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
6 years ago
Reply to  Scooletz

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ę zapychasz pamięć Gen2, gdzie GC w końcu sam mówi dość i czyści wszystko. Dodaj do tego domorosłe rozwiązania funkcyjne, które niekoniecznie są zoptymalizowane, dlatego uważam takie inicjatywy jak Wasze za potrzebne, zresztą sam chcę usłyszeć różnego rodzaju smaczki.

Marcin Z
Marcin Z
6 years ago

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 do “unsafe”.
Jednak w pelni sie zgadzam, ze warto znac te wszystkie mechanizmy i miec je w swoim toolboxie, by w odpowiednim momencie moc je wyjac i w poprawny sposob wykorzystac.

Pozdrawiam.

Scooletz
6 years ago
Reply to  Marcin Z

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
6 years ago

“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ą się jedynie żywe obiekty.
Popularny jest obecnie trend na podejście funkcyjne oraz obiekty “immutable”. Oczywiście każde dodatkowe stworzenie obiektu nakłada pewien narzut, szczególnie jeśli robimy to miliony razy na sekundę. Tego typu podejście zwiększa również zapotrzebowanie na pamięć – aplikacja potrzebuje zwykle więcej pamięci, żeby utrzymać częstotliwość gc w ryzach.
Z drugiej strony dzięki takiemu podejściu trudniej jest nam wprowadzać błędy podczas wielowątkowych operacji, jako że dane obiektu po jego stworzeniu są w zasadzie read-only. No i są ludzie, którzy argumentują to czytelnością kodu :p

Czy w środowisku .NET występuje jakiś dodatkowy czynnik wpływający negatywnie na czas tworzenia obiektu? Jaki jest stosunek autora do podejścia funkcyjnego, jest to ślepa uliczka, czy może zwyczajnie jak wszystko trzeba wykorzystywać z umiarem i rozsądnie? Czy tego typu trend występuje również w tym środowisku?

Piotr
6 years ago
Reply to  Marek

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 przetrzymuje faktycznie takie obiekty i taki obiekt dzieli się na kolejne.
Weźmy na przykład klasę Person, posiada ona pola _name, _surname i teraz taki obiekt w pamięci dzieli się na Object Header Word(OHW, również znany pod Sync Block Index), Method Table Pointer(MTP) oraz resztę własności jak Name i Surname. MTP sam w sobie posiada kolejny podział jak Method Table(MT) i EEClass(Execution Engine Class), gdzie MT jest swoistym systemem do przetrzymywania często używanych informacji jak metody a EEClass dla tych mniej.
Między innymi dlatego metody w C# nie są z założenia wirtualne, gdyby JIT nie mógł przewidzieć podczas kompilacji jaka metoda będzie wywołana(według polimorfizmu), która faktycznie posiada tylko jedną implementacje to nie można byłoby zastosować coś co w świecie CLR jest znane jako “method inlining”. W skrócie zatłukł byś aplikacje bardzo szybko przez brak pamięci, gdyż za każdym razem dla każdego obiektu musiałaby tworzyć MT w MTP dla każdej przedefiniowanej metody, które jest bardzo kosztowne zamiast po prostu ją wywołać.
“Dzięki użyciu tzw. TLAB unikamy również synchronizacji”
W .NET’cie synchronizacją, GC, finalizacją, hash code’mi i jeszcze kilkoma innymi rzeczami zajmuje się właśnie OWH, jednak tworzenie synchronizacji jest bardzo drogą czynnością, dlatego kompilator używa czegoś takiego jak “thiny lock”, jednak w momencie wywołania lock’a czy Monitor’a(lock to taki lukier składniowy dla Monitora) i próbie synchronizacji przez inny wątek zostanie stworzony tzw. „sync block” w OWH(czy tam SBI) z inkrementacją dla każdego lock’a tzw “sync block #1” w pamięci itd. CLR przydziela nowe miejsce dla kolejnych sync block’ów.
Wszystko to co napisałem u góry jest narzutem na pamięć, teraz pomyśl ile to dla systemu znaczy wywoływanie „new” milion razy

Co do Twojego pytania o podejście funkcyjne, ten temat poruszyłem wyżej. Nie będę wypowiadał się jako autor, jednak sam odczuwam dążenie do przejścia na funkcyjne programowanie, sam używam takiego podejścia, jednak tylko przeważnie w testach w celu łączenia ze sobą DRY i DAMP.

Scooletz
6 years ago
Reply to  Piotr

Amen :)

Marek
Marek
6 years ago
Reply to  Piotr

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 generacji, a czasem nawet wycieków pamięci.

Co do TLAB i synchronizacji – chyba nie potrzebnie pojechałem tak głęboko. TLAB to po prostu fragment pamięci do wyłącznego użytku danego wątku (w sensie tylko dany wątek może tam tworzyć obiekty, dostępne są one oczywiście dla każdego innego wątku) – dzięki temu wątki nie biją się o pamięć do alokacji – taki niuans. Synchronizacja w sensie “lock” ponownie działa na podobnej zasadzie, każdy obiekt posiada header i on jest wykorzystywany do zakładania locków.

Nie za bardzo rozumiem jaki problem powodują wirtualne metody. Każda klasa ma swoją vtable, która trzyma wskaźniki do odpowiednich metod. Obiekt zaś musi mieć jedynie wskaźnik na klasę. Co takiego dzieje się w .NET, że muszą być tworzone nowe obiekty? “Method inlining” też jest wykonywany przez JIT Javy :D chociaż faktycznie wirtualne metody mogą mu w tym przeszkodzić. Swoją drogą, o ile dobrze pamiętam, każda metoda poniżej 32 bajtów jest automatycznie inlinowana – z tego powodu w zasadzie nie ma narzutu na gettery/settery względem bezpośredniego dostępu (wiem, i tak jest to brzydkie rozwiązanie w porównaniu z properties).

Dzięki za obszerne wyjaśnienia, każdego dnia człowiek może nauczyć się czegoś nowego :)

Piotr
6 years ago
Reply to  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 to ładowanie takich obiektów do LOH(Large Object HEAP), jest to kolejna pamieć po generacjach, która przetrzymuje duże pliki danych. GC troszkę inaczej traktuje ten rodzaj miejsca w pamięci, jednak w momencie przepełnienia go robi full-GC, tak samo jak podczas przepełnienia Gen2. Full-GC dla wszystkiego rodzaju serwisów web’owych ma katastrofalne skutki, ponieważ jak wspomniałem usuwa wszystkie dane podręczne z których korzystają wszystkie osoby i mechanizmy w systemie.
Architekci .NET i C# podjęli takie podejście, że nie wszystkie metody są wirtualne ze względu na to, że JIT musi wiedzieć przed runtime’m informacje nt. implementacji którą będzie optymalizował. W .NET’cie metody oznaczone jako virtual automatycznie są pomijane przez JIT podczas inlining’owania.
Gdy metoda jest oznaczona jako virtual to w MTP masz tworzone specjalne miejsce dla niej tj. w MT. Teraz zauważ, że faktycznie metody, które faktycznie mogły by być inlining’owane nie są, a zarazem tworzysz dla GC kolejny wierzchołek w grafie, który musi odwiedzić a czas m.in mechanizmu “mark” zależy od ilości obiektów w programie, które musi przejść.
Sprawa kolejna i bardziej błaha. Im więcej obiektów tym większa szansa, że jakiś zostanie wypromowany do Gen2 i problem zaczyna się w miejscach oznaczanych przez GC jako “root” od których zaczyna a który może być długo żyjącym obiektem.

Scooletz
6 years ago
Reply to  Marek

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 ), które znów mogą bazować na wsześniej wspomnianych strukturach.

marbel82
6 years ago

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
6 years ago
Reply to  marbel82

Dziękuję!

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

Kurs Gita

Zaawansowany frontend

Szkolenie z Testów

Szkolenie z baz danych

Książka

Zobacz również