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

Thread safety PDFium: один TPdf на thread — не ізоляція

PDFium не thread-safe на рівні модуля, тож два екземпляри TPdf, що працюють над двома різними файлами у двох thread-ах, усе ще можуть пошкодити одне одного. PDFium Component для Delphi опікується цим двома способами: від v3.125.1 ValidatePdfFilesParallel серіалізує кожен native виклик PDFium за одним загальнопроцесним замком, тоді як TPdf.RenderPagesParallel дає кожному worker-у власну ізольовану копію модуля PDFium. Bug, який змусив зробити fix, — найгірший вид епізодичного. Тест batch validation проходив здебільшого, потім звітував один із двох хороших файлів як провалений, потім валив наступний тест у тому самому процесі з access violation, а часом забирав із собою весь runner з exit code замість stack trace. З тестом було все гаразд, і з жодним окремим документом теж. Неправильним було припущення: один TPdf на thread — це не ізоляція

Чому одного TPdf на thread недостатньо?

Один TPdf на thread недостатньо, бо PDFium тримає свій небезпечний стан у модулі, а не в документі. Кожен TPdf володіє власним handle FPDF_DOCUMENT, але кожен handle у процесі обслуговується тією самою завантаженою DLL, а та DLL тримає загальнопроцесні синглетони: font cache, page module та інші глобальні структури, яких торкаються завантаження документів, парсинг і рендеринг. Два thread-и, що завантажують два непов’язані файли, — це два thread-и, що пишуть в той самий font cache водночас. Ніхто на Delphi боці не володіє тими даними, тож нічого на Delphi боці не може замкнути їх per document

У компонента є замок, і з нього легко зробити неправильний висновок. TPdf обгортає власні render-шляхи внутрішньою critical section (EnterRenderLock / LeaveRenderLock, приватні методи TPdf). Той замок per instance. Він заважає двом thread-ам гнати той самий TPdf водночас — це справжня небезпека, — але не бачить другого екземпляра на іншому thread, тож cross-instance конкуренція проходить повз нього прямо. Загальне правило просте настільки, що вміщується в один рядок: в одному завантаженому модулі PDFium щомиті щонайбільше один thread може бути всередині PDFium, скільки б документів не було відкрито

Діаграма PDFium Component двох thread-ів, що гонять окремі екземпляри TPdf над різними документами, поки кожен виклик сходиться на одному завантаженому модулі pdfium.dll, чиї font cache, page module та інші загальнопроцесні глобали спільні, що продукує провали завантаження, access violations і fail-fast виходи
PDFium тримає свій небезпечний стан у модулі, а не в документі, тож два екземпляри TPdf на двох thread-ах пишуть в той самий font cache, наскільки б непов’язаними файли не були

Як виглядає cross-document пошкодження в Delphi-процесі?

Cross-document пошкодження виглядає як випадковий мікс непов’язаних провалів, і шкода переживає код, що її спричинив. До v3.125.1 ValidatePdfFilesParallel створював по одному TPdf на worker thread і гнав Active := True плюс build preflight-звіту конкурентно на спільному модулі. Симптоми, побачені на збірках і Delphi, і Free Pascal, покрили весь спектр:

  • Валідний файл не завантажується, чи повертається з batch як провалений, коли мав би пройти
  • Access violation виходить на поверхню в пізнішому, непов’язаному виклику, часто в іншому тесті чи іншому документі
  • У Delphi з’являється External exception C000001D. Той код — STATUS_ILLEGAL_INSTRUCTION, який підіймає інструкція ud2, яку виконують внутрішній CHECK і макрос IMMEDIATE_CRASH PDFium, коли ламається invariant
  • Процес виходить з 0xC0000409 (fail-fast, звітується як stack buffer overrun) чи 0xC0000374 (heap corruption), без жодного Delphi exception

Останні два пункти — причина, чому bug було так важко спіймати. Паралельна валідація завершувалася, пошкоджений глобальний стан лишався, і наступний fixture у тому самому процесі об нього спотикався. В одному регресному прогоні Delphi Win64 хвиля провалів C000001D влучила в тести, які ніколи не торкалися batch validation; вони були просто першим кодом, що вжив PDFium після пошкодження. Виміряні числа роблять масштаб наочним. Delphi-зонд, що гнав той самий зразок крізь двох worker-ів, провалив 122 зі 160 документів в одному прогоні і 138 зі 160 в іншому, а один із тих прогонів підняв External exception C000001D прямо. Стрес-випадок з 8 документами, 4 worker-ами і 5 раундами провалювався чи валився у 5 з 5 прогонів на Free Pascal Win64. Після fix той самий зонд провалив 0 з 1 200 документів

Як ValidatePdfFilesParallel лишається безпечним від v3.125.1

ValidatePdfFilesParallel тепер серіалізує native половину кожної роботи і тримає керовану половину паралельною. Кожен worker бере одну unit-рівневу critical section, перш ніж створити свій TPdf, і тримає її крізь FileName, Active := True, build preflight-звіту та Free. Створення і знищення — всередині замка намірено: закриття документа теж викликається назад у модуль, як і завантаження. Щойно worker має захоплений record TPdfPreflightReport, він відпускає замок і оцінює правила валідації проти того record, що не торкається жодного стану PDFium, тож оцінювання правил одного файлу перекривається з PDFium-роботою наступного

Діаграма ValidatePdfFilesParallel у PDFium Component, що показує кожного worker-а, який тримає одну загальнопроцесну critical section крізь create, load, preflight і free TPdf, тоді як оцінювання правил захопленого звіту працює поза замком паралельно, тож PDFium-половина batch серійна за задумом
Створення і знищення лишаються всередині замка, бо закриття документа викликається назад у модуль, тоді як оцінювання звіту не торкається жодного стану PDFium і перекривається з наступним файлом

З fix прийшли два менші зміни. Провал завантаження тепер підіймає EPdfError з LastLoadReport.ErrorMessage, тож ErrorMessage елемента називає справжню проблему парсингу замість вторинної помилки «no active document». І ціну чесно названо: PDFium-частина batch тепер серійна, тож на batch, домінованому парсингом і preflight, додаткові worker-и купують мало. Якщо ви на версії до v3.125.1, поставте WorkerCount у 1; це прибирає і конкуренцію, і пошкодження разом із нею

uses
  System.SysUtils, PDFium, FPdfPreflightReport;

procedure ValidateBatch(const Files: array of string);
var
  Registry: TPdfValidationRuleRegistry;
  Options: TPdfBatchValidationOptions;
  Report: TPdfBatchValidationReport;
  I: Integer;
begin
  Registry := CreateDefaultPdfValidationRuleRegistry;
  try
    Options := TPdfBatchValidationOptions.Default;
    Options.WorkerCount := 4;          // 0 = кількість процесорів, стеля 8
    Options.Standards := [ppsPdfA];
    // З явним registry профіль, що відповідає, обирайте самі.
    // Порожній список Profiles гоняє кожне зареєстроване правило, а правила для
    // стандартів без preflight звітують "did not pass"
    SetLength(Options.ValidationOptions.Profiles, 1);
    Options.ValidationOptions.Profiles[0] := 'PDF/A';
    Report := ValidatePdfFilesParallel(Files, Registry, Options);
  finally
    Registry.Free;
  end;

  for I := 0 to High(Report.Results) do
    case Report.Results[I].Status of
      pbvisPass:  Writeln('PASS  ', Report.Results[I].FileName);
      pbvisFail:  Writeln('FAIL  ', Report.Results[I].FileName);
      pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
                    Report.Results[I].ErrorMessage);
    else
      Writeln('SKIP  ', Report.Results[I].FileName);   // pbvisCancelled
    end;
  Writeln(Report.PassedDocumentCount, ' passed, ',
    Report.FailedDocumentCount, ' failed, ',
    Report.ErrorDocumentCount, ' errors');
end;

Передати nil як registry — коротший шлях: ValidatePdfFilesParallel тоді сам створює усталений registry, виводить список профілів із Options.Standards і звільняє registry при поверненні. Результати завжди повертаються у порядку входу, у якому б порядку worker-и ні фінішували. Про формати звітів і command-line обгортку над тим самим engine — у batch PDF preflight звітах з PDFium Component CLI, а про те, що покривають самі перевірки PDF/A, — у PDF/A preflight валідації в Delphi

Як RenderPagesParallel гонить сторінки справді паралельно?

TPdf.RenderPagesParallel працює паралельно, бо його worker-и ніколи не ділять модуль PDFium. Метод спершу зберігає активний документ у source store на викликаючому thread. Кожен worker потім копіює завантажену PDFium DLL у файл з унікальним ім’ям у temp-каталозі, завантажує ту копію через LoadLibrary і ініціалізує її. Windows трактує DLL, завантажену з іншого шляху, як інший модуль, тож кожна копія отримує власні глобали: власний font cache, власний page module, власне все. Worker відкриває збережений документ у своєму приватному модулі, рендерить його сторінки прогресивно з перевірками скасування між кроками, потім нищить бібліотеку, вивантажує копію і видаляє файл

Діаграма RenderPagesParallel у PDFium Component, де викликаючий thread зберігає сніпшот документа, потім кожен worker копіює PDFium DLL в унікальний temp-файл, завантажує його як окремий модуль з власними глобалами, рендерить його сторінки з перевірками скасування і вивантажує копію
Справжній паралелізм походить з ізоляції модулів: Windows трактує кожну копію DLL як інший модуль, тож worker-и не ділять нічого, крім сніпшота, який викликаючий thread зберіг під замком

Ізоляція не безкоштовна, і усталені це відображають. Кожен worker платить за копію DLL на диску, другий набір глобалів PDFium у пам’яті і свіжий парс документа. MaxWorkers = 0 означає щонайбільше 4 worker-и, MaxPixelsPerPage і MaxTotalOutputBytes стелять сирий вивід, а inverted та night-duotone render-опції відмовляються, бо буфери повертаються сирими. Результат — TPdfParallelRenderReport, чиї масив Results тримає один top-down 32-бітний буфер на кожну запитану сторінку, у порядку запиту

procedure RenderAllPages(Pdf: TPdf);
var
  Options: TPdfParallelRenderOptions;
  Report: TPdfParallelRenderReport;
  Pages: array of Integer;
  I: Integer;
begin
  SetLength(Pages, Pdf.PageCount);
  for I := 0 to High(Pages) do
    Pages[I] := I + 1;                 // номери сторінок — від 1

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // Вихідний сніпшот береться на спільному модулі, тож тримайте
  // загальнопроцесний PDFium замок, якщо інші thread-и теж уживають TPdf
  PdfiumLock.Acquire;
  try
    Report := Pdf.RenderPagesParallel(Pages, Options);
  finally
    PdfiumLock.Release;
  end;

  for I := 0 to High(Report.Results) do
    if Report.Results[I].Status = pprsSucceeded then
      SavePageBuffer(Report.Results[I])   // Width, Height, Stride, PixelFormat, Pixels
    else
      Writeln('Page ', Report.Results[I].PageNumber, ': ',
        Report.Results[I].ErrorMessage);
end;

Зауважте замок навколо виклику. Worker-модулі приватні, але крок сніпшота на старті ганяє SaveAs на спільному модулі з викликаючого thread-а. Якщо нічого іншого у вашому процесі не торкається TPdf конкурентно, замок можна відпустити; якщо щось торкається, сніпшот потребує того самого захисту, що й кожен інший спільно-модульний виклик

ПатернБезпечно між документамиPDFium-робота йде паралельноЦіна
Один TPdf на thread, без спільного замкаНіТак, до першого пошкодженняЕпізодичні crash-і, пошкоджений стан процесу
Один загальнопроцесний замок навколо всіх PDFium викликівТакНіPDFium-частина серійна
ValidatePdfFilesParallel від v3.125.1ТакНі; оцінювання правил паралельнеПарсинг і preflight серійні
TPdf.RenderPagesParallelТакТакКопія DLL, пам’ять і свіжий парс на кожного worker-а

Як структурувати власний багатопотоковий PDFium код?

Власні thread-и мають ділити один загальнопроцесний замок і тримати його все життя кожного TPdf, яким вони користуються, або ж уживати компонентний API, що ізолює модуль за вас. Замок мусить бути одним об’єктом на весь процес, а не по одному на thread, на форму чи на документ; замок, якого два thread-и не ділять, не захищає нічого. Патерн нижче віддзеркалює те, що компонент робить внутрішньо від v3.125.1: create, load, read і free всередині замка, а все, що не торкається PDFium, — поза ним

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // один замок на весь процес

type
  TTextExtractThread = class(TThread)
  private
    FFileName: string;
    FText: string;
  protected
    procedure Execute; override;
  public
    constructor Create(const AFileName: string);
    property ExtractedText: string read FText;
  end;

constructor TTextExtractThread.Create(const AFileName: string);
begin
  inherited Create(True);
  FFileName := AFileName;
end;

procedure TTextExtractThread.Execute;
var
  Pdf: TPdf;
  Page: Integer;
  Raw: TStringBuilder;
begin
  Raw := TStringBuilder.Create;
  try
    PdfiumLock.Acquire;
    try
      Pdf := TPdf.Create(nil);
      try
        Pdf.FileName := FFileName;
        Pdf.Active := True;
        if not Pdf.Active then
          raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
        for Page := 1 to Pdf.PageCount do
        begin
          Pdf.PageNumber := Page;
          Raw.AppendLine(Pdf.Text);
        end;
      finally
        Pdf.Free;                      // закриття документа — теж PDFium-робота
      end;
    finally
      PdfiumLock.Release;
    end;
    // Нижче цього рядка PDFium немає, тож ця частина працює паралельно
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

Кілька правил тримають патерн чесним у реальному застосунку:

  • Кладіть TPdf.Create і Free всередину замка, а не лише очевидні виклики. Завантаження, закриття, читання властивостей на кшталт PageCount, зміни сторінок, витяг тексту, рендеринг і збереження — все дістає до модуля
  • Перевіряйте Active після його присвоєння. Провалене завантаження лишає Active на False, а LastLoadReport.ErrorMessage каже чому
  • Тримайте замок per document, а не per call. Тонше замикання можливе в принципі, але лише якщо жоден член TPdf ніколи не працює поза ним, а груба версія — та, на яку сам компонент покладається
  • Тримайте повільну не-PDFium роботу, як-от записи в базу, індексацію та мережеві виклики, поза замком, інакше один повільний consumer серіалізує все
  • Не тракуйте приватний per-instance render замок як заміну. Він охороняє один TPdf від самого себе і не більше

Та сама обережність стосується коду, який ви не писали як сирі thread-и. Background futures — добрий спосіб тримати довгі рендери подалі від UI thread, як описано в background PDF рендерингу з cancellable futures, але executor future не додає власного глобального PDFium замка. Якщо кілька futures можуть гнати різні TPdf екземпляри водночас, беріть той самий загальнопроцесний замок всередині кожного worker-а і трактуйте viewer на головному thread як ще одного клієнта спільного модуля. Cross-instance уживання крізь асинхронні API окремо не аудирувалося, тож консервативне припущення — воно потребує тієї самої серіалізації, що й рукописні thread-и. Коли вам потрібен справжній PDFium паралелізм для чогось іншого за рендеринг сторінок, окремі worker-процеси дають кожній роботі власний модуль за конструкцією

Швидка довідка: правила threading PDFium для Delphi

  • Небезпечний стан PDFium — module-wide: font cache, page module та інші глобали ділять всі документи в процесі
  • Один TPdf на thread не ізолює нічого; два екземпляри на двох thread-ах усе ще можуть пошкодити одне одного
  • Типові симптоми — провали завантаження, access violations у пізнішому коді, External exception C000001D, і виходи з 0xC0000409 чи 0xC0000374
  • Пошкодження залишається в процесі, тож провалюваний виклик часто не той, що його спричинив
  • ValidatePdfFilesParallel безпечний від v3.125.1; на старіших версіях уживайте WorkerCount := 1
  • TPdf.RenderPagesParallel справді паралельний, бо кожен worker завантажує ізольовану копію модуля PDFium
  • Власні thread-и, tasks і futures потребують одного загальнопроцесного замка, що покриває кожен TPdf від Create до Free

PDFium Component обгортає PDFium engine для Delphi з batch preflight та валідацією, ізольованим паралельним рендерингом, cancellable background роботою і детальною діагностикою завантаження. Деталі та видання — на сторінці продукту PDFium Component