Technisch artikel

PDFium thread safety: waarom locks per document falen

PDFium is niet thread-safe op moduleniveau, dus twee TPdf-instanties die aan twee verschillende bestanden in twee threads werken kunnen elkaar nog steeds corrumperen. PDFium Component for Delphi pakt dat op twee manieren aan: sinds v3.125.1 serialiseert ValidatePdfFilesParallel elke native PDFium-aanroep achter één procesbrede lock, terwijl TPdf.RenderPagesParallel elke worker zijn eigen geïsoleerde kopie van de PDFium-module geeft. De bug die de fix afdwong was het ergste soort intermitterend. Een batchvalidatietest slaagde meestal, meldde toen één van twee goede bestanden als gefaald, crashte daarna de volgende test in hetzelfde proces met een access violation, en nam soms de hele runner mee naar beneden met een exitcode in plaats van een stack trace. Er was niets mis met de test, en niets mis met één enkel document. De aanname was fout: één TPdf per thread is geen isolatie

Waarom is één TPdf per thread niet genoeg?

Eén TPdf per thread is niet genoeg omdat PDFium zijn onveilige toestand in de module bewaart, niet in het document. Elke TPdf bezit zijn eigen handle FPDF_DOCUMENT, maar elke handle in het proces wordt bediend door dezelfde geladen DLL, en die DLL huisvest procesbrede singletons: de font cache, de page module en andere globale structuren die het laden, parsen en renderen van documenten allemaal aanraken. Twee threads die twee ongerelateerde bestanden laden zijn twee threads die tegelijk in dezelfde font cache schrijven. Niemand is op de Delphi-kant eigenaar van die data, dus niets op de Delphi-kant kan haar per document vergrendelen

De component heeft wel een lock, en het is makkelijk om er de verkeerde conclusie uit te trekken. TPdf wikkelt zijn eigen renderpaden in een interne critical section (EnterRenderLock / LeaveRenderLock, private methodes van TPdf). Die lock is per instantie. Ze verhindert dat twee threads tegelijk dezelfde TPdf besturen, een reëel gevaar, maar ze kan geen tweede instantie op een andere thread zien, dus cross-instance-concurrentie loopt er dwars doorheen. De algemene regel is simpel genoeg om in één regel te zeggen: in één geladen PDFium-module mag op elk moment hooguit één thread binnen PDFium zitten, hoeveel documenten er ook open staan

PDFium Component-diagram van twee threads die afzonderlijke TPdf-instanties op verschillende documenten draaien terwijl elke aanroep samenkomt in één geladen pdfium.dll-module waarvan de font cache, de page module en andere procesbrede globals gedeeld zijn, wat laadfalingen, access violations en fail-fast-exits oplevert
PDFium bewaart zijn onveilige toestand in de module, niet in het document, dus twee TPdf-instanties op twee threads schrijven in dezelfde font cache hoe ongerelateerd de bestanden ook zijn

Hoe ziet cross-document-corruptie eruit in een Delphi-proces?

Cross-document-corruptie ziet eruit als een willekeurige mix van ongerelateerde falingen, en de schade overleeft de code die haar veroorzaakte. Vóór v3.125.1 maakte ValidatePdfFilesParallel één TPdf per workerthread en draaide Active := True plus de preflight-rapportbouw tegelijk op de gedeelde module. De symptomen op zowel Delphi- als Free Pascal-builds bestreken het hele spectrum:

  • Een geldig bestand laadt niet, of komt uit de batch als gefaald terug terwijl hij geslaagd had moeten zijn
  • Een access violation komt boven in een latere, ongerelateerde aanroep, vaak in een andere test of een ander document
  • External exception C000001D verschijnt in Delphi. Die code is STATUS_ILLEGAL_INSTRUCTION, geworpen door de instructie ud2 die de interne macro's CHECK en IMMEDIATE_CRASH van PDFium uitvoeren zodra een invariant breekt
  • Het proces eindigt met 0xC0000409 (fail-fast, gemeld als een stack buffer overrun) of 0xC0000374 (heap corruption), zonder enige Delphi-exception

De laatste twee punten zijn de reden dat de bug zo moeilijk te pakken was. De parallelle validatie klaarde, de gecorrumpeerde globale toestand bleef achter, en de volgende fixture in hetzelfde proces struikelde erover. In één Delphi Win64-regressierun trof een golf C000001D-falingen tests die batchvalidatie nooit hadden aangeraakt; zij waren simpelweg de eerste code die na de schade PDFium gebruikte. De gemeten cijfers maken de schaal duidelijk. Een Delphi-probe die dezelfde sample door twee workers liet lopen faalde in één run op 122 van 160 documenten en in een andere op 138 van 160, en één van die runs wierp ronduit External exception C000001D. Een stressgeval van 8 documenten, 4 workers en 5 rondes faalde of crashte in 5 van 5 runs op Free Pascal Win64. Na de fix faalde dezelfde probe op 0 van 1.200 documenten

Hoe ValidatePdfFilesParallel sinds v3.125.1 veilig blijft

ValidatePdfFilesParallel serialiseert nu de native helft van elke job en houdt de beheerde helft parallel. Elke worker pakt één critical section op unitniveau voordat hij zijn TPdf maakt, en houdt haar vast door FileName, Active := True, de preflight-rapportbouw en Free heen. Creatie en destructie zitten met opzet binnen de lock: het sluiten van een document roept terug in de module net zoals laden dat doet. Zodra de worker een vastgelegd record TPdfPreflightReport heeft, geeft hij de lock vrij en evalueert de validatieregels tegen dat record, wat geen PDFium-toestand raakt, dus de regelsevaluatie voor het ene bestand overlapt het PDFium-werk voor het volgende

PDFium Component ValidatePdfFilesParallel-diagram waarin elke worker één procesbrede critical section vasthoudt over TPdf-aanmaken, laden, preflight en vrijgeven, terwijl de regelsevaluatie van het vastgelegde rapport buiten de lock parallel draait, zodat de PDFium-helft van de batch by design serial is
Creatie en destructie blijven binnen de lock omdat het sluiten van een document terugroept in de module, terwijl rapportevaluatie geen PDFium-toestand raakt en met het volgende bestand overlapt

Twee kleinere veranderingen kwamen met de fix mee. Een laadfaling werpt nu EPdfError met LastLoadReport.ErrorMessage, dus de ErrorMessage van het item noemt het werkelijke parseprobleem in plaats van een secundaire fout geen actief document. En de prijs wordt eerlijk genoemd: het PDFium-deel van de batch is nu serial, dus op een batch die wordt gedomineerd door parsen en preflight kopen extra workers weinig. Zit u op een versie vóór v3.125.1, zet dan WorkerCount op 1; dat haalt de concurrentie weg en de corruptie ergelijk mee

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 = processoraantal, geplafonneerd op 8
    Options.Standards := [ppsPdfA];
    // Met een expliciete registry kiest u zelf het bijpassende profiel.
    // Een lege lijst Profiles draait elke geregistreerde regel, en regels
    // voor standards die u niet preflightte melden "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 als registry meegeven is de kortere route: ValidatePdfFilesParallel maakt dan zelf de defaultregistry, leidt de profieldijst af uit Options.Standards en geeft de registry vrij wanneer hij terugkeert. Resultaten komen altijd terug in invoervolgorde, in welke volgorde de workers ook klaar raakten. Voor de rapportformaten en de command-line-wrapper rond dezelfde engine, zie batch-PDF-preflight-rapporten met de PDFium Component CLI, en voor wat de PDF/A-controles zelf dekken, PDF/A-preflightvalidatie in Delphi

Hoe draait RenderPagesParallel pagina's echt parallel?

TPdf.RenderPagesParallel draait parallel omdat zijn workers nooit een PDFium-module delen. De methode slaat eerst het actieve document op in een bronopslag op de aanroepende thread. Elke worker kopieert daarna de geladen PDFium-DLL naar een uniek benoemd bestand in de temp-directory, laadt die kopie met LoadLibrary en initialiseert haar. Windows beschouwt een DLL die vanaf een ander pad is geladen als een andere module, dus elke kopie krijgt zijn eigen globals: zijn eigen font cache, zijn eigen page module, zijn eigen alles. De worker opent het opgeslagen document in zijn privémodule, rendert zijn pagina's geleidelijk met annuleercontroles tussen de stappen, vernietigt daarna de library, ontlaadt de kopie en verwijdert het bestand

PDFium Component RenderPagesParallel-diagram waarin de aanroepende thread een documentsnapshot opslaat, daarna elke worker de PDFium-DLL kopieert naar een uniek temp-bestand, haar als afzonderlijke module met eigen globals laadt, zijn pagina's rendert met annuleercontroles en de kopie ontlaadt
Echte paralleliteit komt uit module-isolatie: Windows beschouwt elke DLL-kopie als een andere module, dus de workers delen niets behalve de snapshot die de aanroepende thread onder de lock heeft opgeslagen

De isolatie is niet gratis, en de defaults weerspiegelen dat. Elke worker betaalt voor een DLL-kopie op schijf, een tweede set PDFium-globals in het geheugen en een verse parse van het document. MaxWorkers = 0 betekent hooguit 4 workers, MaxPixelsPerPage en MaxTotalOutputBytes plafonneren de rauwe output, en de renderopties inverted en night-duotone worden geweigerd omdat de buffers rauw worden teruggegeven. Het resultaat is een TPdfParallelRenderReport waarvan de array Results per gevraagde pagina één top-down buffer van 32 bits bevat, in aanvraagvolgorde

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;                 // paginanummers zijn 1-based

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // De bronsnapshot wordt op de gedeelde module genomen, dus houd de
  // procesbrede PDFium-lock vast als andere threads ook TPdf gebruiken
  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;

Let op de lock rond de aanroep. De modules van de workers zijn privé, maar de snapshotstap aan het begin draait SaveAs op de gedeelde module vanaf de aanroepende thread. Raakt niets anders in uw proces tegelijk TPdf, dan kunt u de lock laten vallen; raakt iets anders haar wel, dan heeft de snapshot dezelfde bescherming nodig als elke andere gedeelde-module-aanroep

PatroonVeilig over documenten heenPDFium-werk draait parallelPrijs
Eén TPdf per thread, geen gedeelde lockNeeJa, totdat het corrumpeertIntermitterende crashes, beschadigde procestoestand
Eén procesbrede lock rond alle PDFium-aanroepenJaNeeHet PDFium-deel is serial
ValidatePdfFilesParallel sinds v3.125.1JaNee; regelsevaluatie is parallelParsen en preflight zijn serial
TPdf.RenderPagesParallelJaJaDLL-kopie, geheugen en een verse parse per worker

Hoe structureert u uw eigen multithreaded PDFium-code?

Uw eigen threads horen één procesbrede lock te delen en haar vast te houden gedurende de hele levensduur van elke TPdf die ze gebruiken, of anders een component-API te gebruiken die de module voor u isoleert. De lock moet één enkel object voor het hele proces zijn, niet één per thread, per form of per document; een lock die twee threads niet delen beschermt niets. Het patroon hieronder spiegelt wat de component intern sinds v3.125.1 doet: aanmaken, laden, lezen en vrijgeven binnen de lock, en alles wat PDFium niet raakt erbuiten doen

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // één lock voor het hele 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;                      // het document sluiten is ook PDFium-werk
      end;
    finally
      PdfiumLock.Release;
    end;
    // Geen PDFium onder deze regel, dus dit deel draait parallel
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

Enkele regels houden het patroon eerlijk in een echte applicatie:

  • Zet TPdf.Create en Free binnen de lock, niet alleen de voor de hand liggende aanroepen. Laden, sluiten, property-lezingen zoals PageCount, paginawissels, tekstextractie, renderen en opslaan reiken allemaal in de module
  • Controleer Active na toewijzing. Een mislukte load laat Active op False staan, en LastLoadReport.ErrorMessage zegt waarom
  • Houd de lock per document vast in plaats van per aanroep. Fijnmaziger vergrendelen kan in principe, maar alleen als geen enkel TPdf-lid ooit erbuiten draait, en de grove versie is degene waar de component zelf op leunt
  • Houd traag niet-PDFium-werk, zoals databasewrites, indexering en netwerkaanroepen, buiten de lock, anders serialiseert één trage consument alles
  • Beschouw de privé-renderlock per instantie niet als vervanging. Ze bewaakt één TPdf tegen zichzelf en niets meer

Dezelfde voorzichtigheid geldt voor code die u niet als kale threads schreef. Achtergrondfutures zijn een goede manier om lange renders van de UI-thread weg te houden, zoals beschreven in PDF op de achtergrond renderen met annuleerbare futures, maar de future-executor voegt geen globale PDFium-lock van eigen bodem toe. Kunnen meerdere futures tegelijk verschillende TPdf-instanties besturen, pak dan dezelfde procesbrede lock binnen elke worker, en behandel een viewer op de hoofdthread als nog een klant van de gedeelde module. Cross-instance-gebruik via de asynchrone APIs is niet apart geauditeerd, dus de conservatieve aanname is dat het dezelfde serialisatie nodig heeft als handgeschreven threads. Heeft u echte PDFium-paralleliteit nodig voor iets anders dan paginarendering, dan geven afzonderlijke werkprocessen elke job uit constructie zijn eigen module

Snelnaslag: de PDFium-threadingregels voor Delphi

  • De onveilige toestand van PDFium is modulebreed: font cache, page module en andere globals worden door elk document in het proces gedeeld
  • Eén TPdf per thread isoleert niets; twee instanties op twee threads kunnen elkaar nog steeds corrumperen
  • Typische symptomen zijn laadfalingen, access violations in latere code, External exception C000001D, en exits met 0xC0000409 of 0xC0000374
  • Corruptie blijft in het proces achter, dus de falende aanroep is vaak niet degene die haar veroorzaakte
  • ValidatePdfFilesParallel is veilig sinds v3.125.1; op oudere versies gebruikt u WorkerCount := 1
  • TPdf.RenderPagesParallel is echt parallel omdat elke worker een geïsoleerde kopie van de PDFium-module laadt
  • Uw eigen threads, tasks en futures hebben één procesbrede lock nodig die elke TPdf dekt van Create tot Free

PDFium Component wikkelt de PDFium-engine voor Delphi met batch-preflight en validatie, geïsoleerd parallel renderen, annuleerbaar achtergrondwerk en gedetailleerde laaddiagnostiek. Details en edities staan op de PDFium Component-productpagina