PDFium не е thread-safe на ниво модул, така че два екземпляра TPdf, работещи върху два различни файла в две нишки, все пак могат да си опорчат данните. PDFium Component за Delphi се справя по два начина: от v3.125.1 насам ValidatePdfFilesParallel серилизира всяко native PDFium извикване зад едно process-wide заключване, а TPdf.RenderPagesParallel дава на всеки работник собствен изолиран екземпляр на PDFium модула. Бъгът, който наложи поправката, беше най-лошият вид — прекъсващ. Тест за batch validation минаваше повечето време, после докладваше един от два добри файла като провален, после крашваше следващия тест в същия процес с access violation, а понякога сваляше целия runner с exit код вместо stack trace. С теста нямаше нищо наред, и с нито един отделен документ нямаше нищо наред. Грешно беше предположението: един TPdf на нишка не е изолация
Защо един TPdf на нишка не стига?
Един TPdf на нишка не стига, защото PDFium пази небезопасното си състояние в модула, а не в документа. Всеки TPdf притежава свой FPDF_DOCUMENT handle, но всеки handle в процеса се обслужва от една и съща заредена DLL, а тази DLL държи process-wide синглетони: font cache-ът, page модулът и други глобални структури, които зареждането на документи, парсингът и рендерирането всички пипат. Две нишки, зареждащи два несвързани файла, са две нишки, пишещи в един и същ font cache по едно и също време. Никой не притежава тези данни от Delphi страната, значи нищо от Delphi страната не може да ги заключва на документ
Компонентът има заключване, и от него лесно се извади грешен извод. TPdf обвива собствените си render пътове във вътрешен critical section (EnterRenderLock / LeaveRenderLock, частни методи на TPdf). Това заключване е на екземпляр. Спира две нишки да карат един и същ TPdf едновременно — реална опасност — но не вижда втори екземпляр на друга нишка, така че cross-instance конкурентността минава право през него. Общото правило е просто достатъчно, за да се каже на един ред: в един зареден PDFium модул най-много една нишка може да е в PDFium във всеки момент, без значение колко документа са отворени
Как изглежда cross-document опорачване в Delphi процес?
Cross-document опорачването изглежда като случайна смес от несвързани провали, а щетите надживяват кода, който ги е причинил. Преди v3.125.1 ValidatePdfFilesParallel създаваше един TPdf на работна нишка и въртеше Active := True плюс строенето на preflight доклада конкурентно върху споделения модул. Симптомите, видяни и на Delphi, и на Free Pascal билдове, покриха целия диапазон:
- Валиден файл не се зарежда, или се връща от партидата като провален, макар да би трябвало да мине
- Access violation излиза наяве в по-късно, несвързано извикване — често в друг тест или друг документ
External exception C000001Dсе появява в Delphi. Този код еSTATUS_ILLEGAL_INSTRUCTION, вдиган от инструкциятаud2, която вътрешнитеCHECKиIMMEDIATE_CRASHмакроси на PDFium изпълняват, когато инвариант се счупи- Процесът излиза с
0xC0000409(fail-fast, докладван като stack buffer overrun) или0xC0000374(heap corruption), без никакво Delphi exception
Последните две точки обясняват защо бъгът се хванеше с толкова труд. Паралелната валидация приключваше, опораченото глобално състояние си оставаше, а следващата fixture в същия процес се спъваше в него. В един Delphi Win64 regression прогон вълна от провали с C000001D улучи тестове, които изобщо не пипаха batch validation — бяха просто първият код, ползвал PDFium след щетите. Измерените числа правят мащаба явен. Delphi проба, пуснала същата извадка през двама работници, провали 122 от 160 документа в един прогон и 138 от 160 в друг, а един от тези прогони вдигна направо External exception C000001D. Стресов случай от 8 документа, 4 работници и 5 рунда проваляше или крашваше в 5 от 5 прогона на Free Pascal Win64. След поправката същата проба провали 0 от 1,200 документа
Как ValidatePdfFilesParallel остава безопасен от v3.125.1
ValidatePdfFilesParallel вече серилизира native половината на всяка задача и държи managed половината паралелна. Всеки работник взема един critical section на ниво unit, преди да създаде своя TPdf, и го държи през FileName, Active := True, строенето на preflight доклада и Free. Създаването и разрушаването са вътре в заключването нарочно: затварянето на документ вика обратно в модула точно както зареждането. Щом работникът има уловен запис TPdfPreflightReport, той пуска заключването и преценява валидационните правила срещу този запис, което не пипа PDFium състояние, така че оценката на правилата за един файл се застъпва с PDFium работата по следния
Две по-малки промени дойдоха с поправката. Провал при зареждане вече вдига EPdfError с LastLoadReport.ErrorMessage, така че ErrorMessage на записа назовава истинския parse проблем вместо вторична грешка „no active document“. И цената е казана често: 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];
// С изричен registry изберете съответния profile сами.
// Празен 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 тогава сама създава default registry-я, извежда списъка с profile-и от Options.Standards и освобождава registry-я, когато се върне. Резултатите винаги идват в реда на входа, какъвто и да е бил редът, в който работниците са приключили. За форматите на докладите и command-line wrapper-а около същия двигател вижте batch PDF preflight доклади с PDFium Component CLI, а за това какво покриват самите PDF/A проверки — PDF/A preflight валидация в Delphi
Как RenderPagesParallel върви страници истински паралелно?
TPdf.RenderPagesParallel върви паралелно, защото работниците ѝ изобщо не споделят PDFium модул. Методът първо записва активния документ в source store на извикващата нишка. После всеки работник копира заредената PDFium DLL във файл с уникално име в temp директорията, зарежда това копие с LoadLibrary и го инициализира. Windows третира DLL, зареден от друг път, като друг модул, така че всяко копие получава собствени глобали: собствен font cache, собствен page модул, собствено всичко. Работникът отваря записания документ в частния си модул, рендерира страниците си прогресивно с проверки за прекъсване между стъпките, после разрушава библиотеката, разтоварва копието и трие файла
Изолацията не е безплатна, и default-ите го отразяват. Всеки работник плаща за DLL копие на диска, втори набор PDFium глобали в паметта и свеж parse на документа. MaxWorkers = 0 значи най-много 4 работници, MaxPixelsPerPage и MaxTotalOutputBytes ограничават суровия изход, а обърнатите и нощните 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;
// Source snapshot-ът се взима върху споделения модул, затова дръжте
// process-wide 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;
Обърнете внимание на заключването около извикването. Работническите модули са частни, но стъпката snapshot в началото върви SaveAs върху споделения модул от извикващата нишка. Ако нищо друго във вашия процес не пипа TPdf конкурентно, можете да махнете заключването; ако нещо пипа, snapshot-ът се нуждае от същата защита като всяко друго споделено-модулно извикване
| Шаблон | Безопасно между документи | PDFium работата върви паралелно | Цена |
|---|---|---|---|
Един TPdf на нишка, без споделено заключване | Не | Да, докато не опорочи | Прекъсващи крашове, опорачено процесно състояние |
| Едно process-wide заключване около всички PDFium извиквания | Да | Не | PDFium частта е серийна |
ValidatePdfFilesParallel от v3.125.1 | Да | Не; оценката на правилата е паралелна | Parsing и preflight са серийни |
TPdf.RenderPagesParallel | Да | Да | DLL копие, памет и свеж parse на работник |
Как да структурирате собствен multithread PDFium код?
Вашите собствени нишки трябва да споделят едно process-wide заключване и да го държат през целия живот на всеки TPdf, който ползват, или да ползват component API, който изолира модула вместо вас. Заключването трябва да е един-единствен обект за целия процес — не по едно на нишка, на форма или на документ; заключване, което две нишки не споделят, не защитава нищо. Шаблонът по-долу огледалава това, което компонентът върши вътрешно от v3.125.1 насам: създаване, зареждане, четене и освобождаване вътре в заключването, после всичко, което не пипа 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 работа — записвания в база, индексиране, мрежови извиквания — извън заключването, иначе един бавен консуматор ще серилизира всичко
- Не третирайте частното заключване за render на екземпляра като заместител. То пази един
TPdfот самия него и нищо повече
Същата предпазливост важи и за код, който не сте писали като сурови нишки. Background futures са добър начин да държите дълги рендери на разстояние от UI нишката, както е описано в background PDF рендериране с прекъсваеми futures, но future executor-ът не добавя собствено глобално PDFium заключване. Ако няколко futures могат да карат различни TPdf екземпляри едновременно, вземете същото process-wide заключване вътре във всеки работник, и третирайте viewer на главната нишка като още един клиент на споделения модул. Cross-instance ползването през асинхронните API не е одитирано отделно, така че консервативното предположение е, че се нуждае от същата серилизация като ръчно писаните нишки. Когато ви трябва реален PDFium паралелизъм за нещо друго освен рендериране на страници, отделни работни процеси дават на всяка задача собствен модул по конструкция
Бърза справка: PDFium правила за нишки в Delphi
- Небезопасното състояние на PDFium е на ниво модул: font cache-ът, page модулът и другите глобали са споделени от всеки документ в процеса
- Един
TPdfна нишка не изолира нищо; два екземпляра на две нишки все пак могат да си опорчат данните - Типичните симптоми са провалени зареждания, access violations в по-късен код,
External exception C000001Dи изходи с0xC0000409или0xC0000374 - Опорачването се задържа в процеса, така че провалящото се извикване често не е това, което го е причинило
ValidatePdfFilesParallelе безопасен от v3.125.1 насам; на по-стари версии ползвайтеWorkerCount := 1TPdf.RenderPagesParallelе истински паралелен, защото всеки работник зарежда изолирано копие на PDFium модула- Вашите собствени нишки, задачи и futures се нуждаят от едно process-wide заключване, покриващо всеки
TPdfотCreateдоFree
PDFium Component обвива PDFium двигателя за Delphi с batch preflight и валидация, изолирано паралелно рендериране, прекъсваема background работа и детайлна диагностика при зареждане. Детайли и издания са на страницата на PDFium Component