Технічна стаття

Звільнення графа об'єктів PDF рівно раз у Delphi: HotPDF

HotPDF Delphi Component звільняє кожен об'єкт PDF, яким володіє документ, коли той документ закривається або перезавантажується: THotPDF.CloseIndirectObjects проходить реєстр об'єктів, збирає кожне ребро володіння в множину вказівників, від'єднує всі ці ребра і лише потім звільняє кожен унікальний вузол і кожне навантаження потоку рівно один раз. Саме цей трифазний порядок дозволяє спільним нащадкам, циклам володіння, повторним реєстраціям і аліасам обгортка/тіло зійти вниз без подвійного звільнення і без того, щоб щось лишилося. До v2.752.4 та сама рутина робила дещо набагато простіше й набагато гірше: вона звільняла джерела лінивого файлового потоку, викликала Clear для списку IndirectObjects, звільняла контейнер списку й лишала кожен справжній об'єкт PDF на початку процесу, щоб той його забрав. Коментар у тому коді був чесний щодо цього. Звільнення об'єктів поштучно спричиняло access violation, тож «безпечний підхід» полягав у тому, щоб не звільняти їх узагалі. Ця стаття про те, чому поштучний підхід справді падав і як виглядає робоче знесення мовою з ручним керуванням пам'яттю

Чому не можна просто викликати Free для кожного зареєстрованого об'єкта?

Бо деструктори класів об'єктів не згодні між собою щодо того, хто чим володіє, а реєстр містить записи на кількох рівнях того самого ланцюга володіння. Тож прохід списком із викликом Free для кожного запису звільняє частину пам'яті двічі, а частину — ніколи, залежно від того, які класи випадково стоять поруч

Проблему творять три асиметрії в HPDFObjs.pas і HPDFDoc.pas. THPDFDictionaryObject.Destroy проходить свої Items і звільняє значення лише тоді, коли IsIndirect дорівнює False, виходячи з припущення, що непрямі нащадки належать реєстру й будуть звільнені там. THPDFArrayObject.Destroy такого розрізнення не робить і звільняє кожен елемент, який тримає. А THPDFIndirectObject.Destroy, обгортка, що несе номер об'єкта, звільняє своє тіло InternalObject. Тепер уявіть реєстр, який тримає непрямий словник, масив, що перелічує той самий словник в одному зі своїх слотів, і обгортку, чиє тіло теж зареєстроване як окремий корінь, — а саме це й виробляє парсер на реальних файлах. Звільніть першим масив — і словник зникає раніше, ніж реєстр до нього дійде. Звільніть обгортку й тіло в будь-якому порядку — і другий виклик виконає деструктор за висячим вказівником. Звільніть сам словник — і будь-який непрямий нащадок, який він пропустив, лишиться виділеним назавжди. Жоден порядок обходу реєстру це не виправляє, бо реєстр — це плаский список, а відношення володіння — це граф, і єдиний вихід — міркувати про граф

Чому звільнення кожного запису реєстру HotPDF падало: THPDFDictionaryObject.Destroy пропускає непрямих нащадків, тоді як THPDFArrayObject.Destroy звільняє все, що тримає, а THPDFIndirectObject.Destroy звільняє своє тіло InternalObject, тож коли в одному пласкому списку IndirectObjects лежать обгортка, масив і спільний словник, частина пам'яті вмирає двічі, а частина — ніколи
Деструктори не згодні щодо того, хто чим володіє, а реєстр тримає записи на кількох рівнях того самого ланцюга володіння, тож жоден порядок плаского списку не перетворить наївний поштучний Free на коректне знесення

Що вважається ребром володіння в графі об'єктів PDF?

Ребро володіння — це вказівник, за чию ціль відповідає джерело; посилання — це все інше, і знесення має йти першим різновидом і ігнорувати другий. У HotPDF це дає рівно чотири різновиди ребер: Items у THPDFDictionaryObject, Items у THPDFArrayObject, InternalObject за THPDFIndirectObject та обидві половини THPDFStreamObject — його Dictionary і його навантаження Stream. Різновиди посилань важать не менше, бо піти за одним із них означає перетворити обхід графа на нескінченний цикл або на використання після звільнення. THPDFLink тримає номер об'єкта й покоління, а це і є визначення непрямого посилання з ISO 32000-1 §7.3.10: ім'я для об'єкта, що живе деінде, а не сам об'єкт. Розв'язання цього номера через реєстр дає вузол, яким уже володіє якесь інше ребро, тож CloseIndirectObjects взагалі не розіменовує посилання. Зворотний вказівник FParent, який тримають словники й масиви, — це та сама історія з іншого боку; батько вже володіє нащадком, тож рух угору лише повернув би обхід до вузла, який він уже пройшов. Обидва лишаються недоторканими, і коментар у коді каже це одним рядком: посилання й батьківські вказівники — це посилання, а не ребра володіння

Ребра володіння проти посилань у графі об'єктів HotPDF: Items у DictionaryObject, Items у ArrayObject, InternalObject у IndirectObject та обидві половини StreamObject відстежуються й від'єднуються, тоді як номер об'єкта в THPDFLink і зворотний вказівник FParent — це лише імена для об'єктів, що живуть деінде, тож CloseIndirectObjects їх не розіменовує
Ребро володіння — це вказівник, чию ціль джерело мусить знищити; рух за посиланням натомість перетворив би обхід у ширину на нескінченний цикл або на використання після звільнення, тож посилання й батьківські вказівники лишаються недоторканими

Як працює трифазне знесення?

Фаза один — це збирання в ширину. Рутина засіває робочий список кожним записом IndirectObjects, а далі для кожного вузла додає цілі його ребер володіння, пропускаючи все вже бачене. Множина баченого — це масив із відкритою адресацією сирих вказівників, хешованих через HPDFFastCacheHashInt64 за значенням вказівника, з лінійним пробуванням і подвоєнням у GrowSeen, коли вона заповнюється до половини. Ніщо в цій структурі не виділяє пам'ять на кожен вузол, а це важливо, коли документ несе кількасот тисяч об'єктів. Навантаження потоків ідуть в окремий список Streams, бо вони є нащадками TStream, а не вузлами THPDFObject, і звільняються окремим проходом

Трифазне знесення CloseIndirectObjects у HotPDF: збирання в ширину засіває робочий список із IndirectObjects і йде лише ребрами володіння через множину з відкритою адресацією, хешовану HPDFFastCacheHashInt64, фаза два від'єднує кожне ребро через MarkAsFreed і присвоєння nil, а фаза три звільняє кожен вузол і навантаження потоку рівно один раз
Обрізання ребер до запуску будь-якого деструктора — це те, що робить наявні деструктори безпечними для повторного використання: кожен із них не знаходить куди рекурсувати, тож спільні нащадки, цикли й аліаси обгортка-тіло сходять без подвійного звільнення
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 і виставляє прапорець, який її деструктор перевіряє першим. Об'єкту потоку присвоюються nil у Dictionary і Stream. У кожного елемента словника очищається 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 КіБ — по одному на екземпляр, і це те навантаження content stream, яким кожен із них володів і яке ніколи не звільнив. Збільшення масштабу зробило цю саму картину незаперечною. Дворазовий запуск паралельного конвеєра рендерингу посунув показник виділених великих блоків із 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 PDF