Teknisk artikel

PDFium Thread Safety: Hvorfor dokumentlåse fejler i Delphi

PDFium er ikke trådsikker på modulniveau, så to TPdf-instanser, der arbejder på to forskellige filer i to tråde, kan stadig korruptere hinanden. PDFium Component til Delphi håndterer det på to måder: siden v3.125.1 serialiserer ValidatePdfFilesParallel hvert nativt PDFium-kald bag én procesomfattende lås, mens TPdf.RenderPagesParallel giver hver worker sin egen isolerede kopi af PDFium-modulet. Buggen, der tvang fixet frem, var den værst tænkelige slags intermitterende. En batchvalideringstest bestod det meste af tiden, rapporterede så én af to gode filer som fejlet, crashede derefter næste test i samme proces med en access violation og tog nogle gange hele runneren ned med en exit-kode i stedet for en stack trace. Intet var galt med testen, og intet var galt med noget enkelt dokument. Antagelsen var forkert: én TPdf pr. tråd er ikke isolation

Hvorfor er én TPdf pr. tråd ikke nok?

Én TPdf pr. tråd er ikke nok, fordi PDFium holder sin utrygge tilstand i modulet, ikke i dokumentet. Hver TPdf ejer sin egen FPDF_DOCUMENT-handle, men hver handle i processen betjenes af den samme indlæste DLL, og den DLL holder procesomfattende singletons: font-cachen, page-modulet og andre globale strukturer, som dokumentindlæsning, parsing og rendering alle rører. To tråde, der indlæser to urelaterede filer, er to tråde, der skriver i samme font-cache på samme tid. Ingen ejer de data på Delphi-siden, så intet på Delphi-siden kan låse dem pr. dokument

Komponenten har da også en lås, og det er let at drage den forkerte konklusion ud af den. TPdf wrapper sine egne render-veje i en intern critical section (EnterRenderLock / LeaveRenderLock, private metoder på TPdf). Låsen er pr. instans. Den stopper to tråde i at drive samme TPdf på én gang, hvilket er en reel fare, men den kan ikke se en anden instans på en anden tråd, så cross-instance-concurrency går direkte forbi den. Den generelle regel er enkel nok til at sige i én linje: i ét indlæst PDFium-modul må højst én tråd være inde i PDFium på ethvert tidspunkt, uanset hvor mange dokumenter der er åbne

PDFium Component-diagram over to tråde, der kører separate TPdf-instanser på forskellige dokumenter, mens hvert kald konvergerer i ét indlæst pdfium.dll-modul, hvis font-cache, page-modul og andre procesomfattende globals deles, hvilket producerer indlæsningsfejl, access violations og fail-fast-exits
PDFium holder sin utrygge tilstand i modulet, ikke i dokumentet, så to TPdf-instanser på to tråde skriver i samme font-cache, uanset hvor urelaterede filerne er

Hvordan ser cross-document-korruption ud i en Delphi-proces?

Cross-document-korruption ser ud som en tilfældig blanding af urelaterede fejl, og skaden overlever koden, der forårsagede den. Før v3.125.1 oprettede ValidatePdfFilesParallel én TPdf pr. worker-tråd og kørte Active := True plus preflight-rapportbygning samtidigt på det delte modul. Symptomerne set på både Delphi- og Free Pascal-builds dækkede hele spektret:

  • En gyldig fil fejler at indlæse, eller kommer tilbage fra batchen som fejlet, når den burde have bestået
  • En access violation kommer frem i et senere, urelateret kald, ofte i en anden test eller et andet dokument
  • External exception C000001D optræder i Delphi. Den kode er STATUS_ILLEGAL_INSTRUCTION, udløst af ud2-instruktionen, som PDFiums interne CHECK- og IMMEDIATE_CRASH-makroer eksekverer, når en invariant brydes
  • Processen exiter med 0xC0000409 (fail-fast, rapporteret som stack buffer overrun) eller 0xC0000374 (heap corruption), uden nogen Delphi-exception overhovedet

De sidste to punkter er grunden til, at buggen var så svær at få greb om. Den parallelle validering fuldførte, den korrupterede globale tilstand blev tilbage, og næste fixture i samme proces snublede i den. I én Delphi Win64-regressionskørsel ramte en bølge af C000001D-fejl tests, der aldrig rørte batchvalidering; de var simpelthen den første kode, der brugte PDFium efter skaden. De målte tal gør skalaen klar. En Delphi-probe, der kørte det samme eksempel gennem to workers, fejlede 122 af 160 dokumenter i én kørsel og 138 af 160 i en anden, og én af de kørsler udløste External exception C000001D på stedet. Et stresstilfælde med 8 dokumenter, 4 workers og 5 runder fejlede eller crashede i 5 af 5 kørsler på Free Pascal Win64. Efter fixet fejlede samme probe 0 af 1.200 dokumenter

Hvordan ValidatePdfFilesParallel forbliver sikker siden v3.125.1

ValidatePdfFilesParallel serialiserer nu den native halvdel af hvert job og holder den managede halvdel parallel. Hver worker tager én unit-niveau critical section, før den opretter sin TPdf, og holder den gennem FileName, Active := True, preflight-rapportbygningen og Free. Oprettelse og destruktion er med vilje inde i låsen: at lukke et dokument kalder tilbage ind i modulet ligesom indlæsning. Så snart workeren har en fanget TPdfPreflightReport-record, slipper den låsen og evaluerer valideringsreglerne mod den record, som ikke rører nogen PDFium-tilstand, så regelevaluering for én fil overlapper PDFium-arbejdet for den næste

PDFium Component ValidatePdfFilesParallel-diagram, der viser hver worker holde én procesomfattende critical section gennem TPdf create, load, preflight og free, mens regelevaluering af den fangne rapport kører uden for låsen parallelt, så PDFium-halvdelen af batchen er serial by design
Oprettelse og destruktion forbliver inde i låsen, fordi at lukke et dokument kalder tilbage ind i modulet, mens rapportevaluering ikke rører nogen PDFium-tilstand og overlapper næste fil

To mindre ændringer fulgte med fixet. En indlæsningsfejl udløser nu EPdfError med LastLoadReport.ErrorMessage, så elementets ErrorMessage nævner det faktiske parseproblem i stedet for en sekundær "no active document"-fejl. Og omkostningen nævnes ærligt: PDFium-delen af batchen er nu serial, så på en batch domineret af parsing og preflight køber ekstra workers lidt. Er du på en version før v3.125.1, så sæt WorkerCount til 1; det fjerner concurrency og korruptionen med den

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 = processortal, lofter ved 8
    Options.Standards := [ppsPdfA];
    // Med et eksplicit registry vælger du selv det matchende profil.
    // En tom Profiles-liste kører hver registrerede regel, og regler for
    // standarder, du ikke preflightede, rapporterer "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;

At sende nil som registry er den kortere vej: ValidatePdfFilesParallel opretter så selv default-registryet, udleder profillisten fra Options.Standards og frigør registryet, når den returnerer. Resultater kommer altid tilbage i input-orden, uanset i hvilken orden workers fuldførte. For rapportformaterne og kommandolinje-wrapperen omkring samme motor, se batch PDF preflight-rapporter med PDFium Component CLI, og for hvad PDF/A-tjekkene selv dækker, PDF/A preflight-validering i Delphi

Hvordan kører RenderPagesParallel sider virkelig parallelt?

TPdf.RenderPagesParallel kører parallelt, fordi dens workers aldrig deler et PDFium-modul. Metoden gemmer først det aktive dokument i et kilde-lager på kaldende tråd. Hver worker kopierer derefter den indlæste PDFium-DLL til en unikt navngivet fil i temp-mappen, indlæser den kopi med LoadLibrary og initialiserer den. Windows behandler en DLL indlæst fra en anden sti som et andet modul, så hver kopi får sine egne globals: sin egen font-cache, sit eget page-modul, sit eget alt. Workeren åbner det gemte dokument i sit private modul, renderer sine sider progressivt med annulleringstjek mellem trin, ødelægger derefter biblioteket, aflæsser kopien og sletter filen

PDFium Component RenderPagesParallel-diagram, hvor den kaldende tråd gemmer et dokument-snapshot, derefter hver worker kopierer PDFium-DLL'en til en unik temp-fil, indlæser den som et separat modul med egne globals, renderer sine sider med annulleringstjek og aflæsser kopien
Ægte parallelisme kommer fra modulisolation: Windows behandler hver DLL-kopi som et andet modul, så workers deler ingenting undtagen snapshotet, den kaldende tråd gemte under låsen

Isolationen er ikke gratis, og defaulterne afspejler det. Hver worker betaler for en DLL-kopi på disken, et andet sæt PDFium-globals i hukommelsen og en frisk parsing af dokumentet. MaxWorkers = 0 betyder højst 4 workers, MaxPixelsPerPage og MaxTotalOutputBytes lofter det rå output, og de inverted- og night-duotone-render-options afvises, fordi bufferne returneres rå. Resultatet er en TPdfParallelRenderReport, hvis Results-array holder én top-down 32-bit buffer pr. forespurgt side, i forespørgsels-orden

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;                 // sidenumre er 1-baserede

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

  // Kilde-snapshottet tages på det delte modul, så hold den
  // procesomfattende PDFium-lås, hvis andre tråde også bruger 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;

Bemærk låsen omkring kaldet. Worker-modulerne er private, men snapshot-trinnet i starten kører SaveAs på det delte modul fra den kaldende tråd. Rører intet andet i din proces TPdf samtidigt, kan du droppe låsen; gør noget det, behøver snapshotet samme beskyttelse som ethvert andet shared-module-kald

MønsterSikkert på tværs af dokumenterPDFium-arbejde kører paralleltOmkostning
Én TPdf pr. tråd, ingen delt låsNejJa, indtil det korruptererIntermittente crashes, beskadiget proces-tilstand
Én procesomfattende lås omkring alle PDFium-kaldJaNejPDFium-delen er serial
ValidatePdfFilesParallel siden v3.125.1JaNej; regelevaluering er parallelParsing og preflight er serial
TPdf.RenderPagesParallelJaJaDLL-kopi, hukommelse og en frisk parsing pr. worker

Hvordan bør du strukturere din egen multitrådede PDFium-kode?

Dine egne tråde bør dele én procesomfattende lås og holde den gennem hele levetiden af hver TPdf, de bruger, eller ellers bruge et komponent-API, der isolerer modulet for dig. Låsen skal være ét enkelt objekt for hele processen, ikke én pr. tråd, pr. form eller pr. dokument; en lås, to tråde ikke deler, beskytter ingenting. Mønsteret nedenfor spejler, hvad komponenten gør internt siden v3.125.1: opret, indlæs, læs og frigør inde i låsen, og gør alt, der ikke rører PDFium, uden for den

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

var
  PdfiumLock: TCriticalSection;        // én lås for hele processen

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;                      // at lukke dokumentet er også PDFium-arbejde
      end;
    finally
      PdfiumLock.Release;
    end;
    // Ingen PDFium under denne linje, så denne del kører parallelt
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Et par regler holder mønsteret ærligt i en reel applikation:

  • Sæt TPdf.Create og Free inde i låsen, ikke kun de åbenlyse kald. Indlæsning, lukning, property-læsninger som PageCount, sideskift, tekstudtrækning, rendering og gemning når alle ind i modulet
  • Tjek Active efter tildeling. En fejlet indlæsning efterlader Active på False, og LastLoadReport.ErrorMessage siger hvorfor
  • Hold låsen pr. dokument frem for pr. kald. Finere låsning er mulig i princippet, men kun hvis intet TPdf-medlem nogensinde kører uden for den, og den grove version er den, komponenten selv stoler på
  • Hold langsomt ikke-PDFium-arbejde, som database-skrivninger, indeksering og netværkskald, uden for låsen, ellers serialiserer én langsom forbruger alt
  • Behandl ikke den private per-instans render-lås som en erstatning. Den vogter én TPdf mod sig selv og ikke mere

Samme forsigtighed gælder kode, du ikke selv skrev som rå tråde. Baggrunds-futures er en god måde at holde lange renders væk fra UI-tråden, som beskrevet i baggrunds-rendering af PDF'er med annullerbare futures, men future-executoren tilføjer ikke nogen global PDFium-lås af egen art. Kan flere futures drive forskellige TPdf-instanser på samme tid, så tag samme procesomfattende lås inde i hver worker, og behandl en viewer på hovedtråden som endnu en klient af det delte modul. Cross-instance-brug gennem de asynkrone API'er er ikke separat auditeret, så den konservative antagelse er, at den behøver samme serialisering som håndskrevne tråde. Behøver du ægte PDFium-parallelisme til noget andet end side-rendering, giver separate worker-processer hvert job sit eget modul ved konstruktion

Hurtig reference: PDFium-threading-regler for Delphi

  • PDFiums utrygge tilstand er modulomfattende: font-cache, page-modul og andre globals deles af hvert dokument i processen
  • Én TPdf pr. tråd isolerer ingenting; to instanser på to tråde kan stadig korruptere hinanden
  • Typiske symptomer er indlæsningsfejl, access violations i senere kode, External exception C000001D og exits med 0xC0000409 eller 0xC0000374
  • Korruptionen persisterer i processen, så det fejlende kald er ofte ikke det, der forårsagede den
  • ValidatePdfFilesParallel er sikker siden v3.125.1; på ældre versioner brug WorkerCount := 1
  • TPdf.RenderPagesParallel er genuint parallel, fordi hver worker indlæser en isoleret kopi af PDFium-modulet
  • Dine egne tråde, tasks og futures behøver én procesomfattende lås, der dækker hver TPdf fra Create til Free

PDFium Component wrapper PDFium-motoren til Delphi med batch-preflight og validering, isoleret parallel rendering, annullerbar baggrundsarbejde og detaljeret indlæsningsdiagnostik. Detaljer og udgaver står på produktsiden for PDFium Component