Tehnični članak

Varnost niti PDFium: zakaj zaklepi na dokument spodletijo

PDFium ni varen za nite na ravni modula, zato si lahko dve instanci TPdf, ki delata na dveh različnih datotekah v dveh nitih, vseeno pokvarita druga drugo. Komponenta PDFium Component za Delphi to opravi na dva načina: od v3.125.1 ValidatePdfFilesParallel vrsti vsak izvorni klic PDFium za enim zaklepom po celem procesu, TPdf.RenderPagesParallel pa vsakemu delavcu da lastno izolirano kopijo modula PDFium. Hrošč, ki je vsilil popravek, je bila najhujša vrsta občasnega. Preizkus paketne validacije je večino časa šel skozi, potem pa je eno od dveh dobrich datotek poročal kot spodletelo, nato pa sesul naslednji preizkus v istem procesu s kršitvijo dostopa, včasih pa vzel s seboj celego poganjalnika z izstopno kodo namesto sledi skladu. Preizkus ni imel nič narobe in noben posamezen dokument ni imel nič narobe. Narobe je bila domneva: ena TPdf na nič ni izolacija

Zakaj ena TPdf na nit ne zadošča?

Ena TPdf na nit ne zadošča, ker PDFium hrani svoje nevarno stanje v modulu, ne v dokumentu. Vsaka TPdf lasti svoj ročaj FPDF_DOCUMENT, vsakemu ročaju v procesu pa služi isti naloženi DLL, ta DLL pa hrani singleton-e po celem procesu: predpomnilnik pisav, stranski modul in druge globalne strukture, ki se jih dotikajo nalaganje dokumentov, razčlenjevanje in izrisovanje. Dve niti, ki nalagata dve nepovezani datoteki, sta dve niti, ki istočasno pišeta v isti predpomnilnik pisav. Nihče na strani Delphi ne lasti teh podatkov, zato jih na strani Delphi nič ne more zakleniti na dokument

Komponenta sicer ima zaklep in iz njega je lahko sklepati napačno. TPdf ovije svoje izrisovalne poti v notranji kritični odsek (EnterRenderLock / LeaveRenderLock, privatni metodi TPdf). Ta zaklep je na instanco. Prepreči dvema nitema, da istega TPdf poganjata hkrati, kar je prava nevarnost, druge instance na drugi niti pa ne vidi, zato sočasnost med instancami gre naravnost mimo nje. Splošno pravilo je preprosto dovolj, da ga zapišemo v eni vrstici: v enem naloženem modulu PDFium sme biti v vsakem trenutku znotraj PDFiuma največ ena nič, ne glede na to, koliko dokumentov je odprtih

PDFium Component diagram dveh niti, ki poganjata ločeni instanci TPdf nad različnimi dokumenti, medtem ko se vsak klic steka v enega naloženega modula pdfium.dll, katerega predpomnilnik pisav, stranski modul in drugi globalni podatki po celem procesu so v skupni rabi, kar da spodletitve nalaganja, kršitve dostopa in hitre izhode
PDFium hrani svoje nevarno stanje v modulu, ne v dokumentu, zato dve instanci TPdf na dveh nitih pišeta v isti predpomnilnik pisav, ne glede na to, kako nepovezani sta datoteki

Kako izgleda pokvarjenost med dokumenti v procesu Delphi?

Pokvarjenost med dokumenti izgleda kot naključna mešanica nepovezanih spodletitev, škoda pa preživi kodo, ki jo je povzročila. Pred v3.125.1 je ValidatePdfFilesParallel ustvaril eno TPdf na delavsko nič in pognal Active := True plus gradnjo predpoletnega poročila sočasno na skupnem modulu. Simptomi, opaženi na gradnjah Delphi in Free Pascal, so pokrili celoten razpon:

  • Veljavna datoteka se ne naloži ali pa pride iz paketa kot spodletela, čeprav bi morala iti skozi
  • Kršitev dostopa pride na površje v poznejšem, nepovezanem klicu, pogosto v drugem preizkusu ali drugem dokumentu
  • V Delphiju se pokaže External exception C000001D. Ta koda je STATUS_ILLEGAL_INSTRUCTION, sprožena z inštrukcijo ud2, ki jo izvajata makra internega CHECK in IMMEDIATE_CRASH PDFium, kadar se zlomi neka invarianta
  • Proces izstopi s 0xC0000409 (fail-fast, poročan kot prekoračitev medpomnilnika sklada) ali 0xC0000374 (pokvarjenost kupčke), brez vsakršne izjeme Delphi

Zadnji dve točki sta razlog, zakaj je bilo hrošča tako težko pripeti. Vzporedna validacija se je zaključila, pokvarjeno globalno stanje je ostalo za sabo in naslednja naprava v istem procesu se je o njega spotaknila. V enem regresijskem zagonu Delphi Win64 je val spodletitev C000001D zadel preizkuse, ki paketne validacije nikoli niso smeli; bili so preprosto prva koda, ki je po škodi uporabila PDFium. Izmerjene številke naredijo obseg jasen. Preskusni program Delphi, ki je enak vzorec pognal skozi dva delavca, je v enem zagonu spodletel na 122 od 160 dokumentov, v drugem na 138 od 160, eden od teh zagonov pa je naravnost sprožil External exception C000001D. Stresni primer 8 dokumentov, 4 delavcev in 5 krogov je spodletel ali sesul v 5 od 5 zagonov na Free Pascal Win64. Po popravku je isti preskus spodletel na 0 od 1200 dokumentov

Kako ValidatePdfFilesParallel ostane varen od v3.125.1

ValidatePdfFilesParallel zdaj vrsti izvorno polovico vsakega posla in upravljano polovico pusti vzporedno. Vsak delavec vzame eno kritični odsek na ravni enote, preden ustvari svojo TPdf, in ga drži skozi FileName, Active := True, gradnjo predpoletnega poročila in Free. Ustvarjanje in uničenje sta znotraj zaklepa namenoma: zapiranje dokumenta kliče nazaj v modul tako kot nalaganje. Ko delavec dobi zajet zapis TPdfPreflightReport, sprosti zaklep in pravila validacije vrednoti proti temu zapisu, kar se ne dotika nobenega stanja PDFium, zato vrednotenje pravil za eno datoteko prekriva delo PDFium za naslednjo

PDFium Component diagram ValidatePdfFilesParallel, ki prikazuje vsakega delavca, ki drži eno kritični odsek po celem procesu čez TPdf ustvari, naloži, preflight in sprosti, medtem ko vrednotenje pravil zajetega poročila teče izven zaklepa vzporedno, tako da je PDFium polovica paketa po zasnovi serijska
Ustvarjanje in uničenje ostajata znotraj zaklepa, ker zapiranje dokumenta kliče nazaj v modul, vrednotenje poročila pa se ne dotika nobenega stanja PDFium in prekriva naslednjo datoteko

S popravkom sta prišli dve manjši spremembi. Spodletitev nalaganja zdaj sproži EPdfError z LastLoadReport.ErrorMessage, tako da ErrorMessage elementa poimenuje dejansko težavo razčlenjevanja namesto sekundarne napake »ni aktivnega dokumenta«. In cena je iskreno navedena: PDFium del paketa je zdaj serijski, zato pri paketu, ki ga dominirata razčlenjevanje in preflight, dodatni delavci ne kupijo veliko. Če ste na različici pred v3.125.1, nastavite WorkerCount na 1; to odstrani sočasnost in z njo pokvarjenost

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 = število procesorjev, omejeno na 8
    Options.Standards := [ppsPdfA];
    // Z izrecnim registrom izberite ujemajoč profil sami.
    // Prazna tabela Profiles požene vsako registrirano pravilo, pravila za
    // standarde, ki jih niste preflightali, pa poročajo "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;

Podajanje nil kot registra je krajša pot: ValidatePdfFilesParallel potem sam ustvari privzeti register, seznam profilov izpelje iz Options.Standards in register sprosti, ko se vrne. Rezultati vedno pridejo nazaj v vrstnem redu vnosa, kakršen koli že je bil vrstni red, v katerem so delavci končali. Za formate poročil in ovijalnik ukazne vrstice okoli istega pogona glej paketa predpoletnih poročil PDF s CLI komponente PDFium Component, kaj sami preizkusi PDF/A pokrijejo pa validacija preflight PDF/A v Delphiju

Kako RenderPagesParallel poganja strani resnično vzporedno?

TPdf.RenderPagesParallel teče vzporedno, ker njegovi delavci nikoli ne delijo modula PDFium. Metoda najprej shrani aktivni dokument v izvorno shrambo na klicajoči niti. Vsak delavec nato kopira naloženi DLL PDFium v enolično poimenovano datoteko v začasni imenik, to kopijo naloži z LoadLibrary in jo inicializira. Windows DLL, naložen iz druge poti, obravnava kot drugačen modul, tako da vsaka kopija dobi svoje globalne podatke: svoj predpomnilnik pisav, svoj stranski modul, svoje vse. Delavec odpre shranjen dokument v svojem zasebnem modulu, postopoma izriše svoje strani s preizkusi preklica med koraki, nato uniči knjižnico, razloži kopijo in izbriše datoteko

PDFium Component diagram RenderPagesParallel, kjer klicajoča nit shrani posnetek dokumenta, nato vsak delavec kopira DLL PDFium v enolično začasno datoteko, jo naloži kot ločen modul z lastnimi globalnimi podatki, izriše svoje strani s preizkusi preklica in razloži kopijo
Prava vzporednost prihaja iz izolacije modulov: Windows vsako kopijo DLL obravnava kot drugačen modul, zato si delavci ne delijo ničesar razen posnetka, ki ga je klicajoča nit shranila pod zaklepom

Izolacija ni brezplačna in privzetosti to odražajo. Vsak delavec plača kopijo DLL na disku, drugi nabor globalnih podatkov PDFium v pomnilniku in svežo razčlenitev dokumenta. MaxWorkers = 0 pomeni največ 4 delavce, MaxPixelsPerPage in MaxTotalOutputBytes omejita surov izhod, obrnjeni in nočni dvo-tonski možnosti izrisa pa sta zavrnjeni, ker se medpomnilniki vračajo surovi. Rezultat je TPdfParallelRenderReport, katerega tabela Results drži en zgornje-navzdol 32-bitni medpomnilnik na zahtevano stran, v vrstnem redu zahtev

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;                 // številke strani so eno-osnovne

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

  // Izvorni posnetek se naredi na skupnem modulu, zato držite zaklep
  // PDFium po celem procesu, kadar tudi druge nite uporabljajo 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;

Upoštevajte zaklep okoli klica. Delavski moduli so zasebni, korak posnetka na začetku pa požene SaveAs na skupnem modulu iz klicajoče niti. Če v vašem procesu nič drugega sočasno ne dotakne TPdf, lahko zaklep spustite; če karkoli ga dotakne, potrebuje posnetek isto zaščito kot vsak drug klic skupnega modula

VzorecVarno med dokumentiDelo PDFium teče vzporednoCena
Ena TPdf na nit, brez skupnega zaklepaNeDa, dokler ne pokvariObčasna sesutja, poškodovano stanje procesa
En zaklep po celem procesu okoli vseh klicev PDFiumDaNePDFium del je serijski
ValidatePdfFilesParallel od v3.125.1DaNe; vrednotenje pravil je vzporednoRazčlenjevanje in preflight sta serijska
TPdf.RenderPagesParallelDaDaKopija DLL, pomnilnik in sveža razčlenitev na delavca

Kako naj uredite svojo večnitno kodo PDFium?

Vaše nite naj si delijo en zaklep po celem procesu in ga držijo za celotno življenje vsake TPdf, ki jo uporabljajo, ali pa uporabijo API komponente, ki modul izolira namesto njih. Zaklep mora biti en sam objekt za ves proces, ne eden na nit, na obrazec ali na dokument; zaklep, ki ga dve niti ne delita, ne ščiti nič. Vzorec spodaj zrcali, kar komponenta dela interno od v3.125.1: ustvari, naloži, preberi in sprosti znotraj zaklepa, vse, česar se ne dotika PDFium, pa zunaj njega

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

var
  PdfiumLock: TCriticalSection;        // en zaklep za ves 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;                      // zapiranje dokumenta je tudi delo PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Pod to vrstico ni PDFium, zato ta del teče vzporedno
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Nekaj pravil ohrani vzorec poštenega v pravi aplikaciji:

  • TPdf.Create in Free dajte znotraj zaklepa, ne samo očitnih klicev. Nalaganje, zapiranje, branja lastnosti, kot je PageCount, spremembe strani, izločanje besedila, izrisovanje in shranjevanje vsa dosežejo v modul
  • Preverite Active po dodelitvi. Spodletel nalaganje pusti Active na False, LastLoadReport.ErrorMessage pa pove zakaj
  • Zaklep držite na dokument, ne na klic. Finejše zaklepanje je v načelu mogoče, a samo, če noben član TPdf nikoli ne steče izven njega, groba različica pa je tista, na katero se zanaša sama komponenta
  • Počasno delo brez PDFium, kot so zapisi v bazo, indeksiranje in klici po omrežju, držite izven zaklepa, sicer bo en počasen potrošnik vrstil vse v serijo
  • Zasebnega zaklepa na instanco za izrisovanje ne obravnavajte kot nadomestek. Ščiti enega TPdf pred samim seboj in nič več

Ista previdnost velja za kodo, ki je niste napisali kot surove niti. Ozadnje prihodnosti so dober način, da dolgi izrisi ostanejo izven niti vmesnika, kot opisuje izrisovanje PDF v ozadju s preklicljivimi prihodnostmi, izvrševalec prihodnosti pa ne doda lastnega globalnega zaklepa PDFium. Če lahko več prihodnosti istočasno poganja različne instance TPdf, vzemite isti zaklep po celem procesu znotraj vsakega delavca in pregledovalnik na glavni niti obravnavajte kot še enega klienta skupnega modula. Uporaba med instancami skozi asinhrone API-je ni bila posebej revidirana, zato je konservativna domneva, da potrebuje isto serizacijo kot ročno napisane niti. Ko potrebujete pravo vzporednost PDFium za kaj drugega kot izrisovanje strani, ločeni delavski procesi vsakemu poslu dajo lasten modul po konstrukciji

Hiter pregled: pravila nitenja PDFium za Delphi

  • Nevarno stanje PDFium je po celem modulu: predpomnilnik pisav, stranski modul in drugi globalni podatki si jih delijo vsi dokumenti v procesu
  • Ena TPdf na nič ne izolira ničesar; dve instanci na dveh nitih si lahko še vedno pokvarita druga drugo
  • Tipični simptomi so spodletitve nalaganja, kršitve dostopa v poznejši kodi, External exception C000001D in izhodi s 0xC0000409 ali 0xC0000374
  • Pokvarjenost vztraja v procesu, zato klic, ki spodleti, pogosto ni tisti, ki jo je povzročil
  • ValidatePdfFilesParallel je varen od v3.125.1; na starejših različicah uporabite WorkerCount := 1
  • TPdf.RenderPagesParallel je resnično vzporeden, ker vsak delavec naloži izolirano kopijo modula PDFium
  • Vaše lastne nite, naloge in prihodnosti potrebujejo en zaklep po celem procesu, ki pokrije vsako TPdf od Create do Free

Komponenta PDFium Component ovije pogon PDFium za Delphi s paketnim preflightom in validacijo, izoliranim vzporednim izrisovanjem, preklicljivim ozadnjim delom in podrobnimi diagnostikami nalaganja. Podrobnosti in izdaje so na strani izdelka PDFium Component