PDFium nie jest bezpieczny wątkowo na poziomie modułu, więc dwa obiekty TPdf pracujące nad dwoma różnymi plikami w dwóch wątkach wciąż mogą nawzajem się uszkadzać. PDFium Component dla Delphi ogarnia to na dwa sposoby: od v3.125.1 ValidatePdfFilesParallel szereguje każde natywne wywołanie PDFium za jedną blokadą na cały proces, a TPdf.RenderPagesParallel daje każdemu workerowi własną izolowaną kopię modułu PDFium. Bug, który wymusił poprawkę, był najgorszego rodzaju przerywany. Test walidacji wsadowej przechodził większość czasu, potem zgłaszał jeden z dwóch dobrych plików jako nieudany, potem wykładał kolejny test w tym samym procesie access violation, a czasami ściągał całą aparaturę uruchomieniową kodem wyjścia zamiast stack trace'u. Z testem nie było nic nie tak i z żadnym pojedynczym dokumentem też nie. Założenie było złe: jeden TPdf na wątek to nie izolacja
Dlaczego jeden TPdf na wątek nie wystarcza?
Jeden TPdf na wątek nie wystarcza, bo PDFium trzyma swój niebezpieczny stan w module, a nie w dokumencie. Każdy TPdf posiada własny uchwyt FPDF_DOCUMENT, ale każdy uchwyt w procesie jest obsługiwany przez tę samą wczytaną bibliotekę DLL, a ta biblioteka trzyma singletony na cały proces: cache czcionek, moduł stron i inne globalne struktury, których dotyka wczytywanie, parsowanie i renderowanie dokumentów. Dwa wątki wczytujące dwa niepowiązane pliki to dwa wątki piszące w tym samym czasie do tego samego cache czcionek. Nikt po stronie Delphi nie posiada tych danych, więc nic po stronie Delphi nie może ich zablokować per dokument
Komponent ma blokadę, i łatwo wyciągnąć z niej zły wniosek. TPdf owija własne ścieżki renderowania w wewnętrzną sekcję krytyczną (EnterRenderLock / LeaveRenderLock, prywatne metody TPdf). Ta blokada jest per instancja. Powstrzymuje dwa wątki przed napędzaniem tego samego TPdf naraz — to realne zagrożenie — ale nie widzi drugiej instancji na innym wątku, więc współbieżność między instancjami przechodzi prosto obok niej. Ogólna reguła da się streścić w jednej linijce: w jednym wczytanym module PDFium najwyżej jeden wątek może być w środku PDFium w danym momencie, niezależnie od tego, ile dokumentów jest otwartych
Jak wygląda uszkodzenie między dokumentami w procesie Delphi?
Uszkodzenie między dokumentami wygląda jak losowa mieszanka niepowiązanych porażek, a szkoda przeżywa kod, który ją spowodował. Przed v3.125.1 ValidatePdfFilesParallel tworzył jeden TPdf na wątek roboczy i puszczał Active := True plus budowę raportu preflight równolegle na współdzielonym module. Objawy widziane na buildach Delphi i Free Pascal pokrywały cały zakres:
- poprawny plik nie chce się wczytać albo wraca z paczki jako nieudany, choć powinien przejść
- access violation wychodzi na wierzchu w późniejszym, niepowiązanym wywołaniu, często w innym teście albo przy innym dokumencie
- w Delphi pojawia się
External exception C000001D. Ten kod toSTATUS_ILLEGAL_INSTRUCTION, podnoszony przez instrukcjęud2, którą wykonują wewnętrzne makraCHECKiIMMEDIATE_CRASHPDFium, gdy dojdzie do naruszenia niezmiennika - proces wychodzi z
0xC0000409(fail-fast, raportowany jako przepełnienie bufora stosu) albo0xC0000374(uszkodzenie sterty), bez żadnego wyjątku Delphi
Dwa ostatnie punkty to powód, dla którego bug tak ciężko było przygwoździć. Równoległa walidacja się kończyła, uszkodzony globalny stan zostawał w tyle, a następny fixture w tym samym procesie o niego się potykał. W jednym przebiegu regresji Delphi Win64 fala porażek C000001D trafiła w testy, które nigdy nie dotykały walidacji wsadowej; były po prostu pierwszym kodem używającym PDFium po szkodzie. Wyzmierzone liczby pokazują skalę wprost. Sonda Delphi puszczająca tę samą próbkę przez dwóch workerów wykładała 122 z 160 dokumentów w jednym przebiegu i 138 z 160 w innym, a jeden z tych przebiegów podnosił z marszu External exception C000001D. Przypadek stresowy z 8 dokumentami, 4 workerami i 5 rundami zawodził albo crashował w 5 z 5 przebiegów na Free Pascal Win64. Po poprawce ta sama sonda wykładała 0 z 1200 dokumentów
Jak ValidatePdfFilesParallel pozostaje bezpieczny od v3.125.1
ValidatePdfFilesParallel szereguje teraz natywną połowę każdego zadania i trzyma zarządzaną połowę równoległą. Każdy worker bierze jedną sekcję krytyczną na poziomie modułu, zanim utworzy swój TPdf, i trzyma ją przez FileName, Active := True, budowę raportu preflight i Free. Tworzenie i niszczenie są w blokadzie celowo: zamykanie dokumentu zawraca do modułu dokładnie tak jak wczytywanie. Gdy worker ma już przechwycony rekord TPdfPreflightReport, puszcza blokadę i ocenia reguły walidacji względem tego rekordu, co nie dotyka żadnego stanu PDFium, więc ocena reguł jednego pliku nakłada się na pracę PDFium nad następnym
Z poprawką przyszły dwie mniejsze zmiany. Porażka wczytania podnosi teraz EPdfError z LastLoadReport.ErrorMessage, więc ErrorMessage pozycji nazywa faktyczny problem parsowania zamiast wtórnego błędu „no active document”. A koszt jest podany uczciwie: natywna część paczki jest teraz szeregowa, więc przy paczce zdominowanej przez parsowanie i preflight dodatkowe workery kupują niewiele. Jeśli jesteś na wersji sprzed v3.125.1, ustaw WorkerCount na 1; to usuwa współbieżność razem z uszkodzeniami
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 = liczba procesorów, ograniczona do 8
Options.Standards := [ppsPdfA];
// Przy jawnym rejestrze wybierz pasujący profil sam.
// Pusta lista Profiles puszcza każdą zarejestrowaną regułę, a reguły
// standardów bez preflightu raportują "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;
Podanie nil jako rejestru to krótsza droga: ValidatePdfFilesParallel tworzy wtedy domyślny rejestr sam, wyprowadza listę profili z Options.Standards i zwalnia rejestr przy powrocie. Wyniki wracają zawsze w kolejności wejścia, w jakimkolwiek porządku skończyli workerzy. O formatach raportów i nakładce linii poleceń nad tym samym silnikiem pisze wsadowe raporty preflight PDF z CLI PDFium Component, a o tym, co same sprawdzenia PDF/A obejmują, walidacja preflight PDF/A w Delphi
Jak RenderPagesParallel puszcza strony naprawdę równolegle?
TPdf.RenderPagesParallel działa równolegle, bo jego workerzy nigdy nie dzielą modułu PDFium. Metoda najpierw zapisuje aktywny dokument do magazynu źródłowego na wątku wołającym. Każdy worker kopiuje potem wczytaną bibliotekę DLL PDFium do pliku o unikalnej nazwie w katalogu tymczasowym, wczytuje tę kopię przez LoadLibrary i ją inicjalizuje. Windows traktuje bibliotekę DLL wczytaną z innej ścieżki jako inny moduł, więc każda kopia dostaje własne globalne: własny cache czcionek, własny moduł stron, własne wszystko. Worker otwiera zapisany dokument w swoim prywatnym module, renderuje swoje strony stopniowo ze sprawdzeniami anulowania między krokami, potem niszczy bibliotekę, wyładowuje kopię i kasuje plik
Izolacja nie jest darmowa i domyślne to odzwierciedlają. Każdy worker płaci za kopię biblioteki DLL na dysku, drugi komplet globalnych PDFium w pamięci i świeże sparsowanie dokumentu. MaxWorkers = 0 znaczy najwyżej 4 workerów, MaxPixelsPerPage i MaxTotalOutputBytes ograniczają surowe wyjście, a opcje renderowania odwrócone i nocny duotone są odrzucane, bo bufory wracają surowe. Wynikiem jest TPdfParallelRenderReport, którego tablica Results trzyma jeden bufor 32-bitowy od góry na żądaną stronę, w kolejności żądań
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; // numery stron liczą się od 1
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// Migawka źródłowa jest brana na współdzielonym module, więc trzymaj
// blokadę PDFium na cały proces, jeśli inne wątki też używają 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;
Zauważ blokadę wokół wywołania. Moduły workerów są prywatne, ale krok migawki na starcie wykonuje SaveAs na współdzielonym module z wątku wołającego. Jeśli nic innego w twoim procesie nie dotyka TPdf współbieżnie, możesz blokadę odpuścić; jeśli cokolwiek dotyka, migawka potrzebuje tej samej ochrony co każde inne wywołanie współdzielonego modułu
| Wzorzec | Bezpieczne między dokumentami | Praca PDFium biegnie równolegle | Koszt |
|---|---|---|---|
Jeden TPdf na wątek, bez wspólnej blokady | Nie | Tak, aż zacznie uszkadzać | Przerywane crashe, uszkodzony stan procesu |
| Jedna blokada na cały proces wokół wszystkich wywołań PDFium | Tak | Nie | Część PDFium jest szeregowa |
ValidatePdfFilesParallel od v3.125.1 | Tak | Nie; ocena reguł jest równoległa | Parsowanie i preflight są szeregowe |
TPdf.RenderPagesParallel | Tak | Tak | Kopia biblioteki DLL, pamięć i świeży pars na workera |
Jak ustrukturyzować własny wielowątkowy kod PDFium?
Twoje własne wątki powinny dzielić jedną blokadę na cały proces i trzymać ją przez całe życie każdego używanego TPdf albo użyć API komponentu, które za ciebie izoluje moduł. Blokada musi być jednym obiektem dla całego procesu, nie jedną na wątek, na formularz albo na dokument; blokada, której dwa wątki nie dzielą, nie chroni niczego. Wzorzec niżej zwierciadli to, co komponent robi w środku od v3.125.1: tworzenie, wczytywanie, czytanie i zwalnianie w blokadzie, a wszystko, co nie dotyka PDFium, poza nią
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // jedna blokada na cały proces
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; // zamykanie dokumentu to też praca PDFium
end;
finally
PdfiumLock.Release;
end;
// Poniżej tej linii już żadnego PDFium, więc ta część biegnie równolegle
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
Kilka reguł trzyma ten wzorzec uczciwym w prawdziwej aplikacji:
- Wkładaj
TPdf.CreateiFreedo blokady, a nie tylko oczywiste wywołania. Wczytywanie, zamykanie, odczyty właściwości takich jakPageCount, zmiany stron, ekstrakcja tekstu, renderowanie i zapis — wszystko sięga do modułu - Sprawdzaj
Activepo przypisaniu. Nieudane wczytanie zostawiaActivenaFalse, aLastLoadReport.ErrorMessagemówi dlaczego - Trzymaj blokadę per dokument, a nie per wywołanie. Drobniejsza blokada jest możliwa teoretycznie, ale tylko jeśli żaden członek
TPdfnigdy nie wykona się poza nią, a gruboziarnista wersja to ta, na której sam polega komponent - Trzymaj powolną pracę nie-PDFium, jak zapisy do bazy, indeksowanie i wywołania sieciowe, poza blokadą, inaczej jeden powolny konsument szereguje wszystko
- Nie traktuj prywatnej blokady renderowania per instancja jako zamiennika. Strzeże jednego
TPdfprzed nim samym i niczego więcej
Ta sama ostrożność dotyczy kodu, którego nie napisałeś jako surowych wątków. Futures w tle to dobry sposób, żeby trzymać długie rendery z dala od wątku UI, jak opisuje renderowanie PDF w tle z anulowalnymi futures, ale egzekutor futures nie dodaje żadnej globalnej blokady PDFium od siebie. Jeśli kilka futures potrafi napędzać różne TPdf w tym samym czasie, weź tę samą blokadę na cały proces wewnątrz każdego workera i traktuj viewer na wątku głównym jako jeszcze jednego klienta współdzielonego modułu. Użycie między instancjami przez API asynchroniczne nie było osobno audytowane, więc konserwatywne założenie brzmi: potrzebuje tej samej serializacji co ręcznie pisane wątki. Gdy potrzebujesz prawdziwej równoległości PDFium do czegoś innego niż renderowanie stron, osobne procesy robocze dają każdemu zadaniu własny moduł z konstrukcji
Ściąga: reguły wątków PDFium dla Delphi
- Niebezpieczny stan PDFium jest na cały moduł: cache czcionek, moduł stron i inne globalne są współdzielone przez każdy dokument w procesie
- Jeden
TPdfna wątek nie izoluje niczego; dwie instancje na dwóch wątkach wciąż mogą się nawzajem uszkadzać - Typowe objawy to porażki wczytania, access violation w późniejszym kodzie,
External exception C000001Di wyjścia z0xC0000409albo0xC0000374 - Uszkodzenie trwa w procesie, więc zawodzące wywołanie często nie jest tym, które je spowodowało
ValidatePdfFilesParalleljest bezpieczny od v3.125.1; na starszych wersjach używajWorkerCount := 1TPdf.RenderPagesParalleljest naprawdę równoległy, bo każdy worker wczytuje izolowaną kopię modułu PDFium- Twoje własne wątki, taski i futures potrzebują jednej blokady na cały proces obejmującej każdy
TPdfodCreatedoFree
PDFium Component opakowuje silnik PDFium dla Delphi z wsadowym preflightem i walidacją, izolowanym renderowaniem równoległym, anulowalną pracą w tle i szczegółową diagnostyką wczytywania. Szczegóły i wydania są na stronie produktu PDFium Component