Strona używa cookies (ciasteczek). Dowiedz się więcej o celu ich używania i zmianach ustawień. Korzystając ze strony wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki.    X

O wydajności w aplikacjach .NET cz. 3

W poprzednim odcinku cyklu O wydajności w aplikacjach .NET omawialiśmy mechanizm Opakowywania i Rozpakowywania (ang. boxing/unboxing), czyli przekształcania pewnych typów danych w typ bazowy (tu Object) i z powrotem. Dziś zajmiemy się kwestią bardziej przyziemną, doskonale znaną wszystkim programistom... Ale czy na pewno?

Operacje na ciągach znaków

Środowisko .NET Framework udostępnia nam typ danych System.String na potrzeby reprezentowania ciągów znaków. Klasa String zawiera chyba wszystko "co tygrysy lubią najbardziej" i jest naprawdę obszernie i dokładnie opisana na stronach MSDN. Taki stan rzeczy wynika z prostego faktu - nasze całe życie opiera się na słowie pisanym i reprezentowaniu znaków w dowolnym języku naturalnym. W wirtualnym świecie słowa i znaki także odgrywają wiodącą rolę. Toteż decydując się na wybór języka programowania (a jest ich kilka) wielu z nas swą decyzję opiera na dostępności, w ramach danej platformy programistycznej, bogatej gamy metod umożliwiających przetwarzanie zarówno pojedynczych znaków jak i ich dłuższych odpowiedników.

Platforma .NET wprowadza znaczny poziom abstrakcji. Nie wszystko co wydaje się z pozoru proste - okiem początkującego programisty - musi mieć równie prostą implementację "za kulisami dotnetu". Obiekt klasy String w kodzie zarządzanym to nic innego jak pewna kolekcja obiektów klasy System.Char reprezentujących dany łańcuch znaków. Z chwilą utworzenia instancji (czyt. utworzenia obiektu) klasy String, wartość tego obiektu nie będzie mogła już ulec zmianie, jest niezmienna (ang. immutable)!.

Przeczytaj ponownie poprzednie zdanie

Wartości obiektów klasy String są wyłącznie do odczytu. Właśnie ta cecha obiektów reprezentujących ciągi znaków i jej powszechna nieznajomość jest powodem poważnych strat w wydajności kodu zarządzanego. Ale każdy popełnia błędy, dlatego spróbujmy czym prędzej naprawić własne.

    Każdorazowa operacja na obiekcie klasy String powodująca zmianę wartości przechowywanego w nim łańcucha znaków sprawia, że:
  • pamięć, w której przechowywany jest obecny ciąg znaków (wartość obiektu), zostanie porzucona
  • konieczna będzie alokacja nowego bloku pamięci zdolnego przechować nową wartość obiektu
  • ponieważ typ String jest typem referencyjnym, a wartości obiektów typu referencyjnego przechowywane są na stercie, stare, nieużywane już łańcuchy znaków będą podlegały - z czasem - odśmiecaniu (ang. garbage collection)

Analogicznie do poprzednich rozważań, pojedyncza operacja tego rodzaju nam nie zaszkodzi (z wyjątkiem naprawdę długich ciągów). W przypadkach, w których zarówno same ciągi znaków jak również ich ilość jest niewielka i z góry znana, śmiało wykorzystuj operator + w celu ich łączenia. Tu wykonamy tę operację wielokrotnie, w pętli, przy użyciu "standardowych praktyk" jak i zoptymalizowanego rozwiązania.

using System; using System.Text; using System.ComponentModel; using System.Security; using System.Runtime.InteropServices; namespace TestPerformance3 { class Program { static void Main(string[] args) { // Implementacja klasy PerformanceCounter dostępna w pierwszej części cyklu PerformanceCounter counter = new PerformanceCounter(); // Ilość iteracji ograniczona. Nie chcemy przecież wyjątku OutOfMemoryException int iterations = 100000; string str = "tekst"; string result = ""; // Domyślna pojemność początkowa StringBuilder sbResult = new StringBuilder(); // Pojemność StringBuilder jest ograniczona, jednak w pętlach nie będziemy sprawdzali przekroczenia zakresu StringBuilder sbCapacityResult = new StringBuilder(iterations * str.Length <= Int32.MaxValue ? iterations * str.Length : Int32.MaxValue); counter.Start(); for (int i = 0; i < iterations; i++) { result += str; } counter.Stop(); Console.WriteLine("Operator +:"); // Każda kolejna iteracja była bardziej kosztowna od poprzedniej Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.WriteLine(); counter.Start(); for (int i = 0; i < iterations; i++) { sbResult.Append(str); } counter.Stop(); Console.WriteLine("StringBuilder.Append:"); Console.WriteLine("StringBuilder.Capacity = {0}", sbResult.Capacity); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.WriteLine(); counter.Start(); for (int i = 0; i < iterations; i++) { sbCapacityResult.Append(str); } counter.Stop(); Console.WriteLine("StringBuilder.Append, wersja 2:"); Console.WriteLine("StringBuilder.Capacity = {0}", sbCapacityResult.Capacity); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.ReadKey(); } } }

Dla wielu, następujących po sobie konkatenacji (piękne słowo) lepszym rozwiązaniem jest wykorzystanie obiektu klasy StringBuilder, który traktować możemy jak akumulator (czyt. bufor).

Domyślna pojemność instancji StringBuilder to 16 znaków a jej przekroczenie skutkować będzie powołaniem do życia nowego obiektu będącego w stanie pomieścić nowy łańcuch tekstowy (dawniej pojemność wzrastała dwukrotnie). Tym samym oszczędzimy na wielu, dla nas niewidocznych, choć zupełnie niepotrzebnych operacjach związanych ze zwalnianiem, odśmiecaniem i przydzielaniem zasobów. StringBuilder nie jest typem pochodnym klasy String, lecz jak każdy inny dziedziczy po typie bazowym metodę ToString, którą w dowolnej chwili możemy wykorzystać w celu przekształcenia typu StringBuilder na typ String. Pamiętajmy, że im początkowa pojemność (ustalana w wywołaniu konstruktora klasy) będzie bliższa rzeczywiście wykorzystywanej w środowisku produkcyjnym - tym więcej zyskamy. Dokonać tego możemy w dwojaki sposób, bądź jawnie definiując zakres i wielkość wprowadzanych danych bądź mierząc średnie zużycie zasobów z wykorzystaniem narzędzi typu Profiler, ale o tym, miejmy nadzieję, wspomnę w przyszłości. Powodzenia!

P.S. Tym razem zaprezentuję wyniki.

Operator +:
231736,764 ns - pojedyncza iteracja
23173,676 ms - caˆłość

StringBuilder.Append:
StringBuilder.Capacity = 504192
12,695 ns - pojedyncza iteracja
1,269 ms - caˆłość

StringBuilder.Append, wersja 2:
StringBuilder.Capacity = 500000
12,728 ns - pojedyncza iteracja
1,273 ms - caˆłość
 

porady programowanie

Komentarze

0 nowych
djfoxer   18 #1 25.02.2012 18:49

Dobre, b.dobre, mało osób o tym wie, a jak dziś pamiętam jak na rozmowie o pracę spytano się o różnicę pomiędzy String a StringBuilderem :) Na szczęście w książce O'REILLY była o tym wzmianka :P

alucosoftware   7 #2 25.02.2012 18:52

@djfoxer
No to tylko "taka mała" różnica :) Ale parę sekund można już zaoszczędzić.

  #3 26.02.2012 01:54

wpisy coraz ciekawsze, bardzo pomocne informacje. czekam na kolejne części.

revcorey   7 #4 26.02.2012 20:55

W javie to samo Stringbuilder.

anakkin   6 #5 26.02.2012 22:27

Na razie nie robiłem testów wydajnościowych, ale nowe jar'y dla Java'y 7 i skompilowane kody dla C# (x64) można pobrać tutaj:
http://chomikuj.pl/anakkin/O+wydajnosci+w+aplikacjach+.net+i+java

Dodatkowo zaktualizowałem programy z drugiego wpisu o metody clear()/Clear().

anakkin   6 #6 27.02.2012 11:56

W Java'ie jest dodatkowo StringBuffer. Różni się jedynie od StringBuildera tym, że jego metody są 'synchronized', czyli jednocześnie maksymalnie jeden wątek może uruchamiać metody obiektu (reszta wątków, jeśli też coś chce wywołać na obiekcie, musi poczekać)

alucosoftware   7 #7 27.02.2012 12:37

Pytanie do zainteresowanych: Dlaczego klasa StringBuilder, jak i większość klas w .NET nie jest domyślnie przystosowana do pracy wielowątkowej? Tzn. dlaczego metody nie są zsynchronizowane?

revcorey   7 #8 27.02.2012 12:51

@alucosoftware
Z tego co pamiętam poprzedzenie funkcji w javie słowem synchronized powoduje pewien dodatkowy narzut(dlatego może czasami warto bardziej użyć sekcji synchronizujących), być może podobnie jest w .net i właśnie dlatego.

alucosoftware   7 #9 27.02.2012 13:54

@revcorey
+1
Dlatego też, jeżeli nie jest nam potrzebna synchronizacja, to po co obciążać aplikację na poziomie takiej klasy (tu StringBuilder). Jeżeli będzie już to niezbędne, deweloper zaimplementuje synchronizację na wyższym poziomie abstrakcji - a czy zrobi to poprawnie czy też z opłakanym skutkiem, to już leży w jego gestii.

anakkin   6 #10 27.02.2012 15:36

Ale z drugiej strony takie klasy jak StringBuffer powinny istnieć. Nie ma sensu pisać superszybkiej synchronizacji samemu, jeśli wiadomo, że zawsze trzeba będzie czekać długo na wątek np. pobierający coś z bazy. Jeśli ktoś uważa inaczej, to powinien zacząć pisać w assemblerze zamiast w C#/Java.

alucosoftware   7 #11 27.02.2012 16:10

@anakkin
Powinny istnieć, owszem. Ale nie ze względu na konieczność pisania własnej implementacji "superszybkiej synchronizacji". Bo cóż prostszego znajdziesz od:
lock (syncObject)
{
}
albo innej implementacji z użyciem WaitHandle...

anakkin   6 #12 27.02.2012 16:55

Zawsze można zejść poziom niżej przez np. JNI, ale nie wiem czy to faktycznie przyspieszy...

  #13 03.03.2012 11:41

kiedy kolejna czesc?

alucosoftware   7 #14 04.03.2012 10:38

@Anonim
Niebawem, ostatnio mam dużo pracy. Dziękuję za wytrwałość :)