Teknisk artikkel

PDFium trådsikkerhet: Per-dokument-låser feiler i Delphi

PDFium er ikke trådsikkert på modulnivå, så to TPdf-instanser som jobber på to ulike filer i to tråder, kan fortsatt korrumpere hverandre. PDFium Component for Delphi håndterer dette på to måter: siden v3.125.1 serialiserer ValidatePdfFilesParallel hvert native PDFium-kall bak én prosessomfattende lås, mens TPdf.RenderPagesParallel gir hver arbeider sin egen isolerte kopi av PDFium-modulen. Buggen som tvang frem fiksen, var den verste typen intermittent. En batchvalideringstest passerte mesteparten av tiden, rapporterte så én av to gode filer som feilet, krasjet så neste test i samme prosess med en access violation, og tok noen ganger hele testløperen ned med en exit-kode i stedet for en stack trace. Ingenting var galt med testen, og ingenting var galt med noe enkelt dokument. Antakelsen var gal: én TPdf per tråd er ikke isolasjon

Hvorfor er ikke én TPdf per tråd nok?

Én TPdf per tråd er ikke nok fordi PDFium beholder sin utruelige tilstand i modulen, ikke i dokumentet. Hver TPdf eier sitt eget FPDF_DOCUMENT-håndtak, men hvert håndtak i prosessen betjenes av samme lastede DLL, og den DLL-en holder prosessomfattende singletons: font-cachen, sidemodulen, og andre globale strukturer som dokumentlasting, parsing og rendering alle rører. To tråder som laster to urelaterte filer, er to tråder som skriver inn i samme font-cache samtidig. Ingen eier de dataene på Delphi-siden, så ingenting på Delphi-siden kan låse dem per dokument

Komponenten har faktisk en lås, og det er lett å trekke feil konklusjon av den. TPdf pakker sine egne renderstier inn i en intern kritisk seksjon (EnterRenderLock / LeaveRenderLock, private metoder på TPdf). Den låsen er per instans. Den stopper to tråder fra å drive samme TPdf samtidig, noe som er en reell fare, men den kan ikke se en andre instans på en annen tråd, så kryss-instans-samtidighet går rett forbi den. Den generelle regelen er enkel nok til å si på én linje: i én lastet PDFium-modul, kan høyst én tråd være inne i PDFium i ethvert øyeblikk, uansett hvor mange dokumenter som er åpne

PDFium Component-diagram av to tråder som kjører separate TPdf-instanser på ulike dokumenter mens hvert kall konvergerer mot én lastet pdfium.dll-modul hvis font-cache, sidemodul og andre prosessomfattende globaler er delt, noe som gir lastefeil, access violations og fail-fast-avslutninger
PDFium beholder sin utruelige tilstand i modulen, ikke i dokumentet, så to TPdf-instanser på to tråder skriver inn i samme font-cache uansett hvor urelaterte filene er

Hvordan ser kryss-dokument-korrupsjon ut i en Delphi-prosess?

Kryss-dokument-korrupsjon ser ut som et tilfeldig miks av urelaterte feil, og skaden overlever koden som forårsaket den. Før v3.125.1 opprettet ValidatePdfFilesParallel én TPdf per arbeidertråd og kjørte Active := True pluss byggingen av preflight-rapporten samtidig på den delte modulen. Symptomene sett på både Delphi- og Free Pascal-bygg dekket hele spekteret:

  • En gyldig fil feiler å laste, eller kommer tilbake fra batchen som feilet når den skulle ha bestått
  • En access violation kommer til syne i et senere, urelatert kall, ofte i en annen test eller et annet dokument
  • External exception C000001D dukker opp i Delphi. Den koden er STATUS_ILLEGAL_INSTRUCTION, reist av ud2-instruksjonen som PDFiums interne CHECK- og IMMEDIATE_CRASH-makroer utfører når en invariant brytes
  • Prosessen avslutter med 0xC0000409 (fail-fast, rapportert som stack buffer overrun) eller 0xC0000374 (heap corruption), uten noe Delphi-unntak i det hele tatt

De to siste punktene er grunnen til at buggen var så vanskelig å få has på. Den parallelle valideringen ble ferdig, den korrupte globale tilstanden ble igjen, og neste fixture i samme prosess snublet i den. I én Delphi Win64-regresjonskjøring rammet en bølge av C000001D-feiler tester som aldri rørte batchvalidering; de var rett og slett den første koden som brukte PDFium etter skaden. De målte tallene gjør skalaen tydelig. En Delphi-probe som kjørte samme prøve gjennom to arbeidere, feilet 122 av 160 dokumenter i én kjøring og 138 av 160 i en annen, og én av de kjøringene reiste External exception C000001D rett ut. Et stress-tilfelle på 8 dokumenter, 4 arbeidere og 5 runder feilet eller krasjet i 5 av 5 kjøringer på Free Pascal Win64. Etter fiksen feilet samme probe 0 av 1 200 dokumenter

Slik forblir ValidatePdfFilesParallel trygg siden v3.125.1

ValidatePdfFilesParallel serialiserer nå den native halvdelen av hver jobb og holder den administrerte halvdelen parallell. Hver arbeider tar én kritisk seksjon på enhetsnivå før den oppretter sin TPdf, og holder den gjennom FileName, Active := True, byggingen av preflight-rapporten, og Free. Opprettelse og ødeleggelse er inne i låsen med vilje: å lukke et dokument kaller tilbake inn i modulen akkurat som lasting gjør. Så snart arbeideren har en fangnet TPdfPreflightReport-post, slipper den låsen og evaluerer valideringsreglene mot den posten, noe som ikke rører noen PDFium-tilstand, så regelevaluering for én fil overlapper PDFium-arbeidet for den neste

PDFium Component ValidatePdfFilesParallel-diagram som viser hver arbeider holde én prosessomfattende kritisk seksjon over TPdf opprett, lasting, preflight og frigjøring mens regelevalueringen av den fangne rapporten kjører utenfor låsen parallelt, slik at PDFium-halvdelen av batchen er seriell etter design
Opprettelse og ødeleggelse forblir inne i låsen fordi å lukke et dokument kaller tilbake inn i modulen, mens rapportevaluering ikke rører noen PDFium-tilstand og overlapper neste fil

To mindre endringer fulgte med fiksen. En lastefeil reiser nå EPdfError med LastLoadReport.ErrorMessage, så elementets ErrorMessage navngir det faktiske parseproblemet i stedet for en sekundær «no active document»-feil. Og kostnaden er oppgitt ærlig: PDFium-delen av batchen er nå seriell, så på en batch dominert av parsing og preflight, kjøper ekstra arbeidere lite. Er du på en versjon før v3.125.1, sett WorkerCount til 1; det fjerner samtidigheten og korrupsjonen 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 = prosessorantall, takket til 8
    Options.Standards := [ppsPdfA];
    // Med et eksplisitt register velger du selv det matchende profilen.
    // En tom Profiles-liste kjører hver registrerte regel, og regler for
    // standarder du ikke preflightet, 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;

Å sende nil som register er den kortere ruten: ValidatePdfFilesParallel oppretter da standardregisteret selv, utleder profillisten fra Options.Standards, og frigjør registeret når den returnerer. Resultater kommer alltid tilbake i inndaterekkefølge, uansett rekkefølge arbeiderne ble ferdige i. For rapportformatene og kommandolinje-wrapperen rundt samme motor, se batch PDF preflight-rapporter med PDFium Component CLI, og for hva PDF/A-sjekkene selv dekker, PDF/A preflight-validering i Delphi

Hvordan kjører RenderPagesParallel sider ekte parallelt?

TPdf.RenderPagesParallel kjører parallelt fordi arbeiderne aldri deler en PDFium-modul. Metoden lagrer først det aktive dokumentet inn i et kilde-lager på kalletråden. Hver arbeider kopierer så den lastede PDFium DLL-en til en unikt navngitt fil i temp-katalogen, laster den kopien med LoadLibrary, og initialiserer den. Windows behandler en DLL lastet fra en annen sti som en annen modul, så hver kopi får sine egne globaler: sin egen font-cache, sin egen sidemodul, sitt eget alt. Arbeideren åpner det lagrede dokumentet i sin private modul, renderer sidene sine progresivt med kanselleringssjekker mellom stegene, ødelegger så biblioteket, losser kopien og sletter filen

PDFium Component RenderPagesParallel-diagram der kalletråden lagrer et dokumentøyeblikksbilde, så kopierer hver arbeider PDFium DLL-en til en unik temp-fil, laster den som en separat modul med egne globaler, renderer sidene sine med kanselleringssjekker og losser kopien
Ekte parallellisme kommer fra modulisolasjon: Windows behandler hver DLL-kopi som en annen modul, så arbeiderne deler ingenting unntatt øyeblikksbildet kalletråden lagret under låsen

Isolasjonen er ikke gratis, og standardverdiene gjenspeiler det. Hver arbeider betaler for en DLL-kopi på disk, et andre sett PDFium-globaler i minnet, og en fersk parsing av dokumentet. MaxWorkers = 0 betyr høyst 4 arbeidere, MaxPixelsPerPage og MaxTotalOutputBytes takker det rå utdataet, og de inverterte og natt-duotone render-alternativene avvises fordi bufferne returneres rå. Resultatet er en TPdfParallelRenderReport hvis Results-matrise holder én top-down 32-bit buffer per forespurt side, i forespørselsrekkefølge

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-baserte

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

  // Kildeøyeblikksbildet tas på den delte modulen, så hold den
  // prosessomfattende PDFium-låsen hvis andre tråder også bruker 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;

Merk låsen rundt kallet. Arbeidermodulene er private, men øyeblikksbildesteget ved starten kjører SaveAs på den delte modulen fra kalletråden. Rører ingenting annet i prosessen din TPdf samtidig, kan du droppe låsen; rører noe det, trenger øyeblikksbildet samme beskyttelse som hvert annet delt-modul-kall

MønsterTrygt på tvers av dokumenterPDFium-arbeid kjører paralleltKostnad
Én TPdf per tråd, ingen delt låsNeiJa, til det korrumpererIntermitterende krasj, skadet tilstand i prosessen
Én prosessomfattende lås rundt alle PDFium-kallJaNeiPDFium-delen er seriell
ValidatePdfFilesParallel siden v3.125.1JaNei; regelevaluering er parallellParsing og preflight er serielle
TPdf.RenderPagesParallelJaJaDLL-kopi, minne og en fersk parsing per arbeider

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

Dine egne tråder bør dele én prosessomfattende lås og holde den for hele levetiden til hver TPdf de bruker, eller ellers bruke en komponent-API som isolerer modulen for deg. Låsen må være ett enkelt objekt for hele prosessen, ikke én per tråd, per skjema eller per dokument; en lås to tråder ikke deler, beskytter ingenting. Mønsteret under speiler det komponenten gjør internt siden v3.125.1: opprett, last, les og frigjør inne i låsen, og gjør alt som ikke rører PDFium utenfor den

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

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

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

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

Noen få regler holder mønsteret ærlig i en virkelig applikasjon:

  • Legg TPdf.Create og Free inne i låsen, ikke bare de åpenbare kallene. Lasting, lukking, egenskapslesinger som PageCount, sidebytter, tekstuttrekk, rendering og lagring når alle inn i modulen
  • Sjekk Active etter å ha tildelt den. En feilet lasting etterlater Active på False, og LastLoadReport.ErrorMessage sier hvorfor
  • Hold låsen per dokument snarere enn per kall. Finere låsing er mulig i prinsippet, men bare hvis intet TPdf-medlem noensinne kjører utenfor den, og den grove versjonen er den komponenten selv er avhengig av
  • Hold tregt ikke-PDFium-arbeid, som database-skrivinger, indeksering og nettverkskall, utenfor låsen, ellers vil én treg konsument serialisere alt
  • Ikke behandle den private per-instans render-låsen som en erstatning. Den vokter én TPdf mot seg selv og ingenting mer

Samme forsiktighet gjelder kode du ikke skrev som rå tråder. Bakgrunnsfutures er en god måte å holde lange renderinger vekk fra UI-tråden, som beskrevet i bakgrunnsrendering av PDF med kansellerbare futures, men future-eksekutøren legger ikke til en global PDFium-lås av seg selv. Kan flere futures drive ulike TPdf-instanser samtidig, ta den samme prosessomfattende låsen inne i hver arbeider, og behandle en viser på hovedtråden som én klient til av den delte modulen. Kryss-instans-bruk gjennom de asynkrone API-ene er ikke revidert separat, så den konservative antakelsen er at den trenger samme serialisering som håndskrevne tråder. Trenger du ekte PDFium-parallellisme til noe annet enn siderendering, gir separate arbeiderprosesser hver jobb sin egen modul av konstruksjon

Hurtigreferanse: PDFium trådingsregler for Delphi

  • PDFiums utruelige tilstand er modulomfattende: font-cache, sidemodul og andre globaler deles av hvert dokument i prosessen
  • Én TPdf per tråd isolerer ingenting; to instanser på to tråder kan fortsatt korrumpere hverandre
  • Typiske symptomer er lastefeil, access violations i senere kode, External exception C000001D, og avslutninger med 0xC0000409 eller 0xC0000374
  • Korrupsjonen vedvarer i prosessen, så det feilende kallet er ofte ikke det som forårsaket den
  • ValidatePdfFilesParallel er trygg siden v3.125.1; på eldre versjoner bruk WorkerCount := 1
  • TPdf.RenderPagesParallel er genuint parallell fordi hver arbeider laster en isolert kopi av PDFium-modulen
  • Dine egne tråder, tasks og futures trenger én prosessomfattende lås som dekker hver TPdf fra Create til Free

PDFium Component wrapper PDFium-motoren for Delphi med batch preflight og validering, isolert parallell rendering, kansellerbart bakgrunnsarbeid og detaljert lastediagnostikk. Detaljer og utgaver finner du på produktsiden for PDFium Component