Rozdział 18.
Zarządzanie pamięcią
W tym rozdziale: Dostępne rodzaje pamięci Zarządzanie pamięcią przez system operacyjny
Podobieństwa i różnice w pamięci pomiędzy Windows 98 a Windows NT
Dynamiczne alokowanie prywatnej pamięci
Dzielenie pamięci pomiędzy procesami
Pamięć to waluta oprogramowania. Bez odpowiedniej pamięci programy działają wolno lub nie działają wcale. Przy odpowiedniej ilości pamięci oprogramowanie działa poprawnie i może wykonywać swoje zadania. Jednak wyznaczenie odpowiedniej ilości pamięci dla programu zależy od tego, w jaki sposób program wykorzystuje dostępną pamięć. Choć cena pamięci wciąż spada, jednak pamięć nie jest dostępna za darmo. Programiści muszą więc realistycznie określać wymagania pamięciowe dla swoich aplikacji.
W tym rozdziale zajmiemy się wszystkimi rodzajami pamięci dostępnymi dla programów MFC/Win32 działających w Windows 98. Podobnie jak istnieje wiele walut - na przykład dolary, marki czy franki - istnieje także wiele rodzajów pamięci. Oprócz skatalogowania rodzajów pamięci w tym rozdziale powiemy także, które rodzaje i dlaczego są użyteczne w typowym programie MFC. Jedno jest jasne: nawet prosty program wymaga różnych rodzajów pamięci. Rodzaje pamięci dostępne dla programu MFC/Win32 zebrano w tabeli 18.1.
Zarządzanie pamięcią systemową
Najważniejszym chyba ulepszeniem zastosowanym w Windows 95 w stosunku do wcześniejszych wersji Windows jest wprowadzenie płaskiego, 32-bitowego sposobu adresowania. Programy Win 16, działające w Windows 3.x musiały być dzielone na segmenty, czyli bloki pamięci o rozmiarze od 16 bajtów do 64K. Budując aplikacje Win 16 programiści musieli zmagać się z odpowiednim użyciem słów kluczowych kompilatora, takich jak _near i _far w celu określenia odpowiedniej metody adresowania.
Tabela 18.1. Rodzaje pamięci dostępnej dla programów MFC/Win32 działających w Windows 98
Kategoria
Typ
Opis
(kateg.)Kompilator
Automatyczna-Lokalne zmienne lub lokalne obiekty. Tworzone na stosie podczas wejścia do funkcji i zwalniane przy wyjściu z niej.
Statyczna-Globalne zmienne lub globalne obiekty zdefiniowane poza zakresem jakiejkolwiek funkcji. Dane statyczne zawierające także stałe łańcuchowe. Zmienne i obiekty zdefiniowane z użyciem słowa kluczowego static. Czas życia tych danych obejmuje czas rezydowania pliku wykonywalnego w pamięci.
Klasa-Dane składowych klasy odnoszące się do zmiennych i obiektów zaalokowanych w obiekcie jako dane składowe klasy. Dane te żyją tak długo, jak długo istnieje zawierający je obiekt. Dane w obiektach lokalnych (tworzonych na stosie) są alokowane przy wejściu do funkcji i zwalniane przy wyjściu. Dane w obiektach globalnych (statycznych) istnieją dopóty, dopóki plik wykonywalny znajduje się w pamięci.
Alokowana dynamicznie-Pamięć alokowana wywołaniem malloc () lub operatorem new. W celu poprawnego zarządzania pamięcią aplikacje powinny wywoływać funkcję f ree () lub operator delete dla wszystkich zaalokowanych obszarów pamięci.
(kateg.)Win16
Sterta globalna-Pamięć zaalokowana wywołaniem GlobalAlloc (). W Winló odnosi się to do zaalokowanego segmentu. W Win32 takie obiekty są alokowane na stercie procesu Win32 (tak jak pamięć alokowana wywołaniem LocalAlloc ()).
Sterta lokalna-Pamięć zaalokowana wywołaniem LocalAlloc (). W Winló odnosi się to do zaalokowanego podsegmentu. W Win32 takie obiekty są alokowane na stercie procesu (tak jak pamięć alokowana wywołaniem GlobalAlloc ()).
Zasoby-Dane tylko do odczytu zdefiniowane w plikach zasobów (.RC). Do predefiniowanych typów należą menu, okna dialogowe oraz tablice łańcuchów. Program może także tworzyć własne rodzaje zasobów, korzystając z technik, które opiszemy w dalszej części rozdziału.
Atomy-Łańcuchy przechowywane w tablicach w pamięci i dostępne poprzez unikalne identyfikatory. Zarówno w API Winló, jak i w Win32 występuj ą funkcje AddAtom (). FindAtom () oraz DeleteAtom (). Wewnętrznie Windows używa atomów dla zarejestrowanych formatów schowka oraz zarejestrowanych komunikatów. Aplikacje korzystają z globalnej tabeli atomów przy obsłudze dynamicznej wymiany danych (DDE, Dynamie Data Echange). Do globalnych funkcji operujących atomami należąGlobalAddAtomf) oraz GlobalFIndAtom().
Dodatkowe bajty okna-Windows alokuje dodatkowe dane dla każdego tworzonego okna. Dodatkowe bajty okna to dodatkowa pamięć należąca do okna, poza danymi umieszczonymi w nim przez system. Dostęp do tych danych odbywa się przez użycie różnorodnych funkcji Winl6/Win32 API, które opiszemy w tym rozdziale.
Dodatkowe bajty klasy-Windows alokuje dodatkowe dane dla każdej definiowanej klasy okna (nie należy jej mylić z klasą okna MFC). Dodatkowe bajty klasy okna to dodatkowa pamięć należąca do klasy okna, poza danymi umieszczonymi w niej przez system. Dostęp do tych danych odbywa się przez użycie różnorodnych funkcji Winl6/Win32 API, które opiszemy w tym rozdziale.
Właściwości-Atrybuty dynamicznie wiązane z oknami. Na przykład, możesz stworzyć atrybut COLOR i dołączyć mu indeks koloru. Dostępne w wyniku funkcji Winl6/Win32 SetProp (), GetProp () oraz RemoveProp ().
(kateg.)Win32
Strony prywatne-Pamięć zaalokowana w stronach (4K w Windows 98, być może więcej w Windows NT) w wyniku wywołania funkcji VirtualAlloc (). Ta funkcja Win32 wywołana ze znacznikiem MEM_RESERVE służy także do rezerwowania zakresu adresów.
Prywatna sterta - Pamięć zarezerwowana wywołaniem HeapAlloc ().
Proces Win32 może tworzyć kilka stert za pomocą wywołań HeapCreate (). W celu uzyskania uchwytu domyślnej sterty procesu wywołaj funkcję GetProcessHeap ().
Lokalny obszar- Pamięć zaalokowana dla wątku. Istnieją dwa rodzaje takich danych wątku obszarów: dynamiczny obszar danych wątku jest alokowany
wywołaniem TlsAlloc (), zaś obszar statyczny jest alokowany
przez kompilator.
Wspólne strony- Pamięć zaalokowana wywołaniem MapViewOf File (), które może także tworzyć widok na plik odwzorowany w pamięci, w zależności od podanego uchwytu pliku. Błędny uchwyt pliku (-1) powoduje utworzenie wspólnych stron, zaś poprawny uchwyt pliku powoduje utworzenie pliku odwzorowanego w pamięci.
Wspólne sekcje- Statyczne (globalne) zmienne zadeklarowane z użyciem słowa kluczowego _declspec (thread) definiują wspólną sekcję w pliku wykonywalnym. Po załadowaniu do pamięci są one wspólne dla procesów w programach zarówno Windows 98, jak i Windows NT.
Pliki odwzorowane w pamięci-Wspólne strony połączone z przestrzenią adresową procesu
wywołaniami MapViewOf File (). Zwróć uwagę że w ten
sam sposób API tworzy wspólne strony.
Obraz wykonywalny -Plik wykonywalny (.EXE) lub dynamicznie łączona biblioteka (.DII) w pamięci.
Programiści piszący dla Win32 API nie muszą się już tym martwić. Bardzo niewielu programistów przechodzących z Wini6 API do programowania w Win32 tęskni do problemów z segmentacją i adresowaniem pamięci.
W Windows 98 działa wiele różnych rodzajów programów, włącznie z programami MS-DOS, programami Win 16 oraz oczywiście programami Win32. Aby mieć pełny obraz, w jaki sposób procesory Intela z rodziny x86 dają sobie z tym radę, musisz zrozumieć cztery tryby adresowania w 32-bitowych procesorach Intela. Do trybów tych należą: tryb rzeczywisty, 16-bitowy tryb chroniony, 32-bitowy tryb stronicowany oraz 16-bitowy tryb wirtualny 8086. Ponieważ programy Win32 działają w 32-bitowym trybie stronicowanym, skupimy się właśnie na nim.
32-bitowy, stronicowany tryb adresowania Intela
Win32 API zostało zaprojektowane dla procesorów mogących obsłużyć dwie cechy pamięci: adresowanie 32-bitowe oraz pamięć stronicowaną. W rodzinie procesorów Intel x86 jedynie procesor Intel 80386 i nowsze spełniają te wymagania. Wcześniejsze procesory - włącznie z 8088 oraz 80286 - nie spełniają wymagań adresowania pamięci w Win32 i w związku z tym nie da się na nich uruchomić Windows 95/98.
Późniejsi przedstawiciele tej rodziny - do których należą między innymi 80386, i486 oraz Pentium - spełniają oba wymagania. Wszystkie te procesory obsługują pojedynczy rozmiar strony: 4K. Jako takie, programy Win32 działające w Windows 95/98 muszą działać właśnie przy takim rozmiarze strony.
Nie należy jednak zakładać, że każda implementacja Win32 będzie miała strony po 4K. Na przykład, procesory Digital Alpha, na których może działać Windows NT, ma strony o rozmiarze 8K. Zaś Win32 zostało zaprojektowane tak, by działało poprawnie także z innymi rozmiarami stron, włącznie z 16K i nawet 32K. Jeśli tworzysz oprogramowanie Win32 w jakiś sposób zależne od stron o rozmiarach 4K, możesz napotkać kłopoty po uruchomieniu programu z procesorem wykorzystującym strony o innych rozmiarach.
Opisywany schemat adresowania jest po prostu zaszyty na stałe w procesory Intela. Ponieważ reprezentuje on sensowny schemat dla stronicowanej, wirtualnej pamięci, takie samo podejście zastosowano w Windows NT - nawet w przypadku procesorów innych niż procesory Intela.
Trzy części adresu wirtualnego
32-bitowe stronicowane adresowanie Intela wiąże się z podziałem 32-bitowego adresu na trzy części. 32-bitowy adres posiada jedną część 12-bitową i dwie części 10-bitowe. Aby pomóc Ci w ich zrozumieniu, jako pierwszą opiszemy część 12-bitową.
Najniższe 12 bitów zostanie opisane jako pierwsze, gdyż są najłatwiejsze do zrozumienia. Określają one przesunięcie (offset) w zakresie pojedynczej strony pamięci. Jak już wspomniano, strona ma 4K, co odpowiada dokładnie dwunastu bitom adresu (212 = 4K = 4096 bajtów). Poszczególne strony to niewiele więcej niż tablice bajtów o 4K pozycjach. Oczywiście, sam 12-bitowy adres do niczego nie wystarczy. Aby przechować użyteczną ilość danych, konieczne jest posiadanie wielu stron.
Do poszczególnych stron możesz odwołać się, korzystając ze środkowych 10 bitów w 32-bitowym adresie wirtualnym. Te dziesięć bitów jest używane jako przesunięcie (offset) wewnątrz specjalnej strony pamięci nazywanej tablicą stron (ang. page table). Tablica stron zajmuje 4K pamięci i może odwoływać się do 1024 innych stron, z których każda może zawierać kod lub dane aplikacji. Po zebraniu razem, owe 22 bity adresu mogą zaadresować 4MB pamięci RAM. Choć swojego czasu 4MB było ogromną ilością pamięci, jednak obecnie to niewiele. Aby uzyskać dostęp do pełnej przestrzeni adresowej procesora, musisz użyć ostatnich dziesięciu bitów adresu wirtualnego.
Najstarsze dziesięć bitów 32-bitowego adresu wirtualnego jest używanych jako indeks w kolejnej tablicy stron. Jednak ta tablica stron jest specjalna, gdyż odwołuje się do innych tablic stron, a nie do stron kodu lub danych. Z tego powodu otrzymała specjalną nazwę: kartoteka stron (ang. page directory). Podobnie jak inne tablice stron, także kartoteki stron zawierają tablice wartości 32-bitwoych. Wynika z tego, że dana kartoteka stron może zaadresować do 1024 tablic stron. Jeśli wysilisz się i dokonasz pewnych obliczeń, zobaczysz, że pojedyncza kartoteka stron może odwołać się do 4GB pamięci. Jak być może wiesz, właśnie taki jest rozmiar przestrzeni adresowej 32-bitowych procesorów Intela z rodziny x86.
Pozostaje jeszcze jedno pytanie: skąd mechanizm adresowania wie, od którego miejsca zacząć? To znaczy, skąd wie, która strona zawiera kartotekę stron? Kartoteka stron jest wskazywana przez specjalny rejestr procesora: rejestr CR3. (Istni ej ą także rejestry CRO, CR1 oraz CR2, ale ich przeznaczenie wykracza poza zakres tej książki). Zarządzaniem zawartości tych rejestrów zajmuje się oprogramowanie systemu operacyjnego. Właśnie dlatego różne systemy operacyjne z różnymi schematami pamięci mogą działać na procesora Intela x86. Do takich systemów należą MS-DOS, 16-bitowe Windows 3.x, Windows NT, UNIX, OS/2 oraz iRMX (system operacyjny Intela).
Bufor translacji stron
Zanim zagłębimy się w zagadnienia związane z adresowaniem pamięci w Windows 98, musimy przekazać jeszcze jedną informację dotyczącą stronicowanego adresowania. Może wyglądać na to, że każdy dostęp do komórki pamięci wymaga dostępu do dwóch innych miejsc w pamięci: kartoteki stron oraz tablicy stron. Mogłoby to powodować trzykrotnie większy koszt odwołania się do komórki pamięci niż w przypadku pamięci niestronicowanej.
Byłoby to prawdą, gdyby nie to, że procesor posiada bufor - nazywany buforem TLB (translation lookaside buffer) - zawierający ostatnio używane pozycje tablic stron. Rozmiar bufora zależy od modelu procesora, lecz zwykle jest raczej niewielki. Na przykład, TLB w procesorze 486 może pomieścić do 32 pozycji tablic stron, co wystarcza do zaadresowania 128K kodu lub danych.
Mimo niewielkiej ilości pozycji w buforze zwykle w większości przypadków jest ona wystarczająca, gdyż większość programów korzysta w większości z odwołań lokalnych. Innymi słowy - mówiąc obrazowo - większość programów kręci się jak pies za ogonem, operując niewielkim zestawem danych, zwykle występujących blisko siebie. Stopień wykorzystania bufora zwykle sięga 95 lub więcej procent, co sprawia, że spadek wydajności związany z koniecznością stronicowania jest niewielki, aczkolwiek może wystąpić.
Choć większość oprogramowania działa w sposób zgodny z przeznaczeniem TLB, jednak nie ma gwarancji, że będzie tak działał każdy program. Z naszej dyskusji na temat adresowania pamięci możemy łatwo wywnioskować sposób napisania programu, tak aby bufor TLB stał się bezużyteczny. Na przykład, zaalokuj 33 tablice w pamięci i następnie cyklicznie się pomiędzy nimi przełączaj. Program bazujący na takim wykorzystaniu pamięci będzie działał zauważalnie wolniej niż program uwzględniający rolę TLB. Pamiętaj o tym, opracowując sposób, w jaki Twój program odwołuje się do pamięci.
Przestrzeń adresowa procesu w Windows 98
W jaki więc sposób Windows 98 tworzy prywatną przestrzeń adresową procesu? Mogłoby się wydawać, że po prostu w momencie przełączania kontekstów aktualizuje rejestr CR3 procesora. Choć w ten sposób działa Windows NT i inne systemy operacyjne, jednak nie jest to prawdą w przypadku Windows 98. Zamiast tego Windows 98 ustawia rejestr CR3 tylko raz - podczas inicjalizacji systemu - i już więcej go nie modyfikuje. Prywatna przestrzeń adresowa procesu w Windows 98 nie jest tworzona przez wykorzystanie rejestru CR3. Zamiast tego Windows 98 wykonuje podczas przełączania kontekstu pewne sztuczki, przygotowując kontekst pamięci.
Konteksty pamięci w Windows 98
Windows 98 jest mniej szczodre niż Windows NT, które każdemu procesowi przydziela osobną przestrzeń adresową. Jak już wspominaliśmy, wynika to częściowo z faktu, że samo Windows 98 ma mniejszy budżet. Nadanie każdemu procesowi osobnej przestrzeni adresowej wymagałoby przeznaczenia dla każdego procesu 4K strony na kartotekę stron plus kilka 4K stron dla tablic stron.
Zamiast tego Windows 98 tworzy pojedynczy zestaw tablic stron. Podczas przełączania kontekstu pomiędzy wątkiem jednego procesu a wątkiem innego procesu, Windows 98 aktualizuje tablice stron. Obraz, jaki widzi każdy z wątków, jest nazywany kontekstem pamięci (ang. memory contexf). Część przestrzeni adresowej składa się z systemowych bibliotek DLL, wspólnych dla wszystkich procesów. Pozycje tablicy stron odnoszące się do tej części przestrzeni adresowej - od 2GB do 4GB - nigdy nie muszą być zmieniane. Zamiast tego, podczas przełączania kontekstów, jedynymi pozycjami tablicy stron, które muszą być zmienione, są pozycje powiązane z prywatnymi stronami należącymi do procesu - tj. z zakresu od 2GB do 4GB. Jak można oczekiwać, zmiany dokonywane przez Windows 98 w tablicach stron z punktu widzenia procesów są zupełnie niewidoczne. Każdy proces widzi własne dane w jednym zakresie adresów, zaś wspólne dane w drugim zakresie.
Bez względu na sztuczki wykonywane przez system operacyjny, sam mechanizm adresowania procesora pozostaje bez zmian. Decyzja, jaka pamięć powinna być widoczna dla procesu, należy wyłącznie do systemowego menedżera pamięci. Dana strona kodu lub danych może być odwzorowana w przestrzeń adresową pojedynczego procesu lub w przestrzeń adresową kilku procesów. Windows 98 korzysta z obu możliwości, dzięki elastyczności procesorów Intela.
W celu zachowania zgodności z Windows 3.x programy Win 16 działające w Windows 98 nie posiadają prywatnych przestrzeni adresowych. Zamiast tego działają w jednej, wspólnej przestrzeni adresowej. Dzięki temu możliwe jest wspólne korzystanie z pamięci, do którego przywykły programy Win 16: swobodny dostęp do wskaźników pamięci, uchwytów obiektów oraz uchwytów pamięci. Podczas konwersji programu Winló na program korzystający z API Win32 wszelkie odwołania do pamięci w stylu Winló muszą zostać zmienione na techniki zgodne z Win32.
Dla programów Win32 zarówno Windows 98, jak i Windows NT korzystają ze wspólnego formatu plików wykonywalnych. Ma to duże znaczenie, gdyż w obu systemach operacyjnych pliki wykonywalne są ładowane jako pliki odwzorowane w pamięci. Jak powiemy w dalszej części rozdziału, ten mechanizm opiera się na połączeniu przez menedżera pamięci wirtualnej pliku dyskowego z zakresem adresów pamięci. Strony są odczytywane z dysku tylko wtedy, gdy następuje odwołanie do powiązanych z nimi adresów pamięci.
Format pliku programu wykonywalnego Win32 jest nazywany przenośnym formatem pliku wykonywalnego (ang. portable executable format). Jest to jeden z kilku formatów plików wykonywalnych stworzonych dla różnych systemów operacyjnych Microsoftu. Tabela 18.2 zawiera formaty plików wykonywalnych używanych w różnych systemach operacyjnych Microsoftu. Kolumna Sygnatura zawiera dwuliterowy kod w nagłówku pliku, identyfikujący dany typ pliku wykonywalnego.
Tabela 18.2. Formaty plików wykonywalnych używane w różnych systemach operacyjnych Microsoftu
Format pliku wykonywalnego
Sygnatura
Opis
Program MS-DOS
MZ
Relokowalny format pliku wykonywalnego Marka Zbikowskiego. Mark stworzył format pliku wykonywalnego dla MS-DOS 2.0 i uwiecznił się, stosując jako sygnaturę pliku własne inicjały.
Win16
NE
Nowy format pliku wykonywalnego (New Executable) dla zorientowanych na segmenty programów Winló. Tego samego formatu używa OS/2 dla swoich programów 16-bitowych.
Win3
PEPrzenośny format pliku wykonywalnego (Portable Executable) dla programów Win32. Ten format pliku jest stosowany w programach Win32 zbudowanych dla Windows 98 i Windows NT.
Wirtualny dla Windows
LE
Liniowy format pliku wykonywalnego (Linear Executable) dla 32-bitowych sterowników urządzeń, nazywanych czasem VxD, wprowadzonych w Windows 3.0 i wciąż wykorzystywanych w Windows 98.
Z perspektywy przestrzeni adresowej procesu, Windows 98 i Windows NT posiadają jeszcze jedną wspólną cechę: korzystanie z tzw. zakresu ziemi niczyjej (ang. no-man's land). Oznacza to niedozwolone adresy, istniejące w celu ułatwienia wyłapywania różnorodnych błędów wskaźników, a w szczególności użycia wskaźnika NULL. W Windows NT występują dwa takie obszary, każdy o rozmiarze 64K. Pierwszy z nich znajduje się na początku przestrzeni adresowej (adresy od 0 do.10000h Drugi znajduje się na szczycie przestrzeni adresowej aplikacji (adres od 7FFF0000h do TFFFFFFFh).
Z drugiej strony, w Windows 98 ziemia niczyja to pojedynczy 4MB obszar na początku przestrzeni adresowej. Każda próba odwołania się do pamięci w tym zakresie powoduje wyjątek procesora (podobnie jak w Windows NT). Oba systemy operacyjne kończą działanie procesu o nieobsłużonym wyjątku. Ukryte w obszarze ziemi niczyjej w Windows 98 są części MS-DOS-a w trybie rzeczywistym, a także obszar zarezerwowany dla programów Win 16.
Interesujące jest to, że ziemia niczyja dla programów Win 16 ma tylko 64K. Występuje ona w celu ułatwienia wyłapania wskaźników odwołujących się do segmentu 0. Poza tym podobieństwem widok pamięci dla programów Win 16 jest zupełnie odmienny od widoku dla programów Win32. W celu zachowania zgodności wstecz programy Win 16 działające w Windows 98 mają prawie taki sam widok pamięci jaki miały, działając w Windows 3.1.
Kolejną wspólną cechą Windows 98 i Windows NT jest sposób odwzorowania bibliotek DLL w przestrzeń adresową procesu. W obu systemach biblioteki DLL specyficzne dla aplikacji są odwzorowywane w zakres adresów przeznaczonych dla samodzielnego wykorzystania przez aplikację - tj. poniżej 2GB.
Jedynym wyjątkiem w Windows 98 są systemowe biblioteki DLL, zawierające funkcje składające się na Win32 API. Są one ładowane do obszaru pamięci wspólnej pomiędzy 2GB a 3GB. Z samej ich natury wynika, że są one wspólne dla wszystkich aplikacji Win32. Jako takie, powinny więc występować w części przestrzeni adresowej wspólnej dla wszystkich procesów.
Jak widać, istnieje kilka podobieństw w sposobie, w jaki Windows 98 i Windows NT udostępnia pamięć procesom Win32. Teraz więc przejdźmy do różnic.
Różnice w pamięci procesu w Windows 98 i Windows NT
Główna różnica pomiędzy Windows 98 a Windows NT wiąże się z ochroną. Windows NT wznosi wysokie bariery pomiędzy procesami. Z drugiej strony, Windows 98 jest bardziej ufne, kontynuując tradycję otwarcia rozpoczętą jeszcze przez MS-DOS i różne 16-bitowe systemy Windows. Choć Windows 98 zapewnia każdemu procesowi prywatną przestrzeń adresową, jednak wszystko we wspólnym obszarze jest całkowicie dostępne.
Główną zaletą tego otwartego, ufnego podejścia Windows 98 jest mniejsza ilość wymaganych zasobów niż w przypadku Windows NT. Podobnie jak wymyślny system alarmowy budynku wiąże się z większą ilością zasobów - okablowania, elektryczności, strażników i kamer - również bardziej bezpieczny system operacyjny wymaga większej ilości zasobów. Windows 98 zostało zaprojektowane do działania na procesorze 80486 z 8MB RAM (choć chyba bardziej realistyczne jest 16MB). Aby obsłużyć swoją krzemową fortecę, Windows NT potrzebuje przynajmniej procesora Pentium i 32MB pamięci RAM. (Także w tym przypadku większa ilość pamięci RAM - powiedzmy 48MB lub 64MB - powoduje, że system zaczyna działać dużo wydajniej).
Windows NT wymaga więcej zasobów procesora, gdyż ogromna większość systemu GUI jest zaimplementowana poza przestrzenią adresową procesu Win32. Na przykład, każde wywołanie w celu stworzenia okna lub narysowania grafiki wiąże się z odwołaniami do funkcji zaimplementowanych w innym procesie i innej przestrzeni adresowej
niż proces wywołujący. Nazwa tego procesu to podsystem Win32 (ang. Win32 subsystem). Działając w osobnej przestrzeni adresowej, rdzeniowe komponenty systemu operacyjnego są zabezpieczone przed zarówno przypadkowym, jak i celowym naruszeniem. Kosztem tego bezpieczeństwa jest wydajność - tzn. większa ilość zasobów wymagana do osiągnięcia tej samej wydajności co w przypadku bardziej otwartych systemów, takich jak Windows 98.
Windows 98 umieszcza wszystkie biblioteki systemowe w obszarze pamięci wspólnej, od 2GB do 3GB. Trudno ocenić wszystkie biblioteki systemowe są w zasięgu każdego programu Win32 (oraz Wini6). Gdy Twój program Win32 działa w Windows 98, do osiągnięcia funkcji systemowej nie potrzebuje niczego więcej niż zwykłe wywołanie (plus być może instrukcja skoku). W Windows NT zwykle w takim przypadku następuje przełączanie kontekstów pomiędzy funkcją wywołującą a kodem wykonującym dane zadanie. (Jednak zwykłe funkcje odczytujące wartość działają w Windows NT tak samo szybko jak w Windows 98).
Ponieważ zostały zaprojektowane z myślą o ochronie, Windows NT nie ułatwia wspólnego korzystania z pamięci. Na przykład, nie ma wspólnego obszaru pamięci takiego jak w Windows 98. Zamiast tego, nic co nie zostanie jawnie przeznaczone do udostępnienia, nie zostanie udostępnione. Ta zwiększona ochrona sprawia równocześnie, że Windows NT jest dużo stabilniejszym systemem niż Windows 98. Zwykle możesz mieć pewność, że udostępnione dane nie zostaną przejrzane przez kogoś, kto nie powinien ich widzieć.
Z drugiej strony, w Windows 98 wspólna pamięć jest alokowana we wspólnym obszarze, gdzie jest natychmiast dostępna dla każdego procesu w systemie. Jeśli ochrona ma dla Ciebie duże znaczenie, może to stanowić problem. Poza znajomością adresu proces nie potrzebuje niczego więcej, aby odczytać lub zapisać dane przechowywane we wspólnym obszarze. (Niczym trudnym nie jest napisanie brutalnego programu skanującego cały wspólny obszar w poszukiwaniu czegoś ciekawego).
Odkładając zagadnienia związane z ochroną, różne podejścia do udostępniania pamięci mogą powodować problemy z działaniem programów. (Zagadnienia ochrony są oczywiście ważne, ale ochrona nie stanowi tematu tego rozdziału). Problem polega na znalezieniu takiego sposobu udostępniania pamięci pomiędzy dwoma programami Win32, który działałby zarówno w Windows 98, jak i w Windows NT. Przy dostępie do wspólnej pamięci korzystasz z tego samego API. Na czym więc polega różnica?
Różnica wynika z faktu, że wspólny obiekt w Windows NT może być w różnych procesach odwzorowany w różne miejsca w pamięci. Weźmy wspólny 64K bufor. Gdy udostępnisz go trzem różnym procesom w Windows NT, może zostać odwzorowany na adres 800000h w jednym procesie, 850000h w drugim i 900000h w trzecim. Choć możesz spróbować odwzorować obiekt w zadany adres, jednak jest to trudne i - w zależności od rozmiaru obiektu oraz zawartości każdej z przestrzeni adresowych - może być niemożliwe. Ten sam obiekt pamięci w różnych procesach Win32 w Windows NT może być odwzorowany pod różnymi adresami, gdyż przestrzeń adresowa każdego procesu jest niezależna od przestrzeni adresowych innych procesów.
W Windows 98, wspólny obiekt stworzony przez jeden proces jest widoczny dla każdego procesu pod tym samym adresem. Jak wspomnieliśmy, ten adres zawsze należy do zakresu od 2GB do 3GB. Gdy 2 - lub nawet 200 - innych procesów Win32 odwołuje się do wspólnej pamięci, w Windows 98 jest ona zawsze pod tym samym adresem.
Zagadnienie sprowadza się do napisania pojedynczego programu Win32, który prawidłowo korzystałby ze wspólnej pamięci w obydwu systemach. Krótką odpowiedzią jest unikanie bezpośredniego przekazywania adresów pomiędzy programami Win32 oraz pamiętaniu o korzystaniu z odpowiednich API do udostępniania pamięci. Dodatkowo, pisząc programy korzystające z pamięci, pamiętaj o przetestowaniu ich w Windows NT. W końcu to właśnie ten system jest bardziej rygorystyczny w tym względzie. Gdy przejdziemy do szczegółowego omawiania udostępniania pamięci, pokażemy także kilka technik wspólnego korzystania z pamięci w każdej z platform.
Ważnym ulepszeniem obu systemów w stosunku do Windows 3.1 jest porządkowanie pamięci systemowej. Przejdźmy więc do tego tematu.
Porządkowanie pamięci systemowej
Problemem wspólnym dla wszystkich 16-bitowych wersji Windows - począwszy od wersji 1.01 z 1985 roku aż do wersji 3.11 z roku 1993 - była niemożność wyczyszczenia całej zaalokowanej pamięci systemowej. Ten problem wyniknął z -jak się okazało -błędnej decyzji projektowej.
Windows wymagało od aplikacji zarządzania tworzeniem i usuwaniem obiektów alokowanych w pamięci systemowej. Gdy aplikacja Win 16 zapomniała usunąć obiekt, pamięć systemowa była utracona. W systemach działających nieprzerwanie przez tygodnie lub miesiące bez ponownego uruchomienia takie zachowanie aplikacji prowadziło do wyczerpania którejś z systemowych stert. W wyniku tego system ulegał jakiejś formie załamania.
Aby zrozumieć jak ten problem rozwiązano w Windows 98 i Windows NT, powinniśmy zrozumieć, z czego ten problem wynika. Podstawowym powodem, dlaczego Windows 3.1 (i wcześniejsze wersje) nie usuwały obiektów systemowych z pamięci, było umożliwienie wspólnego korzystania przez programy z rysunkowych obiektów GDI. Aby rozwiązać ten problem, specyfikacja Win32 różni się od specyfikacji Win16 właśnie w tym: Win32 nie stosuje wspólnego korzystania z obiektów GDI przez różne procesy.
W 16-bitowych Windows wszystkie obiekty GDI miały z założenia być wykorzystywane wspólnie przez różne programy. Do wspólnych obiektów należały bitmapy, pędzle, konteksty urządzeń, czcionki, metapliki, palety, pióra oraz regiony. Ironicznie, powodem wspólnego korzystania z tych obiektów była chęć zaoszczędzenia pamięci; kilka współpracujących programów mogło łączyć swoje obiekty rysunkowe GDI, redukując w ten sposób zużycie sterty GDI. W końcu sześć czerwonych piór zajmuje więcej pamięci niż jedno czerwone pióro. Aby umożliwić takie wspólne korzystanie, Windows 3.1 pozostawiało wszystkie porzucone obiekty rysunkowe GDI w pamięci. Inne rodzaje obiektów - włącznie z oknami, menu, kursorami i ikonami - były automatycznie czyszczone w momencie zakończenia działania aplikacji. Porzucone obiekty GDI, które nie były wykorzystywane przez żaden program, po prostu stanowiły utraconą na zawsze pamięć (przynajmniej do ponownego uruchomienia Windows).
Uruchamiając programy Win 16, zarówno Windows 98, jak i Windows NT respektują prawa programu Win 16 do wspólnego korzystania z obiektów. Może to powodować problemy, gdyż błędny program Win 16 może konsumować zasoby systemowe i potencjalnie doprowadzić do naruszenia działania programów Win32. Tak więc oba systemy operacyjne na kilka sposobów bronią się przed rozrzutnym programami Win 16.
Windows 98 i Windows NT usuwają porzucone przez programy Win 16 obiekty GDI, ale tylko wtedy, gdy w systemie nie działa żaden program Win16. W ten sposób Windows 98 i Windows NT respektuj ą prawa programów Win16 do wspólnego korzystania z obiektów. Jednak gdy wszystkie programy Win16 zakończą działanie, utworzone przez nie obiekty GDI mogą zostać usunięte z pamięci. W miarę upływu czasu i zastępowania programów Win26 programami Win32, błędne programy Win16 będą miały coraz mniejszy wpływ na system.
Windows 98 posiada drugi mechanizm obronny, który zresztą powinien pojawić się już w pierwszych wersjach Windows. Gdy program Win16 lub Win32 próbuje stworzyć obiekt GDI, Windows 98 nie tworzy na ślepo nowego obiektu, tak jak działo się to w poprzednich wersjach systemu. Zamiast tego sprawdza, czy obiekt tego typu już istnieje. Jeśli tak, ponownie wykorzystywany jest poprzednio utworzony obiekt. Licznik odwołań zapewnia, że obiekt nie zostanie przedwcześnie usunięty. Na przykład, jeśli program prosi GDI o stworzenie czerwonego pióra, lecz w systemie nie występuje jeszcze obiekt czerwonego pióra, GDI tworzy je i zwraca uchwyt do obiektu. Jeśli jednak czerwone pióro już występuje w systemie, GDI zwiększa jedynie licznik odwołań do istniejącego pióra i zwraca uchwyt istniejącego obiektu. Ani oryginalny twórca, ani nowy proces nie muszą wiedzieć, że GDI umożliwia wspólne korzystanie z obiektów.
Co ciekawe, takie niejawne wspólne korzystanie z obiektów GDI w Windows 98 może w starszych wersjach MFC prowadzić do ostrzegawczych komunikatów o asercjach. Gdy program MFC tworzy obiekt GDI - reprezentowany na przykład przez klasę MFC CBitmap, CBrush czy CPen - wersje programów przeznaczone dla debuggowania sprawdzają, czy wartości uchwytów są unikalne. Na przykład, gdy program tworzy dwa czerwone pióra, Windows 98 w obu przypadkach udostępni ten sam uchwyt. Takie zachowanie nie jest oczekiwane przez wersje MFC starsze niż 3.1. Wynika to z faktu, że te starsze wersje MFC sprawdzają, czy w tablicach uchwytów nie występują powielone wartości. Gdy zostaną znalezione powielone wartości, MFC wyświetla komunikat asercji - w postaci dużego, brzydkiego okna dialogowego. Możesz zignorować takie komunikaty (lub przejść do wersji MFC zgodnej z Windows 98).
Dla programistów doświadczonych w programowaniu Winl6, automatyczne czyszczenie zapewniane przez programy Win32 w Windows 98 i Windows NT stanowi duże ułatwienie. Nie trzeba się już martwić o załamania systemu spowodowane wyczerpaniem stert systemowych. Wszystkie obiekty należą do procesów, więc gdy jakiś proces kończy działanie, pozostałe po nim obiekty mogą zostać automatycznie usunięte.
Automatyczne czyszczenie obiektów systemowych wiąże się z pewnym kosztem: obiekty Win32 GDI nie mogą być swobodnie wykorzystywane wspólnie przez różne procesy, tak jak miało to miejsce w Winl6. Jeśli dwa programy chcą skorzystać z czerwonego pióra, każdy z nich musi stworzyć swój własny egzemplarz. Zwróć uwagę, że wspólne korzystanie z obiektów GDI przez Schowek - obejmujące metapliki i bitmapy -jest w pełni obsługiwane w Win32 tak samo jak było obsługiwane w Winló. Zmieniła się jedynie wewnętrzna implementacja, co nie powinno wpływać na normalne działanie Schowka.
Automatyczne czyszczenie pozostawionych obiektów nie może stanowić wymówki dla niedbałego programowania. Wszystkie stworzone obiekty powinny zostać usunięte. Masz przy tym do dyspozycji kilka sposobów zapewniających, że wszystkie obiekty zostaną poprawnie usunięte. Jedna z takich metod jest wbudowana w Visual Studio i w de-bugger. Przeznaczone do debuggowania wersje programów utrzymują listy wszystkich zaalokowanych obiektów. W momencie zakończenia działania programu zostaje wyświetlona lista wszystkich pozostawionych obiektów - tzw. wycieków pamięci - zawierająca zarówno obiekty systemowe, jak i dynamicznie zaalokowane obszary pamięci. Aby przejrzeć tę listę, musisz uruchomić program w debuggerze, na przykład takim jak wbudowany debugger, który może wyświetlać komunikaty debuggowania MFC. Innym narzędziem wykrywającym pozostawione obiekty systemowe - lecz nie wewnętrznie zaalokowane dane -jest program Bounds Checker firmy Nu-Mega.
Jedno z pomniejszych niedociągnięć automatycznego czyszczenia wiąże się z globalnymi atomami. Atom jest unikalnym identyfikatorem, który może zostać użyty w celu dostępu do łańcucha znaków o zmiennej długości. Biblioteki systemowe w Windows 98 i Windows NT tworzą, w globalnej tablicy atomów, atomy dla zarejestrowanych komunikatów systemowych oraz zarejestrowanych formatów Schowka. Ponieważ takie wartości są wspólnie wykorzystywane pomiędzy procesami, muszą istnieć we wspólnym obszarze pamięci. Choć oba systemy operacyjne mogłyby czyścić globalną tablicę atomów, jednak nie robią tego. Dobrą wiadomością jest to, że ilość dostępnych atomów jest dość duża: około l K atomów w Windows 98 i 16K atomów w Windows NT. A ponieważ te same łańcuchy tworzą ten sam atom, nie wyczerpiesz sterty, uruchamiając wiele razy ten sam program rejestrujący jako atom ten sam łańcuch i zapominający o jego usunięciu. Mimo to powinieneś pamiętać, że jest to jeden z zasobów, którym system nie zarządza perfekcyjnie.
Globalna tablica atomów może zostać stosunkowo łatwo przepełniona przez aplikacje korzystające z mechanizmu dynamicznej wymiany danych (DDE, dynamie data exchange). Jak być może wiesz, jest to mechanizm wymiany danych polegający na wymianie komunikatów (takich jak WM_DDE_DATA) pomiędzy oknami. DDE nie używa atomów do wymiany danych, lecz raczej do zarejestrowania czytelnych dla człowieka identyfikatorów. (Na przykład, zakres komórek arkusza kalkulacyjnego może być identyfikowany poprzez łańcuch "A12:C52"). Jeśli tworzysz programy Windows używające do wymiany danych niskopoziomowego DDE, pamiętaj o niszczeniu wszystkich utworzonych atomów. (Możesz ewentualnie użyć biblioteki DDEML - DDE-Management Library w Windows - która zajmie się poprawnym oczyszczeniem globalnej tablicy atomów).
Prywatna pamięć procesu
Choć być może wolałbyś pozostać przy operatorze new jako swoim alokatorze pamięci, jednak nie zawsze jest to odpowiedni wybór. Na przykład w przypadku dużych obiektów większą kontrolę uzyskasz, stosując oryginalne alokatory pamięci Win32. W przypadku gdy krytyczna staje się wydajność lub praca w czasie rzeczywistym, możesz zdecydować się na użycie alokatorów specyficznych dla klasy. Bjarne Stroustrup zaleca takie podejście w szóstym rozdziale swojej książki, "Język C++".
Nawet jeśli operator new jest Twoim podstawowym alokatorem, możesz zechcieć przechować otrzymany wskaźnik gdzieś indziej niż w zwykłej zmiennej. W szczególności, Win32 API zapewnia obszary pamięci - dodatkowe bajty okna lub lokalne dane wątku
- w celu umieszczenia danych powiązanych z obiektami systemu operacyjnego. Takie "kieszonki" mogą uprościć zależność pomiędzy danymi programu a obiektami systemu operacyjnego. Nawet jeśli nigdy dotąd z tego nie korzystałeś, poznanie tej możliwości pomoże Ci w zrozumieniu działania różnych obiektów interfejsu użytkownika. Na przykład, kontrolki okna dialogowego - pola edycji, listy, rozwijane listy czy przyciski
- używają dodatkowych bajtów okna do przechowywania danych specyficznych dla kontrolki.
Poniższe sekcje opisują każde możliwe miejsce przechowywania danych prywatnych dla procesu. W tej sekcji skupiamy się na pamięci prywatnej; pamięć wspólna zostanie omówiona w dalszej części rozdziału.
W tym warstwowym podejściu na samym dole znajduje się sprzęt, zaś na samej górze połączenie z aplikacją. Choć dostępnych jest wiele rodzajów pamięci, wszystkie opierają się na prywatnych stronach zaalokowanych w przestrzeni adresowej procesu. Oprócz zapewnienia prywatnych stron pamięci dla procesu Win32 umożliwia tworzenie danych prywatnych dla wątku. Na koniec, występuje także zestaw interfejsów API przeznaczonych dla alokacji sterty. Visual C++ dostarcza własny alokator sterty, zbudowany w oparciu o gołe strony pamięci. Oprócz tego, alokator sterty w Win32 także zapewnia podstawową obsługę sterty oraz - w celu zachowania zgodności wstecz - obsługuje oba rodzaje funkcji alokacji sterty w Win 16.
Wygląda na to, że w systemach operacyjnych Microsoftu interfejsy API do alokacji sterty zawsze występują parami. Na przykład, 16-bitowe API OS/2 udostępnia funkcję DosAllocSeg () do alokowania segmentów oraz funkcję DosSubAlloc () do alokowania podsegmentów. (Tak, tą część API OS/2 tworzył Microsoft). Win 16 udostępnia porównywalną parę funkcji: GlobalAlloc () do alokowania segmentów oraz LocalAllocO do dalszego dzielenia segmentów na mniejsze fragmenty.
Win32 kontynuuje tę tradycję dostarczając funkcję virtualAlloc () do alokowania stron oraz funkcję HeapAllocO do alokowania fragmentów stron. Właśnie te dwa rodzaje pamięci omówimy jako pierwsze.
Alokowanie stron
W środowisku stronicowanej pamięci w Windows 98 (i Windows NT) cała pamięć dostępna dla procesu Win32 występuje w postaci stron pamięci. Obsługiwanych jest także kilka alokatorów dzielących strony na mniejsze fragmenty. Do dodawania nowych stron pamięci dla procesu służą jedynie dwie funkcje Win32. Jedna z nich, virtualAlloc (), alokuje strony prywatne. Druga funkcja, MapYiewOfFile (), alokuje wspólne strony. Wszystkie inne alokatory przy dostarczaniu stron opierają się - pośrednio lub bezpośrednio - na tych dwóch funkcjach.
Alokatorem prywatnych stron w Win32 jest funkcja virtualAlloc (). Oprócz alokowania stron prywatnych dla procesu pełni jeszcze dodatkową rolę. Rezerwuje zakresy adresów pamięci bez faktycznego powodowania wykorzystania jakiejkolwiek przestrzeni fizycznej - czy to w postaci stron w pamięci RAM, czy też w postaci stron w pliku wymiany na dysku. Dzięki temu program może zarezerwować duży zakres adresów, bez niepotrzebnego zajmowania stron. W razie potrzeby zestawy stron pamięci mogą zostać odwzorowane w zarezerwowany zakres adresów. Na przykład, w razie potrzeby do "rzeczywistej" pamięci mogą zostać załadowane tylko niewielkie, przyrostowe porcje ogromnej, gigabajtowej bazy danych. Aby zaalokować nową stronę w zakresie zarezerwowanych adresów, program wywołuje tę samą funkcję - virtualAlioc O - w rzeczywiście powierzając pamięć procesowi.
Do alokowania wspólnych stron procesy Win32 wywołują funkcję MapViewOf File {}. Nazwa funkcji jest dość dziwna, jednak wynika z podwójnej roli tej funkcji. Z jednej strony, funkcja tworzy widok w pliku odwzorowanym w pamięci. Z drugiej strony, alokuje wspólne strony, co jest innym sposobem opisania pliku odwzorowanego w pamięci, którym w takim przypadku jest systemowy plik stronicowania. Alokowanie wspólnych stron zostanie opisane w dalszej części rozdziału.
Na razie skupimy się na alokacji stron specyficznych dla procesu, zaczynając od przyjrzenia się w jaki sposób funkcja virtuaiAlloc () umożliwia rezerwowanie przestrzeni adresowej. Następnie omówimy alokowanie stron. Na koniec zajmiemy się alokacją stosu w programach Win32.
Rezerwowanie przestrzeni adresowej
Win32 umożliwia procesowi zarządzanie własną przestrzenią adresową. Większość programistów nie traktuje przestrzeni adresowej jako zasobu, którym można zarządzać. W końcu, mając do dyspozycji miliardy adresów, po co wybrzydzać? Jednak w pewnych sytuacjach bezpośrednia kontrola nad przestrzenią adresową może być bardzo pomocna.
Na przykład, w przypadku dynamicznego alokowania bloku pamięci, który może się rozrastać, dobrze jest mieć kontrolę na przestrzenią adresową. Choć nie jest to zawsze konieczne, utrzymanie wszystkich części obiektu w ciągłej pamięci ułatwia obsługę danych. Aby umożliwić powiększanie bloku z zachowaniem ciągłości, większość systemów operacyjnych wymaga wcześniejszego zaalokowania pamięci poza aktualnie potrzebny rozmiar.
Alokowanie większej ilości pamięci niż potrzeba to marnotrawstwo, lecz czasem stanowi najprostszy sposób osiągnięcia zamierzonego celu. W przeciwnym razie, gdy obiekt przekroczy rozmiar fragmentu pamięci, trzeba przenieść go w miejsce o większym rozmiarze. Przenoszenie dużych obiektów jest drogie, w odniesieniu do czasu procesora, oraz może powodować fragmentację przestrzeni adresowej. Oczywiście, istnieją alternatywy dla wcześniejszego alokowania nadmiarowych obszarów pamięci. Możesz przenosić obiekty, gdy się zwiększą, lub możesz użyć połączonej listy w celu logicznego powiązania kolejnych bloków danych. Jednak wynikają z tego kolejne problemy, takie jak narzut związany z obsługą listy oraz błędy płynące z większej złożoności obsługi danych.;
Win32 API udostępnia bardziej eleganckie rozwiązanie tego problemu. Zamiast marnować pamięć, alokując większy obszar niż potrzeba, możesz zaalokować dokładną ilość stron dla danego bloku danych. (Przeciętnie, zmarnowana pamięć wyniesie 2048 bajtów). Jednak jest to już druga część rozwiązania.
Przed zaalokowaniem jakichkolwiek stron dla obiektu, którego rozmiar może wzrosnąć, powinieneś zarezerwować zakres adresów, wystarczający dla największego oczekiwanego rozmiaru obiektu. Po zarezerwowaniu zakresu stron będziesz alokował pamięć, powierzając procesowi strony z zarezerwowanego zakresu adresów. Na początku alokujesz jedynie potrzebne strony. Gdy wzrośnie zapotrzebowanie na pamięć, powierzysz następne strony. Dzięki temu pamięć będzie efektywnie wykorzystana, bez marnotrawnego alokowania nadmiarowej pamięci. Oprócz tego, nowe strony dodane do istniejącego zestawu będą dostępne w zakresie adresów ciągłym z poprzednio zaalokowanymi stronami.
W wyjaśnieniu tego pomoże przykład. Przypuśćmy, że chcesz odczytać do pamięci plik zajmujący 100K i przewidujesz, że ten obiekt nigdy nie będzie miał rozmiaru większego niż 500K. Używając API Win32 do obsługi pamięci wirtualnej, rezerwujesz najpierw 500K zakres adresów pamięci. Z zakresu zarezerwowanych adresów powierzasz sobie 100K jako "rzeczywistą" pamięć, tak aby móc załadować do niej plik. W razie potrzeby rozszerzysz zakres powierzonych stron w celu dostosowania się do zwiększonego rozmiaru obiektu.
Ważne jest, by zdawać sobie sprawę, kiedy faktycznie pamięć jest zajmowana. W szczególności, podczas rezerwowania adresów nie są zajmowane żadne strony pamięci fizycznej (poza niewielką ilością pamięci na liście adresów menedżera pamięci wirtualnej). Jako dowód, spróbuj odwołać się do pamięci z zakresu zarezerwowanych (lecz nie powierzonych) adresów. Otrzymasz błąd - ogólny wyjątek ochrony - który, jeśli nie zostanie obsłużony, spowoduje zakończenie działania programu.
Choć rezerwowanie adresów nie daje dostępu do pamięci fizycznej, jednak jest koniecznym pierwszym krokiem w planowaniu wzrostu objętości obiektu. W przeciwnym przypadku inna część Twojej aplikacji - lub biblioteka DLL używana przez Twoją aplikację - może użyć adresów tuż za obszarem, który zaalokowałeś. Gdy tak się stanie, ponownie stajesz przed problemem przeniesienia całego obiektu danych w momencie powiększenia się jego rozmiarów.
Aby zarezerwować zakres adresów, wywołaj funkcję virtualAlloc () i przekaż jej znacznik MEM_RESERVE. Jest to jeden z trzech znaczników zdefiniowanych dla trzeciego parametru tej funkcji. Sama funkcja jest zadeklarowana następująco:
LPYOID VirtualAlloc( LPYOID lpvAddress, DWORD dwSize, DWORD dwAction, DWORD dwAccess );
Parametr ipvAddress to adres pamięci. Rezerwując zakres adresów, ustaw go na NULL w celu umożliwiania systemowi wybrania początkowego adresu lub przekaż wartość różną od NULL, jeśli chcesz sam wybrać adres początkowy. Podczas powierzania stron pamięci poprzednio zarezerwowanemu adresowi ta wartość jest zaokrąglana w dół do granicy strony (4K). Podczas rezerwowania zakresu adresów ta wartość jest zaokrąglana w dół do granicy regionu (64K).
Argument dwSize zawiera ilość bajtów do zarezerwowania lub powierzenia. Podczas powierzania ta wartość jest zaokrąglana w dół do granicy strony (4K), zaś podczas rezerwowania adresów ta wartość jest zaokrąglana w dół do granicy regionu (64K).
Parametr dwAction może mieć wartość MEM_RESERVE w celu zarezerwowania zakresu adresów lub MEM_COMMIT w celu powierzenia stron pamięci poprzednio zarezerwowanym adresom lub może stanowić kombinację MEM_RESERVE l MEM_COMMIT w celu zarezerwowania i powierzenia pamięci. (Sam znacznik MEM_COMMIT także powoduje zarezerwowanie adresów i powierzenie stron pamięci).
Argument dwAccess to pole znaczników określających stan dostępu do strony. Do zdefiniowanych znaczników należą PAGE_READWRITE dla pełnego dostępu oraz PAGE_NOACCESS uniemożliwiający dostęp do strony. (Windows 98 nie obsługuje znacznika PAGE_GUARD).
Wartością zwracaną przez funkcję VirtualAlloc () jest albo bazowy adres regionu pamięci, albo wartość NULL oznaczająca błąd. Zestaw zarezerwowanych adresów jest nazywany regionem. Jednostką podziału regionów - zarówno w Windows 98, jak i w Windows NT - jest64K. Innymi słowy, gdy zarezerwujesz choćby jednobajtowy zakres adresów, do regionu będzie należeć co najmniej 64K adresów. Tak więc cała 4GB wirtualna przestrzeń adresowa może pomieścić 64K regionów po 64K.
Alokowanie stron pamięci
Alokowanie stron pamięci za pomocą funkcji YirtualAlloc () może odbywać się w jednym lub w dwóch krokach. Przy dwu krokach pierwszy z nich wiąże się z zarezerwowaniem zakresu pamięci, a drugi z rzeczywistym powierzaniem pamięci stronom. Oto przykład takiego podejścia:
// Rezerwujemy 64K zakres adresów
LPVOID p = VirtualAlloc( O, 0x10000, MEM_RESERVE, PAGE_READWRITE ); if( p == NULL ){ /* Błąd */
}
// Powierzamy 4K zakres adresów
LPYOID pTemp = VirtualAlloc( p, 0x1000, MEM_COMMIT, PAGE_READWRITE ); if( pTemp == NULL ){ /* Błąd */
} .
Pierwsze wywołanie funkcji VirtualAlloc o rezerwuje 64K region adresów, położony w miejscu wybranym przez system operacyjny. Drugie wywołanie powierza 4K zarezerwowanych adresów stronie pamięci. Zwróć uwagę, że wartości zwrócone w obu wywołaniach są przechowywane oddzielnie. Z powodu rozdzielczości stron i regionów, a także możliwości wystąpienia błędu w dowolnym z wywołań, powinieneś traktować każdy rodzaj wywołania jako osobną, niezależną akcję alokacji pamięci.
Podejście korzystające z jednego kroku polega na wywołaniu funkcji VirtualAlloc () ze znacznikiem MEM_COMMIT. Innymi słowy, nie rezerwujesz adresów przed powierzeniem stron; zamiast tego łączysz rezerwację i powierzanie w jedno wywołanie:
// Powierzenie 4K zakresu adresów
LPYOID pTemp = VirtualAlloc( O, 0x1000, MEM_COMMIT, PAGE_READWRITE if( pTemp == NULL ){ /* Błąd */
}
Aby zwolnić uprzednio powierzone strony lub aby zwolnić rezerwację uprzednio zarezerwowanego zakresu adresów, wywołaj funkcję virtuaiFree,(). Wywołana ze znacznikiem MEM_DECOMMIT powoduje zwolnienie stron (jednak zakres adresów wciąż pozostaje zarezerwowany). Aby zwolnić zakres zarezerwowanych adresów, wywołaj funkcję VirtualFree () ze znacznikiem MEM_RELEASE.
Pełny zestaw funkcji pamięci wirtualnej Win32 został zebrany w tabeli 18.3. Większość z tych funkcji jest oczywista i nie wymaga , szerszego komentarza poza skierowaniem uwagi na nazwę funkcji w dokumentacji Win32. Ostatnim zestawem prywatnych stron bezpośrednio alokowanych dla procesu Win32 są strony stosu, więc przejdziemy teraz do nich.
Tabela 18.3. Funkcje sterujące alokacją stron dla procesu
Funkcja
Opis
VirtualAlloc()
Rezerwuje zakres adresów lub alokuje strony pamięci.
VirtualFree()
Zwalania zarezerwowany zakres adresów lub zwalnia zaalokowane strony pamięci.
VirtualLock()
Dodaje zestaw stron do zestawu roboczych stron procesu. Choć takie strony mogą zostać zrzucone do pliku stronicowania, jednak zostaną załadowane z powrotem do pamięci w momencie przełączenia wykonania do któregoś z wątków należących do procesu.
VirtualProtect()
Zmienia stan ochrony dla zestawu powierzonych stron bieżącego procesu.
VirtualProtectEx
Zmienia stan ochrony dla zestawu powierzonych stron dowolnego procesu w systemie, dla którego proces wywołujący posiada odpowiednie uprawnienia.
VirtualQuery()
Zwraca szczegóły dotyczące pamięci wirtualnej dla podanego adresu należącego do bieżącego procesu.
VirtualQueryEx()
Zwraca szczegóły dotyczące pamięci wirtualnej dla podanego adresu należącego do dowolnego procesu w systemie, dla którego proces wywołujący posiada odpowiednie uprawnienia.
VirtualUnlockO
Usuwa zestaw stron z roboczego zestawu stron procesu. Usunięte strony nie muszą znajdować się w pamięci w momencie przełączenia wykonania do któregoś z wątków należących do procesu.
Alokowanie przestrzeni stosu
Stos (ang. stack) to tymczasowy obszar pamięci używany przez kompilator do krótkotrwałego przechowywania danych. Stos przechowuje cztery rodzaje danych: lokalne zmienne funkcji, parametry przekazywane wywoływanej funkcji, wartości rejestrów oraz adresy powrotu. Stopień wykorzystania stosu jest bardzo zmienny; stos rośnie i opada jak fala pływowa.
Chyba najważniejszym zagadnieniem dotyczącym stosu jest jego rozmiar. Domyślnym rozmiarem stosu dla wątków Win32 zarówno w Windows 98, jak w Windows MT jest l MB. Dla programów Win32 działających w Win32s dostępny jest mniejszy stos: tylko 128K. Jeśli piszesz program Win32 korzystający z dużej ilości zmiennych lokalnych lub usilnie korzystający z rekurencji, powinieneś przetestować go w środowisku Win32s, aby mieć pewność, że nie przekroczysz dostępnego rozmiaru stosu.
Pamięć alokowana przez kompilator
Choć zrozumienie mechanizmu alokowania stron rozszerza Twą wiedzę o API Win32, jednak większość programistów MFC prawdopodobnie pozostanie przy domyślnym alokatorze Yisual C++. Już choćby z tego powodu alokator oferowany przez kompilator zasługuje na opisanie w pierwszej kolejności, przed alokatorami prywatnych stert Win32.
Ważną charakterystyką alokatora kompilatora jest to, że dla osobnych modułów są tworzone osobne sterty. (W terminologii Microsoftu moduł nie odnosi się do pliku źródłowego programu, lecz do obrazu wykonywalnego, takiego jak plik .EXE czy dynamicznie łączona biblioteka, .DLL). Ma to duże znaczenie podczas tworzenia bibliotek DLL, gdyż aplikacja może użyć dowolnego obiektu pamięci, jaki stworzyła dla niej biblioteka DLL (ponieważ istnieje on w tej samej przestrzeni adresowej). Jeśli jednak biblioteka DLL zostanie usunięta z pamięci, biblioteka czasu wykonania C zniszczy stertę utworzoną przez kompilator dla tej biblioteki.
Decydując się na użycie konkretnego alokatora, ważnym zagadnieniem jest ilość pamięci zużywanej na samo zaalokowanie obiektu. Omawiając funkcję VirtualAlloc (), nie wspominaliśmy o tym, gdyż narzut związany z alokacją strony jest dość niewielki w stosunku do jej rozmiaru. Jednak ponieważ alokatory sterty są często używane do alokowania stosunkowo niewielkich obszarów pamięci, ważne stają się zagadnienia, takie jak narzut pamięciowy związany z alokowaniem.
Zamiast korzystania z alokatorów Win32 MFC korzysta z własnego alokatora. (Mówiąc o alokatorach sterty w Win32, mamy na myśli funkcję HeapAlloc () i powiązane z nią funkcje API). Powody są głównie historyczne. Alokator sterty Win32 w Windows NT 3.1 był nieefektywny. Przy rozdzielczości 32 bajtów i narzucie 16 bajtów na obiekt okazywał się zbyt kosztowny dla alokowania dużej liczby niewielkich obiektów. Jak wkrótce opowiemy, obsługa stert w Windows NT została znacznie poprawiona, zaś w Windows 98 jest jeszcze lepsza. W pewnym momencie Microsoft może zmodyfikować MFC tak, by opierało się na systemowym alokatorze sterty, a nie na własnym. Aby dać Ci pojęcie, co może motywować taką zmianę, najpierw omówimy domyślny alokator sterty w Visual C++.
Domyślny alokator sterty MFC
Domyślny alokator sterty MFC to stary znajomy z nową twarzą. Znajomy element - dla programistów C - to funkcja malloc (). Jego nowa twarz to implementacja specyficzna dla Win32, przy alokacji stron dla swojej sterty opierająca się na funkcji virtualAlioc ().
Koszt alokacji każdego obiektu na stercie wynosi 12 bajtów. Te dwanaście bajtów jest wykorzystywanych następująco: każdy blok na stercie posiada 4-bajtowy nagłówek plus 8-bajtowy węzeł na połączonej liście deskryptorów bloków. W rezultacie, alokowanie 24-bajtowego bufora kosztuje 36 bajtów. Oto przykład:
// Bajty danych: 24
// + Bajty nagłówka: 4
// + Deskryptor bloku: 8
// -----
//
// Całkowity koszt: 36 bajtów
void *pData = malloc( 24 );
Kolejne zagadnienie związane z alokacją sterty wiąże się z rozdzielczością alokacji. Rozdzielczość alokacji to wartość zaokrąglania przy żądaniu konkretnego rozmiaru. Domyślny alokator ma rozdzielczość 4 bajtów. Średnia strata pamięci w wyniku zaokrąglania dużej ilości obiektów wynosi w przybliżeniu 2 bajty - czyli stosunkowo niewiele.
Domyślny alokator Yisual C++ to prosty alokator ogólnego przeznaczenia. Podczas przeszukiwania sterty w celu spełnienia żądania alokacji używany jest pierwszy nadający się blok. W czasie wyszukiwania są także łączone położone obok siebie wolne bloki. W miarę potrzeby do sterty dodawane są nowe strony, w blokach po 64K. Każdy region może rozrosnąć się do l MB, zaś sam alokator może obsłużyć do 64 regionów, czyli do 64MB.
W ostatecznych (release) wersjach programów MFC wywołania operatora new - zarówno w klasach wyprowadzonych z klasy cobject, jak i w innych - powodują wywołanie funkcji malioco. To co nas w tym momencie interesuje to fakt, że w ostatecznych wersjach programów nie jest zdefiniowany symbol preprocesora _DEBUG. Oprócz tego, połowa bibliotek MFC jest zarezerwowana dla wersji ostatecznych, które nie zawierają diagnostycznych asercji, występujących w wersjach dla debuggowania.
Alokacja pamięci staje się dużo ciekawsza w wersjach programów MFC przeznaczonych do debuggowania. W takich wersjach jest zdefiniowany symbol preprocesora _DEBUG. Między innymi powoduje on obecność diagnostycznego alokatora MFC. (Oprócz tego powoduje zastosowanie bibliotek w wersjach do debuggowania). Kod źródłowy alokatora diagnostycznego znajduje się w pliku AFXMEM.CPP. Alokator diagnostyczny nie zastępuje domyślnego alokatora Yisual C++. Zamiast tego stanowi pewną dodatkową warstwę wyłapującą standardowe problemy związane z pamięcią. Ostatecznie alokator diagnostyczny sam wywołuje funkcję malloc (). W następnej sekcji opiszemy działanie alokatora diagnostycznego i pokażemy kilka jego zastosowań.
Diagnostyczny alokator MFC
Oto standardowe pytanie, które Twój szef może Ci zadać - lub, być może, powinien zadać: w jaki sposób wykryjesz błąd polegający na zapisaniu czegoś za końcem zaaloko-wanego bufora? Niektórzy programiści polegają na szczęściu i ciężkiej pracy, starając się unikać pisania takiego kodu. Jednak biorąc pod uwagę dynamiczną naturę kodu, utrzymywanie takiego podejścia szybko staje się zbyt trudne. Inni programiści używają funkcji odwołujących się do pamięci automatycznie wykrywających tego typu problemy. Gdy problem zostanie wykryty, programista zostaje odpowiednio poinformowany. MFC zapewnia tego typu funkcje, co sprawia, że wykrywanie pewnego rodzaju błędów jest prawie automatyczne.
Diagnostyczny alokator MFC definiuje własny operator new () oraz własny operator cob-ject: : operator new (). Oba ostatecznie wywołują funkcję malloc (), lecz przedtem wykonuj ą czynności służące do wychwycenia pewnych popularnych błędów.
Do rodzajów problemów, które pomaga zlokalizować diagnostyczny alokator MFC, należą wycieki pamięci (zaalokowane obiekty, które nie zostały zwolnione), przepełnienie lub niedopełnienie bufora (zapis przed początkiem lub za końcem bufora), użycie niezainicjalizowanego bufora oraz użycie bufora, który został zwolniony. Choć alokator diagnostyczny nie zawsze może precyzyjnie podać przyczynę takich problemów, jednak może ukierunkować poszukiwania w odpowiednią stronę. Jeśli na przykład występuje problem z konkretnym blokiem pamięci, alokator diagnostyczny może zidentyfikować plik źródłowy i linię kodu alokującą ten blok.
Aby móc to robić, alokator diagnostyczny musi przechowywać dodatkowe informacje; w tym celu do każdego zaalokowanego operatorem new () obiektu dodawanych jest 36 bajtów. Każdy tak zaalokowany obiekt posiada 28-bajtowy nagłówek zawierający kluczowe wartości dla alokowanych bloków pamięci. Osiem dodatkowych bajtów jest alokowanych jako ziemia niczyja: cztery przed i cztery po obiekcie. Te bajty pomagają w zlokalizowaniu zapisów poza granicami zaalokowanego obiektu. (Ten mechanizm różni się od zastosowania ziemi niczyjej w przestrzeni adresowej procesu Win32 w Windows 98 i Windows NT). Oto struktura dla nagłówka bloku pamięci alokatora diagnostycznego, zdefiniowana wp\\kuAFXMEM.H:
struct CBlockHeader
{
struct CBlockHeader* pBlockHeaderNext; struct CBlockHeader* pBlockHeaderPrev;
LPCSTR 1pszFileName;
int nLine;
size_t nDataSize;
enum CMemoryState::blockUsage use;
LONG 1Reąuest;
BYTE gap[nNoMansLandSize];
// po którym następuje:
// BYTE data[nDataSize];
// BYTE anotherGapfnNoMansLandSize];
BYTE* pbData{){ return (BYTE*) (this + 1); } ;
Choć 36 bajtów dodawane do każdego alokowanego obiektu to dość dużo, jednak należy pamiętać, że są one dodawane wyłącznie w wersjach programów do debuggowania. Gdy zlokalizujesz i usuniesz wszystkie problemy związane z obsługą pamięci, możesz stworzyć ostateczną wersję programu. Mimo że obecność dodatkowych diagnostycznych bajtów powoduje większe zużycie pamięci i sprawia, że program działa wolniej, jest tego warta. W końcu programiści zwykle i tak korzystają z najszybszych, najbardziej pojemnych systemów, w związku z czym możesz nawet nie zauważyć, że program w wersji do debuggowania działa nieco wolniej.
Oprócz alokowania nagłówka bloku pamięci alokator diagnostyczny wypełnia pamięć znanymi, różnymi od zera wartościami. Pomaga to w wykrywaniu różnych błędów wskaźników, włącznie z przepełnieniem i niedopełnieniem bufora oraz odwoływaniem
się do obiektów, które zostały zwolnione. Wszystkie alokowane obszary są wypełniane znaną wartością: OxCD. Wiedza o tym pozwala na stwierdzenie, czy jakiekolwiek dane zostały zapisane do bufora. Jeśli tak, ujrzysz dane, jeśli nie, debugger wyświetli tablicę wartości OxCD zamiast tego, czego oczekiwałeś w buforze. (Na marginesie, 32-bitowe biblioteki OLE wypełniają pustą pamięć wartością OxOBADFOOD co, jak sugeruje pewien programista Microsoftu, odnosi się do posiłków serwowanych w firmowej stołówce).
Na stercie diagnostycznej wartownicy - nazywani w MFC ziemią niczyją - są ustawiani na początku i na końcu alokowanego obiektu, co umożliwia wychwycenie przepełnienia i niedopełnienia bufora. W ostatecznym kodzie zapis poza początek lub koniec bufora może powodować załamanie programu, utratę danych i niekończącą się frustrację użytkownika. W wersji do debuggowania nadpisane obiekty mogą być łatwo zidentyfikowane, co pomaga w znalezieniu przyczyny problemu.
Co ciekawe, choć rozdzielczość alokatora malloco wynosi 4 bajty, rozdzielczością alokatora diagnostycznego jest jeden bajt. Obszar ziemi niczyjej występuje tuż za końcem żądanego obszaru danych. Jeśli nadpiszesz bufor o choćby l bajt, i tak wywołasz asercję. To sprawia, że diagnostyczna wersja alokatora jest bardziej restrykcyjna niż alokator używany w wersji ostatecznej. Błędy nadpisania o jeden bajt, które w ostatecznej wersji mogłyby zostać nie zauważone, w wersji do debuggowania spowodują podniesienie alarmu.
Oto trzy wartości używane przez diagnostyczny alokator do wypełniania pamięci, zdefiniowane w pliku AFXMEM.CPP:
// wypełnia tym ziemię niczyją:
ttdefine bNoMansLandFill OxFD
// wypełnia tym obszar zwalnianych obiektów:
#define bDeadLandFill OxDD
// wypełnia tym obszar alokowanych obiektów:
#define bCleanLandFill OxCD
Blok komentarza występujący w tym pliku źródłowym tuż przed tymi definicjami wyjaśnia, dlaczego zostały wybrane właśnie takie wartości. Komentarz można podsumować następująco:
Użycie wartości różnych od zera sprawia, że nadpisanie pamięci jest bardziej oczywiste, gdyż większość operacji wypełniania pamięci polega na wypełnianiu zerami.
Stałe wartości sprawiają, że błędy pamięci stają się bardziej deterministyczne, co wiąże się z powtarzalnością wystąpienia błędu. Jednak w zależności od użytych danych użycie wartości różnych od zera może od czasu do czasu maskować wystąpienie błędu.
Liczby nieparzyste pomagają w wykryciu błędów zakładających, że najmłodszy bit jest wyzerowany oraz pomagają przy wychwytywaniu błędów Macintosha.
Duże liczby (przynajmniej jeśli chodzi o bajty) są mniej typowe i są użyteczne przy wyszukiwaniu błędnych adresów.
Wartości nietypowe są przydatniejsze, gdyż zwykle umożliwiają wczesne wykrycie błędów w kodzie.
W przypadku ziemi niczyjej i zwolnionych bloków w przypadku zapisania w nich jakiejś wartości, wykryje to funkcja sprawdzania integracji pamięci.
W wersjach MFC przeznaczonych do debuggowania, do sterowania działaniem alokatora diagnostycznego służy kilka zmiennych globalnych. Jedną z nich jest pole znaczników przechowywane w zmiennej globalnej afxMemDF. Ta całkowita wartość jest w pliku źródłowym MFC APPDATA.CPP zdefiniowana następująco:
int afxMemDF = allocMemDF;
To domyślne ustawienie włącza alokator diagnostyczny. Inne wartości tego znacznika, zdefiniowane w pliku AFX.H, zostały zebrane w tabeli 18.4.
Tabela 18.4. Diagnostyczne znaczniki alokatora
Znacznik
Wartość
Opis
--
0
Wyłącza alokator diagnostyczny.
allocMemDF
0x01
Włącza alokator diagnostyczny, aby dodawał nagłówek i ziemię niczyją dla każdego alokowanego obiektu.
delayFreeMemDF
0x02Opóźnia zwalnianie pamięci w celu umożliwienia testowania odwoływania się do zwolnionej pamięci. Dzięki temu można także sprawdzić, czy nie są wykorzystywane zwolnione wskaźniki.
checkAlwaysMemDF
0x04
Sprawdza spójność sterty przy każdym odwołaniu się do alokatora (zarówno new jak i delete). Gdy ten znacznik jest ustawiony, do sprawdzania sterty jest wywoływana funkcja Af xCheckMemory (). Tą funkcję możesz także w dowolnym momencie wywołać osobiście w celu sprawdzenia sterty.
Możesz także ustawić licznik, _afxBreakAlloc, wywołujący debugger po określonej liczbie alokacji. Gdy alokator diagnostyczny jest włączony, utrzymuje licznik zliczający ilość wywołań alokatora i oznacza kolejno każdy alokowany obiekt. Gdy zostanie wykryty wyciek pamięci, alokator diagnostyczny zgłasza numer utraconego bloku. Gdy używasz zintegrowanego debuggera, ta właściwość umożliwia Ci wychwycenie w swoim programie bloków pamięci, które zostały zaalokowane, lecz nie zostały zwolnione. W swoim wyłączonym stanie licznik _afxBreakAlloc jest w pliku AFXMEM.H zdefiniowany następująco:
// dla wykrywania wycieków pamięci static LONG _afxBreakAlloc = -1;
Jednak aby wywołać debugger po określonej liczbie alokacji, wystarczy po prostu ustawić tę.zmienną na pożądaną wartość. Co ciekawe, takie przypisanie powinieneś umieścić wewnątrz warunkowo kompilowanego bloku kodu, gdyż ta zmienna nie jest zdefiniowana w ostatecznych wersjach programów:
#ifdef _DEBUG _afxBreakCount = 1000;
#endif
Kolejnym elementem dostarczanym przez alokator diagnostyczny w celu ułatwienia testowania oprogramowania jest funkcja haka alokatora. Tuż przed zaalokowaniem obiektu alokator wywołuje łańcuch funkcji haków w celu stwierdzenia, czy powinien zostać zgłoszony błąd alokacji pamięci. Przez zasymulowanie sytuacji braku pamięci możesz sprawdzić, czy poprawnie działa obsługa tego typu błędu. Szczegóły znajdziesz w funkcji AfxSetAllocHook() w p\ikuAFXMEM.CPP.
W celu wyłapania błędów wskaźników nadpisujących stertę możesz zażądać jej sprawdzenia. Masz do wyboru dwa sposoby. Możesz po prostu wywołać funkcję AfxCheckMemory (), która sprawdza stertę, lub możesz ustawić w znaczniku diagnostycznym afxMemDF wartość checkAlwaysMemDF. Powoduje to, Że funkcja AfxCheck-MemoryO będzie wywoływana za każdym razem, gdy pamięć będzie alokowana lub zwalniana. Znacznik powoduje także, że sterta jest sprawdzana nawet w jałowym czasie aplikacji (z funkcji cwinthread: -.onidle (), umieszczonej w pliku THRDCORE.CPP).
Alokator diagnostyczny wykrywa także wycieki pamięci, które są obiektami zaaloko-wanymi, lecz nie zwolnionymi. W wersjach do debuggowania, w momencie zakończenia działania programu MFC jest automatycznie wywoływana funkcja Af xDumpMemory-Leaks (). Każdy obiekt wciąż występujący na stercie jest wypisywany w oknie Output debuggera. Aby otrzymać listę obiektów, musisz uruchomić program przy działającym Visual Studiu. Nie wystarczy przy tym samo działanieVisual Studia; musisz uruchomić swój program MFC, wybierając w menu Debug polecenie Go (lub odpowiadający mu przycisk na pasku narzędzi). Wykrywanie wycieków pamięci stanowi znacznie wsparcie w zapewnieniu, że program efektywnie wykorzystuje pamięć.
Prywatne sterty Win32
Win32 API dostarcza zestawu funkcji służących do tworzenia i alokowania pamięci na kilku prywatnych stertach. Choć do alokowania dużych obiektów - o wielokrotności 4K w Windows 98 - powinien być stosowany alokator stron Win32, jednak w przypadku podziału stron na mniejsze fragmenty można wykorzystywać alokator stert prywatnych.
Obsługę stert w Win32 można uważać za prywatną, gdyż Win32 nie dostarcza żadnego mechanizmu udostępniania stert pomiędzy procesami (choć dwa wątki tego samego procesu mogą łatwo odwołać się do tej samej sterty). Ponieważ takie sterty są prywatne dla procesu, są tworzone na stronach pamięci widocznych jedynie przez dany proces. Jeśli chcesz korzystać ze wspólnej pamięci w kilku procesach, Win32 w żaden sposób Ci nie pomoże i musisz radzić sobie sam.
Win32 posiada możliwość tworzenia wielu stert, więc możesz podzielić swoje dane, tak jak Ci będzie wygodnie. Na przykład, duża aplikacja może stworzyć kilka stert i umieścić na nich dane należące do osobnych podsystemów. W ten sposób problemy z wyciekiem pamięci lub błędnymi wskaźnikami na jednej ze stert mogą w mniejszym stopniu wpływać na działanie innych podsystemów.
Kolejnym powodem tworzenia kilku stert jest podział obiektów według rozmiarów. W przypadku alokatorów ogólnego przeznaczenia największa fragmentacja pamięci następuje w wyniku alokowania i zwalniania obiektów o różnych rozmiarach. Na przykład, tworząc stertę dla danej klasy, znacznie ograniczasz fragmentację sterty i związaną z tym utratę pamięci.
Szczegóły implementacji sterty
Tabela 18.5 zawiera podsumowanie charakterystyk procedur alokacji sterty w dwóch implementacjach Win32, w Windows 98 oraz w Windows NT. Może zaskoczyć Cię fakt, że w tych systemach to samo API zostało tak różnie zaimplementowane. Jednak wszystko staje się jasne, gdy weźmiesz pod uwagę, że oba systemy były tworzone przez zupełnie różne zespoły Microsoftu przy odmiennych założeniach.
Tabela 18.5. Charakterystyki procedur alokacji sterty
Element Windows 98 Windows NT
Minimalny rozmiar obiektu 12 bajtów 8 bajtów
Narzut pamięciowy na obiekt 4 bajty 8 bajtów
Rozdzielczość alokacji 4 bajty 8 bajtów
Należy chyba dorzucić słowo na temat tabeli 18.5. Minimalny rozmiar obiektu jest chyba oczywisty. Przy alokowaniu małych obiektów ta wartość identyfikuje najmniejszy obszar pamięci, jaki może być zaalokowany. Zarówno w Windows 98, jak i w Windows NT -jeśli dodasz narzut pamięciowy na zaalokowany obiekt - każda alokacja zajmuje minimum 16 bajtów.
Narzut pamięciowy na obiekt to rozmiar nagłówka dla każdego obiektu na stercie. W obu systemach nagłówek jest umieszczany tuż przed początkiem zaalokowanych danych obiektu. Choć zwykle podczas alokowania obiektu nie pamięta się o tym narzucie, jednak przy precyzyjnym wyliczaniu wymagań pamięciowych programu także należy brać go pod uwagę.
Rozdzielczość alokatora sterty w Windows 98 wynosi 4 bajty. Na przykład, gdy aloka-tor zostanie poproszony o zaalokowanie 15 bajtów, dostarczy 16-bajtowego obszaru. Gdy dodasz do tego 4-bajtowy nagłówek, żądanie zaalokowania 15 bajtów spowoduje zajęcie 20 bajtów pamięci.
Jak wynika z tabeli 18.5, Windows 98 ma mniejszy narzut alokacji i mniejszą wartość rozdzielczości sterty niż Windows NT. Powodem jest fakt, że docelową maszyną dla Windows 98 jest powolny system 80486 z 8MB pamięci RAM. Z drugiej strony, Windows NT zostało opracowane z myślą o systemach z większą ilością pamięci RAM i bardziej wydajnym procesorem. "Drobniejszy" alokator sterty w Windows 98 umożliwia bardziej oszczędną pracę systemu.
Porównując alokatory pamięci, potrzebujesz jakiegoś wyznacznika określającego różnice. Jednym ze sposobów jest obliczenie pamięciowego "podatku", jaki alokator nalicza sobie w zależności od rozmiaru zaalokowanych obiektów. W poprzednim przykładzie,
w którym żądanie zaalokowania 15 bajtów wiązało się z zajęciem 20 bajtów danych, "podatek" wyniósł 5/15, czyli 33 procent. O ile 5 bajtów nie robi wrażenia, jednak 33 procentowy podatek można uważać za poważny. W Windows NT zażądanie tych samych 15 bajtów zajmie 24 bajty pamięci,-co daje 60 procentowy podatek od tego, co chcesz przechować na stercie.
Jeśli któraś część Twojego programu Win32/MFC korzysta z funkcji prywatnej sterty, musisz dokładnie przetestować działanie programu na obu platformach. W przeciwnym razie ryzykujesz przeoczenie subtelnych błędów, które mogą wystąpić na jednej platformie, lecz nie na drugiej. Na przykład, błąd powodujący nadpisanie 6 bajtów poza końcem zaalokowanego obiektu może przejść niezauważony w Windows NT, lecz w Windows 98 taki błąd spowoduje nadpisanie innego obiektu.
W Windows 98 sterta występuje zawsze w obrębie prywatnych adresów procesu - tj. pomiędzy 4MB a 2GB. Jednak w Windows NT położenie sterty w przestrzeni adresowej procesu w żaden sposób nie określa, czy jest ona prywatna czy wspólna. W Windows NT sterty są zawsze prywatne. Aby się o tym przekonać, możesz wywołać funkcję virtu-alQuery () dla któregoś z adresów ze sterty. Funkcja zgłosi, że strony pamięci sterty są stronami prywatnymi (MEM_PRIVATE).
Choć różnią się w szczegółach, jednak ogólne działanie stert w Windows 98 i w Windows NT jest bardzo podobne. Funkcją Win32 API do tworzenia sterty jest HeapCreate (), która z kolei dwukrotnie wywołuje funkcję virtualAiioc(). Pierwsze wywołanie rezerwuje zakres adresów pamięci dla przewidywanego maksymalnego rozmiaru sterty. Drugie wywołanie służy do powierzenia stron pamięci na początku zarezerwowanego adresu.
API sterty Win32
Tabela 18.6 zawiera listę funkcji Win32 przeznaczonych do zarządzania stertą. Większość z nich jest raczej oczywista. Aby poznać szczegóły dotyczące wykorzystania, zajrzyj do dokumentacji Win32 lub systemu pomocy. Jednak pewne koncepcje i szczegóły implementacji warte są omówienia. Najważniejszy z nich jest fakt, że stertę otrzymuje każdy proces Win32. Większość z funkcji sterty Win32 jako parametr otrzymuje uchwyt sterty. Aby otrzymać uchwyt sterty dostarczonej przez system, wywołaj funkcję GetProcessHeap (). Gdy już będziesz miał uchwyt, będziesz mógł do woli alokować obiekty na stercie.
Kolejnym ważnym zagadnieniem związanym ze stertami Win32 jest serializacja. Praktycznie każda funkcja sterty posiada parametr zawierający znaczniki alokacji, zaś jednym ze znaczników jest HEAP_NO_SERIALIZE. Ten znacznik powoduje wyłączenie domyślnego działania sterty, czyli serializacji - lub synchronizacji - dostępu do sterty przez poszczególne wątki. Zwróć uwagę, że w tym kontekście termin serializacja nie ma nic wspólnego z serializacja obiektów C++. Innymi słowy, w tym kontekście serializacja nie odnosi się do przenoszenia danych pomiędzy pamięcią a dyskiem. Gdy jest włączona, serializacja sterty wymusza na wątkach oczekiwanie na swoją kolejność, gdy dwa lub więcej z nich chcą zapisać lub odczytać obiekt ze sterty.
Tabela 18.6. Funkcje obsługi prywatnych stert w Win32
Funkcja
Opis
GetProcessHeap()
Zwraca uchwyt domyślnej sterty procesu.
GetProcessHeaps()
Zwraca listę uchwytów wszystkich stert dostępnych dla bieżącego procesu
.HeapAlloc()
Alokuje pamięć na stercie określonej przez uchwyt sterty. Wszystkie obiekty są stale, w odróżnieniu do obiektów Win 16, które mogą być alokowane jako przenaszalne lub odrzucalne. Więcej na ten temat w dalszej części rozdziału.
HeapCompact()
Przeprowadza w pewnym stopniu czyszczenie sterty przez łączenie wolnych obszarów i, jeśli to możliwe, zwalnianie niepotrzebnych stron.
HeapCreate()
Tworzy nową stertę w przestrzeni adresowej procesu. Dwukrotnie wywołuje funkcję YirtualAlloc (): najpierw w celu zarezerwowania zakresu adresów, a następnie w celu powierzenia żądanego minimum pamięci dla sterty.
HeapDestroy()
Niszczy stertę utworzoną wywołaniem HeapCreate ().
HeapFree()
Zwalnia obiekt na stercie zaalokowany wywołaniem HeapAlloc ()
lub HeapReAlloc () .
HeapLock()
Przejmuje krytyczną sekcję sterty. Jest to wymagane tylko, gdy dwa lub więcej wątków odwołuje się do sterty w tym samym czasie. Tak szybko jak to możliwe powinieneś wywołać także funkcję
HeapUnlock() .
HeapReAlloc()
Zmienia rozmiar obiektu zaalokowanego wywołaniem HeapAlloc () lub poprzednim wywołaniem HeapReAlloc {).
HeapSize()
Zwraca rozmiar obiektu. W Windows 98 nie jest to rozmiar samego obiektu, lecz rozmiar zaalokowanego obszaru (zaokrąglonego do granicy następnych czterech bajtów). W Windows NT funkcja zwraca rozmiar podany funkcji HeapAlloc () lub HeapReAlloc ().
HeapUnlock()
Zwalnia sekcję krytyczną sterty. Patrz opis funkcji HeapLock ().
HeapValidate
Sprawdza spójność całej sterty lub obiektu na stercie
. HeapWalkO
Wylicza wszystkie obiekty zaalokowane na stercie.
Wątki to jeden z elementów Win32 umożliwiający tworzenie wielu, niezależnie wykonywanych jednostek w ramach procesu Win32. Funkcje sterty Win32 umożliwiają kilku wątkom dostęp do sterty w tym samym momencie. Domyślnie jednak funkcje sterty Win32 nie pozwalają kilku wątkom na modyfikację sterty w tym samym czasie. Rodzajem obiektu systemu operacyjnego stosowanym do sterowania dostępem do sterty jest tzw. sekcja krytyczna.
Znacznika HEAP_NO_SERIALIZE użyj wtedy, gdy w swojej aplikacji używasz tylko jednego wątku. Dzięki temu funkcje alokacji sterty będą działały trochę szybciej, gdyż sama serializacja także zajmuje pewien czas procesora. Nie chodzi tu o jakieś szczególnie duże korzyści, jednak przy alokowaniu bardzo dużych ilości obiektów można już zauważyć różnicę.
Do sprawdzania spójności danej sterty służy funkcja HeapValidate (). Może być użyteczna, gdy podejrzewasz, że źródłem problemów jest zły wskaźnik lub przepełniony bufor. Nawet jeśli programujesz w sposób defensywny - tak jak opisał Steve Maguire w książce Writing Solid Code - może okazać się, że pamięć została nadpisana. Strategiczne rozmieszczenie wywołań tej funkcji może pomóc w wykryciu właściwej przyczyny problemu.
Kolejną potencjalnie użyteczną funkcją jest HeapWalk (). Programiści Win 16 być może pamiętają narzędziowy program HeapWalker Microsoftu. Nazywany Lukę HeapWalker (od Lukea Sky Walkera z Gwiezdnych Wojen), ten program wyświetlał zawartość różnych stert. Dzięki tej funkcji możesz programowo sprawdzać obiekty danych zaalokowane na stercie. Gdy zechcesz zbudować własne procedury diagnostyczne i testowania pamięci, dzięki tej funkcji uzyskasz prawie wszystkie informacje o stercie Win32.
Lokalne dane wątku
Lokalne dane wątku stanowią o możliwości powiązania danych z wątkiem. Win32 obsługuje dwa rodzaje lokalnych danych wątku: statyczne i dynamiczne. Choć dane dynamiczne są dużo bardziej skomplikowane w użyciu, jednak zapewniają także dużo większą elastyczność. (Tzn. mogą być używane w dynamicznie ładowanych bibliotekach DLL - czyli bibliotekach DLL ładowanych za pomocą wywołania funkcji LoadLibra-ry ()). Statyczne lokalne dane wątku są dużo prostsze w użyciu - tak proste, że są prawie nie rozróżnialne od zwykłych zmiennych globalnych. Niestety, istnieje kilka ograniczeń co do zastosowań takich statycznych danych. Szczegóły implementacji systemu operacyjnego sprawiają, że nie da się ich użyć w dynamicznie ładowanych bibliotekach DLL.
Jak już wspominaliśmy, wątki są jednostkami wykonania programu Win32. Gdy w Windows 98 i w Windows NT rozpoczyna działanie proces Win32, Windows uruchamia wątek. (Wątki nie są obsługiwane w Win32s). Po tym jedyną metodą stworzenia nowego wątku jest wywołanie funkcji Win32 CreateThread (). Biblioteka czasu wykonania C zawiera pośredniczącą funkcję _beginthread(), także wywołującą funkcję Create-Threado, lecz przed tym inicjalizującą bibliotekę czasu wykonania C, tak aby była "zgodna z wielowątkowością". Także MFC przy tworzeniu nowego wątku wywołuje tę funkcję (a właściwie funkcję _beginthreadex (), stanowiącą jej nieznaczną odmianę).
Aby utrzymać dane specyficzne dla wątku, systemy operacyjne takie jak Windows 98 czy Windows NT alokują pamięć dla danych egzemplarza wątku. W Windows NT ten obszar pamięci jest nazywany blokiem środowiska wątku (TEB, thread environment block). Z kolei w Windows 98 -jak twierdzi Andrew Schulman - ten obszar to blok kontrolny wątku (THCB, thread control block). Bez względu na nazwę, te bloki pamięci zawierają dane systemu operacyjnego dotyczące danego egzemplarza wątku. Przechowywane dane obejmują między innymi stan rejestrów procesora w momencie, gdy wątek oczekuje na wykonanie, wskaźnik do stosu wątku oraz wskaźnik do danych wyjątku przechowywany na stosie.
Dynamiczne lokalne dane wątku - podobnie jak dodatkowe bajty okna - nie są niczym innym niż danymi aplikacji przechowywanymi w strukturze danych, stworzonej przez system operacyjny. Gdy w Windows 98 lub w Windows NT jest tworzony wątek, alokowana jest także struktura TEB lub THCB. Oprócz pamięci alokowanej przez system do
własnego użytku, dla aplikacji jest przygotowywana także tablica wartości DWORD. Każdy element kodu aplikacji - czy to w pliku EXE, czy w bibliotece DLL - może za pomocą odpowiednich wywołań funkcji API zarezerwować pozycje tej tablicy. Odnosi się to do każdego wątku danego procesu. Na przykład, jeśli proces posiada trzy wątki, zakończone powodzeniem zarezerwowanie dynamicznych danych wątku zapewnia dostęp do trzech pozycji DWORD, po jednej dla każdego wątku. Gdy dany blok kodu jest wykonywany, automatycznie odczytuje lokalne dane wątku dla bieżącego wątku, w którym jest wykonywany.
W jaki sposób kod EXE lub DLL używa dynamicznych lokalnych danych wątku? Cztery bajty wartości DWORD mieszczą każdy 32-bitowy wskaźnik, na przykład do dynamicznie zaalokowanych danych. Jeśli program stworzył wątki w celu wykonywania zadań w tle (takich jak drukowanie, sortowanie danych czy sprawdzanie spójności bazy danych), dane specyficzne dla wątku mogą zawierać kolejkę zadań przeznaczonych do wykonania. Funkcje API Win32 służące do operowania dynamicznymi lokalnymi danymi wątku zostały zebrane w tabeli 18.7.
Tabela 18.7. Funkcje dynamicznych lokalnych danych wątku
Funkcja
Opis
TlsAlloc ()
Dla wszystkich wątków w bieżącym procesie żąda zarezerwowania elementu w stworzonej przez Windows tablicy danych wątków.
TlsFree ()
Zwalnia indeks dla danego procesu w tablicy danych wątków.
TlsGetValue () Dla bieżącego wątku odczytuje zawartość wskazanej pozycji w danych egzemplarza wątku.
TlsSetValue ()
Dla bieżącego wątku ustawia zawartość wskazanej pozycji w danych egzemplarza wątku.
Gdy alokowany jest lokalny obszar danych wątku (wywołaniem TlsAlloc ()), zwracany jest indeks do tablic danych wątku. Ten indeks musi zostać przechowany w obszarze pamięci wspólnej dla wątków, czyli najprawdopodobniej w zmiennych globalnych. W tym momencie można już dynamicznie zaalokować dane wątku, zaś wskaźnik do tych danych będzie przechowywany w lokalnych danych wątku.
Drugim rodzajem danych prywatnych dla wątku są statyczne lokalne dane wątku. Są one najprostsze w użyciu, gdyż - po zadeklarowaniu - wyglądają jak zmienne globalne. W rzeczywistości, kompilator i program ładujący współpracują ze sobą w celu automatycznego zaalokowania nowego zestawu danych dla każdego wątku tworzonego w ramach procesu. A jeśli sprawdzisz kod asemblera wygenerowany przez kompilator w celu dostępu do tych danych, ujrzysz dodatkowe instrukcje stanowiące dodatkowy pośredni poziom w stosunku do kodu odwołującego się do zwykłych danych. Jednak całe piękno statycznych lokalnych danych wątku polega właśnie na tym, że mimo iż wyglądają jak zmienne lokalne, są danymi prywatnymi dla wątku.
Kluczem do statycznych lokalnych danych wątku jest para słów kluczowych specyficznych dla kompilatora Microsoft C: _dęci spec oraz thread. Oto jak można zdefiniować wartość całkowitą prywatną dla wątku:
_declspec (thread) int i = 0;
Wszystkie słowa kluczowe kompilatora rozpoczynające się od znaku podkreślenia (_) reprezentują słowa kluczowe nie należące do standardu ANSI C/C++. W przeszłości Microsoft dowolnie dodawał do swojego kompilatora nowe deklaracje: _pascai, _cdecl, _near, _far, _stdcall itd. Jednak w końcu mógł pojawić się problem, jeśli któraś z takich deklaracji zaczęłaby kolidować z nowymi słowami kluczowymi, które stałyby się częścią standardu C++.
W celu kontrolowania tworzenia nowych deklaratorów Microsoft stworzył pojedynczy, ogólny deklarator: _dęci spec. Ma on pełnić rolę głównego deklaratora. Wszystkie nowe rozszerzenia kompilatora - takie jak statyczne lokalne dane wątków - będą dostępne przez użycie modyfikatorów głównego deklaratora. Obecnie wiemy jedynie o trzech innych specyfikacjach deklaratora: dl l import, dllexport oraz naked. W razie potrzeby zostaną stworzone kolejne rozszerzenia kompilatora. Aby uczynić deklarację statycznej lokalnej danej wątku nieco bardziej czytelną, stwórz symbol preprocesora:
#define THREADDATA _declspec (thread)
Poprzednia deklaracja zmiennej całkowitej prywatnej dla wątku teraz przyjmie nieco bardziej czytelną formę:
THRADDATA int i = 0;
Dane prywatne dla wątku muszą być zainicjowane. W poprzednim przykładzie ustawialiśmy wartość całkowitą na zero. Choć to niewielka różnica dla programisty, jednak dla kompilatora ma duże znaczenie, gdyż traktuje on zainicjowane dane zupełnie inaczej niż dane niezainicjowane. Obecna wersja kompilatora dopuszcza jedynie zainicjowane lokalne dane wątku. Ta mała różnica zapewnia, że otrzymujesz potrzebne wsparcie w obsłudze danych. (W dalszej części rozdziału, przy omawianiu wspólnych danych, powiemy również dlaczego także globalne dane muszą być zainicjowane, aby mogły być udostępniane pomiędzy procesami).
Jak już wspomnieliśmy, główną zaletą tego rodzaju prywatnych danych wątku jest to, że wyglądają jak zwykłe zmienne globalne. Po zdefiniowaniu nie musisz już korzystać z wywołań API w celu dostępu do danych: one po prostu są. Jeśli przyjrzysz się kodowi maszynowemu użytemu przy odwołaniach do tych danych, zauważysz, że zawiera on pewne dodatkowe instrukcje. Jednak gdy potrzebujesz danych lokalnych dla wątku, jest to dość niska cena.
Kolejną zaletą tego rodzaju prywatnych danych wątku jest to, że możesz alokować tyle danych, ile potrzebujesz. Choć w tym przykładzie pokazaliśmy alokowanie pojedynczej zmiennej, nie ma problemu z alokowaniem tysięcy czy nawet dziesiątek tysięcy bajtów. Zmienne alokowane prywatnie dla wątku są umieszczane we własnych sekcjach pliku wykonywalnego. Tak więc, podobnie jak inne sekcje danych lub kodu, mogą w miarę potrzeby rozciągać się na wiele megabajtów.
Jednak statyczne dane lokalne wątku posiadają jedno ograniczenie. Nie mogą być używane w bibliotekach DLL dynamicznie ładowanych do pamięci. Jeśli program w celu jawnego załadowania biblioteki DLL do pamięci wywołuje funkcję LoadLibraryo, statyczne lokalne dane wątku nie mogą być używane. Przykładem bibliotek DLL w ten sposób ładowanych do pamięci są sterowniki urządzeń dla drukarek. W przypadku
statycznie ładowanych bibliotek DLL statyczne lokalne dane wątku działają bez problemów. Takie biblioteki DLL są ładowane do pamięci w momencie uruchamiania wywołującego pliku wykonywalnego. Poza tym ograniczeniem statyczne lokalne dane wątku są doskonałą metodą powiązania danych aplikacji z wątkami systemowymi.
Na tym kończymy omawianie pamięci prywatnej dla procesu. Teraz skierujemy uwagę na wszystkie rodzaje pamięci, które mogą być wspólnie wykorzystywane przez różne procesy.
Pamięć wspólna
Pozostałą część rozdziału poświęcimy tej części Win32 API, która związana jest z alokowaniem pamięci wspólnej - czyli pamięci dostępnej dla dwóch lub więcej procesów jednocześnie. Jak dotąd zajmowaliśmy się alokowaniem pamięci prywatnej. Pamięć prywatna jest zdecydowanie bardziej ważna, gdyż większość danych procesu to dane prywatne. Być może właśnie z tego powodu Win32 zawiera więcej funkcji związanych z pamięcią prywatną niż związanych z pamięcią wspólną. Ważną zaletą korzystania z prywatnej pamięci jest to, że zwiększa ona stabilność systemu. Ponieważ zmniejsza ryzyko, że jedna aplikacja nadpisze obszar pamięci innej aplikacji, właśnie taka pamięć jest domyślnie alokowana dla procesu. Aby móc udostępnić pamięć, aplikacja musi tego jawnie zażądać.
Dla twórcy aplikacji różnica pomiędzy pamięcią wspólną a prywatną jest względna. W końcu dane, które w jakiejś chwili są prywatne dla procesu, za moment mogą być już udostępniane. Jednak dla systemu operacyjnego ta różnica jest ważna i zasadnicza. Jak już wspominaliśmy, Win32 API uwypukla tę różnicę oferując dwie funkcje do tworzenia przestrzeni adresowych: virtualAlloc() dla prywatnej przestrzeni adresowej i MapView-of File () dla pamięci wspólnej. Zanim proces zaalokuje strony - zarówno w Windows 98, jak i w Windows NT - musi zdecydować, czy ma być dozwolone udostępnianie. Przyjrzyjmy się subtelnym metodom na jakie oba z systemów operacyjnych uwzględniają różnice pomiędzy pamięcią prywatną a dzieloną.
Windows 98 umieszcza strony prywatne w jednej części przestrzeni adresowej, zaś strony wspólne w innej. Strony prywatne mieszczą się w zakresie adresów od 4MB do 2GB. Jak wcześniej pisaliśmy, gdy następuje przełączenie kontekstu z jednego procesu Win32 do innego, zestaw stron jest usuwany z przestrzeni adresowej, a na jego miejsce odwzorowywany jest inny zestaw; dzieje się to poprzez aktualizację tablic stron procesora. Z drugiej strony, strony wspólne występują w zakresie adresów od 2GB do 3GB. Podczas przełączania kontekstów menedżer pamięci w Windows 98 nie rusza tablic stron dla stron odwzorowanych w tym obszarze. Pozostawienie tych tablic bez zmian sprawia, że korzystanie ze wspólnej pamięci jest proste i łatwe.
Sposób, w jaki Windows 98 umieszcza obraz plików wykonywalnych w przestrzeni adresowej procesu, może wydawać się sprzeczny z tą zasadą. Ponieważ pliki wykonywalne są elementami wykorzystywanymi wspólnie, mogłoby się wydawać, że Windows 98 powinno umieścić je w zakresie adresów wspólnych. Choć pewien rodzaj bibliotek DLL - systemowe biblioteki DLL - są umieszczane w tym zakresie, jednak nie jest to
prawdą dla prywatnych plików wykonywalnych. Windows 98 odwzorowuje prywatne pliki wykonywalne (.EXE) oraz prywatne biblioteki DLL w prywatny zakres adresów. Umieszczając je w zakresie prywatnym, Windows 98 może wybrać sposób traktowania prywatnych danych wykonywalnych. W celu efektywnego użycia prywatnych bibliotek DLL i prywatnych plików EXE, Windows 98 może na przykład odwzorować pojedynczy zestaw stron fizycznych na przestrzeń adresową kilku procesów.
Windows NT także dokonuje rozróżnienia pomiędzy stronami wspólnymi i prywatnymi, jednak w sposób nieco subtelniejszy niż różne zakresy adresów w Windows 98. Różnica polega na wypełnianiu przez Windows NT tablic stron procesora. Prywatne strony w Windows NT korzystają po prostu z opisanego na początku rozdziału schematu adresowania stronicowanej pamięci procesora Intel-86. Każdy proces posiada własną kartotekę stron oraz własne tablice stron, zaś przełączenie kontekstu odbywa się poprzez aktualizację rejestru CR3 dla nowej przestrzeni adresowej.
Subtelniejsze rozróżnianie stron wspólnych i prywatnych w Windows NT polega na tym, że w momencie odwołania do wspólnej strony generowany jest wyjątek odwołania do strony. W wielu systemach operacyjnych taki wyjątek oznacza, że strona albo znajduje się w pliku stronicowania, albo że wystąpił błąd adresowania pamięci - na przykład użycie wskaźnika o wartości NULL. Windows NT rozpoznaje te dwa tradycyjne zastosowania wspomnianego wyjątku, lecz oprócz tego wykorzystuje go także w inny sposób, udostępniając wspólne strony procesowi. Gdy wystąpi wyjątek odwołania do strony w przypadku wspólnej strony, Windows NT odpowiada na niego tak, że wspólne strony w magiczny sposób pojawiają się w przestrzeni adresowej procesu. Taka strona powodująca wyjątek przy dostępie jest specjalnie zakodowana w tablicy stron, w formacie nazywanym prototypową pozycją tablicy stron. Prototypowa pozycja tablicy stron zawiera odwołanie do zestawu systemowych tablic stron - dodatkowego poziomu tablic stron nie wymaganego dla stron prywatnych - wskazującego położenie wspólnej strony. Tak jak w Windows 98, decyzja o udostępnieniu strony lub uczynieniu jej prywatną musi więc poprzedzać przydzielenie strony do przestrzeni adresowej procesu.
Różnice pomiędzy Windows 98 a Windows NT opisujemy w tym miejscu tylko po to, by ułatwić Ci dogłębne zrozumienie sposobu działania obu systemów. W żadnym wypadku nie powinieneś pisać kodu, który na przykład sprawdza, czy dana pamięć w Windows 98 jest wspólna przez sprawdzenie czy wartość wskaźnika należy do przedziału pomiędzy 2GB a 3GB. (Ta charakterystyka implementacji może zmienić się w przyszłych wersjach Windows. Aby sprawdzić czy strona jest wspólna, wywołaj funkcję VirtualQuery ()). Nie powinieneś także martwić się tym, że Windows NT działa nieco wolniej z powodu narzutu związanego z obsługą wyjątku przy odwołaniu do wspólnej strony; czas procesora związany z obsługą wyjątku strony dla wspólnych stron jest dużo krótszy niż czas wymagany do narysowania pojedynczej linii na krawędzi okna!
Występuje także pewien element wspólny dla wszystkich dzielonych stron. W takiej czy innej postaci dzielenie stron pomiędzy procesy w Win32 opiera się na obsłudze plików odwzorowanych w pamięci. Plik odwzorowany w pamięci używa pamięci wspólnej w celu zapewnienia wspólnego obrazu zawartych w pliku danych dla dwóch lub więcej procesów.
To, co nie jest tak oczywiste w związku z operacjami na plikach odwzorowanych w pamięci to fakt, że takie operacje umożliwiają wspólne korzystanie z pamięci. Odwracając definicję Schulmana, dzielenie pamięci pomiędzy procesy Win32 polega na użyciu części wirtualnego pliku stronicowania systemu operacyjnego jako pliku odwzorowanego w pamięci. Choć nacisk został położony na wspólne korzystanie z pamięci, jednak zawarte w niej dane mogą w pewnym momencie znaleźć się w pliku na dysku. Jednak zamiast znaleźć się w jakimś szczególnym, nazwanym pliku, znajdą się one w systemowym pliku stronicowania.
W następnych sekcjach opiszemy sposoby udostępniania pamięci pomiędzy procesy, zaczynając od plików odwzorowanych w pamięci, gdyż właśnie one stanowią punkt wyjścia dla innych typów udostępniania. Następnie przyjrzymy się udostępnianiu stron pamięci, które nie występują w konkretnie nazwanych plikach odwzorowanych w pamięci. Na koniec przejdziemy do obu rodzajów wspólnych stron: alokowanych dynamicznie oraz alokowanych statycznie.
Pliki odwzorowane w pamięci
Pliki odwzorowane w pamięci są jednym z elementów Win32 API ułatwiającym przenoszenie danych pomiędzy plikiem na dysku a zakresem adresów w pamięci. Gdy plik zostaje odwzorowany w obszar pamięci, odczyt bajtów z tej odwzorowanej pamięci daje te same rezultaty co odczyt bajtów z pliku, z tą różnicą, że buforowaniem danych zajmuje się menedżer pamięci wirtualnej. Zapis bajtów do tej odwzorowanej pamięci daje te same rezultaty co zapis bajtów do pliku, z tą różnicą, że także w tym przypadku buforowaniem danych zajmuje się menedżer pamięci wirtualnej.
Pliki odwzorowane w pamięci są wysoce efektywne. Menedżer pamięci wirtualnej nie wykonuje żadnej pracy, która nie jest potrzebna. Gdy najpierw tworzony jest widok pliku odwzorowanego w pamięci, rezerwowany jest zakres adresów, jednak nie następuje odczyt żadnych danych. Pierwszy raz z ideą rezerwowania adresów zetknąłeś się przy dyskusji na temat rezerwowania prywatnych adresów zakresów funkcją VirtuaiAlloc (). W przypadku pliku odwzorowanego w pamięci, odwołanie do miejsca w pamięci zarezerwowanego dla stron odwzorowanych w pamięci powoduje wygenerowanie wyjątku strony, na który menedżer pamięci wirtualnej odpowiada odczytaniem stron z pliku i ponownym wykonaniem instrukcji powodującej wyjątek. Zmodyfikowane strony są zapisywane z powrotem na dysk tylko wtedy, gdy menedżer pamięci wirtualnej potrzebuje wolnych stron (lub gdy aplikacja wymusi taki zapis, wywołując funkcję Win32 API FlushFiieBuffers ()). To leniwe podejście sprawia, że użycie procesora i pamięci jest ograniczane do minimum.
Pliki odwzorowane w pamięci umożliwiają dwóm (lub więcej) procesom wspólne korzystanie z danych opartych na pliku. Każdy proces związany ze wspólnymi danymi ma bezpośredni dostęp do wspólnego zestawu stron. Ponieważ udostępnianie występuje z minimalnym narzutem, korzystanie ze wspólnego pliku odwzorowanego w pamięci może być całkiem użyteczne nawet przy dużych ilościach wspólnych danych. A ponieważ każdy proces ma bezpośredni dostęp do danych, uzyskiwane są duże szybkości przekazywania danych.
Aby skorzystać z pliku odwzorowanego w pamięci, nie musisz udostępniać pamięci innym procesom. Na tym polega różnica pomiędzy pamięcią nadającą się do udostępnienia a pamięcią rzeczywiście udostępnioną kilku procesom. Strony przygotowane jako strony pliku odwzorowanego w pamięci są udostępnialne - tzn. możesz je udostępnić -ale jeśli nie chcesz, wcale nie musisz tego robić. Nie ma niczego złego w wykorzystaniu pliku odwzorowanego w pamięci w celu zwykłego dostępu do danych w pliku. Możliwość udostępniania jest po prostu dodatkową zaletą, z której możesz w razie potrzeby skorzystać.
Do obsługi pliku odwzorowanego w pamięci są wymagane trzy obiekty KERNEL Windows: obiekt pliku, obiekt odwzorowania pliku oraz obiekt widoku. (MFC nie zawiera klas reprezentujących te obiekty). Aby odwzorować plik w pamięci, należy najpierw otworzyć plik, co powoduje utworzenie obiektu pliku. Następnie obiekt pliku trzeba połączyć z obiektem odwzorowania pliku w pamięci, co zapewnia połączenie logiczne z plikiem na dysku w celu synchronizacji danych pomiędzy różnymi procesami. Jednak sama obecność obiektu pliku odwzorowanego w pamięci nie zapewnia wskaźnika do danych. Aby go uzyskać, wymagany jest obiekt widoku. Obiekt widoku dostarcza wskaźnik do bloku danych w pliku. Dla pojedynczego obiektu pliku odwzorowanego w pamięci można tworzyć wiele widoków, dzięki czemu proces może wybierać pomiędzy różnymi częściami pliku, do których chce się odwołać.
Tworzenie obiektu pliku
Do otwierania plików można użyć dwóch funkcji: jednej z Win 16 API i drugiej z Win32 API. Najpierw omówimy funkcję Win32, mimo że funkcja Win 16 jest prostsza w użyciu. Funkcją Win32 do otwierania plików jest CreateFile (), której nazwa wynika z faktu, że funkcja tworzy systemowy obiekt pliku. Ta funkcja może tworzyć pliki, otwierać je oraz otwierać inne obiekty systemu operacyjnego, takie jak porty komunikacyjne czy nazwane kanały. Funkcją Winló do otwierania plików jest OpenFile (). Osobiście wolimy korzystać właśnie z niej, gdyż wymaga podania mniejszej liczby parametrów oraz posiada dodatkową cechę, którą można wykorzystać do usuwania plików. Funkcja nie jest tak elastyczna jak CreateFile (), która może otwierać inne rodzaje obiektów systemu operacyjnego oraz ustawiać atrybuty pliku i ochrony. Aby otworzyć normalny plik na dysku, użyj funkcji OpenFile () - co w mniej lub bardziej pośredni sposób spowoduje wywołanie funkcji CreateFile (). Szczegóły na temat ważnej różnicy pomiędzy obsługą błędów tych funkcji a obsługą błędów funkcji pozostałej części systemu znajdziesz w ramce w tekście. Aby otworzyć plik, zaleca się użycie funkcji OpenFile (), zdefiniowanej następująco:
HFILE WINAPI OpenFile( LPCSTR IpFileName, LPOFSTRUCT IpReOpenBuff, UINT uStyle);
Parametr IpFileName jest łańcuchem znaków zawierającym ścieżkę i nazwę pliku przeznaczonego do otwarcia. Argument IpReOpenBuf f jest wskaźnikiem do struktury typu OFSTRUCT. Ta struktura została stworzona w celu wsparcia dla operacji wejścia-wyjścia wymaganych podczas dostępu do plików na dyskietce, która wymaga, by pliki były przez prawie cały czas zamknięte. W przeciwnym razie, jeśli użytkownik zmieniłby
dyskietkę, mógłby zostać zniszczony cały system plików na dyskietce. W rzeczywistości jedynym użytecznym polem tej struktury jest pole fFixedDisk określające, czy została otwarta dyskietka. (Mimo nazwy, to pole nie określa otwarcia napędu CD-ROM, który jest traktowany jak dysk sieciowy). Struktura OFSTRUCT jest w plikach nagłówkowych Windows zdefiniowana następująco:
typedef struct _OFSTRUCT {
BYTE cBytes;
BYTE fFixedDisk;
BYTE nErrCode;
BYTE Reservedl;
BYTE Reserved2;
CHAR szPathName[OFS_MAXPATHNAME]; } OFSTRUCT, *LPOFSTRUCT, *POFSTRUCT;
Parametr ustyle zawiera znaczniki otwarcia pliku. Poszczególne znaczniki określają sposób dostępu do pamięci (OF_READ, OF_WRITE, OF_READWRITE), sposób udostępniania pliku innym procesom (OF_SHARE_EXCLUSIVE, OF_SHARE_DENY_WRITE itd.). oraz podejmowaną akcję (OF_CREATE, OF_DELETE, OF_EXIST itd.). Pełną listę znaczników znajdziesz w dokumentacji Win32. Oto przykład wywołania tej funkcji w celu otwarcia pliku:
HFILE hfile; OFSTRUCT of; hfile = OpenFilet "FILE.DAT", &of,
OF_READ | OF_SHARE_EXCLUSIVE ); if( hfile == (HFILE) -l )
{
// Błąd otwarcia pliku
}
A oto porównywalne wywołanie funkcji CreateFile ():
HFILE hfile;//
hfile = CreateFile(achFile,Nazwa pliku
GENERIC_READ,// Tryb dostępu
O,// Udostępnianie
O,// Ochrona
OPEN_EXISTING,// Znaczniki tworzenia
FILE_ATTRIBUTE_NORMAL,// Atrybuty pliku
O ) ;// Emulacja pliku
if( hfile == (HFILE) -l ) {
// Błąd otwarcia pliku
}
W przypadku powodzenia funkcja OpenFile () oraz jej kuzynka, funkcja CreateFile o zwracają poprawny uchwyt pliku HFILE. Aby zamknąć plik, wywołaj funkcję cioseHandle (). Tej samej funkcji używa się do zamykania także innych typów obiektów KERNEL, włącznie z obiektami odwzorowania plików, które omówimy jako następne. Nie używa się jej jednak do zamykania obiektów interfejsu użytkownika (takich jak okna, menu, kursory i ikony) lub obiektów rysunkowych GDI (takie jak konteksty urządzeń, pióra, pędzle czy czcionki). Niestety, ogólna nazwa tej funkcji mylnie sugeruje szersze zastosowanie, niedostępne jednak w Win32 API.
Obsługa błędów plików w Windows
Jeśli chodzi o obsługę plików, pojawia się pewna niespójność w stosunku do innych rodzajów obiektów w Windows. Sposób wykrywania błędów otwarcia plików różni się od sposobu obsługi błędów tworzenia innych obiektów Win32. W przypadku innych rodzajów obiektów zwrócony uchwyt o wartości NULL (zero) oznacza błąd. W przypadku błędu otwarcia pliku funkcje otwierające pliki zwracają uchwyt o wartości -l. Ta wartość jest reprezentowana przez stałą INVALID_HANDLE_VALUE, dość długi, lecz opisowy symbol preprocesora. Niektórzy programiści wolą więc używać zamiast niego po prostu wartości -i.
Dla nowicjuszy w programowaniu Windows, symbol INVALID_HANDLE_ VALUE jest w dwójnasób mylący. Po pierwsze, jego nazwa sugeruje, że jest to symbol dla wszelkich błędnych uchwytów, mimo że w rzeczywistości odnosi się on jedynie do uchwytów plików. Po drugie, ten symbol nie ma nic wspólnego z innymi rodzajami obiektów. Nie sprawdzaj tej wartości podczas tworzenia okien, menu, okien dialogowych, kursorów, ikon, a także tworząc obiekty GDI, takie jak pióra, pędzle, czcionki, konteksty urządzeń, bitmapy czy regiony, oraz obiekty KERNEL, takie jak procesy, wątki czy semafory. (Protestując przeciw tej niespójności można używać wpisanej "na sztywno" wartości -l).
Pewnie zastanawiasz się, dlaczego właśnie błąd otwarcia pliku powoduje w Win32 zwrócenie uchwytu o wartości -1. Powody są historyczne. Ten element Win32 jest zgodny wstecz z Winl6 API. Lecz pewnie zastanawiasz się, skąd w WinlG wzięła się wartość -l, mimo że wartością błędnego uchwytu w Winl6 także jest wartość NULL. Po prostu programy Winl6 (w Windows 3.x i wcześniejszych) opierają się ma operacjach wejścia-wyjścia plików MS-DOS-a, więc programy WinlG muszą operować na tym, co dostarcza MS-DOS - czyli wartością -l oznaczającą błąd otwarcia pliku. Saga ciągnie się dalej, jeśli weźmiesz pod uwagę fakt, że MS-DOS został zbudowany w oparciu o stworzoną przez Tima Pattersona z Seattle Computer odmianę systemu CP/M. W CP/M wartością błędnego uchwytu było oczywiście -1 (a właściwie Oxff). W rezultacie Win32 działające w Windows 98 - oraz w Windows NT! -zwraca wartość błędnych uchwytów pochodzącą jeszcze z systemu operacyjnego zbudowanego w połowie lat siedemdziesiątych.
Tworzenie obiektu odwzorowania pliku
Gdy plik już zostanie otwarty, drugi krok obsługi pliku odwzorowanego w pamięci wiąże się z tworzeniem obiektu odwzorowania pliku. Służy do tego wywołanie funkcji CreateFileMapping(), która jako pierwszy parametr przyjmuje uchwyt pliku. Mimo że mógłbyś oczekiwać, że w przypadku podania błędnego uchwytu pliku (wartości -1) wywołanie funkcji się nie powiedzie, jednak nie jest to prawdą. Jak to za chwilę opiszemy, błędna wartość uchwytu przekazana jako pierwszy parametr powoduje stworzenie wspólnej pamięci zamiast pliku o wspólnym dostępie. Funkcja CreateFileMapping () jest zdefiniowana następująco:
HANDLE WINAPI CreateFileMapping( HANDLE hFile,
LPSECURITY_ATTRIBUTES Ipsa; DWORD dwProtect, DWORD dwMaxSizeHigh, DWORD dwMaxSizeLow, LPCSTR IpszMapName );
Parametr hFile to uchwyt pliku zwrócony przez funkcję OpenFile () lub createFile (}. Argument 1psa wskazuje strukturę SECURITY_ATTRIBUTES definiującą atrybuty ochro ny obiektu odwzorowania pliku (w Windows NT). Wartość NULL oznacza domyślny poziom ochrony.
Argument dwProtect jest polem znaczników ochrony strony, takich jak PAGE_READONLY, PAGE_READWRITE czy PAGE_WRiTECOPY. Muszą one być spójne ze znacznikami dostępu podanymi podczas otwierania pliku.
Argument dwMaxSizeHigh to starsze 32 bity maksymalnego rozmiaru pliku. (W przewidywaniu systemów plików obsługujących pliki większe niż 4GB Win32 stosuje 64-bitowe przesunięcia w plikach). Parametr dwMaxSizeLow to młodsze 32 bity maksymalnego rozmiaru pliku.
Argument 1pszMapName to opcjonalny łańcuch znaków nazwy identyfikującej obiekt odwzorowania pliku. Możesz podać wartość NULL lub łańcuch znaków jednoznacznie określający obiekt odwzorowania pliku. Oto przykład wywołania tej funkcji w celu stworzenia nienazwanego obiektu odwzorowania pliku:
// Tworzenie obiektu odwzorowania pliku.
HANDLE hfm;
hfm = CreateFileMapping( hfile, // Uchwyt pliku.
O, // Wskaźnik do atrybutów ochrony.
PAGE_READONLY, // Ochrona strony.
O, // Rozmiar, starsze 32 bity.
dwFileSize, // Rozmiar, młodsze 32 bity.
O ); // Nazwa odwzorowania.
Gdy tworzysz obiekt odwzorowania pliku, jeśli chcesz, możesz nadać mu unikalną nazwę (w polu 1pszMapName) na przykład RICK_SHARED. Pamiętaj, by nazwa była unikalna, gdyż nieoczekiwana kolizja nazw z nieznanym procesem może powodować udostępnianie w nieoczekiwany i niechciany sposób.
Aby dwa lub więcej procesy mogły wspólnie korzystać z pojedynczego pliku, konieczne jest stworzenie i udostępnienie pomiędzy procesy pojedynczego obiektu odwzorowania pliku. Po utworzeniu (wywołaniem CreateFileMapping ()) obiektu odwzorowania pliku wspólne korzystanie z obiektu umożliwiają trzy różne funkcje: CreateFileMapping (),OpenFileMapping() oraz DuplicateHandle() .
Jednym ze sposobów udostępnienia pojedynczego obiektu odwzorowania jest nadanie nazwy temu obiektowi. Następnie, gdy dwa lub więcej procesów wywoła funkcję CreateFileMapping () z tą samą nazwą, pomiędzy te procesy będzie dzielony pojedynczy
obiekt odwzorowania. W rzeczywistości, pierwszy proces tworzy obiekt odwzorowania pliku, a następne procesy po prostu się do niego dołączają. Dziwne może się wydawać, że tworzony jest tylko jeden obiekt odwzorowania pliku mimo wywołań tej funkcji w kilku procesach, lecz sama natura wspólnego korzystania jest taka, że nie zawsze łatwo jest określić, który proces pierwszy zaczął działanie. W celu sprawdzenia, że wywołanie funkcji nie stworzyło nowego i unikalnego obiektu odwzorowania pliku, wywołaj funkcję GetLastError o i sprawdź, czy zwróconą przez nią wartością nie było ERROR_
ALREADY_EXISTS.
Drugą funkcją dla międzyprocesowego dzielenia pliku odwzorowanego w pamięci jest OpenFiieMapping (), która jako jeden ze swoich parametrów przyjmuje nazwę obiektu odwzorowania pliku. Ta funkcja zakłada, że obiekt odwzorowania pliku został już utworzony (wywołaniem CreateFileMapping ()). Jeśli obiekt o tej nazwie nie istnieje, funkcja zwraca uchwyt odwzorowania pliku o wartości NULL. (Pamiętaj, że wartość uchwytu -l odnosi się jedynie do błędów otwarcia pliku).
Trzecią funkcją do łączenia istniejącego obiektu odwzorowania pliku jest Duplicate-Handle (), tworząca uchwyt KERNEL, który może być użyty przez inny proces. Uchwyty KERNEL służą do prywatnego wykorzystania przez pojedynczy proces, lecz ta funkcja umożliwia dowolne tworzenie nowych uchwytów z uchwytów istniejących w celu wykorzystania w innych procesach. Zwróć uwagę, że ta funkcja może zostać wykorzystana do tworzenia uchwytów jedynie określonych rodzajów (pełna lista jest zawarta w dokumentacji Win32). Ogólnie, ta funkcja nie może powielać uchwytów obiektów interfejsu użytkownika ani uchwytów obiektów rysunkowych GDI. Może jednak być użyta do powielania obiektów KERNEL - w Windows NT zwanych bardziej poprawnie obiektami zarządzającymi - obejmujących uchwyty plików, obiekty odwzorowania plików, procesy, wątki, semafory, muteksy oraz zdarzenia.
Tworzenie obiektu widoku
Po utworzeniu obiektu pliku oraz obiektu odwzorowania pliku trzecim krokiem w dostępie do pliku odwzorowanego w pamięci jest stworzenie jednego lub kilku widoków pliku danych. Gdy jest tworzony widok, rezerwowana jest przestrzeń adresowa w celu umożliwienia dostępu do części pliku odwzorowanego w pamięci. Przy odwołaniu do tego zakresu adresów następuje alokacja stron pamięci oraz odczyt danych z dysku. Dla pojedynczego pliku można tworzyć wiele widoków, dzięki czemu proces może wybierać części pliku, do których chce się odwołać. Na przykład, można stworzyć widok dla początku pliku w celu odczytania treści nagłówka, dla końca pliku w celu zapisania kopii nagłówka oraz dla obszarów w środku pliku, gdzie występują odpowiednie dane. Aby stworzyć widok pliku odwzorowanego w pamięci, wywołaj funkcję Mapview0f File (). Ta funkcja jest zdefiniowana następująco:
LPYOID WINAPI MapViewOfFile( HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, DWORD dwNumberOfBytesToMap);
Parametr hFileMappingObject to uchwyt obiektu odwzorowania pliku. Argument dwDesiredAccess to żądany dostęp do danych w odwzorowanym pliku. Na przykład, dla dostępu jedynie w celu odczytu może mieć wartość FILE_MAP_READ, zaś dla dostępu w celu odczytu i zapisu może mieć wartość FILE_MAP_READ i FILE_MAP_WRITE.
Parametr dwFileOffsetHigh to starsze 32 bity przesunięcia w pliku początku obszaru, jaki ma zostać odwzorowany w pamięci. Argument dwFileOffsetLow to młodsze 32 bity wartości tego przesunięcia. Parametr dwNumberOfBytesToMap to ilość bajtów do odwzorowania. Jest ona zaokrąglana w górę do najbliższej granicy stron (4K w procesorach Intela). Jeśli podasz wartość zero, zostanie odwzorowany cały plik.
Oto przykład wywołania tej funkcji w celu utworzenia widoku odnoszącego się do pierwszych 4096 bajtów pliku:
// Utworzenie widoku pliku odwzorowanego w pamięci. LPYOID IpData;
lpData = MapViewOfFile( hFileMapping,
FILE_MAP_READ, // Ochrona strony.
0, // Offset w pliku - starsze 32 bity. 0, // Offset w pliku - młodsze 32 bity. 4096 ); // Rozmiar widoku.
Pewna odmiana procedur umożliwiających dostęp do pliku odwzorowanego w pamięci umożliwia także udostępnianie stron pomiędzy procesami. Właśnie tym zajmiemy się w następnych sekcjach.
Dynamiczne alokowanie wspólnych stron
Druga technika współkorzystania z pamięci wiąże się z dynamicznie alokowanymi wspólnymi stronami. Choć implementacja w Windows 98 nieco różni się od implementacji w Windows NT, jednak gdy z nich korzystasz, chcesz osiągnąć ten sam rezultat: otrzymanie dostępu do tych samych fizycznych stron w dwóch lub więcej różnych procesach. W celu utrzymania całkowitej zgodności pomiędzy Windows 98 a Windows NT musisz jednak uważać właśnie na te różnice w implementacji.
Różnice w udostępnianiu stron w Windows 98 i w Windows NT
Kluczowa różnica w implementacji udostępniania pamięci w Windows 98 i w Windows NT wiąże się z adresami pamięci. W Windows 98 wspólna strona jest widoczna dla wszystkich procesów Win32 pod tym samym adresem - gwarantowane. Dzieje się takie, ponieważ wspólne strony są odwzorowane w okno wspólnych adresów, w obszarze pomiędzy 2GB a 3 GB. Z drugiej strony, w Windows NT adres wspólnej strony może być różny w różnych korzystających z niej procesach. Oczywiście, te adresy mogą być takie same w zależności od tego, jakie obiekt zostały wcześniej zaalokowane w przestrzeni adresowej każdego z procesów. Jednak aby poprawnie korzystać ze wspólnych stron w obu systemach operacyjnych, proces Win32 musi być przygotowany na najgorszy wariant - to znaczy na sposób udostępniania stron w Windows NT.
Kuszące skróty w Windows 98 doprowadzą do załamania aplikacji w Windows NT. Na przykład, jeśli po prostu skorzystasz z tego samego adresu w dwóch procesach - co zawsze działa w Windows 98 - z pewnością doprowadzisz do załamania w Windows NT. Ten skrót może być szczególnie kuszący dla programistów przenoszących programy Win 16 z Windows 3.1 do Win32 w celu uruchomienia w Windows 98. Ponieważ programy Win 16 w Windows 3.1 mogły udostępniać pamięć przez zwykłe przekazanie wskaźników, prostą poprawką w celu uruchomienia programu w Windows 98 może być zwykłe udostępnienie wskaźnika do wspólnego bloku. Jednak nie rób tego; pozostań przy wywoływaniu odpowiednich funkcji w celu zapewnienia, że program będzie działał zarówno w Windows NT, jak i w przyszłych implementacjach Win32.
Istnieje ważna różnica w sposobie korzystania ze wspólnych danych w Windows 98 i w Windows 3.1. Aby zaalokować wspólny obiekt w Windows 3.1, programiści wywoływali funkcję GlobalAiloc () i przekazywali jej znacznik GMEM_SHARE (lub GMEM_DDESHARE). Ten znacznik zabraniał automatycznego czyszczenia pamięci, które w przeciwnym razie Windows 3.1 mogłoby przeprowadzić na dynamicznie alokowanym segmencie. W Win32 ten znacznik nie daje żadnego efektu. Jak już mówiliśmy, cała pamięć zaalokowana funkcją GlobalAiloc () jest umieszczona w zakresie adresów pamięci prywatnej dla procesu. Tak więc taki mechanizm udostępniania pamięci nie działa w Win32 API.
Funkcje Win32 do udostępniania stron
Funkcje, które wywołujesz w celu udostępnienia stron pamięci, są takie same jak w przypadku udostępniania plików odwzorowanych w pamięci. Najpierw, wywołując funkcję CreateFiieMapping (), tworzysz obiekt odwzorowania pliku. Gdy zostanie ona wywołana z uchwytem pliku (HFILE) -l, obiekt odwzorowania pliku automatycznie wiąże się z systemowym plikiem stronicowania. Aby dostać się do określonych wspólnych bajtów, wywołujesz funkcję MapYiewOf File (). Obie funkcje zostały omówione w poprzedniej sekcji. Poniższy fragment kodu tworzy obiekt wspólnej pamięci o nazwie "RICK_DATA":
DWORD dwSize; // Żądany rozmiar obiektu odwzorowania pliku
HANDLE hfm; // Uchwyt obiektu odwzorowania pliku
LPVOID IpData; // Wskaźnik do wspólnych danych
// Tworzenie obiektu odwzorowania pliku.
hfm = CreateFiieMapping((HANDLE)
Oxffffffff, // Uchwyt pliku.
0, // Wskaźnik do atrybutów ochrony. PAGE_READWRITE, // Ochrona strony. 0, // Rozmiar, starsze 32 bity. dwSize, // Rozmiar, młodsze 32 bity. "RICK DATA" ); // Nazwa odwzorowania.
if{ hfm == O
{
AfxMessageBox(
"Nie powiodło się utworzenie obiektu odwzorowania pliku."); return FALSE;
}
// Wyjście, jeśli nazwany obiekt już istnieje. if( GetLastError() == ERROR_ALREADY_EXISTS ) {
AfxGetMessageBox( "Obiekt odwzorowania pliku już istnieje.");
CloseHandle( hfm );
return FALSE;
}
// Utworzenie widoku pliku odwzorowanego w pamięci. IpData = MapYiewOfFile( hfm,
FILE_MAP_WRITE,// Ochrona strony.
O, // Offset w pliku - starsze 32 bity.
O, // Offset w pliku - młodsze 32 bity.
O } ; // Rozmiarem jest rozmiar całego obiektu.
if {
IpData == O )
{
AfxMessageBox(
"Nie powiodło się utworzenie widoku obiektu odwzorowania." ) ; CloseHandle( hfm ) ; return FALSE;
}
Zwróć uwagę na fragment kodu, w którym w wywołaniu funkcji CreateFileMappingO jako uchwyt pliku podajemy wartość Oxffffffff, czyli -1. Równie dobrze moglibyśmy zastosować wartość INVALID_HANDLE_VALUE, co mogłoby jednak sprawić, że kod stałby się nieco mniej zrozumiały.
Rozmiar wspólnego obszaru jest określany w wywołaniu CreateFileMapping () i w tym przykładzie jest zawarty w zmiennej dwSize. Zakładamy, że rozmiar wspólnych danych jest ograniczony do 4GB. W wywołaniu funkcji MapViewOf File () w tym przykładzie przekazujemy wielkość widoku równą zeru, co powoduje ustawienie obszaru widoku na wielkość równą wszystkim wspólnym stronom.
Podczas tworzenia obszarów wspólnej pamięci atrybut ochrony obiektu odwzorowania pliku ogranicza to, co można ustawić w obiekcie widoku. Na przykład, znacznik strony tylko do odczytu (PAGE_READONLY) w obiekcie odwzorowania pliku powoduje, że nie powiedzie się odwzorowanie widoku jako przeznaczonego do zapisu (FILE_MAP_WRITE). Obiekt odwzorowania pliku określa maksymalną dozwoloną ochronę, jaka zostanie wymuszona dla poszczególnych widoków.
Jak już wspomniano, dana wspólna strona w Windows NT może zostać w różnych procesach odwzorowana pod różnymi adresami. Projektując strategię współkorzystania z pamięci, musisz wystrzegać się opierania się na implementacji stosowanej w Windows 98, gwarantującej, że wspólne strony w różnych procesach występują zawsze pod tym samym adresem. Kompilator Visual C++ Microsoftu obsługuje tzw. wskaźniki bazowane - mechanizm, który upraszcza dostęp do wspólnych danych przy minimalnym dodatkowym zaangażowaniu ze strony programisty.
Udostępnianie wskaźników pomiędzy procesami z wykorzystaniem wskaźników bazowanych
Dane zawierające absolutne wskaźniki nie mogą być wprost udostępniane innym procesom Win32. Przez wskaźniki absolutne rozumiemy po prostu absolutne adresy pamięci wewnątrz 4GB przestrzeni adresowej procesu Win32. Ponieważ jednak wskaźniki są tak użyteczne i efektywne w powiązywaniu struktur danych, więc kompilator Visual C++ zawiera mechanizm zwany wskaźnikami bazowanymi (ang. based pointer), pozwalający tworzyć względne wskaźniki. Mimo niewielkiego kosztu ukrytej dodatkowej arytmetyki na wskaźnikach wskaźniki bazowane oferują wszystkie zalety zwykłych wskaźników, lecz w odniesieniu do wspólnej pamięci w Win32.
Absolutne wskaźniki stanowią problem tylko podczas udostępniania pamięci pomiędzy procesami Win32 w Windows NT. W Win32s, w których wszystkie procesy działają we wspólnej przestrzeni adresowej, absolutne wskaźniki nie są problemem. Zaś w Windows 98, jak już wspominaliśmy, fakt, że obszar pamięci wspólnej występuje w zakresie adresów od 2GB do 3GB uprasza udostępnianie pamięci zarówno przez systemu operacyjny -który może działać szybciej i pewniej - jak i przez procesy Win32, które wszystkie mają taki sam widok obiektów w tym obszarze. W Windows NT wszystkie procesy Win32 posiadają prywatną przestrzeń adresową w zakresie adresów od 0 do 2GB. Choć może się zdarzyć, że pamięć zostanie odwzorowana pod tym samym adresem w różnych przestrzeniach adresowych, jednak nie ma na to żadnej gwarancji. Z tego powodu, jeśli chcesz, by program Win32 poprawnie dzielił wspólną pamięć w Windows NT, musisz założyć, że wspólna pamięć w każdym z procesów występuje pod innym adresem. Wewnątrz wspólnego obiektu pamięci mogą być używane jedynie wskaźniki względne.
Wskaźnik względny stanowi przesunięcie (offset) podobne do przesunięcia znanego programistom Win 16, przyzwyczajonym do segmentów pamięci procesorów Intela. Choć możesz wykonywać własną arytmetykę na wskaźnikach, wsparcie kompilatora dla bazowanych wskaźników sprawia, że takie obliczenia są wykonywane automatycznie. Na początku Microsoft wprowadził obsługę wskaźników bazowanych w celu poprawienia efektywności dostępu do segmentowanej pamięci w programach 16-bitowych. W Win32 ten sam mechanizm jest wykorzystywany w tworzeniu i wykorzystaniu względnych wskaźników we wspólnej pamięci.
W celu obsługi bazowanych wskaźników Microsoft dodał do swojego kompilatora nowe słowo kluczowe: _based, które jako operand otrzymuje adres bazowy. Przyjmując adres bazowy p Dat a - będący na przykład typu cha r* - oto przykład definicji bazowanego wskaźnika o nazwie pBasedPointer:
char _based(pData) ^pBasedPointer;
Po zadeklarowaniu bazowany wskaźnik może być używany tak samo jak zwykły wskaźnik:
char *pData;
char _based(pData)
^pBasedPointer;
pData = (char*)MapViewOfFile( ... ); pBasedPointer = pData;
strcpy( pBasedPointer, "Wskaźnik względny (bazowany)"); strcpy( pData, "Wskaźnik absolutny" );
Wskaźniki bazowane są przydatne wtedy, gdy chcesz umieścić wskaźnik w bloku pamięci wspólnej. Nie musisz korzystać z bazowanych wskaźników w celu samego dostępu do wspólnej pamięci - do tego w zupełności wystarczą zwykłe wskaźniki. Jeśli skrupulatnie liczysz takty procesora - co ma coraz mniejsze znaczenie w dobie coraz szybszych procesorów - okazuje się, że wskaźniki absolutne są nieco szybsze niż wskaźniki bazowane. Jednak gdy chcesz umieścić rzeczywisty wskaźnik w bloku wspólnej pamięci, powinieneś użyć wskaźnika bazowanego. Dzięki temu różne procesy będą mogły swobodnie korzystać z tego wskaźnika nie martwiąc się, że sam wspólny blok pamięci występuje w różnych procesach pod różnymi adresami w przestrzeni adresowej.
Statyczne alokowanie wspólnych stron
Kompilator Visual C++ Microsoftu zapewnia możliwość udostępniania zmiennych globalnych pomiędzy różnymi procesami. Ten mechanizm opiera się na alokowaniu przez kompilator i linker wspólnych stron wewnątrz pliku wykonywalnego. W czasie wykonania, program ładujący umożliwia takim stronom odwzorowanie w przestrzeń adresową kilku procesów jednocześnie. Tak jak w przypadku dynamicznie alokowanych wspólnych stron pojedynczy zestaw fizycznych stron w magiczny sposób pojawia się w przestrzeniach adresowych kilku procesów.
Aby wspólne zmienne globalne były dostępne, konieczne jest spełnienie trzech wymagań. Po pierwsze, zmienne globalne muszą być zainicjowane. Po drugie, wspólne zmienne globalne muszą być zadeklarowane we własnej sekcji. Zaś po trzecie, sekcji pliku wykonywalnego, w której znajdą się te zmienne, konieczne jest przypisanie atrybutu udostępniania. Zapewnia się to przez dokonanie wpisu do pliku definicji modułu (.DEF). Przyjrzyjmy się kolejno poszczególnym wymaganiom.
Wspólne zmienne globalne muszą zostać zainicjowane, ponieważ kompilator, linker oraz program ładujący traktują dane niezainicjowane inaczej niż dane zainicjowane. Kopia zainicjowanych danych występuje w pliku wykonywalnym. Z drugiej strony, nie ma potrzeby, by niezainicjowane dane zajmowały miejsce w pliku wykonywalnym, więc program ładujący może zaalokować pamięć dla niezainicjowanych danych bez konieczności odczytywania danych z pliku wykonywalnego. Dodatkową korzyścią płynącą ze stosowania zainicjowanych danych jest to, że mają one znane wartości bez konieczności jakiejkolwiek działalności ze strony programu.
Drugie wymaganie, by wspólne zmienne globalne były zadeklarowane w swojej własnej sekcji, nie jest bezwzględnie konieczne. Na przykład, plik wykonywalny może udostępnić wszystkie swoje zmienne globalne. W końcu, biblioteki DLL Win 16 posiadają pojedynczy segment danych, wspólny dla wszystkich procesów. Ponieważ jednak powoduje to więcej problemów niż korzyści, prawdopodobnie zechcesz użyć zmiennych zarówno wspólnych, jak i prywatnych.
Aby zadeklarować zmienne globalne jako występujące we własnej sekcji, powinieneś ująć ich deklarację pomiędzy parę instrukcji #pragma data_seg. Jak zapewne podejrzewasz, część s eg tej nazwy pochodzi jeszcze z czasów segmentowanej pamięci MS-DOS-a i 16-bitowych Windows. Oto przykład zadeklarowania tablicy znaków jako należącej do sekcji o nazwie .shared:
// Umieszczenie wspólnych danych w sekcji o nazwie .shared
#pragma data_seg (".shared")
char achPublic[BUFSIZE] = "";
// Ustawienie nazwy segmentu danych ponownie na domyślne _DATA
#pragma data_seg ()
Słowo kluczowe ttpragma wygląda jak instrukcja procesora, lecz mimo to nią nie jest. Jest to instrukcja dla kompilatora, że ma dostarczyć jakąś specjalną usługę. W tym przypadku słowo kluczowe data_seg wymaga zmiany nazwy segmentu, w którym są umieszczane deklarowane dane. Gdy nie zostanie podana nazwa segmentu, tak jak w drugiej instrukcji #pragma w tym przykładzie, używana jest domyślna nazwa _DATA. Co ważne, sama nazwa użyta jako nazwa sekcji danych (.shared) nie sprawia, że ta sekcja staje się wspólna, można więc zastosować każdą inną nazwę, choćby "DanielBoone". Udostępnianie stanie się faktem dopiero wtedy, gdy daną sekcję oznaczymy jako udostępnianą w pliku definicji modułu, czyli kiedy wykonujemy trzeci krok.
Trzeci krok w udostępnianiu globalnych zmiennych pomiędzy procesami wiąże się z zaznaczeniem jako wspólnej tej sekcji, która zawiera wspólne zmienne globalne. W tym celu musisz dokonać wpisu w pliku definicji modułu. Ten plik zwykle zawiera zestaw opcji linkera. Na przykład podczas budowania bibliotek DLL, w tym pliku, po słowie kluczowym EXPORTS, zawarta jest lista funkcji udostępnianych światu zewnętrznemu. W celu ustawienia atrybutów pamięci dla sekcji pliku wykonywalnego konieczne jest użycie słowa kluczowego SECTIONS. Poniższy przykład pokazuje cały plik definicji modułu dla biblioteki DLL zawierającej wspólne zmienne globalne. Wpis po słowie kluczowym SECTIONS umożliwia wspólne korzystanie pomiędzy procesami z danych zawartych w sekcji o nazwie .shared:
LIBRARY DATASTOR
DESCRIPTION 'Przykładowy DLL Win32'
SECTIONS
.shared READ WRITE SHARED
EKPORTS
GetPrivateData SetPrivateData GetPublicData SetPublicData
Podsumowanie
Z tematów opisywanych w tym rozdziale wynika, że API Win32 udostępnia wiele rodzajów pamięci. Choć funkcja malloc() biblioteki czasu wykonania C oraz operator new w C++ zapewniają podstawowe mechanizmy alokowania pamięci, są jednak one ogólnego przeznaczenia. Specjalne zadania mogą wymagać bardziej wyspecjalizowanych mechanizmów alokacji, zapewniających lepszą kontrolę, wyższą wydajność lub precyzyjniejsze określenie właściwości pamięci.
Wyszukiwarka
Podobne podstrony:
Wykład 9 Zarządzanie pamięcią8 Systemy Operacyjne 21 12 2010 Zarządzanie Pamięcią Operacyjnąsołtys,systemy operacyjne, zarządzanie pamięcią05 Zarządzanie pamięcią2006 08 Zarządzanie pamięcią w systemach operacyjnych [Inzynieria Oprogramowania]14 Zarządzanie pamięcią9 Systemy Operacyjne 04 01 2011 Zarządzanie Pamięcią Operacyjną206 Zarządzanie pamięciąwięcej podobnych podstron