Model obiektowy w Delphi odzwierciedlający sparsowany plik PDF zwykle kończy jako jedna płaska lista wszystkich obiektów plus zwisający z niej graf wskaźników. Przejdź tę listę, wołając Free na każdym wpisie, a przebieg padnie w połowie z access violation wewnątrz destruktora — na obiekcie, który wcześniejszy Free już zwolnił przez innego rodzica. Zamień pętlę na List.Clear, a crash znika, ale proces rośnie o mniej więcej jeden dokument przy każdym ponownym wczytaniu. Oba objawy mają jedną przyczynę: lista jest płaska, a własność jest grafem. Sprzątanie, które działa, rozstrzyga, które wskaźniki posiadają swoje cele, zbiera to domknięcie raz, tnie każdą krawędź własności i dopiero wtedy zwalnia każdy węzeł — dokładnie raz
Dlaczego nie można po prostu zwolnić każdego zarejestrowanego obiektu?
W HotPDF płaską listą jest rejestr obiektów dokumentu, a klasy, które trzyma, nie zgadzają się co do tego, kto niszczy co. Każdy destruktor jest rozsądny na własnych warunkach. Złóż je razem nad rejestrem zawierającym wpisy z kilku poziomów tego samego łańcucha własności, a zwalnianie wpis po wpisie Free zwolni część pamięci dwa razy, a części nigdy
Trzy destruktory w HPDFObjs.pas i HPDFDoc.pas układają tę scenę. THPDFDictionaryObject.Destroy przechodzi swoje Items i zwalnia wartość tylko wtedy, gdy IsIndirect jest False — w założeniu, że pośrednie dzieci należą do rejestru. THPDFArrayObject.Destroy zwalnia każdy element, który trzyma, pośredni albo nie. THPDFIndirectObject, wrapper dla obiektów wyciągniętych ze strumieni obiektów (ISO 32000-1 §7.5.7), zwalnia swoje ciało InternalObject. Wyobraź sobie teraz rejestr trzymający pośredni słownik, tablicę wymieniającą ten sam słownik w jednym ze swoich slotów i wrapper, którego ciało jest też zarejestrowane jako własny korzeń. Zwolnij najpierw tablicę, a słownik zniknie, zanim rejestr do niego dotrze. Zwolnij wrapper, a potem ciało, i drugie wywołanie puści destruktor po pamięci, która już nikomu nie należy. Zwolnij sam słownik, a każde pośrednie dziecko, które pominął, zostanie zaalokowane do wyjścia z procesu
Przestawienie rejestru tego nie ocali. Jakąkolwiek kolejność wybierzesz, jakaś para wpisów wyląduje po złej stronie krawędzi własności, bo lista nie ma sposobu, by powiedzieć, że wpis 12 posiada wpis 40
Krawędzie własności i referencje w grafie obiektów PDF
Krawędź własności to wskaźnik, którego cel źródło jest zobowiązane zniszczyć. Wszystko inne to referencja, a sprzątanie idzie za pierwszym rodzajem. W HotPDF są dokładnie cztery rodzaje krawędzi własności: Items THPDFDictionaryObject, Items THPDFArrayObject, InternalObject za THPDFIndirectObject oraz oba pola THPDFStreamObject — jego Dictionary i ładunek Stream
Referencje zasługują na tę samą troskę, bo pójście za którąś z nich zamienia przejście po grafie w nieskończoną pętlę albo odczyt zwolnionej pamięci. THPDFLink trzyma numer obiektu i generację, czyli dokładnie odwołanie pośrednie z ISO 32000-1 §7.3.10: nazwę obiektu zapisanego gdzie indziej w pliku. Rozwiązanie tego numeru przez rejestr ląduje na węźle, który inna krawędź już posiada, więc sprzątanie w ogóle nigdy nie dereferencjonuje linków. Wskaźnik wsteczny FParent, który trzymają słowniki i tablice, biegnie w przeciwną stronę. Rodzic już posiada dziecko, a wejście po tym wskaźniku w górę tylko odwiedziłoby węzeł, przez który przejście przeszło. Oba rodzaje zostają nietknięte przez całe sprzątanie
Zbierz, odłącz, zwolnij: sprzątanie w trzech przebiegach
HotPDF zwalnia obiekty dokumentu w trzech przebiegach wewnątrz chronionej metody THotPDF.CloseIndirectObjects, a kolejność tych przebiegów to cały trik. Przebieg pierwszy zbiera. Lista robocza startuje z każdego wpisu rejestru, a dla każdego węzła przejście dopisuje cele jego krawędzi własności, pomijając wszystko, co już widziano. Zbiór odwiedzonych to tablica surowych wskaźników z adresowaniem otwartym i próbkowaniem liniowym, podwajająca się, gdy zapełni się do połowy, więc nic nie alokuje na węzeł — co zaczyna się liczyć, gdy dokument niesie setki tysięcy obiektów. Ładunki strumieni trafiają do drugiej listy, bo są potomkami TStream, a nie węzłami THPDFObject
Dwa wypisy poniżej to uproszczone fragmenty wewnętrznej implementacji komponentu, pokazane po to, żeby przebiegi było widać w typach obiektowych biblioteki. Kod aplikacji nigdy niczego z tego nie woła; LoadFromFile, LoadFromStream i destruktor robią to za ciebie. We fragmencie hash adresu jest sprowadzony do przesunięcia i maski, a krok podwajający tablicę zbioru odwiedzonych, gdy się zapełni, jest pominięty
// Uproszczony wycinek wewnętrznego sprzątania, nie wywoływalne API
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (prawdziwa rutyna podwaja i rehashuje tablicę, gdy zapełni się do połowy)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // już zebrane
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// Przebieg pierwszy: zasila rejestr, potem idzie tylko po krawędziach własności
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
// Wartości THPDFLink i wskaźniki FParent nigdy nie są odwiedzane
Inc(I);
end;
Przebieg drugi tnie. Każda krawędź własności jest ustawiana na nil, zanim ruszy choć jeden destruktor. Wrapper dostaje MarkAsFreed, które czyści InternalObject i ustawia flagę IsFreed, którą jego destruktor sprawdza w pierwszej kolejności. Obiekt strumienia ma Dictionary i Stream przypisane do nil, każdy element słownika ma wyczyszczone Value, a każdy slot tablicy jest nadpisywany nil-em. Przebieg trzeci czyści rejestr, zwalnia każdy węzeł w Nodes, a potem każdy ładunek w Streams
// Uproszczony wycinek wewnętrznego sprzątania, ciąg dalszy; nie wywoływalne API
// Przebieg drugi: odłącz każdą krawędź własności, zanim cokolwiek zwolnisz
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// Przebieg trzeci: każdy unikalny węzeł i ładunek jest zwalniany dokładnie raz
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
Cięcie to to, co pozwala istniejącym destruktorom zostać dokładnie takimi, jakie są. Przy braku krawędzi każdy destruktor nie znajduje niczego do rekurencji i niszczy tylko siebie. Druga droga do tego samego celu to nauczenie każdego destruktora rejestru, żeby słownik, tablica i wrapper każde pytały, czy jakaś inna ścieżka zwolni ich dzieci. To rozsmarowuje wiedzę o grafie na cztery klasy i na każdą dodaną później, a jedna zła odpowiedź gdziekolwiek przyprowadza podwójne zwolnienie z powrotem. Trzymanie całego modelu własności w pojedynczej rutynie sprzątania to bezpieczniejsze ułożenie
Przepuść przez to niewygodne przypadki — żaden nie potrzebuje specjalnej gałęzi. Słownik współdzielony przez dwa obiekty strumieni jest zbierany raz, odłączany od obu i zwalniany raz. Cykl, w którym tablica wymienia własny słownik nadrzędny, kończy się, bo zbiór odwiedzonych odmawia drugiej wizyty. Wrapper i jego ciało zarejestrowane jako dwa korzenie to dwa różne wskaźniki w zbiorze, więc oba są zwalniane, a destruktor wrappera zostawia ciało w spokoju, bo MarkAsFreed odebrało mu tę krawędź. Jeden TMemoryStream przypisany jako ładunek dwóch obiektów strumieni siedzi w Streams dokładnie raz
Co musi się zatrzymać, zanim graf zejdzie na dół?
Wszystko, co pożycza obiekty z grafu, musi najpierw się zatrzymać. HotPDF potrafi renderować strony na wątku roboczym przez PrefetchLoadedPages i cacheuje bitmapy oraz listy wyświetlania skompilowane z obiektów dokumentu; wątek roboczy albo wpis cache, który przeżyłby graf, czytałby zwolnioną pamięć. Sprzątanie otwiera się więc CancelLoadedPagePrefetch i unieważnia cache wyrenderowanych stron, zanim dotknie rejestru. LoadFromFile, LoadFromStream i destruktor THotPDF przechodzą wszystkie przez tę samą ścieżkę, więc kolejność jest identyczna, czy wymieniasz dokument, czy zwalniasz komponent — i na tej gwarancji opierają się zasady ponownego używania jednego THotPDF dla wielu dokumentów
Kontrakt powiadomień znaczy tyle samo. InvalidateRenderedPageCache to metoda, która podnosi OnLoadedDocumentModified z PageIndex = -1, a czytnik wczytujący plik od nowa nie powinien dostawać powiadomienia o edycji za wewnętrzne sprzątanie starego dokumentu. Wewnątrz sprzątania handler jest zapisywany, ustawiany na nil na czas tego wywołania i przywracany w bloku finally. Ponowne wczytanie i tak daje jedno wywołanie PageIndex = -1, podnoszone przez samo wczytanie, gdy resetuje cache dla nowego dokumentu — i na nie czytnik powinien przemalować. W destruktorze cache są już pozbyte, zanim graf się zamyka, więc sprzątanie pomija tam unieważnienie całkowicie
procedure TViewerForm.FormCreate(Sender: TObject);
begin
FPdf := THotPDF.Create(Self);
FPdf.OnLoadedDocumentModified := PdfModified;
end;
procedure TViewerForm.PdfModified(Sender: TObject; PageIndex: Integer);
begin
if PageIndex = -1 then
RepaintAllPages // twoja własna metoda czytnika
else
RepaintPage(PageIndex); // twoja własna metoda czytnika
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile anuluje biegnący prefetch i zwalnia obiekty poprzedniego
// dokumentu przed parsowaniem; PdfModified widzi jedno wywołanie -1
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
Jawne wołanie CancelLoadedPagePrefetch nie jest potrzebne w OpenDocument, bo robi je samo wczytywanie. Samodzielne wołanie to wciąż właściwy ruch, gdy zamierzasz modyfikować wczytany dokument, podczas gdy prefetch może jeszcze biec; komentarz deklaracji w HPDFDoc.pas prosi dokładnie o to. Dla renderowania pulą wątków na większą skalę artykuł o równoległym potoku renderowania opisuje, jak praca w tle jest dławiona
Jak odróżnić wyciek od zatrzymania pamięci przez alokator?
Obserwuj licznik żywych alokacji menedżera pamięci przy powtarzanych wczytaniach i ignoruj working set procesu. Menedżer pamięci Delphi trzyma zwolnioną pamięć w puli na potem, więc proces stojący na wysokim rozmiarze po zamknięciu dokumentu niekoniecznie cokolwiek przecieka. Przecieka proces, którego liczba żywych dużych bloków rośnie przy każdym ponownym wczytaniu. Pętla poniżej wczytuje ten sam plik dwadzieścia razy na jednej instancji i wypisuje liczby dużych bloków po każdym przebiegu
procedure ReportLargeBlocks(const Tag: string);
var
State: TMemoryManagerState;
begin
GetMemoryManagerState(State);
Writeln(Tag, ': ', State.AllocatedLargeBlockCount, ' large blocks, ',
State.TotalAllocatedLargeBlockSize, ' bytes');
end;
var
Pdf: THotPDF;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
for I := 1 to 20 do
begin
Pdf.LoadFromFile('sample.pdf');
ReportLargeBlocks('pass ' + IntToStr(I));
end;
finally
Pdf.Free;
end;
ReportLargeBlocks('after Free');
end.
Czytaj wyjście jako kształt. Liczby, które osiądą po pierwszym przebiegu, to zatrzymanie albo zwyczajny stan per dokument. Liczby rosnące o ten sam krok przy każdym przebiegu to wyciek, a dla PDF krokiem jest zwykle rozmiar większych ładunków strumieni — treść stron i osadzone obrazy — bo to alokacje na tyle duże, by wpaść do liczb dużych bloków. Użyj pliku z tuzinem albo więcej stron, żeby krok był łatwy do zobaczenia na tle szumu. Wzrost proporcjonalny do liczby stron to coś, czego zatrzymanie przez alokator wyjaśnić nie potrafi. Żeby znaleźć, które obiekty trzymają pamięć, dopóki dokument jest otwarty, odpowiada na to graf zależności obiektów z zatrzymanymi bajtami
Ponowne użycie wzorca we własnym modelu obiektowym Delphi
Nic z tego nie jest specyficzne dla PDF. Każdy model obiektowy w Delphi, w którym destruktory posiadają dzieci niekonsekwentnie, w którym jedno dziecko jest osiągalne z kilku rodziców albo w którym wskaźniki wsteczne siedzą obok wskaźników w przód, będzie się wywalał albo przeciekał przy Free obiekt po obiekcie. Poprawka ma zawsze ten sam kształt: sklasyfikuj każde pole wskaźnikowe jako własność albo referencję, zbierz domknięcie krawędzi własności przez zbiór wskaźników tolerujący powtórne wizyty, przetnij każdą krawędź, zniszcz płaską listę. Krok cięcia jest tym, który ludzie pomijają. Pomiń go, a każdy destruktor skończy potrzebując wiedzy o każdym innym
Wzorzec ma granice warte zapisania, zanim go skopiujesz. Zbiór wskaźników używa adresu obiektu jako tożsamości, co jest bezpieczne wyłącznie dlatego, że w czasie zbierania nie biegnie żaden destruktor, więc żaden adres nie może zostać zwolniony i oddany świeżej alokacji w połowie przejścia. Przejście widzi wyłącznie rodzaje krawędzi, dla których zostało napisane; nowa klasa posiadająca dziecko przez inne pole musi zostać dodana i do przebiegu zbierania, i do odłączania. A ponieważ linki są rozwiązywane, a nie odwiedzane, obiekt wskazywany wyłącznie przez link musi być w rejestrze, żeby stać się osiągalnym. Parser HotPDF rejestruje każdy czytany obiekt, a graf budowany ręcznie we własnym kodzie musi trzymać tę samą regułę
Testuj, licząc wywołania destruktorów zamiast bajtów. Zbuduj paskudny graf ręcznie: słownik współdzielony przez dwa strumienie, tablicę trzymającą i ten słownik, i swój własny rodzic, jeden ładunek przypisany obu strumieniom, korzeń zarejestrowany dwa razy i wrapper, którego numeryczne ciało też jest zarejestrowane. Zburz go i oczekuj dokładnie jednego zniszczenia na unikalny obiekt — jeden ładunek, dwa strumienie, dwa słowniki, jedna tablica, jeden wrapper, jedna liczba. We własnych klasach wystarczy do tego licznik na poziomie klasy podbijany w każdym destruktorze (hak pomiarowy dopisany przez ciebie), a sprzątanie, które tylko czyści listę, raportuje zero
W HotPDF to wszystko siedzi za LoadFromFile, LoadFromStream i destruktorem, więc kod aplikacji widzi tylko wynik: zamknięcie albo ponowne wczytanie dokumentu oddaje jego pamięć. HotPDF to natywna biblioteka VCL do PDF dla Delphi i C++Buildera dostarczana z pełnym kodem źródłowym, więc opisane tu sprzątanie można przeczytać linia po linii w HPDFDoc.pas; dokumentacja API i wersja próbna są na stronie HotPDF PDF component for Delphi