Artykuł techniczny

PDFium i wątki: czemu blokady per dokument zawodzą w Delphi

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

Diagram PDFium Component dwóch wątków puszczających osobne instancje TPdf na różnych dokumentach, podczas gdy każde wywołanie zbiega się w jeden wczytany moduł pdfium.dll, którego cache czcionek, moduł stron i inne globalne na cały proces są współdzielone, co produkuje porażki wczytania, access violation i wyjścia fail-fast
PDFium trzyma swój niebezpieczny stan w module, a nie w dokumencie, więc dwie instancje TPdf na dwóch wątkach piszą do tego samego cache czcionek, jakiekolwiek by niepowiązane były pliki

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 to STATUS_ILLEGAL_INSTRUCTION, podnoszony przez instrukcję ud2, którą wykonują wewnętrzne makra CHECK i IMMEDIATE_CRASH PDFium, gdy dojdzie do naruszenia niezmiennika
  • proces wychodzi z 0xC0000409 (fail-fast, raportowany jako przepełnienie bufora stosu) albo 0xC0000374 (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

Diagram ValidatePdfFilesParallel w PDFium Component pokazujący każdego workera trzymającego jedną sekcję krytyczną na cały proces przez tworzenie, wczytywanie, preflight i zwalnianie TPdf, podczas gdy ocena reguł przechwyconego raportu biegnie poza blokadą równolegle, więc natywna połowa paczki jest szeregowa z założenia
tworzenie i niszczenie zostają w blokadzie, bo zamykanie dokumentu zawraca do modułu, a ocena raportu nie dotyka żadnego stanu PDFium i nakłada się na następny plik

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

Diagram RenderPagesParallel w PDFium Component, gdzie wątek wołający zapisuje migawkę dokumentu, potem każdy worker kopiuje bibliotekę DLL PDFium do unikalnego pliku tymczasowego, wczytuje ją jako osobny moduł z własnymi globalnymi, renderuje swoje strony ze sprawdzeniami anulowania i wyładowuje kopię
prawdziwa równoległość bierze się z izolacji modułów: Windows traktuje każdą kopię DLL jako inny moduł, więc workerzy nie dzielą niczego poza migawką, którą wątek wołający zapisał pod blokadą

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

WzorzecBezpieczne między dokumentamiPraca PDFium biegnie równolegleKoszt
Jeden TPdf na wątek, bez wspólnej blokadyNieTak, aż zacznie uszkadzaćPrzerywane crashe, uszkodzony stan procesu
Jedna blokada na cały proces wokół wszystkich wywołań PDFiumTakNieCzęść PDFium jest szeregowa
ValidatePdfFilesParallel od v3.125.1TakNie; ocena reguł jest równoległaParsowanie i preflight są szeregowe
TPdf.RenderPagesParallelTakTakKopia 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.Create i Free do blokady, a nie tylko oczywiste wywołania. Wczytywanie, zamykanie, odczyty właściwości takich jak PageCount, zmiany stron, ekstrakcja tekstu, renderowanie i zapis — wszystko sięga do modułu
  • Sprawdzaj Active po przypisaniu. Nieudane wczytanie zostawia Active na False, a LastLoadReport.ErrorMessage mówi dlaczego
  • Trzymaj blokadę per dokument, a nie per wywołanie. Drobniejsza blokada jest możliwa teoretycznie, ale tylko jeśli żaden członek TPdf nigdy 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 TPdf przed 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 TPdf na 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 C000001D i wyjścia z 0xC0000409 albo 0xC0000374
  • Uszkodzenie trwa w procesie, więc zawodzące wywołanie często nie jest tym, które je spowodowało
  • ValidatePdfFilesParallel jest bezpieczny od v3.125.1; na starszych wersjach używaj WorkerCount := 1
  • TPdf.RenderPagesParallel jest 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 TPdf od Create do Free

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