HotPDF Delphi Component освобождает каждый объект PDF, которым владеет документ, когда тот закрывается или перезагружается: THotPDF.CloseIndirectObjects обходит реестр объектов, собирает каждое владеющее ребро в набор указателей, отсоединяет все эти рёбра и только после этого освобождает каждый уникальный узел и каждую нагрузку потока ровно один раз. Именно этот трёхфазный порядок позволяет общим дочерним объектам, циклам владения, дублирующим регистрациям и псевдонимам «обёртка/тело» разойтись без двойного освобождения и без утечек. До v2.752.4 та же процедура делала куда более простую и куда более плохую вещь: освобождала ленивые источники файловых потоков, вызывала Clear у списка IndirectObjects, освобождала контейнер списка и оставляла все настоящие объекты PDF процессу, завершающему работу. Комментарий в том коде честно это и признавал: освобождение объектов по отдельности приводило к нарушениям доступа, так что «безопасный подход» состоял в том, чтобы не освобождать их вовсе. Этот материал о том, почему освобождение по отдельности действительно падало и как выглядит работающий разбор на языке с ручным управлением памятью
Почему нельзя просто вызвать Free для каждого зарегистрированного объекта?
Потому что деструкторы классов объектов расходятся во мнениях о том, кто чем владеет, а в реестре лежат записи с нескольких уровней одной и той же цепочки владения. Поэтому проход по списку с вызовом Free для каждой записи освобождает часть памяти дважды, а часть — никогда, в зависимости от того, какие классы оказались рядом друг с другом
Проблему создают три асимметрии в HPDFObjs.pas и HPDFDoc.pas. THPDFDictionaryObject.Destroy обходит свои Items и освобождает значение только когда IsIndirect равно False, исходя из того, что косвенные дочерние объекты принадлежат реестру и будут освобождены там. THPDFArrayObject.Destroy такого различия не делает и освобождает каждый свой элемент. А THPDFIndirectObject.Destroy — обёртка, несущая номер объекта, — освобождает своё тело InternalObject. Теперь представьте реестр, в котором лежат косвенный словарь, массив, перечисляющий этот же словарь в одном из своих слотов, и обёртка, тело которой зарегистрировано отдельным корнем, — а именно это и порождает парсер на реальных файлах. Освободите массив первым, и словарь исчезнет раньше, чем до него дойдёт реестр. Освободите обёртку и тело в любом порядке, и второй вызов запустит деструктор по висячему указателю. Освободите только словарь, и любой пропущенный им косвенный дочерний объект останется выделенным навсегда. Никакой порядок обхода реестра это не лечит, потому что реестр — плоский список, а отношение владения — граф, и единственный выход — рассуждать именно о графе
Что считается владеющим ребром в графе объектов PDF?
Владеющее ребро — это указатель, цель которого источник обязан уничтожить; ссылка — всё остальное, и разбор обязан идти по первым и игнорировать вторые. В HotPDF это даёт ровно четыре вида рёбер: Items у THPDFDictionaryObject, Items у THPDFArrayObject, InternalObject за THPDFIndirectObject и обе половины THPDFStreamObject — его Dictionary и нагрузка Stream. Виды ссылок важны не меньше, потому что переход по одной из них превращает обход графа в бесконечный цикл или в обращение к освобождённой памяти. THPDFLink хранит номер объекта и поколение — так ISO 32000-1 §7.3.10 и определяет косвенную ссылку: имя объекта, который живёт в другом месте, а не сам объект. Разрешение этого номера через реестр даёт узел, которым уже владеет какое-то другое ребро, так что CloseIndirectObjects вообще не разыменовывает ссылки. Обратный указатель FParent, который держат словари и массивы, — та же история с другой стороны: родитель уже владеет дочерним объектом, так что переход по указателю вверх лишь вернул бы обход к узлу, который он уже прошёл. И то, и другое остаётся нетронутым, и комментарий в исходнике говорит это одной строкой: ссылки и указатели на родителя — это ссылки, а не рёбра владения
Как работает трёхфазный разбор?
Фаза первая — обход в ширину. Процедура кладёт в рабочий список каждую запись IndirectObjects, затем для каждого узла добавляет цели его владеющих рёбер, пропуская всё уже виденное. Множество виденного — это массив с открытой адресацией из сырых указателей, хешируемых функцией HPDFFastCacheHashInt64 по значению указателя, с линейным пробированием и удвоением GrowSeen при заполнении наполовину. Ничто в этой структуре не выделяет память на узел, что важно, когда документ несёт несколько сотен тысяч объектов. Нагрузки потоков уходят в отдельный список Streams, потому что это наследники TStream, а не узлы THPDFObject, и освобождаются они своим проходом
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; // уже собран
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;
// Фаза один: стартуем с реестра, дальше идём только по владеющим рёбрам
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;
Фаза вторая — это то, что делает деструкторы безопасными для запуска: каждое владеющее ребро выставляется в nil до выполнения любого деструктора. Обёртка получает MarkAsFreed, который очищает FInternalObject и выставляет флаг, проверяемый её деструктором первым делом. У объекта потока Dictionary и Stream получают nil. У каждого элемента словаря обнуляется Item^.Value, а каждый слот массива перезаписывается nil. После этого прохода в графе не остаётся рёбер, так что когда фаза третья вызывает Free для каждого узла в Nodes, а затем для каждой нагрузки в Streams, каждому деструктору не во что рекурсировать и он уничтожает только себя
// Фаза два: отсоединяем все владеющие рёбра до любого освобождения
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;
// Фаза три: каждый уникальный узел и нагрузка освобождаются ровно раз
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);
Посмотрите, что даёт это разделение. Словарь, общий для двух объектов потока, собирается один раз, отсоединяется от обоих и освобождается один раз. Цикл, в котором массив перечисляет собственный родительский словарь, завершается, потому что множество виденного отказывает во втором посещении. Обёртка и её тело, зарегистрированные как корни, — это два разных указателя в множестве, поэтому оба освобождаются, а деструктор обёртки больше не пытается освободить тело, потому что MarkAsFreed уже забрал это ребро. Единственный TMemoryStream, назначенный нагрузкой двух объектов потока, лежит в Streams ровно один раз. Ни один из этих случаев не требует особой обработки — это и есть признак того, что модель верна
Как отличить утечку от удержания памяти менеджером?
По тому, движется ли с нагрузкой счётчик живых выделений менеджера памяти, а не только его зарезервированный объём. Менеджер памяти Delphi придерживает освобождённые крупные блоки для повторного использования, поэтому процесс, оставшийся на 400 МиБ после закрытия документа, не обязательно что-то утерял; а вот процесс, у которого счётчик живых блоков растёт на единицу на страницу за прогон, — утерял. Проба, которая вывела на это исправление, была намеренно маленькой: один писатель THotPDF, создающий одну страницу, и затем три читателя, загружающие её. После освобождения всех четырёх отчёт по куче показал ровно четыре живых выделения по 512 КиБ — по одному на экземпляр: это нагрузка потока содержимого, которой каждый из них владел и которую так и не освободил. Увеличение масштаба сделало ту же картину безошибочной. Двойной прогон параллельного конвейера отрисовки сдвинул показатель выделенных крупных блоков с 384 МиБ на 640 МиБ — рост, пропорциональный числу страниц, которого удержание памяти менеджером не объясняет. После переписывания одностраничная диагностика сообщала ноль выделенных крупных байт и ноль зарезервированных, как только экземпляры исчезали. Если вы охотитесь на такой же рост в своём процессе, граф зависимостей объектов с удерживаемыми байтами подскажет, какие объекты держат память, пока документ открыт; этот материал о том, как они освобождаются при закрытии
Пороги по памяти делают регрессионные тесты хрупкими, поэтому поставляемые тесты считают вызовы деструкторов. Фикстура собирает патологический граф вручную: общий словарь под двумя потоками, массив, содержащий и общий словарь, и собственный корень, одна нагрузка, назначенная обоим потокам, корень, зарегистрированный дважды, и обёртка, тело которой зарегистрировано отдельно, — затем освобождает документ и утверждает по одному уничтожению на уникальный объект: одна нагрузка, два потока, два словаря, один массив, одна обёртка, одно число. На старом коде все три теста времени жизни сообщали ноль уничтожений — самое прямое из возможных утверждений о том, что значит «оставить до завершения процесса»
Что должно произойти до того, как граф разберут?
Любая фоновая работа, занимающая объекты из графа, должна сначала остановиться, а любой кеш, держащий списки отображения или растровые карты, собранные из этих объектов, должен быть сброшен, иначе рабочий поток или закешированная ссылка прочитает освобождённую память. Поэтому CloseIndirectObjects открывается вызовом CancelLoadedPagePrefetch, а затем сбрасывает кеш отрисованных страниц, прежде чем тронуть реестр. Путь перезагрузки в LoadFromFile и LoadFromStream и деструктор компонента проходят через неё, так что один и тот же порядок действует и когда вы заменяете документ, и когда избавляетесь от экземпляра; правила повторного использования одного THotPDF для нескольких документов опираются на эту гарантию. Две детали в этом вступлении вылезли только при прогоне тестов. Во-первых, деструктор к моменту закрытия графа уже расправился с частотными эскизами за кешами отрисовки и списков отображения, поэтому сброс защищён проверкой этих полей на не-nil, а не вызывается безусловно. Во-вторых, InvalidateRenderedPageCache — это та процедура, которая возбуждает OnLoadedDocumentModified с индексом страницы -1, а вызывающий, перезагружающий файл, не должен получать уведомление о правке из-за внутреннего разбора старого документа. Обработчик сохраняется, вокруг вызова выставляется в nil и восстанавливается в finally, а регрессионный тест перезагрузки утверждает нулевое число уведомлений после второго LoadFromStream. Исправление памяти, которое тихо меняет контракт событий, — это регрессия с более удачным пиаром, поэтому на неё есть отдельная проверка. Если вы прогоняете параллельный конвейер отрисовки по документу, а затем перезагружаете его, именно шаг отмены не даёт пулу рабочих потоков гоняться с разбором
Повторное использование приёма в вашем коде на Delphi
Этот приём не специфичен для PDF. Любая объектная модель на Delphi, где деструкторы владеют дочерними объектами непоследовательно, где один и тот же дочерний объект достижим из нескольких родителей или где сосуществуют обратные и прямые указатели, будет падать или течь при наивном Free по объектам. Исправление всегда одной формы: решить, какие поля-указатели владеющие, а какие ссылочные, собрать замыкание владеющих рёбер через множество указателей, терпимое к повторным посещениям, разрезать каждое ребро, а затем уничтожить плоский список. Пропускают обычно именно шаг разрезания, а он и делает существующие деструкторы безопасными для повторного использования, вместо того чтобы заставлять переписывать каждый класс модели. Впрочем, границы стоит назвать прямо. Множество указателей использует адрес объекта как идентичность, поэтому объект, который уже освободили и чей адрес переиспользовало свежее выделение, был бы неотличим; порядок гарантирует, что во время сбора не выполняется ни один деструктор, и именно это исключает такой случай. Обход видит только четыре известных ему вида рёбер, поэтому новый класс, владеющий дочерним объектом через поле, которое обход не проверяет, будет течь этим объектом, пока обход не научат про него. А поскольку ссылки разрешаются через реестр, а не преследуются, объект, на который ссылается только ссылка и который никогда не регистрировался, этому разбору вообще недостижим; в HotPDF парсер гарантирует регистрацию, но граф, собранный вручную, обязан уважать то же правило
Всё это находится внутри компонента, так что видимый эффект для приложения прост: закрытие или перезагрузка документа возвращает его память, и никаких изменений в API. HotPDF — нативная VCL-библиотека PDF для Delphi и C++Builder с полным исходным кодом; справочник по API и пробную сборку можно найти на странице компонента HotPDF для Delphi