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, скільки б документів не було відкрито
Як виглядає 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_CRASHPDFium, коли ламається 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-роботою наступного
З 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 відкриває збережений документ у своєму приватному модулі, рендерить його сторінки прогресивно з перевірками скасування між кроками, потім нищить бібліотеку, вивантажує копію і видаляє файл
Ізоляція не безкоштовна, і усталені це відображають. Кожен 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 := 1TPdf.RenderPagesParallelсправді паралельний, бо кожен worker завантажує ізольовану копію модуля PDFium- Власні thread-и, tasks і futures потребують одного загальнопроцесного замка, що покриває кожен
TPdfвідCreateдоFree
PDFium Component обгортає PDFium engine для Delphi з batch preflight та валідацією, ізольованим паралельним рендерингом, cancellable background роботою і детальною діагностикою завантаження. Деталі та видання — на сторінці продукту PDFium Component