Teknisk artikel

PDFium-trådsäkerhet: varför per-dokument-lås inte räcker

PDFium är inte trådsäkert på modulnivå, så två TPdf-instanser som arbetar på två olika filer i två trådar kan fortfarande korrumpera varandra. PDFium Component for Delphi hanterar detta på två sätt: sedan v3.125.1 serialiserar ValidatePdfFilesParallel varje nativt PDFium-anrop bakom ett processövergripande lås, medan TPdf.RenderPagesParallel ger varje arbetare sin egen isolerade kopia av PDFium-modulen. Buggen som tvingade fram fixen var den värsta sortens intermittent. Ett batchvalideringstest passerade större delen av tiden, rapporterade sedan en av två bra filer som misslyckad, kraschade sedan nästa test i samma process med ett access violation, och tog ibland ner hela testlöparen med en exitkod i stället för en stackspårning. Inget var fel på testet, och inget var fel på något enskilt dokument. Antagandet var fel: en TPdf per tråd är ingen isolering

Varför räcker inte en TPdf per tråd?

En TPdf per tråd räcker inte för att PDFium håller sitt otrygga tillstånd i modulen, inte i dokumentet. Varje TPdf äger sitt eget FPDF_DOCUMENT-handtag, men varje handtag i processen betjänas av samma laddade DLL, och den DLL:en håller processövergripande singeltoner: typsnittscachen, sidmodulen och andra globala strukturer som dokumentinläsning, parsning och rendering alla rör. Två trådar som läser in två orelaterade filer är två trådar som skriver i samma typsnittscache samtidigt. Ingen äger den data på Delphi-sidan, så ingenting på Delphi-sidan kan låsa den per dokument

Komponenten har visst ett lås, och det är lätt att dra fel slutsats av det. TPdf vecklar in sina egna renderingsvägar i en intern kritisk sektion (EnterRenderLock / LeaveRenderLock, privata metoder hos TPdf). Det låset är per instans. Det hindrar två trådar från att köra samma TPdf samtidigt, vilket är en verklig risk, men det kan inte se en andra instans på en annan tråd, så samtidighet mellan instanser går rakt förbi det. Huvudregeln är enkel nog att säga på en rad: i en enda laddad PDFium-modul får som mest en tråd vara inne i PDFium i varje ögonblick, oavsett hur många dokument som är öppna

PDFium Component-diagram över två trådar som kör separata TPdf-instanser på olika dokument medan varje anrop konvergerar mot en laddad pdfium.dll-modul vars typsnittscache, sidmodul och andra processövergripande globaler delas, vilket ger laddningsfel, access violations och fail-fast-avslut
PDFium håller sitt otrygga tillstånd i modulen, inte i dokumentet, så två TPdf-instanser på två trådar skriver i samma typsnittscache hur orelaterade filerna än är

Hur ser korrumpering mellan dokument ut i en Delphi-process?

Korrumpering mellan dokument ser ut som en slumpmässig blandning av orelaterade fel, och skadan överlever koden som orsakade den. Före v3.125.1 skapade ValidatePdfFilesParallel en TPdf per arbetstråd och körde Active := True plus preflight-rapportbygget samtidigt på den delade modulen. Symptomen som setts på både Delphi- och Free Pascal-byggen täckte hela spektrumet:

  • En giltig fil misslyckas att läsas in, eller kommer tillbaka från batchen som misslyckad när den borde ha passerat
  • Ett access violation dyker upp i ett senare, orelaterat anrop, ofta i ett annat test eller ett annat dokument
  • External exception C000001D dyker upp i Delphi. Den koden är STATUS_ILLEGAL_INSTRUCTION, kastad av instruktionen ud2 som PDFiums interna CHECK- och IMMEDIATE_CRASH-makron kör när en invariant bryts
  • Processen avslutas med 0xC0000409 (fail-fast, rapporterad som en stack buffer overrun) eller 0xC0000374 (heap corruption), utan något Delphi-undantag alls

De två sista punkterna är varför buggen var så svår att fastställa. Den parallella valideringen fullbordades, det korrumperade globala tillståndet stannade kvar, och nästa fixtur i samma process snubblade på den. I en Delphi Win64-regressionskörning träffade en våg av C000001D-fel tester som aldrig rörde batchvalidering; de var helt enkelt den första koden att använda PDFium efter skadan. De uppmätta siffrorna gör skalan uppenbar. En Delphi-sond som körde samma exempel genom två arbetare misslyckades med 122 av 160 dokument i en körning och 138 av 160 i en annan, och en av de körningarna kastade External exception C000001D rakt av. Ett stresstest med 8 dokument, 4 arbetare och 5 omgångar misslyckades eller kraschade i 5 av 5 körningar på Free Pascal Win64. Efter fixen misslyckades samma sond med 0 av 1 200 dokument

Hur ValidatePdfFilesParallel förblir säkert sedan v3.125.1

ValidatePdfFilesParallel serialiserar nu den nativa halvan av varje jobb och håller den hanterade halvan parallell. Varje arbetare tar en kritisk sektion på enhetsnivå innan den skapar sin TPdf, och håller den genom FileName, Active := True, preflight-rapportbygget och Free. Skapande och förstörelse ligger med flit inuti låset: att stänga ett dokument anropar tillbaka in i modulen precis som inläsning gör. Så snart arbetaren har en infångad TPdfPreflightReport-post släpper den låset och utvärderar valideringsreglerna mot den posten, vilket inte rör något PDFium-tillstånd, så regelutvärdering för en fil överlappar PDFium-arbetet för nästa

PDFium Component ValidatePdfFilesParallel-diagram som visar varje arbetare hålla en processövergripande kritisk sektion över TPdf-skapande, inläsning, preflight och frigöring medan regelutvärdering av den infångade rapporten körs utanför låset parallellt, så att PDFium-halvan av batchen är seriell av design
Skapande och förstörelse stannar inuti låset för att stängning av ett dokument anropar tillbaka in i modulen, medan rapportutvärdering inte rör något PDFium-tillstånd och överlappar nästa fil

Två mindre ändringar kom med fixen. Ett inläsningsfel kastar nu EPdfError med LastLoadReport.ErrorMessage, så postens ErrorMessage namnger det verkliga parsproblemet i stället för ett sekundärt fel "no active document". Och kostnaden anges ärligt: PDFium-delen av batchen är nu seriell, så på en batch dominerad av parsning och preflight köper extra arbetare lite. Är du på en version före v3.125.1, sätt WorkerCount till 1; det tar bort samtidigheten och korrumperingen 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 = processorsantal, tak 8
    Options.Standards := [ppsPdfA];
    // Med en explicit registry väljer du själv matchande profil.
    // En tom Profiles-lista kör varje registrerad regel, och regler för
    // standarder du inte preflightat rapporterar "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;

Att skicka nil som registry är den kortare vägen: ValidatePdfFilesParallel skapar då standardregistret själv, härleder profillistan från Options.Standards och frigör registret när det returnerar. Resultaten kommer alltid tillbaka i inmatningsordning, vilken ordning arbetarna fullbordade i än. För rapportformaten och kommandoradswrappern runt samma motor, se batch-PDF-preflightrapporter med PDFium Component CLI, och för vad PDF/A-kontrollerna själva täcker, PDF/A-preflightvalidering i Delphi

Hur kör RenderPagesParallel sidor verkligen parallellt?

TPdf.RenderPagesParallel körs parallellt för att dess arbetare aldrig delar en PDFium-modul. Metoden sparar först det aktiva dokumentet i ett källförråd på anropstråden. Varje arbetare kopierar sedan den laddade PDFium-DLL:en till en unikt namngiven fil i temp-katalogen, laddar den kopian med LoadLibrary och initierar den. Windows behandlar en DLL laddad från en annan sökväg som en annan modul, så varje kopia får egna globaler: egen typsnittscache, egen sidmodul, eget allt. Arbetaren öppnar det sparade dokumentet i sin privata modul, renderar sina sidor successivt med avbrytskontroller mellan stegen, förstör sedan biblioteket, lossar kopian och raderar filen

PDFium Component RenderPagesParallel-diagram där anropstråden sparar en dokumentsnapshot, sedan kopierar varje arbetare PDFium-DLL:en till en unik temporärfil, laddar den som en separat modul med egna globaler, renderar sina sidor med avbrytskontroller och lossar kopian
Verklig parallellitet kommer från modulisolering: Windows behandlar varje DLL-kopia som en annan modul, så arbetarna delar ingenting utom snapshoten anropstråden sparade under låset

Isoleringen är inte gratis, och standardvärdena speglar det. Varje arbetare betalar för en DLL-kopia på disken, en andra uppsättning PDFium-globaler i minnet och en färsk parsning av dokumentet. MaxWorkers = 0 betyder som mest 4 arbetare, MaxPixelsPerPage och MaxTotalOutputBytes taklägger råutdata, och de inverterade och natt-duotone-renderingsalternativen avvisas för buffrarna returneras råa. Resultatet är en TPdfParallelRenderReport vars Results-matris håller en topp-ned 32-bitarsbuffert per begärd sida, i begäransordning

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;                 // sidonummer är ettbaserade

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

  // Källsnapshoten tas på den delade modulen, så håll det
  // processövergripande PDFium-låset om andra trådar också använder 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;

Notera låset runt anropet. Arbetarmodulerna är privata, men snapshotsteget i början kör SaveAs på den delade modulen från anropstråden. Rör inget annat i din process TPdf samtidigt kan du släppa låset; gör något det behöver snapshoten samma skydd som varje annat anrop till den delade modulen

MönsterSäkert mellan dokumentPDFium-arbete körs parallelltKostnad
En TPdf per tråd, inget delat låsNejJa, tills det korrumperarIntermittenta krascher, skadat processtillstånd
Ett processövergripande lås runt alla PDFium-anropJaNejPDFium-delen är seriell
ValidatePdfFilesParallel sedan v3.125.1JaNej; regelutvärdering är parallellParsning och preflight är seriella
TPdf.RenderPagesParallelJaJaDLL-kopia, minne och en färsk parsning per arbetare

Hur ska du strukturera din egen flertrådade PDFium-kod?

Dina egna trådar ska dela ett processövergripande lås och hålla det under hela livslängden för varje TPdf de använder, eller annars använda ett komponent-API som isolerar modulen åt dig. Låset måste vara ett enda objekt för hela processen, inte ett per tråd, per form eller per dokument; ett lås som två trådar inte delar skyddar ingenting. Mönstret nedan speglar vad komponenten gör internt sedan v3.125.1: skapa, läsa in, läsa och frigöra inuti låset, och göra allt som inte rör PDFium utanför det

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

var
  PdfiumLock: TCriticalSection;        // ett lås för hela 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;                      // att stänga dokumentet är också PDFium-arbete
      end;
    finally
      PdfiumLock.Release;
    end;
    // Ingen PDFium under den här raden, så den här delen körs parallellt
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Några regler håller mönstret ärligt i en verklig applikation:

  • Lägg TPdf.Create och Free inuti låset, inte bara de uppenbara anropen. Inläsning, stängning, egenskapsläsningar som PageCount, sidbyten, textutdrag, rendering och sparande når alla in i modulen
  • Kontrollera Active efter att ha tilldelat den. En misslyckad inläsning lämnar Active på False, och LastLoadReport.ErrorMessage säger varför
  • Håll låset per dokument i stället för per anrop. Finare låsning är möjlig i princip, men bara om ingen TPdf-medlem någonsin körs utanför det, och den grova versionen är den komponenten själv förlitar sig på
  • Håll långsamt icke-PDFium-arbete, som databasskrivningar, indexering och nätverksanrop, utanför låset, annars serialiserar en långsam konsument allting
  • Behandla inte det privata låset per instans för renderingen som en ersättning. Det vakar över en TPdf mot sig själv och inget mer

Samma försiktighet gäller kod du inte skrev som råa trådar. Bakgrundsfutures är ett bra sätt att hålla långa renderningar borta från UI-tråden, som beskrivs i bakgrundsrendering av PDF med avbrytbara futures, men future-exekutorn lägger inte till något globalt PDFium-lås av egen hand. Kan flera futures driva olika TPdf-instanser samtidigt, ta samma processövergripande lås inuti varje arbetare, och behandla en visare på huvudtråden som ytterligare en klient av den delade modulen. Användning mellan instanser via de asynkrona API:erna har inte granskats separat, så det konservativa antagandet är att den behöver samma serialisering som handskrivna trådar. Behöver du verklig PDFium-parallellitet för något annat än sidrendering ger separata arbetarprocesser varje jobb sin egen modul av konstruktion

Snabbreferens: PDFium-trådregler för Delphi

  • PDFiums otrygga tillstånd gäller hela modulen: typsnittscache, sidmodul och andra globaler delas av varje dokument i processen
  • En TPdf per tråd isolerar ingenting; två instanser på två trådar kan fortfarande korrumpera varandra
  • Typiska symptom är laddningsfel, access violations i senare kod, External exception C000001D och avslut med 0xC0000409 eller 0xC0000374
  • Korrumperingen består i processen, så det misslyckande anropet är ofta inte det som orsakade den
  • ValidatePdfFilesParallel är säkert sedan v3.125.1; på äldre versioner använd WorkerCount := 1
  • TPdf.RenderPagesParallel är genuint parallellt för varje arbetare laddar en isolerad kopia av PDFium-modulen
  • Dina egna trådar, uppgifter och futures behöver ett processövergripande lås som täcker varje TPdf från Create till Free

PDFium Component wrappar PDFium-motorn för Delphi med batch-preflight och validering, isolerad parallell rendering, avbrytbart bakgrundsarbete och detaljerad laddningsdiagnostik. Detaljer och utgåvor finns på produktsidan för PDFium Component