Zagadnienia dotyczące wydajności technologii Run-Time w .NET Framework
Emmanuel Schanzer
Microsoft Corporation
Sierpień 2001 r.
Krótki opis: Ten artykuł zawiera badanie różnych technologii w pracy w zarządzanym świecie oraz techniczne wyjaśnienie wpływu na wydajność. Dowiedz się więcej o działaniach odzyskiwania pamięci, JIT, komunikacji zdalnie, valueTypes, zabezpieczeń i nie tylko. (27 drukowanych stron)
Zawartość
Omówienie
Odzyskiwanie pamięci
Pula wątków
The JIT
Domena aplikacji
Zabezpieczenia
Usług zdalnych
ValueTypes
Dodatkowe zasoby
Dodatek: Hostowanie czasu wykonywania serwera
Omówienie
W czasie wykonywania platformy .NET wprowadzono kilka zaawansowanych technologii mających na celu bezpieczeństwo, łatwość programowania i wydajność. Jako deweloper ważne jest, aby zrozumieć każdą z technologii i efektywnie używać ich w kodzie. Zaawansowane narzędzia dostarczane przez czas wykonywania ułatwiają tworzenie niezawodnej aplikacji, ale szybkie latanie aplikacji jest (i zawsze było) obowiązkiem dewelopera.
Ten oficjalny dokument powinien zapewnić dokładniejsze zrozumienie technologii w pracy na platformie .NET i pomoże Ci dostosować kod pod kątem szybkości. Uwaga: nie jest to arkusz specyfikacji. Istnieje już mnóstwo solidnych informacji technicznych. Celem jest dostarczenie informacji o silnym przechyleniu w kierunku wydajności i może nie odpowiadać na każde posiadane pytanie techniczne. Zalecamy dalsze wyszukiwanie w bibliotece MSDN Online, jeśli nie znajdziesz tutaj odpowiedzi.
Omówię następujące technologie, zapewniając ogólny przegląd ich przeznaczenia i ich wpływ na wydajność. Następnie zagłębię się w niektóre szczegóły implementacji niższego poziomu i użyję przykładowego kodu, aby zilustrować sposoby uzyskania szybkiego wyjścia z każdej technologii.
Odzyskiwanie pamięci
Podstawowe informacje
Odzyskiwanie pamięci (GC) zwalnia programistę z typowych i trudnych do debugowania błędów, zwalniając pamięć dla obiektów, które nie są już używane. Ścieżka ogólna, po której następuje okres istnienia obiektu, jest następująca, zarówno w kodzie zarządzanym, jak i natywnym:
Foo a = new Foo(); // Allocate memory for the object and initialize
...a... // Use the object
delete a; // Tear down the state of the object, clean up
// and free the memory for that object
W kodzie natywnym musisz wykonać wszystkie te czynności samodzielnie. Brak faz alokacji lub oczyszczania może spowodować zupełnie nieprzewidywalne zachowanie, które jest trudne do debugowania, i zapomnienie o wolnych obiektach może spowodować przecieki pamięci. Ścieżka alokacji pamięci w środowisku uruchomieniowym języka wspólnego (CLR) znajduje się bardzo blisko ścieżki, którą właśnie omówiliśmy. Jeśli dodamy informacje specyficzne dla GC, w końcu pojawi się coś, co wygląda bardzo podobnie.
Foo a = new Foo(); // Allocate memory for the object and initialize
...a... // Use the object (it is strongly reachable)
a = null; // A becomes unreachable (out of scope, nulled, etc)
// Eventually a collection occurs, and a's resources
// are torn down and the memory is freed
Dopóki obiekt nie zostanie uwolniony, te same kroki zostaną wykonane w obu światach. W kodzie natywnym należy pamiętać, aby zwolnić obiekt po zakończeniu pracy z nim. W kodzie zarządzanym, gdy obiekt nie jest już osiągalny, GC może go zebrać. Oczywiście, jeśli zasób wymaga szczególnej uwagi, aby zwolnić (powiedzmy zamknięcie gniazda) GC może potrzebować pomocy, aby poprawnie go zamknąć. Kod napisany wcześniej w celu wyczyszczenia zasobu przed zwolnieniem jest nadal stosowany w postaci metod Dispose() i Finalize(). Omówię różnice między tymi dwoma później.
Jeśli zachowasz wskaźnik do zasobu wokół, GC nie ma możliwości, aby wiedzieć, czy zamierzasz go używać w przyszłości. Oznacza to, że wszystkie reguły używane w kodzie natywnym do jawnego zwalniania obiektów nadal mają zastosowanie, ale przez większość czasu GC będzie obsługiwać wszystko za Ciebie. Zamiast martwić się o zarządzanie pamięcią sto procent czasu, musisz martwić się tylko o to około pięć procent czasu.
Moduł zbierający pamięci CLR jest modułem zbierającym generacji, znaczników i kompaktowania. Jest to zgodne z kilkoma zasadami, które pozwalają osiągnąć doskonałą wydajność. Po pierwsze, istnieje przekonanie, że obiekty, które są krótkotrwałe, są zwykle mniejsze i często są dostępne. GC dzieli graf alokacji na kilka podrzędnych grafów, nazywanych generacjami, co pozwala poświęcać jak najwięcej czasu na zbieranie, jak to możliwe.* Gen 0 zawiera młode, często używane obiekty. Jest to również najmniejsze i zajmuje około 10 milisekund do zebrania. Ponieważ GC może ignorować inne generacje w tej kolekcji, zapewnia znacznie wyższą wydajność. G1 i G2 są przeznaczone dla większych, starszych obiektów i są zbierane rzadziej. Po wystąpieniu kolekcji G1 zbierane jest również G0. Kolekcja G2 jest pełną kolekcją i jest jedynym razem, gdy GC przechodzi cały graf. Umożliwia również inteligentne wykorzystanie pamięci podręcznych procesora CPU, które mogą dostroić podsystem pamięci dla określonego procesora, na którym działa. Jest to optymalizacja, która nie jest łatwo dostępna w alokacji natywnej i może pomóc aplikacji zwiększyć wydajność.
Kiedy odbywa się kolekcja?
Po utworzeniu alokacji czasu GC sprawdza, czy kolekcja jest potrzebna. Analizuje rozmiar kolekcji, ilość pamięci pozostałej oraz rozmiary każdej generacji, a następnie używa heurystyki do podjęcia decyzji. Do momentu wystąpienia kolekcji szybkość alokacji obiektów jest zwykle tak szybka (lub szybsza) niż C lub C++.
Co się stanie w przypadku wystąpienia kolekcji?
Przeanalizujmy kroki wykonywane przez moduł odśmiecający elementy podczas odzyskiwania pamięci. GC utrzymuje listę korzeni, które wskazują na stertę GC. Jeśli obiekt jest żywy, w stercie znajduje się katalog główny do jego lokalizacji. Obiekty w stercie mogą również wskazywać się na siebie nawzajem. Ten wykres wskaźników jest tym, przez co GC musi wyszukiwać, aby zwolnić miejsce. Kolejność zdarzeń jest następująca:
Zarządzana sterta przechowuje całą przestrzeń alokacji w ciągłej blokadzie, a gdy ten blok jest mniejszy niż żądana kwota, wywoływana jest GC.
GC jest zgodny z każdym elementem głównym i wszystkimi wskaźnikami, które następują, zachowując listę obiektów, które nie są osiągalne.
Każdy obiekt nieosiągalny z żadnego katalogu głównego jest uznawany za możliwy do zbierania i jest oznaczony jako kolekcja.
Rysunek 1. Przed kolekcją: Należy pamiętać, że nie wszystkie bloki są osiągalne z katalogów głównych!
Usunięcie obiektów z wykresu osiągalności sprawia, że większość obiektów można zbierać. Jednak niektóre zasoby muszą być obsługiwane specjalnie. Podczas definiowania obiektu masz możliwość zapisania metody Dispose() lub metody Finalize() (lub obu tych metod). Omówię różnice między nimi, a kiedy ich użyć później.
Ostatnim krokiem w kolekcji jest faza kompaktowania. Wszystkie obiekty, które są używane, są przenoszone do ciągłego bloku, a wszystkie wskaźniki i korzenie są aktualizowane.
Kompaktowanie obiektów na żywo i aktualizowanie adresu początkowego wolnego miejsca, GC utrzymuje, że całe wolne miejsce jest ciągłe. Jeśli za mało miejsca do przydzielenia obiektu, GC zwraca kontrolę do programu. Jeśli nie, zgłasza wartość
OutOfMemoryException
.Rysunek 2. Po kolekcji: bloki osiągalne zostały skompaktowane. Więcej wolnego miejsca!
Aby uzyskać więcej informacji technicznych na temat zarządzania pamięcią, zobacz Rozdział 3 of Programming Applications for Microsoft Windows by Jeffrey Richter (Microsoft Press, 1999).
Oczyszczanie obiektu
Niektóre obiekty wymagają specjalnej obsługi przed zwróceniem ich zasobów. Kilka przykładów takich zasobów to pliki, gniazda sieciowe lub Połączenia z bazą danych. Po prostu zwolnienie pamięci na stercie nie będzie wystarczające, ponieważ chcesz, aby te zasoby zostały zamknięte bezpiecznie. Aby wykonać oczyszczanie obiektów, możesz napisać metodę Dispose(), metodę Finalize() lub obie metody.
Metoda Finalize():
- Jest wywoływany przez GC
- Nie ma gwarancji, że jest wywoływany w dowolnej kolejności lub w przewidywalnym czasie
- Po wywołaniu zwalnia pamięć po następnym GC
- Zachowuje wszystkie obiekty podrzędne do następnego GC
Metoda Dispose():
- Jest wywoływany przez programistę
- Jest uporządkowane i zaplanowane przez programistę
- Zwraca zasoby po zakończeniu metody
Zarządzane obiekty, które przechowują tylko zasoby zarządzane, nie wymagają tych metod. Twój program prawdopodobnie będzie używać tylko kilku złożonych zasobów, a prawdopodobieństwo, że wiesz, czym są i kiedy ich potrzebujesz. Jeśli znasz obie te rzeczy, nie ma powodu, aby polegać na finalizatorach, ponieważ można wykonać czyszczenie ręcznie. Istnieje kilka powodów, dla których chcesz to zrobić, i wszystkie muszą mieć związek z kolejką finalizatora.
W GC, gdy obiekt z finalizatorem jest oznaczony jako zbieralny, a wszystkie obiekty, które wskazuje, są umieszczane w specjalnej kolejce. Oddzielny wątek przechodzi w dół tej kolejki, wywołując metodę Finalize() każdego elementu w kolejce. Programista nie ma kontroli nad tym wątkiem ani kolejności elementów umieszczonych w kolejce. GC może zwrócić kontrolę do programu bez sfinalizowania żadnych obiektów w kolejce. Te obiekty mogą pozostawać w pamięci, schowane w kolejce przez długi czas. Wywołania do finalizacji są wykonywane automatycznie i nie ma bezpośredniego wpływu na wydajność samego wywołania. Jednak niedeterministyczny model finalizacji może mieć na pewno inne pośrednie konsekwencje:
- W scenariuszu, w którym masz zasoby, które należy zwolnić w określonym czasie, utracisz kontrolę z finalizatorami. Załóżmy, że masz otwarty plik i należy go zamknąć ze względów bezpieczeństwa. Nawet po ustawieniu obiektu na wartość null i wymuszenia natychmiastowego wymuszenia GC plik pozostanie otwarty do momentu wywołania metody Finalize() i nie masz pojęcia, kiedy może się to zdarzyć.
- N obiektów, które wymagają usuwania w określonej kolejności, mogą nie być poprawnie obsługiwane.
- Ogromny obiekt i jego dzieci mogą zająć zbyt dużo pamięci, wymagać dodatkowych kolekcji i zaszkodzić wydajności. Te obiekty mogą nie być zbierane przez długi czas.
- Mały obiekt do sfinalizowania może mieć wskaźniki do dużych zasobów, które można zwolnić w dowolnym momencie. Te obiekty nie będą zwalniane, dopóki nie zostanie sfinalizowany obiekt, co spowoduje niepotrzebne wykorzystanie pamięci i wymuszenie częstych kolekcji.
Diagram stanu na rysunku 3 ilustruje różne ścieżki, które obiekt może przyjąć pod względem finalizacji lub usuwania.
Rysunek 3. Ścieżki usuwania i finalizacji, które może przyjmować obiekt
Jak widać, finalizacja dodaje kilka kroków do okresu istnienia obiektu. Jeśli obiekt zostanie usunięty samodzielnie, można go zebrać, a pamięć zwrócona w następnej GC. Po zakończeniu trzeba poczekać, aż zostanie wywołana rzeczywista metoda. Ponieważ nie masz żadnych gwarancji dotyczących tego, kiedy tak się stanie, możesz mieć dużo pamięci związane i być na łasce kolejki finalizacji. Może to być bardzo problematyczne, jeśli obiekt jest połączony z całym drzewem obiektów i wszystkie znajdują się w pamięci do momentu zakończenia.
Wybieranie modułu odśmiecania pamięci do użycia
ClR ma dwa różne GCs: stacja robocza (mscorwks.dll) i serwer (mscorsvr.dll). W przypadku pracy w trybie stacji roboczej opóźnienie jest bardziej niepokojące niż miejsce lub wydajność. Serwer z wieloma procesorami i klientami połączonymi za pośrednictwem sieci może zapewnić pewne opóźnienie, ale przepływność jest teraz priorytetem. Zamiast obu tych scenariuszy obu tych scenariuszy w jednym schemacie GC, firma Microsoft uwzględniła dwa moduły odśmiecanie pamięci, które są dostosowane do każdej sytuacji.
GC serwera:
- Skalowalne, równoległe wieloprocesorowe (MP)
- Jeden wątek GC na procesor CPU
- Program wstrzymany podczas oznaczania
Stacja robocza GC:
- Minimalizuje wstrzymanie, uruchamiając jednocześnie podczas pełnych kolekcji
GC serwera została zaprojektowana pod kątem maksymalnej przepływności i jest skalowana z bardzo wysoką wydajnością. Fragmentacja pamięci na serwerach jest znacznie większym problemem niż na stacjach roboczych, co sprawia, że odzyskiwanie pamięci jest atrakcyjną propozycją. W scenariuszu jednoprocesorowym oba moduły zbierające działają w taki sam sposób: tryb stacji roboczej bez współbieżnej kolekcji. Na maszynie mp stacja robocza GC używa drugiego procesora do współbieżnego uruchamiania kolekcji, minimalizując opóźnienia przy jednoczesnym zmniejszeniu przepływności. Funkcja GC serwera używa wielu wątków stertów i kolekcji, aby zmaksymalizować przepływność i zwiększyć skalę.
Podczas hostowania czasu wykonywania można wybrać GC. Podczas ładowania czasu wykonywania do procesu należy określić, którego modułu zbierającego użyć. Ładowanie interfejsu API zostało omówione w przewodniku dewelopera .NET Framework. Przykład prostego programu, który hostuje czas wykonywania i wybiera serwer GC, przyjrzyj się dodatku.
Mit: Odzyskiwanie pamięci jest zawsze wolniejsze niż robienie go ręcznie
W rzeczywistości, dopóki kolekcja nie zostanie wywołana, GC jest o wiele szybciej niż robi to ręcznie w C. To zaskakuje wiele osób, więc warto wyjaśnić. Po pierwsze zwróć uwagę, że znalezienie wolnego miejsca występuje w stałym czasie. Ponieważ całe wolne miejsce jest ciągłe, GC po prostu podąża za wskaźnikiem i sprawdza, czy jest wystarczająco dużo miejsca. W języku C wywołanie metody malloc()
zazwyczaj
powoduje wyszukiwanie połączonej listy bezpłatnych bloków. Może to być czasochłonne, zwłaszcza jeśli stertę jest źle rozdrobniona. Co gorsza, kilka implementacji czasu wykonywania języka C blokuje stertę podczas tej procedury. Po przydzieleniu lub użyciu pamięci należy zaktualizować listę. W środowisku zbieranym przez śmieci alokacja jest wolna, a pamięć jest zwalniana podczas zbierania. Bardziej zaawansowani programiści będą rezerwować duże bloki pamięci i obsługiwać alokację w ramach tego bloku. Problem z tym podejściem polega na tym, że fragmentacja pamięci staje się ogromnym problemem dla programistów i wymusza dodanie do aplikacji dużej ilości logiki obsługi pamięci. W końcu moduł odśmiecenia pamięci nie dodaje dużo narzutów. Alokacja jest tak szybka lub szybsza, a kompaktowanie jest obsługiwane automatycznie — zwalnia programistów, aby skupić się na swoich aplikacjach.
W przyszłości moduły odśmieceń pamięci mogą wykonywać inne optymalizacje, które jeszcze szybciej. Identyfikacja punktów dostępu i lepsze użycie pamięci podręcznej są możliwe i mogą mieć ogromne różnice szybkości. Inteligentniejszy GC może wydajniej pakować strony, minimalizując w ten sposób liczbę pobrań stron występujących podczas wykonywania. Wszystkie te elementy mogą sprawić, że środowisko zbierane przez śmieci będzie szybsze niż ręczne wykonywanie zadań.
Niektórzy mogą się zastanawiać, dlaczego GC nie jest dostępna w innych środowiskach, takich jak C lub C++. Odpowiedzią są typy. Te języki umożliwiają rzutowanie wskaźników do dowolnego typu, co sprawia, że niezwykle trudno jest wiedzieć, do czego odnosi się wskaźnik. W środowisku zarządzanym, takich jak CLR, możemy zagwarantować wystarczającą ilość wskaźników, aby umożliwić GC. Zarządzany świat jest również jedynym miejscem, w którym można bezpiecznie zatrzymać wykonywanie wątków w celu wykonania GC: w języku C++ te operacje są niebezpieczne lub bardzo ograniczone.
Dostrajanie szybkości
Największym zmartwieniem dla programu w zarządzanym świecie jest przechowywanie pamięci. Niektóre problemy, które znajdziesz w środowiskach niezarządzanych, nie są problemem w zarządzanym świecie: przecieki pamięci i zwisające wskaźniki nie są tutaj problemem. Zamiast tego programiści muszą uważać na pozostawienie zasobów połączonych, gdy już ich nie potrzebują.
Najważniejszą heurystyczną wydajnością jest również najłatwiejsza do nauki dla programistów, którzy są przyzwyczajeni do pisania kodu natywnego: śledzenie alokacji do tworzenia i zwalnianie ich po zakończeniu. GC nie ma możliwości poznania, że nie zamierzasz używać ciągu 20 KB, który został utworzony, jeśli jest częścią obiektu, który jest przechowywany. Załóżmy, że ten obiekt jest gdzieś schowany w wektorze i nigdy nie zamierzasz używać tego ciągu ponownie. Ustawienie pola na wartość null spowoduje, że GC zbierze te 20 KB później, nawet jeśli obiekt będzie nadal potrzebny do innych celów. Jeśli obiekt nie jest już potrzebny, upewnij się, że nie przechowujesz do niego odwołań. (Podobnie jak w kodzie natywnym). W przypadku mniejszych obiektów jest to mniej problemu. Każdy programista, który jest zaznajomiony z zarządzaniem pamięcią w kodzie natywnym, nie będzie miał tutaj problemu: wszystkie te same reguły rozsądku mają zastosowanie. Po prostu nie musisz być tak paranoiczny o nich.
Druga ważna kwestia dotycząca wydajności dotyczy oczyszczania obiektów. Jak wspomniano wcześniej, finalizacja ma głęboki wpływ na wydajność. Najczęstszym przykładem jest to, że program obsługi zarządzanej do niezarządzanego zasobu: musisz zaimplementować jakąś metodę oczyszczania, a w tym miejscu występuje problem z wydajnością. Jeśli zależysz od finalizacji, otwierasz się na problemy z wydajnością wymienione wcześniej. Należy pamiętać o czymś innym, że GC jest w dużej mierze nieświadomy ciśnienia pamięci w świecie natywnym, więc możesz używać mnóstwa niezarządzanych zasobów tylko przez utrzymanie wskaźnika w zarządzanym stosie. Pojedynczy wskaźnik nie zajmuje dużo pamięci, więc może to być chwilę, zanim będzie potrzebna kolekcja. Aby obejść te problemy z wydajnością, podczas gdy nadal odtwarzane jest bezpieczne, jeśli chodzi o przechowywanie pamięci, należy wybrać wzorzec projektowy do pracy ze wszystkimi obiektami, które wymagają specjalnego czyszczenia.
Programista ma cztery opcje podczas oczyszczania obiektów:
Implementowanie obu
Jest to zalecany projekt oczyszczania obiektów. Jest to obiekt z kilkoma zasobami niezarządzanymi i zarządzanymi. Przykładem może być System.Windows.Forms.Control. Ma to zasób niezarządzany (HWND) i potencjalnie zarządzane zasoby (DataConnection itp.). Jeśli nie masz pewności, kiedy używasz zasobów niezarządzanych, możesz otworzyć manifest programu w
ILDASM``
programie i wyszukać odwołania do bibliotek natywnych. Inną alternatywą jestvadump.exe
sprawdzenie, jakie zasoby są ładowane wraz z programem. Oba te elementy mogą zapewnić wgląd w informacje o tym, jakiego rodzaju zasoby natywne są używane.Poniższy wzorzec zapewnia użytkownikom jeden zalecany sposób zamiast zastępowania logiki oczyszczania (przesłanianie metody Dispose(bool)). Zapewnia to maksymalną elastyczność, a także funkcję catch-all na wypadek, gdy funkcja Dispose() nigdy nie jest wywoływana. Połączenie maksymalnej prędkości i elastyczności, a także podejście safety-net sprawia, że jest to najlepszy projekt do użycia.
Przykład:
public class MyClass : IDisposable { public void Dispose() { Dispose(true); GC.SuppressFinalizer(this); } protected virtual void Dispose(bool disposing) { if (disposing) { ... } ... } ~MyClass() { Dispose(false); } }
Implementowanie tylko dispose()
Dzieje się tak, gdy obiekt ma tylko zarządzane zasoby i chcesz upewnić się, że jego oczyszczanie jest deterministyczne. Przykładem takiego obiektu jest System.Web.UI.Control.
Przykład:
public class MyClass : IDisposable { public virtual void Dispose() { ... }
Implementowanie tylko finalizowania()
Jest to potrzebne w bardzo rzadkich sytuacjach i zdecydowanie polecam przeciwko niemu. Implikacja obiektu Finalize() polega na tym, że programista nie ma pojęcia, kiedy obiekt zostanie zebrany, ale używa wystarczająco złożonego zasobu, aby wymagać specjalnego czyszczenia. Ta sytuacja nigdy nie powinna wystąpić w dobrze zaprojektowanym projekcie, a jeśli znajdziesz się w nim, powinieneś wrócić i dowiedzieć się, co poszło nie tak.
Przykład:
public class MyClass { ... ~MyClass() { ... }
Nie implementuj żadnego z nich
Jest to przeznaczone dla zarządzanego obiektu, który wskazuje tylko inne zarządzane obiekty, które nie są możliwe do likwidacji ani nie zostaną sfinalizowane.
Zalecenie
Zalecenia dotyczące zarządzania pamięcią powinny być znane: zwalnianie obiektów po zakończeniu pracy z nimi i pozostawienie wskazówek do obiektów. Jeśli chodzi o oczyszczanie obiektów, zaimplementuj zarówno metodę Finalize() i Dispose()
dla obiektów z niezarządzanymi zasobami. Pozwoli to zapobiec nieoczekiwanemu zachowaniu później i wymusić dobre praktyki programistyczne
Wadą jest to, że zmuszasz ludzi do wywołania Dispose(). Nie ma tutaj utraty wydajności, ale niektórzy ludzie mogą znaleźć to frustrujące, aby myśleć o dysponowaniu swoich obiektów. Myślę jednak, że warto zaostrzyć użycie modelu, który ma sens. Poza tym zmusza to ludzi do bardziej uważnego wobec obiektów, które przydzielają, ponieważ nie mogą ślepo ufać GC, aby zawsze dbać o nich. Dla programistów pochodzących z tła C lub C++, wymuszanie wywołania Dispose() będzie prawdopodobnie korzystne, ponieważ jest to rodzaj rzeczy, z którą są bardziej zaznajomieni.
Funkcja Dispose() powinna być obsługiwana w obiektach, które przechowują zasoby niezarządzane w dowolnym miejscu w drzewie obiektów znajdujących się pod nim; Jednak funkcja Finalize() musi być umieszczana tylko na tych obiektach, które są przeznaczone specjalnie do tych zasobów, takich jak dojście systemu operacyjnego lub alokacja pamięci niezarządzanej. Sugeruję tworzenie małych zarządzanych obiektów jako "otoki" do implementowania Finalize() oprócz obsługi Dispose(),,
które byłyby wywoływane przez obiekt nadrzędny Dispose(). Ponieważ obiekty nadrzędne nie mają finalizatora, całe drzewo obiektów nie przetrwa kolekcji niezależnie od tego, czy wywoływano metodę Dispose().
Dobrą regułą finalizatorów jest użycie ich tylko w najbardziej pierwotnym obiekcie, który wymaga finalizacji. Załóżmy, że mam duży zasób zarządzany, który obejmuje połączenie z bazą danych: umożliwiłbym finalizację samego połączenia, ale sprawi, że pozostałe obiekty zostaną zdysfikowane. Dzięki temu można wywołać metodę Dispose() i natychmiast zwolnić zarządzane części obiektu bez konieczności oczekiwania na sfinalizowanie połączenia. Pamiętaj: używaj funkcji Finalize() tylko wtedy, gdy musisz, gdy musisz.
Uwaga Programiści języka C i C++: semantyka destruktora w języku C# tworzy finalizator, a nie metodę usuwania!
Pula wątków
Podstawowe informacje
Pula wątków środowiska CLR jest podobna do puli wątków NT na wiele sposobów i prawie nie wymaga nowego zrozumienia ze strony programisty. Ma wątek oczekiwania, który może obsługiwać bloki dla innych wątków i powiadamiać je, gdy muszą wrócić, zwalniając je do wykonywania innych czynności. Może ona duplikować nowe wątki i blokować inne w celu optymalizacji pod kątem wykorzystania procesora CPU w czasie wykonywania, co gwarantuje, że wykonywana jest największa ilość przydatnych zadań. Przetwarza również wątki, gdy są gotowe, uruchamiając je ponownie bez narzutu zabijania i duplikowania nowych. Jest to znaczny wzrost wydajności w przypadku ręcznej obsługi wątków, ale nie jest to funkcja catch-all. Wiedza o tym, kiedy należy używać puli wątków, jest niezbędna podczas dostrajania aplikacji wątkowej.
Co wiesz z puli wątków NT:
- Pula wątków będzie obsługiwać tworzenie i oczyszczanie wątków.
- Zapewnia port ukończenia wątków we/wy (tylko platform NT).
- Wywołanie zwrotne może być powiązane z plikami lub innymi zasobami systemowymi.
- Dostępne są interfejsy API czasomierza i oczekiwania.
- Pula wątków określa, ile wątków powinno być aktywnych przy użyciu algorytmów heurystycznych, takich jak opóźnienie od ostatniego wstrzyknięcia, liczba bieżących wątków i rozmiar kolejki.
- Wątki są zasilane z udostępnionej kolejki.
Co się różni na platformie .NET:
- Jest świadomy wątków blokujących w kodzie zarządzanym (np. z powodu odzyskiwania pamięci, oczekiwania zarządzanego) i może odpowiednio dostosować logikę wstrzykiwania wątku.
- Nie ma gwarancji usługi dla poszczególnych wątków.
Kiedy samodzielnie obsługiwać wątki
Efektywne korzystanie z puli wątków jest ściśle powiązane z wiedzą, czego potrzebujesz z wątków. Jeśli potrzebujesz gwarancji usługi, musisz zarządzać nią samodzielnie. W większości przypadków użycie puli zapewni optymalną wydajność. Jeśli masz twarde ograniczenia i potrzebujesz ścisłej kontroli nad wątkami, prawdopodobnie warto używać wątków natywnych i tak, więc uważaj na obsługę zarządzanych wątków samodzielnie. Jeśli zdecydujesz się napisać kod zarządzany i samodzielnie obsłużyć wątki, upewnij się, że wątki nie są duplikowane na podstawie połączenia: będzie to tylko zaszkodzić wydajności. Z reguły należy wybrać obsługę wątków samodzielnie w zarządzanym świecie w bardzo konkretnych scenariuszach, w których rzadko odbywa się duże, czasochłonne zadanie. Przykładem może być wypełnienie dużej pamięci podręcznej w tle lub zapisanie dużego pliku na dysku.
Dostrajanie szybkości
Pula wątków ustawia limit liczby wątków, które powinny być aktywne, a jeśli wiele z nich blokuje, pula będzie głodowa. Najlepiej użyć puli wątków dla krótkotrwałych, nieblokujących wątków. W aplikacjach serwerowych chcesz szybko i wydajnie odpowiedzieć na każde żądanie. Jeśli uruchamiasz nowy wątek dla każdego żądania, zajmujesz się dużym obciążeniem. Rozwiązaniem jest odtwarzanie wątków, dbanie o czyszczenie i zwracanie stanu każdego wątku po zakończeniu. Są to scenariusze, w których pula wątków jest główną wydajnością i zwycięstwem projektowym i gdzie należy dobrze wykorzystać technologię. Pula wątków obsługuje oczyszczanie stanu i upewnia się, że optymalna liczba wątków jest używana w danym momencie. W innych sytuacjach warto samodzielnie obsługiwać wątki.
ClR może używać bezpieczeństwa typów w celu zapewnienia gwarancji dotyczących procesów w celu zapewnienia, że domeny aplikacji mogą współużytkować ten sam proces, ale nie ma takiej gwarancji w wątkach. Programista jest odpowiedzialny za pisanie dobrze zachowywanych wątków, a cała wiedza z kodu natywnego nadal ma zastosowanie.
Poniżej przedstawiono przykład prostej aplikacji, która korzysta z puli wątków. Tworzy kilka wątków roboczych, a następnie wykonuje proste zadanie przed ich zamknięciem. Wyjąłem pewne sprawdzanie błędów, ale jest to ten sam kod, który można znaleźć w folderze Zestawu SDK platformy w obszarze "Samples\Threading\Threadpool". W tym przykładzie mamy kod, który tworzy prosty element roboczy i używa puli wątków do obsługi tych elementów bez konieczności zarządzania nimi przez programistę. Aby uzyskać więcej informacji, zapoznaj się z plikiem ReadMe.html.
using System;
using System.Threading;
public class SomeState{
public int Cookie;
public SomeState(int iCookie){
Cookie = iCookie;
}
};
public class Alpha{
public int [] HashCount;
public ManualResetEvent eventX;
public static int iCount = 0;
public static int iMaxCount = 0;
public Alpha(int MaxCount) {
HashCount = new int[30];
iMaxCount = MaxCount;
}
// The method that will be called when the Work Item is serviced
// on the Thread Pool
public void Beta(Object state){
Console.WriteLine(" {0} {1} :",
Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);
// Do some busy work
int iX = 10000;
while (iX > 0){ iX--;}
if (Interlocked.Increment(ref iCount) == iMaxCount) {
Console.WriteLine("Setting EventX ");
eventX.Set();
}
}
};
public class SimplePool{
public static int Main(String[] args) {
Console.WriteLine("Thread Simple Thread Pool Sample");
int MaxCount = 1000;
ManualResetEvent eventX = new ManualResetEvent(false);
Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
Alpha oAlpha = new Alpha(MaxCount);
oAlpha.eventX = eventX;
Console.WriteLine("Queue to Thread Pool 0");
ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
for (int iItem=1;iItem < MaxCount;iItem++){
Console.WriteLine("Queue to Thread Pool {0}", iItem);
ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
new SomeState(iItem));
}
Console.WriteLine("Waiting for Thread Pool to drain");
eventX.WaitOne(Timeout.Infinite,true);
Console.WriteLine("Thread Pool has been drained (Event fired)");
Console.WriteLine("Load across threads");
for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
}
return 0;
}
}
The JIT
Podstawowe informacje
Podobnie jak w przypadku każdej maszyny wirtualnej clR potrzebuje sposobu kompilowania języka pośredniego w dół do kodu natywnego. Podczas kompilowania programu do uruchomienia w środowisku CLR kompilator pobiera źródło z języka wysokiego poziomu w dół do kombinacji MSIL (Microsoft Intermediate Language) i metadanych. Są one scalane z plikiem PE, który można następnie wykonać na dowolnej maszynie z obsługą środowiska CLR. Po uruchomieniu tego pliku wykonywalnego funkcja JIT rozpoczyna kompilowanie il do kodu natywnego i wykonywanie tego kodu na rzeczywistej maszynie. Jest to wykonywane na podstawie poszczególnych metod, więc opóźnienie jiTing jest tylko tak długo, jak to konieczne dla kodu, który chcesz uruchomić.
Tryb JIT jest dość szybki i generuje bardzo dobry kod. Niektóre z optymalizacji, które wykonuje (i niektóre wyjaśnienia każdego) zostały omówione poniżej. Należy pamiętać, że większość tych optymalizacji ma nałożone limity, aby upewnić się, że dostęp JIT nie spędza zbyt dużo czasu.
Stałe składanie — oblicz wartości stałe w czasie kompilacji.
Stary adres Po x = 5 + 7
x = 12
Stałe i propagacja kopii — zastąp ją do tyłu, aby zwolnić zmienne wcześniej.
Stary adres Po x = a
x = a
y = x
y = a
z = 3 + y
z = 3 + a
Tworzenie wbudowanej metody — zastąp wartości args wartościami przekazywanymi w czasie wywołania i eliminuj wywołanie. Wiele innych optymalizacji można następnie wykonać, aby wyciąć martwy kod. Ze względu na szybkość bieżący dostęp JIT ma kilka granic, co może być wbudowane. Na przykład tylko małe metody są wbudowane (rozmiar IL mniejszy niż 32), a analiza sterowania przepływem jest dość prymitywna.
Stary adres Po ...
x=foo(4, true);
...
}
foo(int a, bool b){
if(b){
return a + 5;
} else {
return 2a + bar();
}
...
x = 9
...
}
foo(int a, bool b){
if(b){
return a + 5;
} else {
return 2a + bar();
}
Pobieranie kodu i dominatory — usuwanie kodu z wewnątrz pętli, jeśli jest zduplikowane na zewnątrz. Poniższy przykład "before" jest rzeczywiście tym, co jest generowane na poziomie IL, ponieważ wszystkie indeksy tablic muszą być sprawdzane.
Stary adres Po for(i=0; i< a.length;i++){
if(i < a.length()){
a[i] = null
} else {
raise IndexOutOfBounds;
}
}
for(int i=0; i<a.length; i++){
a[i] = null;
}
Wyrejestrowywanie pętli — obciążenie związane z przyrostowymi licznikami i wykonywaniem testu można usunąć, a kod pętli można powtórzyć. W przypadku bardzo ciasnych pętli powoduje to wygraną wydajności.
Stary adres Po for(i=0; i< 3; i++){
print("flaming monkeys!");
}
print("flaming monkeys!");
print("flaming monkeys!");
print("flaming monkeys!");
Wspólna eliminacja podexpressionu — jeśli zmienna aktywna nadal zawiera informacje, które są ponownie obliczane, użyj jej zamiast tego.
Stary adres Po x = 4 + y
z = 4 + y
x = 4 + y
z = x
Wyrejestrowanie — nie przydaje się tu przykład kodu, więc wyjaśnienie będzie musiało wystarczyć. Ta optymalizacja może poświęcić czas na przyjrzenie się sposobom użycia ustawień lokalnych i tymczasowych w funkcji i próbować jak najwydajniej obsługiwać rejestrowanie przypisania. Może to być niezwykle kosztowna optymalizacja, a bieżący tryb JIT clR uwzględnia maksymalnie 64 zmienne lokalne na potrzeby wyrejestrowania. Zmienne, które nie są brane pod uwagę, są umieszczane w ramce stosu. Jest to klasyczny przykład ograniczeń jiTing: chociaż jest to dobre 99% czasu, bardzo nietypowe funkcje, które mają ponad 100 lokalizacji lokalnych, zostaną zoptymalizowane lepiej przy użyciu tradycyjnych, czasochłonnych wstępnie kompilacji.
Błędy — inne proste optymalizacje są wykonywane, ale powyższa lista jest dobrym przykładem. Tryb JIT również przechodzi w przypadku utraconych kodów i innych optymalizacji peephole.
Kiedy kod jest jited?
Oto ścieżka, przez którą przechodzi kod po jego wykonaniu:
- Program jest ładowany, a tabela funkcji jest inicjowana za pomocą wskaźników odwołujących się do il.
- Metoda Main to JITed w kodzie natywnym, który jest następnie uruchamiany. Wywołania funkcji są kompilowane w wywołania funkcji pośrednich za pośrednictwem tabeli.
- Po wywołaniu innej metody czas wykonywania analizuje tabelę, aby sprawdzić, czy wskazuje ona kod JITed.
- Jeśli ma (być może został wywołany z innej witryny wywołania lub został wstępnie skompilowany), przepływ sterowania będzie kontynuowany.
- W przeciwnym razie metoda to JITed, a tabela zostanie zaktualizowana.
- W miarę ich wywoływania coraz więcej metod jest kompilowanych w kodzie natywnym i więcej wpisów w punkcie tabeli do rosnącej puli instrukcji x86.
- W miarę uruchamiania programu funkcja JIT jest wywoływana rzadziej, dopóki wszystko nie zostanie skompilowane.
- Metoda nie jest wywoływana, dopóki nie zostanie wywołana, a następnie nigdy nie zostanie ponownie JITed podczas wykonywania programu. Płacisz tylko za to, czego używasz.
Mit: Programy JITed są wykonywane wolniej niż wstępnie skompilowane programy
Rzadko zdarza się tak. Obciążenie związane z programem JITing kilku metod jest niewielkie w porównaniu z czasem spędzonym na odczytywaniu na kilku stronach z dysku, a metody są jiTed tylko wtedy, gdy są potrzebne. Czas spędzony w trybie JIT jest tak niewielki, że prawie nigdy nie jest zauważalny, a po utworzeniu metody JITed nigdy nie ponosisz kosztów dla tej metody ponownie. Powiem więcej o tym w sekcji Precompiling Code (Prekompilowanie kodu).
Jak wspomniano powyżej, funkcja JIT w wersji 1 (v1) wykonuje większość optymalizacji, które wykonuje kompilator i będzie się szybciej tylko w następnej wersji (vNext), ponieważ dodawane są bardziej zaawansowane optymalizacje. Co ważniejsze, funkcja JIT może wykonać pewne optymalizacje, których nie może zwykły kompilator, takich jak optymalizacje specyficzne dla procesora CPU i dostrajanie pamięci podręcznej.
optymalizacje JIT-Only
Ponieważ tryb JIT jest aktywowany w czasie wykonywania, istnieje wiele informacji dotyczących tego, że kompilator nie wie o tym. Dzięki temu można wykonać kilka optymalizacji, które są dostępne tylko w czasie wykonywania:
- Optymalizacje specyficzne dla procesora — w czasie wykonywania funkcja JIT wie, czy może korzystać z instrukcji SSE lub 3DNow. Plik wykonywalny zostanie skompilowany specjalnie dla rodzin procesorów P4, Athlon lub innych przyszłych rodzin procesorów. Wdrożysz raz, a ten sam kod poprawi się wraz z JIT i maszyną użytkownika.
- Optymalizacja poziomów odejmowania pośredniego, ponieważ funkcja i lokalizacja obiektu są dostępne w czasie wykonywania.
- Tryb JIT może wykonywać optymalizacje w zestawach, zapewniając wiele korzyści, jakie uzyskujesz podczas kompilowania programu z bibliotekami statycznymi, ale zachowując elastyczność i niewielki ślad użycia dynamicznych.
- Agresywnie wbudowane funkcje , które są wywoływane częściej, ponieważ jest świadomy przepływu sterowania w czasie wykonywania. Optymalizacje mogą zapewnić znaczny wzrost szybkości i istnieje wiele miejsca na dodatkowe ulepszenia w programie vNext.
Te ulepszenia w czasie wykonywania są kosztem niewielkiego, jednorazowego kosztu uruchamiania i mogą przekraczać przesunięcie czasu spędzonego w trybie JIT.
Wstępna kompilacja kodu (przy użyciu ngen.exe)
W przypadku dostawcy aplikacji możliwość prekompilowania kodu podczas instalacji jest atrakcyjną opcją. Firma Microsoft udostępnia tę opcję w formularzu ngen.exe
, co umożliwi uruchomienie normalnego kompilatora JIT w całym programie raz i zapisanie wyniku. Ponieważ optymalizacje tylko w czasie wykonywania nie mogą być wykonywane podczas wstępnej kompilacji, wygenerowany kod nie jest zwykle tak dobry, jak wygenerowany przez normalny tryb JIT. Jednak bez konieczności używania metod JIT na bieżąco koszt uruchamiania jest znacznie niższy, a niektóre programy będą uruchamiane zauważalnie szybciej. W przyszłości ngen.exe mogą wykonywać więcej niż tylko uruchamianie tego samego trybu JIT w czasie wykonywania: bardziej agresywne optymalizacje o wyższych granicach niż czas wykonywania, narażenie na optymalizację obciążenia dla deweloperów (optymalizacja sposobu, w jaki kod jest pakowany na strony maszyn wirtualnych) i bardziej złożone, czasochłonne optymalizacje, które mogą korzystać z czasu podczas wstępnej kompilacji.
Skrócenie czasu uruchamiania pomaga w dwóch przypadkach i dla wszystkiego innego nie konkuruje z optymalizacjami tylko w czasie wykonywania, które mogą wykonywać zwykłe jiTing. Pierwsza sytuacja polega na tym, że na początku programu wywołujesz ogromną liczbę metod. Musisz JIT z góry JIT wielu metod, co powoduje niedopuszczalny czas ładowania. Nie będzie tak w przypadku większości ludzi, ale pre-JITing może mieć sens, jeśli wpłynie to na Ciebie. Wstępne komkompilowanie ma również sens w przypadku dużych bibliotek udostępnionych, ponieważ płacisz za ładowanie tych bibliotek znacznie częściej. Firma Microsoft prekompiluje struktury środowiska CLR, ponieważ większość aplikacji będzie ich używać.
Łatwo jest użyć ngen.exe
, aby sprawdzić, czy wstępne komkompilowanie jest odpowiedzią dla Ciebie, więc polecam go wypróbować. Jednak w większości przypadków lepiej jest używać normalnego trybu JIT i korzystać z optymalizacji czasu wykonywania. Mają ogromną wypłatę i zrównoważą jednorazowy koszt uruchamiania w większości sytuacji.
Dostrajanie szybkości
Dla programisty są naprawdę tylko dwie rzeczy, które warto zauważyć. Po pierwsze, że JIT jest bardzo inteligentny. Nie próbuj przemyśleć kompilatora. Kodowanie sposobu normalnego działania. Załóżmy na przykład, że masz następujący kod:
...
|
...
|
Niektórzy programiści uważają, że mogą zwiększyć szybkość, przenosząc obliczenia długości i zapisując je w temp, jak w przykładzie po prawej stronie.
Prawda jest taka, że takie optymalizacje nie były przydatne przez prawie 10 lat: nowoczesne kompilatory są bardziej niż w stanie wykonać tę optymalizację. W rzeczywistości czasami takie rzeczy mogą rzeczywiście zaszkodzić wydajności. W powyższym przykładzie kompilator prawdopodobnie sprawdzi, czy długość elementu myArray jest stała, i wstawić stałą w porównaniu pętli for . Jednak kod po prawej stronie może skłonić kompilator do myślenia, że ta wartość musi być przechowywana w rejestrze, ponieważ l
jest aktywna w całej pętli. Najważniejsze jest to: napisanie kodu, który jest najbardziej czytelny i jest najbardziej zrozumiały. Nie pomoże to w wypróbowaniu kompilatora, a czasami może to zaszkodzić.
Drugą rzeczą, o której należy mówić, jest tail-calls. W tej chwili kompilatory C# i Microsoft® Visual Basic® nie zapewniają możliwości określenia, że ma być używane wywołanie ogona. Jeśli naprawdę potrzebujesz tej funkcji, jedną z opcji jest otwarcie pliku PE w dezasemblatorze i zamiast tego użyj instrukcji MSIL .tail. Nie jest to eleganckie rozwiązanie, ale wywołania końcowe nie są tak przydatne w językach C# i Visual Basic, jak w językach takich jak Scheme lub ML. Osoby pisanie kompilatorów dla języków, które naprawdę korzystają z wywołań końcowych, należy pamiętać o użyciu tej instrukcji. Rzeczywistość dla większości ludzi jest taka, że nawet ręczne dostosowywanie IL do używania tail-calls nie zapewnia ogromnej korzyści prędkości. Czasami czas wykonywania rzeczywiście zmieni te z powrotem na regularne wywołania, ze względów bezpieczeństwa! Być może w przyszłych wersjach więcej wysiłku zostanie wprowadzone do obsługi wywołań końcowych, ale w tej chwili wzrost wydajności jest niewystarczający, aby to uzasadnić, a bardzo niewielu programistów będzie chciało z niego korzystać.
Domena aplikacji
Podstawowe informacje
Komunikacja międzyprocesowa staje się coraz bardziej powszechna. Ze względu na stabilność i bezpieczeństwo system operacyjny przechowuje aplikacje w oddzielnych przestrzeniach adresowych. Prostym przykładem jest sposób wykonywania wszystkich 16-bitowych aplikacji w systemie NT: w przypadku uruchomienia w osobnym procesie jedna aplikacja nie może zakłócać wykonywania innego. Problem polega na kosztach przełącznika kontekstu i otwarciu połączenia między procesami. Ta operacja jest bardzo kosztowna i bardzo boli wydajność. W przypadku aplikacji serwerowych, które często hostują kilka aplikacji internetowych, jest to główny odpływ wydajności i skalowalności.
ClR wprowadza koncepcję elementu AppDomain, która jest podobna do procesu, w którym jest to samodzielna przestrzeń dla aplikacji. Jednak domeny aplikacji nie są ograniczone do jednego na proces. Istnieje możliwość uruchomienia dwóch całkowicie niepowiązanych domen aplikacji w tym samym procesie, dzięki bezpieczeństwu typu dostarczonemu przez zarządzany kod. Zwiększenie wydajności jest ogromne w sytuacjach, w których zwykle spędzasz dużo czasu wykonywania w narzucie komunikacji międzyprocesowej: IPC między zestawami jest pięć razy szybciej niż między procesami w NT. Dzięki znacznemu zmniejszeniu tego kosztu uzyskujesz zarówno przyspieszenie, jak i nową opcję podczas projektowania programu: teraz warto używać oddzielnych procesów, w których zanim będzie ona zbyt kosztowna. Możliwość uruchamiania wielu programów w tym samym procesie z tymi samymi zabezpieczeniami, co wcześniej, ma ogromne konsekwencje dla skalowalności i bezpieczeństwa.
Obsługa domen aplikacji nie istnieje w systemie operacyjnym. Domeny aplikacji są obsługiwane przez hosta CLR, takiego jak te obecne w ASP.NET, wykonywalny powłoki lub Microsoft Internet Explorer. Możesz również napisać własne. Każdy host określa domenę domyślną, która jest ładowana po pierwszym uruchomieniu aplikacji i jest zamykana tylko po zakończeniu procesu. Podczas ładowania innych zestawów do procesu można określić, że zostaną one załadowane do określonej domeny aplikacji i ustawić różne zasady zabezpieczeń dla każdego z nich. Opisano to bardziej szczegółowo w dokumentacji zestawu MICROSOFT .NET Framework SDK.
Dostrajanie szybkości
Aby efektywnie korzystać z parametrów AppDomains, musisz zastanowić się nad rodzajem aplikacji, którą piszesz, i jakiego rodzaju działanie musi wykonać. Jako dobrą regułę, aby przejść przez, AppDomains są najbardziej skuteczne, gdy aplikacja pasuje do niektórych z następujących cech:
- Często jest to nowa kopia.
- Współdziała ona z innymi aplikacjami w celu przetwarzania informacji (na przykład zapytań bazy danych wewnątrz serwera internetowego).
- Spędza dużo czasu w IPC z programami, które działają wyłącznie z aplikacją.
- Spowoduje to otwarcie i zamknięcie innych programów.
Przykład sytuacji, w której są przydatne domeny aplikacji, można zobaczyć w złożonej aplikacji ASP.NET. Załóżmy, że chcesz wymusić izolację między różnymi elementami vRoot: w przestrzeni natywnej należy umieścić każdy element vRoot w osobnym procesie. Jest to dość kosztowne, a przełączanie kontekstu między nimi jest dużo obciążenia. W świecie zarządzanym każdy element vRoot może być oddzielną domeną AppDomain. Zachowuje to izolację wymaganą podczas drastycznego cięcia obciążenia.
Domena aplikacji to coś, czego należy używać tylko wtedy, gdy aplikacja jest wystarczająco złożona, aby wymagać ścisłej współpracy z innymi procesami lub innymi wystąpieniami samego siebie. Chociaż komunikacja iter-AppDomain jest znacznie szybsza niż komunikacja między procesami, koszt uruchamiania i zamykania domeny aplikacji może być w rzeczywistości droższy. Aplikacje AppDomains mogą powodować szkody dla wydajności, gdy są używane z nieprawidłowych powodów, więc upewnij się, że używasz ich w odpowiednich sytuacjach. Należy pamiętać, że do domeny AppDomain można załadować tylko zarządzany kod, ponieważ niezarządzany kod nie może być gwarantowany jako bezpieczny.
Zestawy, które są współużytkowane między wieloma domenami, muszą być jiTed dla każdej domeny, aby zachować izolację między domenami. Skutkuje to dużą ilością zduplikowanego tworzenia kodu i marnowania pamięci. Rozważmy przypadek aplikacji, która odpowiada na żądania za pomocą jakiejś usługi XML. Jeśli niektóre żądania muszą być odizolowane od siebie, należy kierować je do różnych domen aplikacji. Problem polega na tym, że każda domena aplikacji będzie teraz wymagać tych samych bibliotek XML, a ten sam zestaw zostanie załadowany wiele razy.
Jednym ze sposobów na to jest zadeklarowanie zestawu jako neutralnego pod względem domeny, co oznacza, że żadne bezpośrednie odwołania nie są dozwolone, a izolacja jest wymuszana za pośrednictwem pośrednia. Pozwala to zaoszczędzić czas, ponieważ zestaw jest jiTed tylko raz. Zapisuje również pamięć, ponieważ nic nie jest zduplikowane. Niestety, występuje trafienie wydajności z powodu wymaganej pośredniości. Deklarowanie zestawu jako neutralnego dla domeny powoduje wygraną wydajności, gdy pamięć jest problemem lub gdy za dużo czasu jest marnowany kod JITing. Takie scenariusze są typowe w przypadku dużego zestawu współużytkowanego przez kilka domen.
Zabezpieczenia
Podstawy
Zabezpieczenia dostępu do kodu to zaawansowana, niezwykle przydatna funkcja. Oferuje użytkownikom bezpieczne wykonywanie częściowo zaufanego kodu, chroni przed złośliwym oprogramowaniem i kilkoma rodzajami ataków oraz umożliwia kontrolowany, oparty na tożsamościach dostęp do zasobów. W kodzie natywnym zabezpieczenia są niezwykle trudne do zapewnienia, ponieważ istnieje niewiele bezpieczeństwa typu, a programista obsługuje pamięć. W środowisku CLR czas wykonywania wie wystarczająco dużo o uruchamianiu kodu, aby dodać silną obsługę zabezpieczeń, czyli funkcję, która jest nowa dla większości programistów.
Zabezpieczenia wpływają zarówno na szybkość, jak i rozmiar zestawu roboczego aplikacji. Podobnie jak w przypadku większości obszarów programowania, sposób, w jaki deweloper korzysta z zabezpieczeń, może znacznie określić jego wpływ na wydajność. System zabezpieczeń jest zaprojektowany z myślą o wydajności i powinien w większości przypadków działać dobrze z niewielką ilością lub bez myślenia podanego przez dewelopera aplikacji. Istnieje jednak kilka rzeczy, które można zrobić, aby wycisnąć ostatni bit wydajności z systemu zabezpieczeń.
Dostrajanie szybkości
Wykonanie kontroli zabezpieczeń zwykle wymaga przewodnika stosu, aby upewnić się, że kod wywołujący bieżącą metodę ma odpowiednie uprawnienia. Czas wykonywania ma kilka optymalizacji, które pomagają uniknąć chodzenia po całym stosie, ale istnieje kilka rzeczy, które programista może zrobić, aby pomóc. Dzięki temu pojęcie bezpieczeństwa imperatywnego i deklaratywnego: deklaratywne zabezpieczenia zdobią typ lub jego elementy członkowskie z różnymi uprawnieniami, podczas gdy zabezpieczenia imperatywne tworzą obiekt zabezpieczeń i wykonują na nim operacje.
- Zabezpieczenia deklaratywne to najszybszy sposób, aby przejść do uwierzytelniania, odmowy i zezwoleniaOnly. Te operacje zazwyczaj wymagają przewodnika stosu w celu zlokalizowania poprawnej ramki wywołań, ale można to uniknąć, jeśli jawnie zadeklarowano te modyfikatory. Wymagania są szybsze, jeśli zostaną wykonane imperatywnie.
- W przypadku współdziałania z kodem niezarządzanym można usunąć kontrole zabezpieczeń w czasie wykonywania za pomocą atrybutu SuppressUnmanagedCodeSecurity. Spowoduje to przeniesienie ewidencjonu do czasu połączenia, co jest znacznie szybsze. Należy pamiętać, że kod nie uwidacznia żadnych luk w zabezpieczeniach do innego kodu, który może wykorzystać usunięte ewidencjonowania do niebezpiecznego kodu.
- Sprawdzanie tożsamości jest droższe niż sprawdzanie kodu. Zamiast tego możesz użyć narzędzia LinkDemand, aby wykonać te kontrole w czasie połączenia.
Istnieją dwa sposoby optymalizacji zabezpieczeń:
- Przeprowadź kontrole w czasie połączenia zamiast czasu wykonywania.
- Upewnij się, że kontrole zabezpieczeń są deklaratywne, a nie imperatywne.
Pierwszą rzeczą, na której należy skoncentrować się, jest przeniesienie jak największej liczby tych kontroli, aby połączyć czas, jak to możliwe. Należy pamiętać, że może to mieć wpływ na bezpieczeństwo aplikacji, dlatego upewnij się, że nie przenosisz kontroli do konsolidatora, który zależy od stanu czasu wykonywania. Po przeniesieniu jak największej ilości czasu połączenia należy zoptymalizować kontrole czasu wykonywania przy użyciu deklaratywnego lub imperatywnego zabezpieczeń: wybierz, który jest optymalny dla określonego rodzaju sprawdzania, którego używasz.
Usług zdalnych
Podstawy
Technologia komunikacji wirtualnej na platformie .NET rozszerza system rozbudowanego typu i funkcjonalność środowiska CLR przez sieć. Za pomocą kodu XML, PROTOKOŁU SOAP i HTTP można wywoływać procedury i przekazywać obiekty zdalnie, tak jakby były hostowane na tym samym komputerze. Można to traktować jako wersję platformy .NET modelu DCOM lub CORBA, ponieważ zapewnia ona nadzbiór ich funkcjonalności.
Jest to szczególnie przydatne w środowisku serwera, gdy masz kilka serwerów obsługujących różne usługi, wszystkie rozmowy ze sobą w celu bezproblemowego łączenia tych usług. Skalowalność jest również ulepszona, ponieważ procesy mogą być fizycznie podzielone na wiele komputerów bez utraty funkcjonalności.
Dostrajanie szybkości
Ponieważ komunikacja zdalna często wiąże się z karą w zakresie opóźnienia sieci, te same reguły mają zastosowanie w środowisku CLR, które zawsze mają: spróbuj zminimalizować ilość wysyłanego ruchu i uniknąć oczekiwania na powrót połączenia zdalnego przez resztę programu. Poniżej przedstawiono kilka dobrych reguł, które należy stosować podczas korzystania z komunikacji wirtualnej w celu zmaksymalizowania wydajności:
- Wykonaj fragmenty Zamiast rozmów czatty — sprawdź, czy możesz zmniejszyć liczbę połączeń, które trzeba wykonać zdalnie. Załóżmy na przykład, że ustawisz niektóre właściwości dla obiektu zdalnego przy użyciu metod get() i set(). Pozwoli to zaoszczędzić czas, aby po prostu ponownie utworzyć obiekt zdalnie, przy użyciu tych właściwości ustawionych podczas tworzenia. Ponieważ można to zrobić przy użyciu pojedynczego połączenia zdalnego, zaoszczędzisz czas zmarnowany w ruchu sieciowym. Czasami warto przenieść obiekt na maszynę lokalną, ustawić tam właściwości, a następnie skopiować go z powrotem. W zależności od przepustowości i opóźnienia, czasami jedno rozwiązanie będzie bardziej zrozumiałe niż inne.
- Równoważenie obciążenia procesora CPU za pomocą obciążenia sieciowego — czasami warto wysłać coś do wykonania za pośrednictwem sieci, a innym razem lepiej samodzielnie wykonać pracę. Jeśli tracisz dużo czasu na przechodzenie przez sieć, wydajność będzie cierpieć. Jeśli używasz zbyt dużej ilości procesora CPU, nie będzie można odpowiedzieć na inne żądania. Znalezienie dobrej równowagi między tymi dwoma elementami jest niezbędne do zwiększenia skali aplikacji.
- Użyj wywołań asynchronicznych — podczas wykonywania wywołania w sieci upewnij się, że jest asynchroniczna, chyba że naprawdę potrzebujesz inaczej. W przeciwnym razie aplikacja zostanie zatrzymana do momentu otrzymania odpowiedzi i może to być niedopuszczalne w interfejsie użytkownika lub na serwerze o dużym woluminie. Dobrym przykładem, aby przyjrzeć się temu, jest dostępny w zestawie SDK platformy Framework dostarczanym z platformą .NET w obszarze "Samples\technologies\remoting\advanced\asyncdelegate".
- Użyj obiektów optymalnie — możesz określić, że nowy obiekt jest tworzony dla każdego żądania (SingleCall) lub że ten sam obiekt jest używany dla wszystkich żądań (Singleton). Posiadanie pojedynczego obiektu dla wszystkich żądań z pewnością jest mniej intensywnie obciążane zasobami, ale należy zachować ostrożność podczas synchronizacji i konfiguracji obiektu z żądania do żądania.
- Korzystanie z wtyczek kanałów i formaterów — zaawansowaną funkcją komunikacji zdalniej jest możliwość podłączania dowolnego kanału lub formatnika do aplikacji. Jeśli na przykład nie trzeba przechodzić przez zaporę, nie ma powodu, aby używać kanału HTTP. Podłączanie kanału TCP zapewnia znacznie lepszą wydajność. Upewnij się, że wybrano kanał lub formater, który jest dla Ciebie najlepszy.
ValueTypes
Podstawy
Elastyczność zapewniana przez obiekty wiąże się z niewielką ceną wydajności. Zarządzanie obiektami stertowania zajmuje więcej czasu, aby przydzielać, uzyskiwać dostęp i aktualizować niż zarządzane przez stos. Dlatego na przykład struktura w języku C++ jest znacznie wydajniejsza niż obiekt. Oczywiście obiekty mogą robić rzeczy, których struktury nie mogą, i są znacznie bardziej uniwersalne.
Ale czasami nie potrzebujesz całej tej elastyczności. Czasami potrzebujesz czegoś tak prostego jak struktura i nie chcesz płacić kosztów wydajności. ClR zapewnia możliwość określenia, co jest nazywane wartością ValueType, a w czasie kompilacji jest ona traktowana tak samo jak struktura. ValueTypes są zarządzane przez stos i zapewniają całą szybkość struktury. Zgodnie z oczekiwaniami są one również wyposażone w ograniczoną elastyczność struktur (na przykład nie ma dziedziczenia). Ale w przypadku wystąpień, w których wszystko, czego potrzebujesz, jest strukturą, ValueTypes zapewniają niesamowity impuls szybkości. Bardziej szczegółowe informacje o typach ValueTypes i pozostałej części systemu typów CLR są dostępne w bibliotece MSDN.
Dostrajanie szybkości
ValueTypes są przydatne tylko w przypadkach, w których są one używane jako struktury. Jeśli musisz traktować wartość ValueType jako obiekt, czas wykonywania będzie obsługiwać boksowanie i rozpakowywanie obiektu. Jednak jest to jeszcze droższe niż utworzenie go jako obiektu w pierwszej kolejności!
Oto przykład prostego testu, który porównuje czas potrzebny na utworzenie dużej liczby obiektów i parametrów ValueTypes:
using System;
using System.Collections;
namespace ConsoleApplication{
public struct foo{
public foo(double arg){ this.y = arg; }
public double y;
}
public class bar{
public bar(double arg){ this.y = arg; }
public double y;
}
class Class1{
static void Main(string[] args){
Console.WriteLine("starting struct loop....");
int t1 = Environment.TickCount;
for (int i = 0; i < 25000000; i++) {
foo test1 = new foo(3.14);
foo test2 = new foo(3.15);
if (test1.y == test2.y) break; // prevent code from being
eliminated JIT
}
int t2 = Environment.TickCount;
Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object
loop....");
t1 = Environment.TickCount;
for (int i = 0; i < 25000000; i++) {
bar test1 = new bar(3.14);
bar test2 = new bar(3.15);
if (test1.y == test2.y) break; // prevent code from being
eliminated JIT
}
t2 = Environment.TickCount;
Console.WriteLine("object loop: (" + (t2-t1) + ")");
}
Spróbuj napisać kod. Różnica czasowa wynosi kilka sekund. Teraz zmodyfikujmy program, aby czas wykonywania musiał pole i rozpakować naszą strukturę. Zwróć uwagę, że korzyści wynikające z używania właściwości ValueType całkowicie zniknęły! Moralnie tutaj jest to, że ValueTypes są używane tylko w bardzo rzadkich sytuacjach, gdy nie używasz ich jako obiektów. Ważne jest, aby zwrócić uwagę na te sytuacje, ponieważ wygrana wydajności jest często bardzo duża, gdy używasz ich dobrze.
using System;
using System.Collections;
namespace ConsoleApplication{
public struct foo{
public foo(double arg){ this.y = arg; }
public double y;
}
public class bar{
public bar(double arg){ this.y = arg; }
public double y;
}
class Class1{
static void Main(string[] args){
Hashtable boxed_table = new Hashtable(2);
Hashtable object_table = new Hashtable(2);
System.Console.WriteLine("starting struct loop...");
for(int i = 0; i < 10000000; i++){
boxed_table.Add(1, new foo(3.14));
boxed_table.Add(2, new foo(3.15));
boxed_table.Remove(1);
}
System.Console.WriteLine("struct loop complete.
starting object loop...");
for(int i = 0; i < 10000000; i++){
object_table.Add(1, new bar(3.14));
object_table.Add(2, new bar(3.15));
object_table.Remove(1);
}
System.Console.WriteLine("All done");
}
}
}
Firma Microsoft wykorzystuje wartości ValueTypes w duży sposób: wszystkie typy pierwotne w strukturach to ValueTypes. Moim zaleceniem jest użycie wartości ValueTypes za każdym razem, gdy czujesz się swędzenie dla struktury. Tak długo, jak nie boksu / rozpakać, mogą zapewnić ogromny impuls prędkości.
Niezwykle ważną rzeczą do zapamiętania jest to, że wartości ValueTypes nie wymagają marshallingu w scenariuszach międzyoperacyjności. Ponieważ marshalling jest jednym z największych trafień wydajności podczas współdziałania z kodem natywnym, użycie valueTypes jako argumentów do funkcji natywnych jest być może jednym z największych ulepszeń wydajności, które można zrobić.
Dodatkowe zasoby
Powiązane tematy dotyczące wydajności w .NET Framework obejmują:
Obejrzyj przyszłe artykuły obecnie opracowywane, w tym omówienie filozofii projektowania, architektury i kodowania, przewodnik po narzędziach do analizy wydajności w świecie zarządzanym oraz porównanie wydajności platformy .NET z innymi dostępnymi obecnie aplikacjami dla przedsiębiorstw.
Dodatek: Hostowanie czasu wykonywania serwera
#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")
long main(){
long retval = 0;
LPWSTR pszFlavor = L"svr";
// Bind to the Run time.
ICorRuntimeHost *pHost = NULL;
HRESULT hr = CorBindToRuntimeEx(NULL,
pszFlavor,
NULL,
CLSID_CorRuntimeHost,
IID_ICorRuntimeHost,
(void **)&pHost);
if (SUCCEEDED(hr)){
printf("Got ICorRuntimeHost\n");
// Start the Run time (this also creates a default AppDomain)
hr = pHost->Start();
if(SUCCEEDED(hr)){
printf("Started\n");
// Get the Default AppDomain created when we called Start
IUnknown *pUnk = NULL;
hr = pHost->GetDefaultDomain(&pUnk);
if(SUCCEEDED(hr)){
printf("Got IUnknown\n");
// Ask for the _AppDomain Interface
_AppDomain *pDomain = NULL;
hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
if(SUCCEEDED(hr)){
printf("Got _AppDomain\n");
// Execute Assembly's entry point on this thread
BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
SysFreeString(pszAssemblyName);
if (SUCCEEDED(hr)){
printf("Execution completed\n");
//Execution completed Successfully
pDomain->Release();
pUnk->Release();
pHost->Stop();
return retval;
}
}
pDomain->Release();
pUnk->Release();
}
}
pHost->Release();
}
printf("Failure, HRESULT: %x\n", hr);
// If we got here, there was an error, return the HRESULT
return hr;
}
Jeśli masz pytania lub komentarze dotyczące tego artykułu, skontaktuj się z Claudio Caldato, menedżerem programu w celu .NET Framework problemów z wydajnością.