HotPDF Delphi Component zwalnia każdy obiekt PDF należący do dokumentu, gdy dokument jest zamykany albo wczytywany od nowa: THotPDF.CloseIndirectObjects przechodzi rejestr obiektów, zbiera każdą krawędź własności do zbioru wskaźników, odłącza wszystkie te krawędzie i dopiero potem zwalnia każdy unikalny węzeł oraz każdy ładunek strumienia dokładnie raz. Ta trójfazowa kolejność sprawia, że współdzielone dzieci, cykle własności, podwójne rejestracje i aliasy wrapper-ciało schodzą na dół bez podwójnego zwolnienia i bez zostawiania czegokolwiek po sobie. Przed wersją v2.752.4 ta sama procedura robiła coś znacznie prostszego i znacznie gorszego: zwalniała leniwe źródła strumieni plikowych, wołała Clear na liście IndirectObjects, zwalniała kontener listy, a każdy faktyczny obiekt PDF zostawiała do odzyskania przy wyjściu procesu. Komentarz w tym kodzie był zresztą uczciwy. Zwalnianie obiektów pojedynczo powodowało access violation, więc bezpiecznym podejściem było nie zwalniać ich wcale. Ten wpis jest o tym, dlaczego podejście pojedyncze naprawdę się wywalało i jak wygląda działające sprzątanie w języku z ręcznym zarządzaniem pamięcią
Dlaczego nie można po prostu zwolnić każdego zarejestrowanego obiektu?
Bo destruktory klas obiektów nie zgadzają się co do tego, kto jest czego właścicielem, a rejestr zawiera wpisy z kilku poziomów tego samego łańcucha własności. Przejście po liście i wywołanie Free na każdym wpisie zwalnia więc część pamięci dwa razy, a części nigdy, w zależności od tego, które klasy akurat leżą obok siebie
Problem tworzą trzy asymetrie w HPDFObjs.pas i HPDFDoc.pas. THPDFDictionaryObject.Destroy przechodzi swoje Items i zwalnia wartość tylko wtedy, gdy IsIndirect jest False, zakładając, że pośrednie dzieci należą do rejestru i tam zostaną zwolnione. THPDFArrayObject.Destroy nie robi takiego rozróżnienia i zwalnia każdy element, który trzyma. A THPDFIndirectObject.Destroy, wrapper noszący numer obiektu, zwalnia swoje ciało InternalObject. Wyobraź sobie teraz rejestr, który trzyma pośredni słownik, tablicę wymieniającą ten sam słownik w jednym ze swoich slotów oraz wrapper, którego ciało jest również zarejestrowane jako osobny korzeń, czyli dokładnie to, co parser produkuje na prawdziwych plikach. Zwolnij najpierw tablicę, a słownik zniknie, zanim rejestr do niego dotrze. Zwolnij wrapper i ciało, w dowolnej kolejności, a drugie wywołanie uruchomi destruktor na wiszącym wskaźniku. Zwolnij sam słownik, a każde pośrednie dziecko, które pominął, zostanie zaalokowane na zawsze. Żadna kolejność rejestru tego nie naprawi, bo rejestr jest płaską listą, a relacja własności jest grafem, i jedynym wyjściem jest rozumowanie o grafie
Co liczy się jako krawędź własności w grafie obiektów PDF?
Krawędź własności to wskaźnik, którego cel źródło jest zobowiązane zniszczyć; referencją jest wszystko inne, a sprzątanie musi iść za pierwszym rodzajem i ignorować drugi. W HotPDF daje to dokładnie cztery rodzaje krawędzi: Items obiektu THPDFDictionaryObject, Items obiektu THPDFArrayObject, InternalObject za THPDFIndirectObject oraz obie połowy THPDFStreamObject, czyli jego Dictionary i ładunek Stream. Rodzaje referencyjne znaczą tyle samo, bo pójście za którąś z nich zamienia przejście po grafie w nieskończoną pętlę albo use-after-free. THPDFLink trzyma numer obiektu i generację, a tak właśnie ISO 32000-1 §7.3.10 definiuje odwołanie pośrednie: nazwę obiektu, który żyje gdzie indziej, a nie sam obiekt. Rozwiązanie tego numeru przez rejestr daje węzeł, który już należy do jakiejś innej krawędzi, więc CloseIndirectObjects nigdy nie dereferencjonuje linków. Wskaźnik wsteczny FParent, który trzymają słowniki i tablice, to ta sama historia w drugą stronę; rodzic już jest właścicielem dziecka, więc pójście wskaźnikiem w górę tylko wróciłoby do węzła, przez który przejście już przeszło. Oba są zostawiane w spokoju, a komentarz w źródle mówi to w jednej linii: linki i wskaźniki rodziców to referencje, nie krawędzie własności
Jak działa trójfazowe sprzątanie?
Faza pierwsza to zbieranie wszerz. Procedura zasila listę roboczą każdym wpisem IndirectObjects, a potem dla każdego węzła 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, hashowanych przez HPDFFastCacheHashInt64 po wartości wskaźnika, z próbkowaniem liniowym i podwajającym się GrowSeen, gdy zapełni się do połowy. Nic w tej strukturze nie alokuje na węzeł, co ma znaczenie, gdy dokument niesie kilkaset tysięcy obiektów. Ładunki strumieni trafiają do osobnej listy Streams, bo są potomkami TStream, a nie węzłami THPDFObject, i są zwalniane w swoim własnym przebiegu
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
Slot := PointerSlot(Pointer(Value), Length(Seen));
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;
// Faza pierwsza: zasil rejestrem, potem idź 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);
Inc(I);
end;
Faza druga to ta część, która czyni destruktory bezpiecznymi w uruchomieniu: każda krawędź własności jest ustawiana na nil, zanim wykona się jakikolwiek destruktor. Wrapper dostaje MarkAsFreed, które czyści FInternalObject i ustawia flagę sprawdzaną przez jego destruktor w pierwszej kolejności. Obiekt strumienia ma Dictionary i Stream przypisane do nil. Każdy element słownika ma wyczyszczone Item^.Value, a każdy slot tablicy jest nadpisywany nil-em. Po tym przebiegu graf nie ma już żadnych krawędzi, więc gdy faza trzecia woła Free na każdym węźle w Nodes, a potem na każdym ładunku w Streams, każdy destruktor nie znajduje nic do rekurencji i niszczy tylko siebie
// Faza druga: 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;
// Faza trzecia: 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);
Spójrz, co daje ten podział. Słownik współdzielony przez dwa obiekty strumieni jest zbierany raz, odłączany od obu i zwalniany raz. Cykl, w którym tablica wymienia swój własny słownik nadrzędny, kończy się, bo zbiór odwiedzonych odmawia drugiej wizyty. Wrapper i jego ciało, zarejestrowane oba jako korzenie, to dwa różne wskaźniki w zbiorze, więc oba są zwalniane, a destruktor wrappera nie próbuje już zwalniać ciała, bo MarkAsFreed odebrało mu tę krawędź. Pojedynczy TMemoryStream przypisany jako ładunek dwóch obiektów strumieni siedzi w Streams dokładnie raz. Żaden z tych przypadków nie wymaga specjalnej obsługi i to jest znak, że model jest właściwy
Jak odróżnić wyciek od zatrzymania pamięci przez alokator?
Sprawdzając, czy liczba żywych alokacji menedżera pamięci zmienia się razem z obciążeniem, a nie tylko jego zarezerwowany obszar. Menedżer pamięci Delphi trzyma zwolnione duże bloki na potem, więc proces, który po zamknięciu dokumentu stoi na 400 MiB, niekoniecznie ma wyciek; ma go proces, w którym liczba żywych bloków rośnie o jeden na stronę na przebieg. Sonda, która napędziła tę poprawkę, była celowo mała: jeden writer THotPDF produkujący jedną stronę, a potem trzech czytelników wczytujących ją. Po zwolnieniu wszystkich czterech raport sterty pokazał dokładnie cztery żywe alokacje po 512 KiB, jedną na instancję, czyli ładunek strumienia treści, który każda z nich posiadała i nigdy nie zwolniła. Zwiększenie skali uczyniło ten sam wzorzec jednoznacznym. Dwukrotne uruchomienie równoległego potoku renderowania przesunęło liczbę zaalokowanych dużych bloków z 384 MiB na 640 MiB, wzrost proporcjonalny do liczby stron, którego zatrzymanie pamięci przez alokator nie wyjaśnia. Po przepisaniu diagnostyka na jednej stronie raportowała zero zaalokowanych dużych bajtów i zero zarezerwowanych, gdy instancje zniknęły. Jeśli tropisz ten sam rodzaj wzrostu we własnym procesie, graf zależności obiektów z zatrzymanymi bajtami podpowie ci, które obiekty trzymają pamięć, dopóki dokument jest otwarty; ten wpis jest o ich zachowaniu przy zwalnianiu, gdy dokument się zamyka
Progi pamięciowe czynią testy regresyjne kruchymi, więc dostarczane testy liczą wywołania destruktorów. Fixture buduje patologiczny graf ręcznie: współdzielony słownik pod dwoma strumieniami, tablica zawierająca i ten współdzielony słownik, i swój własny korzeń, jeden ładunek przypisany do obu strumieni, korzeń zarejestrowany dwa razy oraz wrapper, którego ciało jest zarejestrowane osobno; potem zwalnia dokument i wymaga jednego zniszczenia na unikalny obiekt: jeden ładunek, dwa strumienie, dwa słowniki, jedna tablica, jeden wrapper, jedna liczba. W starym kodzie wszystkie trzy testy czasu życia raportowały zero zniszczeń, co jest najdobitniejszym możliwym stwierdzeniem tego, co znaczy zostaw to wyjściu procesu
Co musi się stać, zanim graf zejdzie na dół?
Każda praca w tle, która pożycza obiekty z grafu, musi najpierw się zatrzymać, a każdy cache trzymający listy wyświetlania albo bitmapy skompilowane z tych obiektów musi zostać odrzucony, inaczej wątek roboczy albo zbuforowane odwołanie czyta zwolnioną pamięć. CloseIndirectObjects zaczyna więc od CancelLoadedPagePrefetch, a potem unieważnia cache wyrenderowanych stron, zanim dotknie rejestru. Ścieżka ponownego wczytania w LoadFromFile i LoadFromStream oraz destruktor komponentu przechodzą przez nią obie, więc ta sama kolejność obowiązuje niezależnie od tego, czy wymieniasz dokument, czy pozbywasz się instancji; zasady ponownego używania jednego THotPDF dla wielu dokumentów opierają się na tej gwarancji. Dwa szczegóły w tej preambule wyszły dopiero przy uruchamianiu testów. Po pierwsze, destruktor zdążył już pozbyć się szkiców częstości stojących za cache'ami renderowania i list wyświetlania, zanim zamyka graf, więc unieważnienie jest strzeżone warunkiem, że te pola nie są nil, a nie wołane bezwarunkowo. Po drugie, InvalidateRenderedPageCache to procedura, która wywołuje OnLoadedDocumentModified z indeksem strony -1, a wywołujący, który wczytuje plik od nowa, nie powinien dostawać powiadomienia o edycji za wewnętrzne sprzątanie starego dokumentu. Handler jest zapisywany, ustawiany na nil na czas wywołania i przywracany w bloku finally, a test regresyjny ponownego wczytania wymaga zerowej liczby powiadomień po drugim LoadFromStream. Poprawka pamięciowa, która po cichu zmienia kontrakt zdarzeń, jest regresją z lepszym PR-em, więc dostaje własną asercję. Jeśli uruchamiasz równoległy potok renderowania na dokumencie, a potem wczytujesz go od nowa, to właśnie ten krok anulowania nie pozwala puli wątków roboczych ścigać się ze sprzątaniem
Ponowne użycie tego wzorca we własnym kodzie Delphi
Ta technika nie jest specyficzna dla PDF. Każdy model obiektowy w Delphi, w którym destruktory posiadają dzieci niekonsekwentnie, w którym to samo dziecko da się osiągnąć z kilku rodziców albo w którym współistnieją wskaźniki wsteczne i w przód, będzie się wywalał albo przeciekał przy naiwnym Free obiekt po obiekcie. Poprawka ma zawsze ten sam kształt: ustal, które pola wskaźnikowe są własnością, a które referencjami, zbierz domknięcie krawędzi własności przez zbiór wskaźników tolerujący powtórne wizyty, przetnij każdą krawędź, a potem zniszcz płaską listę. Krok cięcia jest tym, który ludzie pomijają, i tym, który czyni istniejące destruktory bezpiecznymi do ponownego użycia, zamiast zmuszać do przepisania każdej klasy w modelu. Granice warto jednak wypowiedzieć wprost. Zbiór wskaźników używa adresu obiektu jako tożsamości, więc obiekt już zwolniony, którego adres został ponownie użyty przez świeżą alokację, byłby nierozróżnialny; kolejność gwarantuje, że w czasie zbierania nie biegnie żaden destruktor, i to właśnie to wyklucza. Przejście widzi tylko te cztery rodzaje krawędzi, które zna, więc nowa klasa posiadająca dziecko przez pole, którego przejście nie bada, będzie to dziecko przeciekać, dopóki przejście nie zostanie o tym pouczone. A ponieważ linki są rozwiązywane przez rejestr, a nie odwiedzane, obiekt wskazywany wyłącznie przez link i nigdy niezarejestrowany nie jest dla tego sprzątania osiągalny w ogóle; w HotPDF parser gwarantuje rejestrację, ale graf budowany ręcznie musi respektować tę samą regułę
Wszystko to dzieje się wewnątrz komponentu, więc widoczny efekt dla aplikacji jest po prostu taki, że zamknięcie albo ponowne wczytanie dokumentu zwraca jego pamięć, bez żadnej zmiany w API. HotPDF to natywna biblioteka VCL do PDF dla Delphi i C++Buildera z pełnym kodem źródłowym; dokumentacja API i wersja próbna są na stronie komponentu HotPDF Delphi PDF