Техническая статья

Потокобезопасность PDFium: замок на документ не спасает

PDFium не потокобезопасен на уровне модуля, так что два инстанса TPdf, работающих с двумя разными файлами в двух потоках, всё ещё могут портить друг друга. PDFium Component для Delphi ведает этим двумя способами: с v3.125.1 ValidatePdfFilesParallel сериализует всякий нативный вызов PDFium за одним процессным замком, а TPdf.RenderPagesParallel выдаёт каждому воркеру собственную изолированную копию модуля PDFium. Баг, вынудивший фикс, был худшим сортом перемежающегося. Тест пакетной валидации проходил по большей части, потом объявлял один из двух годных файлов проваленным, потом ронял следующий тест в том же процессе access violation, а иногда укладывал весь раннер кодом выхода вместо трейса. С тестом было всё в порядке, и с любым одиночным документом тоже. Неверным было допущение: один TPdf на поток — не изоляция

Почему одного TPdf на поток недостаточно?

Одного TPdf на поток мало, потому что PDFium держит небезопасное состояние в модуле, а не в документе. Каждый TPdf владеет собственным хэндлом FPDF_DOCUMENT, но всякий хэндл в процессе обслуживается одной и той же загруженной DLL, а та держит синглтоны на весь процесс: шрифтовый кэш, страничный модуль и другие глобальные структуры, которых касаются загрузка, разбор и рендеринг документов. Два потока, грузящие два несвязанных файла, — это два потока, пишущих в один шрифтовый кэш одновременно. Никто на стороне Delphi не владеет теми данными, потому ничто на стороне Delphi не может закрыть их замком на документ

Замок у компонента есть, и из него легко сделать неверный вывод. TPdf оборачивает собственные рендер-пути во внутреннюю критическую секцию (EnterRenderLock / LeaveRenderLock, приватные методы TPdf). Тот замок — на инстанс. Он не даёт двум потокам гнать один TPdf разом — реальная опасность, — но второго инстанса на другом потоке ему не видно, так что межинстансовая параллельность проходит мимо него напрямик. Общее правило просто до одной строки: в единственном загруженном модуле PDFium внутри PDFium в каждый момент может быть не более одного потока, сколько бы документов ни было открыто

Схема PDFium Component двух потоков с раздельными инстансами TPdf на разных документах, при том что всякий вызов сходится в один загруженный модуль pdfium.dll, чей шрифтовый кэш, страничный модуль и прочие процессные глобалы общие, — с отказами загрузки, access violation и fail-fast выходами
PDFium держит небезопасное состояние в модуле, а не в документе, поэтому два инстанса TPdf на двух потоках пишут в один шрифтовый кэш, как бы ни были несвязанны файлы

Как выглядит междокументная порча в процессе Delphi?

Междокументная порча выглядит случайной смесью несвязанных отказов, и ущерб переживает код, его причинивший. До v3.125.1 ValidatePdfFilesParallel создавал по одному TPdf на рабочий поток и гнал Active := True плюс сборку preflight-отчёта конкурентно на общем модуле. Симптомы, виденные на сборках и Delphi, и Free Pascal, покрыли весь диапазон:

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

Последние два пункта — почему баг так трудно было поймать за хвост. Параллельная валидация завершалась, испорченное глобальное состояние оставалось лежать, и следующая фикстура в том же процессе об него спотыкалась. В одном регрессионном прогоне Delphi Win64 волна отказов C000001D ударила по тестам, не касавшимся пакетной валидации вовсе; они просто были первым кодом, воспользовавшимся PDFium после ущерба. Вмеренные числа делают масштаб наглядным. Delphi-зонд, гнавший тот же образец через два воркера, провалил 122 из 160 документов в одном прогоне и 138 из 160 в другом, причём один из прогонов поднял External exception C000001D напрямую. Стресс-кейс из 8 документов, 4 воркеров и 5 раундов падал или крашился в 5 из 5 прогонов на Free Pascal Win64. После фикса тот же зонд провалил 0 из 1200 документов

Как ValidatePdfFilesParallel остаётся безопасным с v3.125.1

ValidatePdfFilesParallel теперь сериализует нативную половину каждого задания, оставляя управляемую половину параллельной. Всякий воркер берёт одну критическую секцию уровня юнита, прежде чем создать свой TPdf, и держит её сквозь FileName, Active := True, сборку preflight-отчёта и Free. Создание и разрушение — внутри замка нарочно: закрытие документа зовётся назад в модуль так же, как загрузка. Захватив запись TPdfPreflightReport, воркер отпускает замок и оценивает правила валидации против той записи, что не трогает состояние PDFium, так что оценка правил одного файла перекрывает PDFium-работу следующего

Схема ValidatePdfFilesParallel в PDFium Component, показывающая, как всякий воркер держит одну процессную критическую секцию через create, load, preflight и free у TPdf, тогда как оценка правил по захваченному отчёту бежит вне замка параллельно, так что PDFium-половина пакета сериальна by design
Создание и разрушение остаются внутри замка, потому что закрытие документа зовётся назад в модуль, тогда как оценка отчёта не трогает состояние PDFium и перекрывает следующий файл

С фиксом пришли два изменения поменьше. Отказ загрузки теперь поднимает EPdfError с LastLoadReport.ErrorMessage, так что ErrorMessage пункта называет настоящую проблему разбора вместо вторичной ошибки «нет активного документа». И цена названа честно: PDFium-часть пакета теперь сериальна, так что на пакете, где доминируют разбор и preflight, лишние воркеры дают мало. Если вы на версии до 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];
    // С явным реестром профиль подбираете сами.
    // Пустой список 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 вместо реестра — путь покороче: тогда ValidatePdfFilesParallel сам создаёт дефолтный реестр, выводит список профилей из Options.Standards и освобождает реестр при возврате. Результаты всегда возвращаются в порядке ввода, в каком бы порядке воркеры ни финишировали. О форматах отчётов и обёртке командной строки над тем же движком — пакетные PDF preflight-отчёты с CLI PDFium Component, а о том, что покрывают сами проверки PDF/A, — preflight-валидация PDF/A в Delphi

Как RenderPagesParallel гоняет страницы по-настоящему параллельно?

TPdf.RenderPagesParallel бежит параллельно, потому что его воркеры никогда не делят модуль PDFium. Метод сперва сохраняет активный документ в хранилище источника на вызывающем потоке. Затем всякий воркер копирует загруженную DLL PDFium в уникально названный файл во временной папке, грузит ту копию через LoadLibrary и инициализирует её. Windows трактует DLL, загруженную с другого пути, как другой модуль, так что каждая копия получает свои глобалы: свой шрифтовый кэш, свой страничный модуль, своё всё. Воркер открывает сохранённый документ в приватном модуле, рендерит свои страницы постепенно с проверками отмены между шагами, затем разрушает библиотеку, выгружает копию и удаляет файл

Схема RenderPagesParallel в PDFium Component, где вызывающий поток сохраняет снапшот документа, затем каждый воркер копирует DLL PDFium в уникальный временный файл, грузит его как отдельный модуль со своими глобалами, рендерит свои страницы с проверками отмены и выгружает копию
Настоящая параллельность — из изоляции модулей: Windows трактует каждую копию DLL как другой модуль, так что воркеры не делят ничего, кроме снапшота, сохранённого вызывающим потоком под замком

Изоляция не бесплатна, и дефолты это отражают. Каждый воркер платит копией DLL на диске, вторым комплектом глобалов PDFium в памяти и свежим разбором документа. MaxWorkers = 0 значит не более 4 воркеров, MaxPixelsPerPage и MaxTotalOutputBytes ограничивают сырой вывод, а варианты рендера «инверсия» и «ночной дуотон» отвергаются, потому что буферы возвращаются сырыми. Результат — 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;                 // номера страниц с единицы

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

  // Исходный снапшот снимается на общем модуле, так что держите
  // процессный замок PDFium, если другие потоки тоже пользуют 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;

Заметьте замок вокруг вызова. Модули воркеров приватны, но шаг снапшота в начале гоняет SaveAs на общем модуле с вызывающего потока. Если больше ничто в вашем процессе не трогает TPdf конкурентно, замок можно снять; если что-то трогает, снапшоту нужна та же защита, что и всякому другому вызову общего модуля

ПаттернБезопасно между документамиPDFium-работа идёт параллельноЦена
Один TPdf на поток, без общего замкаНетДа, пока не испортитПеремежающиеся крахи, испорченное состояние процесса
Один процессный замок вокруг всех вызовов PDFiumДаНетPDFium-часть сериальна
ValidatePdfFilesParallel с v3.125.1ДаНет; оценка правил параллельнаРазбор и preflight сериальны
TPdf.RenderPagesParallelДаДаКопия DLL, память и свежий разбор на воркера

Как структурировать собственный многопоточный код PDFium?

Собственным потокам следует делить один процессный замок и держать его всю жизнь каждого используемого TPdf — либо брать API компонента, изолирующий модуль за вас. Замок обязан быть единственным объектом на весь процесс, а не по одному на поток, форму или документ; замок, который два потока не делят, не защищает ничего. Паттерн ниже повторяет то, что компонент делает внутренне с 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 говорит почему
  • Держите замок на документ, а не на вызов. Более мелкий локинг возможен в принципе, но лишь если ни один член TPdf никогда не бежит вне его, а грубая версия — та, на которую опирается сам компонент
  • Держите медленную не-PDFium работу — записи в базу, индексацию, сетевые вызовы — вне замка, иначе один медленный потребитель сериализует всё
  • Не считайте приватный поинстансовый рендер-замок заменой. Он стережёт один TPdf от самого себя и больше ничего

Та же осторожность касается кода, написанного не сырыми потоками. Фоновые futures — добрый способ убрать долгие рендеры с UI-потока, как описано в статье о фоновом рендеринге PDF с отменяемыми futures, но исполнитель futures не добавляет собственного глобального замка PDFium. Если несколько futures могут гнать разные инстансы TPdf одновременно, берите тот же процессный замок внутри каждого воркера и считайте вьюер на главном потоке ещё одним клиентом общего модуля. Межинстансовое пользование через асинхронные API отдельно не аудировалось, так что консервативное допущение — ему нужна та же сериализация, что и рукописным потокам. Когда нужна настоящая параллельность PDFium для чего-то иного, кроме рендеринга страниц, отдельные процессы-воркеры дают каждому заданию собственный модуль по построению

Шпаргалка: правила потоков PDFium для Delphi

  • Небезопасное состояние PDFium — на весь модуль: шрифтовый кэш, страничный модуль и прочие глобалы делятся всеми документами процесса
  • Один TPdf на поток ничего не изолирует; два инстанса на двух потоках всё ещё могут портить друг друга
  • Типичные симптомы — отказы загрузки, access violation в позднем коде, External exception C000001D и выходы с 0xC0000409 или 0xC0000374
  • Порча живёт в процессе, так что падающий вызов часто не тот, что её причинил
  • ValidatePdfFilesParallel безопасен с v3.125.1; на старых версиях ставьте WorkerCount := 1
  • TPdf.RenderPagesParallel по-настоящему параллелен, потому что каждый воркер грузит изолированную копию модуля PDFium
  • Собственным потокам, задачам и futures нужен один процессный замок, накрывающий каждый TPdf от Create до Free

PDFium Component оборачивает движок PDFium для Delphi пакетным preflight и валидацией, изолированным параллельным рендерингом, отменяемой фоновой работой и подробной диагностикой загрузки. Детали и издания — на странице PDFium Component product page