Объектная модель Delphi, отражающая разобранный PDF-файл, обычно заканчивается одним плоским списком всех объектов плюс графом указателей, свисающим с него. Пройдите по списку с вызовом Free на каждой записи — и прогон умрёт на полпути с access violation внутри деструктора, на объекте, который более ранний Free уже освободил через другого родителя. Замените цикл на List.Clear — краш уйдёт, но процесс теперь растёт примерно на один документ за каждую перезагрузку. У обоих симптомов одна причина: список плоский, а владение — граф. Разбор, который работает, решает, какие указатели владеют своими целями, собирает это замыкание один раз, разрезает каждое владеющее ребро и лишь затем освобождает каждый узел — ровно один раз
Почему нельзя просто вызвать Free для каждого зарегистрированного объекта?
В HotPDF плоский список — это реестр объектов документа, и классы в нём расходятся во мнениях о том, кто что уничтожает. Каждый деструктор разумен на своих условиях. Соберите их вместе над реестром, содержащим записи с нескольких уровней одной цепочки владения, — и Free по каждой записи освободит часть памяти дважды, а часть — никогда
Три деструктора в HPDFObjs.pas и HPDFDoc.pas расставляют эту сцену. THPDFDictionaryObject.Destroy обходит свои Items и освобождает значение, только когда IsIndirect равен False, исходя из того, что косвенные дети принадлежат реестру. THPDFArrayObject.Destroy освобождает каждый свой элемент, косвенный или нет. THPDFIndirectObject — обёртка для объектов, вытащенных из object stream (ISO 32000-1 §7.5.7), — освобождает своё тело InternalObject. Теперь представьте реестр, держащий косвенный словарь, массив, перечисляющий тот же словарь в одном из слотов, и обёртку, чьё тело тоже зарегистрировано отдельным корнем. Освободите сначала массив — и словарь исчезнет прежде, чем реестр до него дойдёт. Освободите обёртку, затем тело — и второй вызов запустит деструктор по памяти, которая уже никому не принадлежит. Освободите один словарь — и каждый пропущенный им косвенный ребёнок останется выделенным до выхода процесса
Перепорядочивание реестра не спасает. Какой бы порядок вы ни выбрали, какая-то пара записей окажется по разные стороны владеющего ребра, потому что список не может сказать, что запись 12 владеет записью 40
Владеющие рёбра и ссылки в графе объектов PDF
Владеющее ребро — это указатель, чью цель источник обязан уничтожить. Всё остальное — ссылка, и разбор идёт только по первому виду. В HotPDF ровно четыре вида владеющих рёбер: Items у THPDFDictionaryObject, Items у THPDFArrayObject, InternalObject за THPDFIndirectObject и оба поля THPDFStreamObject — его Dictionary и нагрузка Stream
Ссылки заслуживают того же внимания, потому что переход по одной превращает обход графа в бесконечный цикл или чтение освобождённой памяти. THPDFLink хранит номер объекта и поколение — это в точности косвенная ссылка из ISO 32000-1 §7.3.10: имя объекта, хранящегося где-то ещё в файле. Разрешение этого номера через реестр приземляет на узел, которым уже владеет другое ребро, поэтому разбор вообще не разыменовывает ссылки. Обратный указатель FParent, который держат словари и массивы, направлен в противоположную сторону. Родитель уже владеет ребёнком, и подъём по этому указателю лишь вернул бы обход к уже пройденному узлу. Оба вида остаются нетронутыми весь разбор
Собираем, отсоединяем, освобождаем: разбор в три прохода
HotPDF освобождает объекты документа в три прохода внутри защищённого метода THotPDF.CloseIndirectObjects, и порядок проходов — весь трюк. Проход один собирает. Рабочий список стартует со всех записей реестра, и для каждого узла обход дописывает цели его владеющих рёбер, пропуская уже виденное. Множество виденного — открытая адресация по сырым указателям с линейным пробированием, удваивающаяся при заполнении наполовину, поэтому на узел не выделяется ничего, что начинает значить, когда документ несёт сотни тысяч объектов. Нагрузки потоков уходят во второй список, потому что это наследники TStream, а не узлы THPDFObject
Два листинга ниже — упрощённые фрагменты внутренней реализации компонента, показанные так, чтобы проходы были видны в собственных объектных типах библиотеки. Прикладной код не вызывает ничего из этого; LoadFromFile, LoadFromStream и деструктор гоняют это за вас. Во фрагменте хеш адреса сведён к сдвигу и маске, а шаг, удваивающий таблицу множества виденного при заполнении, опущен
// Упрощённый фрагмент внутреннего разбора, это не вызываемый API
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (настоящая процедура удваивает и перехеширует таблицу при заполнении наполовину)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
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);
// значения THPDFLink и указатели FParent никогда не преследуются
Inc(I);
end;
Проход два режет. Каждое владеющее ребро выставляется в nil до запуска хоть одного деструктора. Обёртка получает MarkAsFreed, который очищает InternalObject и выставляет флаг IsFreed, проверяемый её деструктором первым делом. У объекта потока Dictionary и Stream получают nil, у каждого элемента словаря очищается Value, а каждый слот массива перезаписывается nil. Проход три очищает реестр, освобождает каждый узел в Nodes, затем каждую нагрузку в Streams
// Упрощённый фрагмент внутреннего разбора, продолжение; это не вызываемый API
// Проход два: отсоединяем каждое владеющее ребро прежде чего-либо освобождать
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 ровно один раз
Что должно произойти до того, как граф разберут?
Всё, что одалживает объекты из графа, обязано остановиться первым. HotPDF умеет рендерить страницы в рабочем потоке через PrefetchLoadedPages и кеширует битмапы и списки отображения, собранные из объектов документа; рабочий поток или запись кеша, пережившие граф, читали бы освобождённую память. Поэтому разбор открывается CancelLoadedPagePrefetch и инвалидирует кеш отрисованных страниц прежде чем тронуть реестр. LoadFromFile, LoadFromStream и деструктор THotPDF все идут через неё, поэтому порядок идентичен, меняете ли вы документ или освобождаете компонент, — и на этом держатся правила повторного использования одного THotPDF для нескольких документов
Контракт уведомлений важен не меньше. InvalidateRenderedPageCache — тот метод, что возбуждает OnLoadedDocumentModified с PageIndex = -1, и вьюер, перезагружающий файл, не должен получать уведомление о правке за внутреннюю уборку старого документа. Внутри разбора обработчик сохраняется, выставляется в nil вокруг того вызова и восстанавливается в блоке finally. Перезагрузка всё равно даёт один вызов PageIndex = -1, поднимаемый самой загрузкой при сбросе кеша для нового документа, — и именно на него вьюеру стоит перерисоваться. В деструкторе кеши уже расправлены к моменту закрытия графа, поэтому разбор пропускает инвалидацию там целиком
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 // ваш собственный метод вьюера
else
RepaintPage(PageIndex); // ваш собственный метод вьюера
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile отменяет идущий prefetch и освобождает объекты
// предыдущего документа перед разбором; PdfModified видит один вызов -1
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
Явный вызов CancelLoadedPagePrefetch в OpenDocument не нужен, потому что загрузка делает это сама. Позвать самому всё ещё правильно, когда вы собираетесь править загруженный документ, пока prefetch может быть в полёте; комментарий к объявлению в HPDFDoc.pas просит ровно этого. Рендеринг в пуле рабочих потоков в большем масштабе разобран в статье о параллельном конвейере отрисовки — как троттлится фоновая работа
Как отличить утечку от удержания памяти менеджером?
Следите за счётчиком живых выделений менеджера памяти на повторных загрузках и игнорируйте working set процесса. Менеджер памяти Delphi придерживает освобождённую память в пуле для повторного использования, поэтому процесс, застывший на высоком следе после закрытия документа, не обязательно что-то утечь. А вот процесс, чей счётчик живых крупных блоков растёт на каждой перезагрузке, — утечь. Цикл ниже грузит один и тот же файл двадцать раз на одном экземпляре и печатает цифры крупных блоков после каждого прохода
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.
Читайте вывод как форму. Числа, устоявшиеся после первого прохода, — удержание или обычное состояние на документ. Числа, растущие на один и тот же шаг за проход, — утечка, и для PDF шаг обычно равен размеру крупных нагрузок потоков, содержимого страниц и встроенных изображений, поскольку это выделения, достаточно большие, чтобы попасть в цифры крупных блоков. Возьмите файл с дюжиной и более страниц, чтобы шаг было легко разглядеть в шуме. Рост, пропорциональный числу страниц, удержание менеджера памяти объяснить не может. Чтобы найти, какие объекты держат память при открытом документе, граф зависимостей объектов с удерживаемыми байтами отвечает на ту сторону вопроса
Повторное использование приёма в собственной объектной модели на Delphi
Здесь нет ничего специфичного для PDF. Любая объектная модель Delphi, где деструкторы владеют детьми непоследовательно, где один ребёнок достижим от нескольких родителей или где обратные указатели сидят рядом с прямыми, будет падать или течь под Free по объектам. Исправление каждый раз одной формы: классифицируйте каждое поле-указатель как владеющее или ссылочное, соберите замыкание владеющих рёбер через множество указателей, терпимое к повторным посещениям, разрежьте каждое ребро, уничтожьте плоский список. Шаг разрезания — тот, что пропускают. Пропустите его — и каждому деструктору придётся знать про каждый другой
С приёмом идут границы, которые стоит записать до копирования. Множество указателей использует адрес объекта как идентичность, и это надёжно только потому, что во время сбора не выполняется ни один деструктор, поэтому адрес не может освободиться и уйти свежему выделению посреди обхода. Обход видит только те виды рёбер, для которых написан; новый класс, владеющий ребёнком через какое-то другое поле, обязан быть добавлен и в проход сбора, и в проход разрезания. И поскольку ссылки разрешаются, а не преследуются, объект, на который ссылается только ссылка, обязан лежать в реестре, чтобы быть достижимым. Парсер HotPDF регистрирует каждый прочитанный объект, и граф, собранный вручную в вашем коде, обязан держать то же правило
Тестируйте подсчётом вызовов деструкторов вместо байтов. Соберите поганый граф руками: словарь, общий для двух потоков, массив, держащий и тот словарь, и собственного родителя, одну нагрузку, назначенную обоим потокам, корень, зарегистрированный дважды, и обёртку, чьё числовое тело тоже зарегистрировано. Разберите его и ожидайте ровно одно уничтожение на уникальный объект — одна нагрузка, два потока, два словаря, один массив, одна обёртка, одно число. В собственных классах для этого хватает счётчика на классе, наращиваемого в каждом деструкторе (инструментирующий хук, который вы добавляете сами), а разбор, только очищающий список, отчитает ноль
В HotPDF всё это сидит за LoadFromFile, LoadFromStream и деструктором, поэтому прикладной код видит только результат: закрытие или перезагрузка документа возвращает его память. HotPDF — нативная VCL-библиотека PDF для Delphi и C++Builder, поставляемая с полными исходниками, так что описанный здесь разбор можно читать построчно в HPDFDoc.pas; справочник API и пробная сборка — на странице HotPDF PDF component for Delphi