Transcript Wyklad_2
Wykład 2. Zarządzanie pamięcią Start i zamykanie systemu Dynamiczne przydziały i zwalnianie pamięci (standardowe implementacje malloc, new, free, delete) na stercie (heap-based allocation): 1. są czasochłonne, ponieważ - jest to system ogólnego przeznaczenia, który musi alokować pamięć dowolnej wielkości i w dowolnej chwili, a zatem, aby to zapewnić, w trakcie alokacji muszą być wykorzystywane rożne algorytmy i techniki, (poszukiwanie wolnego obszaru pamięci, pamięć wirtualna, defragmentacja,...) - wymaga odwołania do systemu operacyjnego (kosztowne przełączenie kontekstu procesora) - prowadzą do niekontrolowanego rozproszenia danych w pamięci (tzw. słaba lokalność odwołań – locality of reference), a to z kolei skutkuje chybianiem w pamięć cache procesora (cache misses) podczas operacji na pamięci 2. prowadzą do fragmentacji pamięci Ogólne dyrektywy: 1. Nie przydzielać dynamicznie pamięci przy wykorzystaniu standardowego heap-based allocation w toku rozgrywki 2. Przydzielać pamięć z ciągłego bloku, prealokowanego w trakcie inicjalizacji gry (być może na stercie lub w pamięci statycznej) przy wykorzystaniu własnej, odpowiedniej strategii zarządzania pamięcią 3. (w celu minimalizacji chybiania w cache) Zorganizować dane w ciągłe i jak najmniejsze kawałki pamięci oraz posortować te dane tak, żeby ich odczyty były jak najbardziej sekwencyjne Jednakże Należy pamiętać o odpowiednim wyrównaniu danych atomowych w pamięci (data alignment), tzn. adres danej w pamięci powinien być wielokrotnością rozmiaru danej (typowo potęga 2). Kontroler pamięci procesora działa wówczas wydajnie (odczyt tylko jednego bloku pamięci, zamiast dwóch lub więcej). Co więcej, niektóre procesory w ogóle nie potrafią odczytywać „niewyrównanych” danych (np. większość procesorów RISC, AltiVec) – x86 potrafią (w nawiasach rozmiary typów dla 32-bitowego x86) • dana 1-bajtowa (char) może znajdować się pod dowolnym adresem (8-bit alignment) • dana 2-bajtowa (short) powinna znajdować się pod adresem parzystym (16-bit alignement) • dana 4-bajtowa (int, long, float, wskaźnik) powinna znajdować się pod adresem będącym wielokrotnością 4 (32-bit alignment) • dana 8-bajtowa (double) powinna znajdować się pod adresem będącym wielokrotnością 8 (Windows – 64-bit alignment) albo 4 (Linux – 32-bit alignment) • dana 16-bajtowa (wektor 4 floatów SEE) musi znajdować się pod adresem będącym wielokrotnością 16 (128-bit alignment) sizeof(S1) = 12 sizeof(S2) = 8 (x86, Visual C++ 2010) • (one-ended) stack allocator • double-ended stack allocator Używane zwykle gdy gra jest liniowa i „zorientowana na poziomy” (tzn. gracz czeka na załadowanie poziomu, następnie przechodzi poziom, następnie czeka na załadowanie kolejnego poziomu, itd.) oraz każdy poziom mieści się całkowicie w pamięci. (patrz np.: S. Ranck: Alokacja oparta na ramkach, w: Perełki programowania gier 1) Cechy: • udostępnia bloki pamięci równych rozmiarów • prealokowany duży, ciągły obszar pamięci będący wielokrotnością rozmiaru przechowywanych obiektów (po kompilacji, tzn. w sensie sizeof – padding!) Działanie: • wskaźniki do niezajętych bloków pamięci przechowywane są w tablicy wolnych elementów (podczas inicjalizacji tablica przechowuje wskaźniki na wszystkie bloki w przydzielonym obszarze pamięci) • tablice wolnych elementów można również zakodować bezpośrednio w wolnych blokach przechowując w tych blokach wskaźniki na kolejny wolny element (przy założeniu, że wielkość bloków jest nie mniejsza od rozmiaru wskaźnika) • przydział pamięci odbywa się poprzez pobranie ostatniego wskaźnika bloku z tablicy wolnych elementów i dekrementacji markera pamietającego indeks ostatniego wskaźnika w tablicy • zwolnienie bloku odbywa się poprzez dołączenie wskaźnika na ten blok do tablicy wolnych elementów i inkrementacje markera (j.w.) Zastosowanie: • do przechowywania wielu (zwykle niewielkich) obiektów tego samego typu (np. macierzy, węzłów drzewa, instancji siatki trójkątów...) • do przechowywania zasobów, które można podzielić na kawałki (zwykle stosunkowo duże) (patrz: P. Glinker: Fight memory fragmentation with templated freelists, w: Game Programming Gems 4; również: N. Mefford: Improving freelists with policy based design, w Game Programming Gems 5) • można zaimplementować swój własny system zarządzania pamięcią bazujący na strukturze sterty (lub podobnej – por. np.: D. Lazarov: High performance heap allocator, w: Game Programming Gems 7) • system może wykonywać częściową defragmentację w kolejnych iteracjach pętli gry bez widocznego wpływu na szybkość działania gry, przy założeniu, że przesuwane bloki zajętej pamięci nie są za duże (co zwykle jest spełnione, jeśli pamięć w ten sposób przydzielana jest dla dynamicznych obiektów gry, które na ogół są relatywnie niewielkie pod względem pamięciowym) • jednakże dokonując defragmentacji należy aktualizować wskaźniki do przesuniętych bloków; z tego względu najlepiej stosować inteligentne wskaźniki, a jeszcze lepiej - uchwyty (patrz np.: P. Isensee: Alokatory STL, w: Perełki programowania gier 3) • kontenery STL umożliwiają zdefiniowanie własnych strategii alokacji pamięci (alokatorów), np. definicja listy STL ma następującą postać: • na ogół zdefiniowanie własnego alokatora sprowadza się do skopiowania domyślnego alokatora z pliku nagłówkowego <memory> i zastąpienie jego funkcji składowych allocate() i deallocate()(oraz ew. konstruktorów i destruktorów, rzadziej operatorów porównania) • allocate() musi zwrócić wskaźnik na pamięć o rozmiarze wystarczającym do pomieszczenia n obiektów typu T (nie zajmuje się konstrukcją tych obiektów). Na przykład alokator dla prealokowanej pamięci wskazywanej przez mpStack może mieć postać: • deallocate(pointer p, size_type n) zwalnia pamięć wskazywaną przez p i zajmowaną przez n obiektów typu T; wskaźnik p musiał zostać wcześniej zwrócony przez allocate() tego samego obiektu alokatora; funkcja nie może zgłaszać wyjątku 1. Choć większość implementacji STL spełnia wymagania standardu ANSI C++, to jednak same implementacje różnią się od siebie. 2. W rezultacie własny alokator zdefiniowany w kontekście danej implementacji może nie działać w innej. 3. W szczególności dotyczy to alokatorów zawierających zmienne składowe (własne dane - np. wskaźnik na prealokowany z zewnątrz blok pamięci albo statyczny blok pamięci wewnątrz alokatora). 4. Dlatego wiele wieloplatformowych silników gier dostarcza własne implementacje kontenerów. Praktyka ta jest również powszechna w silnikach na konsole i platformy mobilne. (Np. EA STL by Electronic Arts) 1. Silnik gry jest złożonym systemem, składającym się z wielu oddziałujących ze sobą podzespołów (modułów). Wzajemne zależności miedzy modułami znajdują m.in. swój wyraz w kolejności tworzenia modułów w trakcie uruchamiania systemu silnika, a także ich usuwania podczas zamykania tego systemu. 2. Wiele z modułów (w szczególności różne moduły zarządców) implementowanych jest przy wykorzystaniu wzorca projektowego singleton. Mając na uwadze pkt 1, istotne jest zatem, aby posiadać kontrolę nad kolejnością tworzenia i usuwania modułów-singletonów. 3. „Książkowy” singleton tworzony na żądanie implementowany jest na bazie wzorca: Niestety rozwiązanie takie pozbawia nas kontroli nad kolejnością usuwania obiektów opartych na takiej implementacji. (patrz: S. Bilas: Automatyczne singletony, w: Perełki programowania gier 1) 4. Lepsze rozwiązanie, które umożliwia tworzenie i usuwanie singletonów przy użyciu operatorów new i delete, opiera się na następującym wzorcu: 5. Wyizolowanie z implementacji klasy cechy „singleton” w formie odpowiednego wzorca singletonu, po którym będą dziedziczyć klasy „singletonowe”, ma postać następującą: Podejście takie stosowane jest np. w siniku Ogre3D. 6. Przykład wykorzystania: