Techninis straipsnis

PDFium gijų sauga: kodėl užraktai dokumentui Delphi neveikia

PDFium nėra gijų saugus modulio lygyje, tad du TPdf egzemplioriai, dirbantys su dviem skirtingais failais dviejose gijose, vis tiek gali vienas kitą sugadinti. PDFium Component for Delphi tai sprendžia dviem būdais: nuo v3.125.1 ValidatePdfFilesParallel serijuoja kiekvieną savąjį PDFium kvietimą už vieno viso proceso užrakto, o TPdf.RenderPagesParallel kiekvienam darbininkui duoda savą atskirtą PDFium modulio kopiją. Klaida, privertusi tai sutvarkyti, buvo blogiausioji trumpalaikių. Paketinės patikros testas dažniausiai praeidavo, tada pranešdavo vieną iš dviejų gerųjų failų esant nesėkmingu, tada susmigdydavo kitą testą tame pačiame procese prieigos pažeidimu, o kartais nutviesdavo visą bėgiką su išėjimo kodu vietoj krūvos pėdsakų. Su testu buvo viskas gerai, ir su jokiu atskiru dokumentu negerai nebuvo. Neteisinga buvo prielaida: vienas TPdf gijai nėra atskirtis

Kodėl vieno TPdf gijai neužtenka?

Vieno TPdf gijai neužtenka, nes PDFium savą nesaugią būseną laiko module, o ne dokumente. Kiekvienas TPdf valdo savą FPDF_DOCUMENT rankenėlę, bet kiekvieną rankenėlę procese aptarnauja ta pati pakrautoji DLL, o ta DLL laiko viso proceso singeltonus: šriftų podėlį, puslapių modulį ir kitas globalias struktūras, kuriomis liečiasi dokumentų krovimas, analizavimas ir atvaizdavimas. Dvi gijos, krančios du nesusijusius failus, – dvi gijos, vienu metu rašančios į tą patį šriftų podėlį. Delphi pusėje tas duomenis niekas nesavaldo, tad Delphi pusėje jo dokumentui neužrakinsi

Komponentas turi užraktą, ir iš jo lengva padaryti neteisingą išvadą. TPdf savuosius atvaizdavimo kelius apgaubia vidine kritine sekcija (EnterRenderLock / LeaveRenderLock, privatūs TPdf metodai). Tas užraktas – vienam egzemplioriui. Jis neleidžia dviem gijoms vienu metu valdyti tą patį TPdf – tai tikras pavojus, – bet kitos gijos antrojo egzemplioriaus nemato, tad tarp egzempliorių lygiagretumas pro jį praeina tiesiai. Bendroji taisyklė paprasta iki vienos eilutės: viename pakrautame PDFium modulyje bet kuriuo momentu PDFium viduje gali būti daugiausia viena gija, kad ir kiek dokumentų atverta

PDFium Component diagrama, kur dvi gijos leidžia atskirus TPdf egzempliorius ant skirtingų dokumentų, kol kiekvienas kvietimas susirenka į vieną pakrautą pdfium.dll modulį, kurio šriftų podėlis, puslapių modulis ir kiti viso proceso globalai bendri, duodami krovimo nesėkmes, prieigos pažeidimus ir fail-fast išėjimus
PDFium savą nesaugią būseną laiko module, o ne dokumente, tad du TPdf egzemplioriai ant dviejų gijų rašo į tą patį šriftų podėlį, kad ir kiek failai nesusiję

Kaip tarpdokumentinė korupcija atrodo Delphi procese?

Tarpdokumentinė korupcija atrodo kaip atsitiktinis nesusijusių nesėkmių kokteilis, ir žala išgyvena ją sukėlusį kodą. Iki v3.125.1 ValidatePdfFilesParallel kurdavo po vieną TPdf kiekvienai darbinei gijai ir Active := True kartu su preflight ataskaitos kūrimu leisdavo lygiagrečiai ant bendrojo modulio. Simptomai, matyti ir Delphi, ir Free Pascal variantuose, dengė visą diapazoną:

  • Teisėtas failas nepakrauna arba grįžta iš paketo kaip nesėkmingas, kai turėjo praeiti
  • Prieigos pažeidimas iškyla vėlesniame, nesusijusiame kvietime, dažnai kitame testą ar kitame dokumente
  • External exception C000001D pasirodo Delphi. Tas kodas yra STATUS_ILLEGAL_INSTRUCTION, keliamas ud2 instrukcijos, kurią PDFium vidiniai CHECK ir IMMEDIATE_CRASH makrosai vykdo, kai invariantas suyra
  • Procesas išeina su 0xC0000409 (fail-fast, pranešamas kaip krūvos buferio perpildymas) arba 0xC0000374 (kršos korupcija), be jokios Delphi išimties

Paskutinieji du punktai – kodėl klaidą buvo sunku prikabinti. Lygiagreti patikra pasibaigdavo, sugadintoji globali būsena likdavo po savimi, ir kitas fiktyras tame pačiame procese apie ją susikliūdavo. Viename Delphi Win64 regresijos paleidime C000001D nesėkmių banga ištiko testus, kurie paketinės patikros niekada nelietė; jie tiesiog buvo pirmasis kodas, po žalos naudojęs PDFium. Išmatuoti skaičiai mastą daro aiškų. Delphi zondas, lekiantis tą patį pavyzdį per du darbininkus, viename paleidime sugedo 122 iš 160 dokumentų, kitame – 138 iš 160, o vienas tų paleidimų iškelė External exception C000001D tiesiai. Įtampos atvejis iš 8 dokumentų, 4 darbininkų ir 5 ratų žlugdavo ar strigdavo 5 iš 5 paleidimų Free Pascal Win64. Po pataisymo tas pats zondas sugedo 0 iš 1 200 dokumentų

Kaip ValidatePdfFilesParallel lieka saugus nuo v3.125.1

ValidatePdfFilesParallel dabar serijuoja kiekvieno darbo savąją pusę ir palieka valdomąją pusę lygiagrečią. Kiekvienas darbininkas paima vieną modulio lygio kritinę sekciją, dar nesukūręs savo TPdf, ir ją laiko per FileName, Active := True, preflight ataskaitos kūrimą ir Free. Kūrimas ir sunaikinimas už rakto – tyčia: dokumento užvėrimas, kaip ir krovimas, kviečiasi atgal į modulį. Gavęs užfiksuotą TPdfPreflightReport įrašą, darbininkas užraktą atlaisvina ir to įrašo atžvilgiu vertina patikros taisykles – tai PDFium būsenos neliečia, tad vieno failo taisyklių vertinimas persidengia su kito failo PDFium darbu

PDFium Component ValidatePdfFilesParallel diagrama, rodanti kiekvieną darbininką, laikantį vieną viso proceso kritinę sekciją per TPdf kūrimą, krovimą, preflight ir atlaisvinimą, kol užfiksuotosios ataskaitos taisyklių vertinimas lekia už rakto lygiagrečiai, tad paketo PDFium pusė projektuota serijinė
Kūrimas ir sunaikinimas lieka už rakto, nes dokumento užvėrimas kviečiasi atgal į modulį, o ataskaitos vertinimas PDFium būsenos neliečia ir persidengia su kitu failu

Su pataisa atkeliavo du smulkesni pakeitimai. Krovimo nesėkmė dabar kelia EPdfError su LastLoadReport.ErrorMessage, tad elemento ErrorMessage įvardija tikrąją analizės problemą, o ne antrinę „nėra aktyvaus dokumento“ klaidą. Ir kaina pasakyta sąžiningai: paketo PDFium dalis dabar serijinė, tad pakete, kurį valdo analizavimas ir preflight, papildomi darbininkai nuperka nedaug. Jei jūsų versija ankstesnė už v3.125.1, nustatykite WorkerCount į 1 – tai pašalina lygiagretumą kartu su korupcija

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 = procesorių skaičius, ribojama iki 8
    Options.Standards := [ppsPdfA];
    // Su aiškiu registru, atitinkamąjį profilį parenkite patys.
    // Tuščias Profiles sąrašas leidžia kiekvieną registruotą taisyklę, o taisyklės
    // standartams, kurių nepreflightinote, praneša „nepriėjo“
    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 perdavimas kaip registro – trumpesnis kelias: ValidatePdfFilesParallel tada pats sukuria numatytąjį registrą, išvestina profilių sąrašą iš Options.Standards ir grįždamas registrą atlaisvina. Rezultatai visada grįžta įvesties tvarka, kad ir kokia tvarka baigdavo darbininkai. Apie ataskaitų formatus ir komandinės eilutės aplinkkalbį to paties variklio aplink – žiūrėkite paketinės PDF preflight ataskaitos su PDFium Component CLI, o ką patys PDF/A tikrinimai dengia – PDF/A preflight patikra Delphi

Kaip RenderPagesParallel puslapius leidžia tikrai lygiagrečiai?

TPdf.RenderPagesParallel lekia lygiagrečiai, nes jo darbininkai niekada nedalijasi PDFium modulio. Metodas pirmiau aktyvųjį dokumentą išsaugoja į šaltinio sandėlį kviečiančiojoje gijoje. Tada kiekvienas darbininkas pakrautąją PDFium DLL nukopijuoja į unikaliai pavadintą failą laikinajame kataloge, tą kopiją pakrauna su LoadLibrary ir inicijuoja. Windows DLL, pakrautą iš kito kelio, laiko kitu moduliu, tad kiekviena kopija gauna savus globalus: savą šriftų podėlį, savą puslapių modulį, savą viską. Darbininkas išsaugotąjį dokumentą atveria savame privačiame module, jo puslapius atvaizduoja po truputį su atšaukimo patikromis tarp žingsnių, tada biblioteką sunaikina, kopiją iškrauna ir failą ištrina

PDFium Component RenderPagesParallel diagrama, kur kviečiančioji gija išsaugoja dokumento momentinę kopiją, tada kiekvienas darbininkas PDFium DLL nukopijuoja į unikalųjį laikinąjį failą, pakrauna jį kaip atskirą modulį su savais globalais, atvaizduoja savo puslapius su atšaukimo patikromis ir iškrauna kopiją
Tikras lygiagretumas ateina iš modulio atskirties: Windows kiekvieną DLL kopiją laiko kitu moduliu, tad darbininkai nesidalija niekuo, išskyrus momentinę kopiją, kurią kviečiančioji gija išsaugojo už rakto

Atskirtis nėra nemokama, ir numatytosios nuostatos tai atspindi. Kiekvienas darbininkas sumoka už DLL kopiją diske, antrą PDFium globalų rinkinį atmintyje ir šviežią dokumento analizę. MaxWorkers = 0 reiškia daugiausia 4 darbininkus, MaxPixelsPerPage ir MaxTotalOutputBytes riboja žaliąją išvestį, o apverstos ir naktinės dvejatonės atvaizdavimo parinktys atmetamos, nes buferiai grąžinami žali. Rezultatas – TPdfParallelRenderReport, kurio Results masyvas laiko po vieną iš viršaus žemyn einantį 32 bitų buferį kiekvienam prašytam puslapiui, prašymo tvarka

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;                 // puslapių numeriai nuo 1

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

  // Šaltinio momentinė kopija imama ant bendrojo modulio, tad laikykite
  // viso proceso PDFium užraktą, jei kitos gijos irgi naudoja 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;

Atkreipkite dėmesį į užraktą aplink kvietimą. Darbininkų moduliai privatūs, bet momentinės kopijos žingsnis pradžioje kviečia SaveAs ant bendrojo modulio iš kviečiančiosios gijos. Jei niekas kito jūsų procese TPdf vienu metu neliečia, užraktą galite mesti; jei kas nors liečia – momentinei kopijai reikia tos pačios apsaugos kaip kiekvienam kitam bendrojo modulio kvietimui

ModelisSaugu tarp dokumentųPDFium darbas lekia lygiagrečiaiKaina
Vienas TPdf gijai, be bendrojo užraktoNeTaip, kol sugadinaTrumpalaikiai strigimai, sugadinta proceso būsena
Vienas viso proceso užraktas aplink visus PDFium kvietimusTaipNePDFium dalis serijinė
ValidatePdfFilesParallel nuo v3.125.1TaipNe; taisyklių vertinimas lygiagretusAnalizavimas ir preflight serijiniai
TPdf.RenderPagesParallelTaipTaipDLL kopija, atmintis ir šviežia analizė kiekvienam darbininkui

Kaip dėlioti savąjį daugiagijį PDFium kodą?

Jūsų pačių gijos turėtų dalintis vienu viso proceso užraktu ir jį laikyti per visą kiekvieno naudojamo TPdf gyvenimą, arba naudoti komponento API, kuri modulį atskiria už jus. Užraktas turi būti vienas objektas visam procesui, o ne po vieną gijai, formai ar dokumentui; užraktas, kurio dvi gijos nedalijasi, nepriegauna nieko. Žemiau esantis modelis atkartoja tai, ką komponentas viduje daro nuo v3.125.1: kurti, krauti, skaityti ir atlaisvinti už rakto, o viską, kas PDFium neliečia, daryti už jo

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

var
  PdfiumLock: TCriticalSection;        // vienas užraktas visam procesui

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;                      // dokumento užvėrimas – irgi PDFium darbas
      end;
    finally
      PdfiumLock.Release;
    end;
    // Žemiau šios eilutės PDFium nebėra, tad ši dalis lekia lygiagrečiai
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Kelios taisyklės modelį realioje taikomojoje programoje laiko sąžiningą:

  • TPdf.Create ir Free dėkite už rakto, o ne tik akivaizdžius kvietimus. Krovimas, užvėrimas, savybių skaitymas, toks kaip PageCount, puslapių keitimas, teksto ištraukimas, atvaizdavimas ir išsaugojimas visi siekia į modulį
  • Patikrinkite Active po priskyrimo. Nesėkmingas krovimas palieka Active ant False, o LastLoadReport.ErrorMessage sako kodėl
  • Laikykite užraktą dokumentui, o ne kvietimui. Smulkesnis užrakinimas teoriškai įmanomas, bet tik jei nė vienas TPdf narys niekada nelekia už jo, o stambusis variantas – tas, kuriuo remiasi pats komponentas
  • Lėtąjį ne PDFium darbą, tokį kaip duomenų bazės rašymai, indeksavimas ir tinklo kvietimai, laikykite už rakto, kitaip vienas lėtas vartotojas serijuos viską
  • Privačiojo vieno egzemplioriaus atvaizdavimo užrakto nelaikykite pakaitalu. Jis saugo vieną TPdf nuo jo paties ir nieko daugiau

Ta pati atsargiai taikoma ir kodui, kurio nerašėte kaip žaliųjų gijų. Foniniai futures – geras būdas laikyti ilgus atvaizdavimus nuo UI gijos, kaip aprašyta straipsnyje foninis PDF atvaizdavimas su atšaukiamais futures, bet future vykdytojas savo globaliojo PDFium užrakto neprideda. Jei keli futures vienu metu gali valdyti skirtingus TPdf egzempliorius, imkite tą patį viso proceso užraktą kiekvieno darbininko viduje, o peržiūrovą pagrindinėje gijoje laikykite dar vienu bendrojo modulio klientu. Tarp egzempliorių naudojimas per asinchronines API atskirai neaudituotas, tad konservatyvioji prielaida – jam reikia tos pačios serijos kaip rankomis rašytoms gijoms. Kai tikras PDFium lygiagretumas reikalingas ko nors kito, ne puslapių atvaizdavimo, atskiros darbinės procesai kiekvienam darbui sava konstrukcija duoda savą modulį

Trumpa atmintinė: PDFium gijų taisyklės Delphi

  • PDFium nesaugi būsena – viso modulio: šriftų podėlį, puslapių modulį ir kitus globalus dalijasi kiekvienas procese esantis dokumentas
  • Vienas TPdf gijai nieko neatskiria; du egzemplioriai ant dviejų gijų vis tiek gali vienas kitą sugadinti
  • Tipiški simptomai – krovimo nesėkmės, prieigos pažeidimai vėlesniame kode, External exception C000001D ir išėjimai su 0xC0000409 arba 0xC0000374
  • Korupcija procese išlieka, tad žlungantis kvietimas dažnai ne tas, kuris ją sukėlė
  • ValidatePdfFilesParallel saugus nuo v3.125.1; senesnėse versijose naudokite WorkerCount := 1
  • TPdf.RenderPagesParallel tikrai lygiagretus, nes kiekvienas darbininkas pakrauna atskirtą PDFium modulio kopiją
  • Jūsų pačių gijos, užduotys ir futures reikalauja vieno viso proceso užrakto, dengiančio kiekvieną TPdf nuo Create iki Free

PDFium Component apgaubia PDFium variklį Delphi aplinkai su paketine preflight ir patikra, atskirtu lygiagrečiu atvaizdavimu, atšaukiamu foniniu darbu ir detaliomis krovimo diagnostikomis. Detalės ir leidimai yra PDFium Component produkto puslapyje