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 в каждый момент может быть не более одного потока, сколько бы документов ни было открыто
Как выглядит междокументная порча в процессе Delphi?
Междокументная порча выглядит случайной смесью несвязанных отказов, и ущерб переживает код, его причинивший. До v3.125.1 ValidatePdfFilesParallel создавал по одному TPdf на рабочий поток и гнал Active := True плюс сборку preflight-отчёта конкурентно на общем модуле. Симптомы, виденные на сборках и Delphi, и Free Pascal, покрыли весь диапазон:
- Годный файл не грузится или возвращается из пакета как проваленный, когда должен был пройти
- Access violation всплывает в более позднем, несвязанном вызове — часто в другом тесте или другом документе
- В Delphi появляется
External exception C000001D. Тот код —STATUS_ILLEGAL_INSTRUCTION, поднимаемый инструкциейud2, которую исполняют внутренние макросы PDFiumCHECKи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-работу следующего
С фиксом пришли два изменения поменьше. Отказ загрузки теперь поднимает 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, загруженную с другого пути, как другой модуль, так что каждая копия получает свои глобалы: свой шрифтовый кэш, свой страничный модуль, своё всё. Воркер открывает сохранённый документ в приватном модуле, рендерит свои страницы постепенно с проверками отмены между шагами, затем разрушает библиотеку, выгружает копию и удаляет файл
Изоляция не бесплатна, и дефолты это отражают. Каждый воркер платит копией 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 := 1TPdf.RenderPagesParallelпо-настоящему параллелен, потому что каждый воркер грузит изолированную копию модуля PDFium- Собственным потокам, задачам и futures нужен один процессный замок, накрывающий каждый
TPdfотCreateдоFree
PDFium Component оборачивает движок PDFium для Delphi пакетным preflight и валидацией, изолированным параллельным рендерингом, отменяемой фоновой работой и подробной диагностикой загрузки. Детали и издания — на странице PDFium Component product page