Tekninen artikkeli

Miksi asiakirjakohtainen lukko ei riitä PDFiumille Delphissä

PDFium ei ole säievarainen moduulitasolla, joten kaksi TPdf-instanssia, jotka työskentelevät kahdella eri tiedostolla kahdessa säikeessä, voivat silti turmella toisiaan. PDFium Component Delphille hoitaa tämän kahdella tavalla: versiosta v3.125.1 alkaen ValidatePdfFilesParallel sarjoittaa jokaisen natiivin PDFium-kutsun yhden prosessinlaajuisen lukon taakse, kun taas TPdf.RenderPagesParallel antaa jokaiselle työntekijälle oman eristetyn kopionsa PDFium-moduulista. Korjauksen pakottanut vika oli pahin mahdollinen kertaluontoinen. Erävalidointitesti läpi useimmiten, sitten ilmoitti yhden kahdesta hyvästä tiedostosta epäonnistuneeksi, sitten kaatoi saman prosessin seuraavan testin pääsyvirheellä ja joskus vei koko ajon alas poistumiskoodilla pinon jäljityksen sijaan. Testissä ei ollut mitään vikaa, eikä missään yksittäisessä asiakirjassa ollut mitään vikaa. Oletus oli väärä: yksi TPdf säiettä kohden ei ole eristystä

Miksi yksi TPdf säiettä kohden ei riitä?

Yksi TPdf säiettä kohden ei riitä, koska PDFium pitää turvattoman tilansa moduulissa, ei asiakirjassa. Jokainen TPdf omistaa oman FPDF_DOCUMENT-kahvansa, mutta jokainen kahva prosessissa palvelee samaa ladattua DLL:ää, ja kyseinen DLL sisältää prosessinlaajuisia singletonrakenteita: fonttivälimuistin, sivumoduulin ja muita globaaleja rakenteita, joita asiakirjan lataaminen, jäsentäminen ja renderöinti kaikki koskettavat. Kaksi säiettä, jotka lataavat kaksi toisiinsa liittymätöntä tiedostoa, ovat kaksi säiettä, jotka kirjoittavat samaan fonttivälimuistiin samaan aikaan. Kukaan ei omista kyseistä dataa Delphi-puolella, joten mikään Delphi-puolella ei voi lukita sitä asiakirjaa kohden

Komponentilla on kyllä lukko, ja siitä on helppo vetää väärä johtopäätös. TPdf käärii omat renderöintipolunsa sisäiseen critical section -osioon (EnterRenderLock / LeaveRenderLock, TPdf:n yksityiset metodit). Kyseinen lukko on instanssia kohden. Se estää kahta säiettä ajamasta samaa TPdfia yhtä aikaa, mikä on aito vaara, mutta se ei näe toista instanssia toisella säikeellä, joten instanssien välinen samanaikaisuus kulkee suoraan sen ohi. Yleissääntö on yksinkertainen sanoa yhdellä rivillä: yhdessä ladatussa PDFium-moduulissa korkeintaan yksi säie saa olla PDFiumin sisällä milloin tahansa, riippumatta siitä, kuinka monta asiakirjaa on avoinna

PDFium Componentin kaavio kahdesta säikeestä, jotka ajavat erillisiä TPdf-instansseja eri asiakirjoilla, kun jokainen kutsu suppenee yhteen ladattuun pdfium.dll-moduuliin, jonka fonttivälimuisti, sivumoduuli ja muut prosessinlaajuiset globaalit ovat jaettuja, tuottaen latausvikoja, pääsyvirheitä ja fail fast -poistumisia
PDFium pitää turvattoman tilansa moduulissa, ei asiakirjassa, joten kaksi TPdf-instanssia kahdella säikeellä kirjoittavat samaan fonttivälimuistiin riippumatta siitä, kuinka toisiinsa liittymättömiä tiedostot ovat

Miltä asiakirjojen välinen turmeltuminen näyttää Delphi-prosessissa?

Asiakirjojen välinen turmeltuminen näyttää satunnaiselta sekoitukselta toisiinsa liittymättömiä vikoja, ja vahinko elää aiheuttaneen koodin ikänsä. Ennen versiota v3.125.1 ValidatePdfFilesParallel loi yhden TPdfin työntekijäsäiettä kohden ja ajoi kohteen Active := True plus preflight-raportin rakentamisen samanaikaisesti jaetulla moduulilla. Sekä Delphi- että Free Pascal -koontiversioilla nähdyt oireet kattoivat koko kirjon:

  • Kelvollinen tiedosto epäonnistuu latautumaan tai palaa erästä epäonnistuneena, vaikka sen olisi pitänyt läpäistä
  • Pääsyvirhe nousee pintaan myöhemmässä, toisiinsa liittymättömässä kutsussa, usein eri testissä tai eri asiakirjassa
  • External exception C000001D ilmestyy Delphissä. Kyseinen koodi on STATUS_ILLEGAL_INSTRUCTION, jonka nostaa ud2-käsky, jonka PDFiumin sisäiset CHECK- ja IMMEDIATE_CRASH-makrot suorittavat, kun invariantti rikkoutuu
  • Prosessi poistuu muodossa 0xC0000409 (fail fast, ilmoitettuna pinopuskurin ylivuotona) tai 0xC0000374 (keon turmeltuminen), ilman mitään Delphi-poikkeusta

Kaksi viimeistä kohtaa ovat syy siihen, miksi vika oli niin vaikea paikallistaa. Rinnakkainen validointi päättyi, turmeltunut globaali tila jäi jäljelle, ja saman prosessin seuraava testitapaus kompastui siihen. Yhdessä Delphi Win64 -regressioajossa aalto C000001D-vikoja osui testeihin, jotka eivät koskaan koskeneet erävalidointiin; ne olivat yksinkertaisesti ensimmäinen koodi, joka käytti PDFiumia vahingon jälkeen. Mitatut luvut tekevät laajuudesta selvän. Delphi-koeajo, joka ajoi saman näytteen kahden työntekijän kautta, epäonnistui 122 dokumentilla 160:sta yhdessä ajossa ja 138:lla 160:sta toisessa, ja yksi kyseisistä ajoista nosti External exception C000001Din suoraan. Stressitapaus 8 dokumentilla, 4 työntekijällä ja 5 kierroksella epäonnistui tai kaatui 5:ssä 5 ajosta Free Pascal Win64:llä. Korjauksen jälkeen sama koeajo epäonnistui 0:lla 1 200 dokumentista

Miten ValidatePdfFilesParallel pysyy turvallisena versiosta v3.125.1 alkaen

ValidatePdfFilesParallel sarjoittaa nykyään jokaisen työn natiivin puoliskon ja pitää hallitun puoliskon rinnakkaisena. Jokainen työntekijä ottaa yhden yksikkötason critical section -osion ennen kuin se luo TPdfinsa ja pitää sitä kohtien FileName, Active := True, preflight-raportin rakentamisen ja Freein ajan. Luominen ja tuhoaminen ovat lukon sisällä tahallaan: asiakirjan sulkeminen kutsuu takaisin moduuliin yhtä lailla kuin lataaminen. Kun työntekijällä on siepattu TPdfPreflightReport-tietue, se vapauttaa lukon ja arvioi validointisäännöt kyseistä tietuetta vasten, mikä ei kosketa PDFium-tilaa, joten yhden tiedoston sääntöarviointi menee päällekkäin seuraavan PDFium-työn kanssa

PDFium Componentin ValidatePdfFilesParallel-kaavio, joka näyttää jokaisen työntekijän pitävän yhtä prosessinlaajuista critical sectionia TPdf-luomisen, latauksen, preflightin ja vapauttamisen yli, kun taas siepatun raportin sääntöarviointi ajaa lukon ulkopuolella rinnakkain, joten erän PDFium-puolisko on sarjallinen suunnittelusta
Luominen ja tuhoaminen pysyvät lukon sisällä, koska asiakirjan sulkeminen kutsuu takaisin moduuliin, kun taas raportin arviointi ei kosketa PDFium-tilaa ja menee päällekkäin seuraavan tiedoston kanssa

Kaksi pienempää muutosta tuli korjauksen mukana. Latausvika nostaa nykyään EPdfErrorin kohteen LastLoadReport.ErrorMessage kanssa, joten tietueen ErrorMessage nimeää varsinaisen jäsennysongelman toissijaisen "ei aktiivista asiakirjaa" -virheen sijaan. Ja kustannus on sanottu rehellisesti: erän PDFium-osa on nykyään sarjallinen, joten jäsentämisen ja preflightin hallitsemalla erällä ylimääräiset työntekijät ostavat vähän. Jos olet versiolla ennen v3.125.1:tä, aseta WorkerCount arvoon 1; se poistaa samanaikaisuuden ja turmeltumisen mukanaan

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 = suoritinmäärä, ylärajana 8
    Options.Standards := [ppsPdfA];
    // Eksplisiittisellä rekisterillä valitse vastaava profiili itse.
    // Tyhjä Profiles-luettelo ajaa jokaisen rekisteröidyn säännön, ja säännöt
    // standardeille, joita ei preflightattu, ilmoittavat "ei läpäissyt"
    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;

nilin välittäminen rekisterinä on lyhyempi tie: ValidatePdfFilesParallel luo sitten oletusrekisterin itse, johtaa profiililuettelon kohteesta Options.Standards ja vapauttaa rekisterin palatessaan. Tulokset palaavat aina syötejärjestyksessä, riippumatta siitä, missä järjestyksessä työntekijät valmistuivat. Raporttimuodoista ja saman moottorin ympärillä olevasta komentorivikääreestä kertoo artikkeli erä-PDF-preflight-raportit PDFium Component CLI:llä, ja siitä, mitä PDF/A-tarkistukset itse kattavat, artikkeli PDF/A-preflight-validointi Delphissä

Miten RenderPagesParallel ajaa sivuja aidosti rinnakkain?

TPdf.RenderPagesParallel ajaa rinnakkain, koska sen työntekijät eivät koskaan jaa PDFium-moduulia. Metodi tallentaa ensin aktiivisen asiakirjan lähdetietovarastoon kutsuvalle säikeelle. Jokainen työntekijä kopioi sitten ladatun PDFium-DLL:n yksilöllisesti nimettyyn tiedostoon väliaikaiskansiossa, lataa kyseisen kopion funktiolla LoadLibrary ja alustaa sen. Windows käsittelee eri polusta ladattua DLL:ää eri moduulina, joten jokainen kopio saa omat globaalinsa: oman fonttivälimuistinsa, oman sivumoduulinsa, oman kaikkensa. Työntekijä avaa tallennetun asiakirjan yksityisessä moduulissaan, renderöi sivunsa edetessä perumistarkistusten kanssa vaiheiden välissä, tuhoaa sitten kirjaston, purkaa kopion ja poistaa tiedoston

PDFium Componentin RenderPagesParallel-kaavio, jossa kutsuva säie tallentaa asiakirjan tilannekuvan, sitten jokainen työntekijä kopioi PDFium-DLL:n yksilölliseen väliaikaistiedostoon, lataa sen erillisenä moduulina omilla globaaleillaan, renderöi sivunsa perumistarkistusten kanssa ja purkaa kopion
Aito rinnakkaisuus tulee moduulieristyksestä: Windows käsittelee jokaisen DLL-kopion eri moduulina, joten työntekijät eivät jaa mitään paitsi tilannekuvan, jonka kutsuva säie tallensi lukon alla

Eristys ei ole ilmaista, ja oletukset heijastavat sitä. Jokainen työntekijä maksaa DLL-kopiosta levylle, toisesta joukosta PDFium-globaaleja muistissa ja tuoreesta asiakirjan jäsennyksestä. MaxWorkers = 0 tarkoittaa korkeintaan 4 työntekijää, kohteet MaxPixelsPerPage ja MaxTotalOutputBytes rajaavat raakatuotoksen, ja käänteiset sekä yöduotone-renderöintiasetukset torjutaan, koska puskurit palautetaan raakoina. Tulos on TPdfParallelRenderReport, jonka Results-taulukko kantaa yhden ylhäältä alas -32-bittisen puskurin pyydettyä sivua kohden, pyyntöjärjestyksessä

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;                 // sivunumerot ovat ykköspohjaisia

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

  // Lähteen tilannekuva otetaan jaetulla moduulilla, joten pidä
  // prosessinlaajuinen PDFium-lukko, jos muutkin säikeet käyttävät 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;

Huomaa lukko kutsun ympärillä. Työntekijöiden moduulit ovat yksityisiä, mutta alussa oleva tilannekuavaihe ajaa SaveAsin jaetulla moduulilla kutsuvalta säikeeltä. Jos mikään muu prosessissasi ei kosketa kohtetta TPdf samanaikaisesti, voit pudottaa lukon; jos jokin tekee niin, tilannekuva tarvitsee saman suojan kuin jokainen muu jaetun moduulin kutsu

KuvioTurvallinen asiakirjojen yliPDFium-työ ajaa rinnakkainKustannus
Yksi TPdf säiettä kohden, ei jaettua lukkoaEiKyllä, kunnes se turmeltuuKertaluontoisia kaatumisia, vaurioitunut prosessin tila
Yksi prosessinlaajuinen lukko kaikkien PDFium-kutsujen ympärilläKylläEiPDFium-osa on sarjallinen
ValidatePdfFilesParallel versiosta v3.125.1 alkaenKylläEi; sääntöarviointi on rinnakkainenJäsentäminen ja preflight ovat sarjallisia
TPdf.RenderPagesParallelKylläKylläDLL-kopio, muisti ja tuore jäsentys työntekijää kohden

Miten sinun kannattaa jäsentää oma monisäikeinen PDFium-koodisi?

Oman säikeesi pitäisi jakaa yksi prosessinlaajuinen lukko ja pitää sitä jokaisen käyttämänsä TPdfin koko eliniän ajan, tai muuten käyttää komponentin API:a, joka eristää moduulin puolestasi. Lukon on oltava yksi objekti koko prosessille, ei yksi säiettä, lomaketta tai asiakirjaa kohden; lukko, jota kaksi säiettä eivät jaa, ei suojele mitään. Alla oleva kuvio peilaa sitä, mitä komponentti tekee sisäisesti versiosta v3.125.1 alkaen: luo, lataa, lue ja vapauta lukon sisällä, tee sitten kaikki, mikä ei kosketa PDFiumia sen ulkopuolella

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

var
  PdfiumLock: TCriticalSection;        // yksi lukko koko prosessille

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;                      // asiakirjan sulkeminen on myös PDFium-työtä
      end;
    finally
      PdfiumLock.Release;
    end;
    // Ei PDFiumia tämän rivin alapuolella, joten tämä osa ajaa rinnakkain
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Muutama sääntö pitää kuvion rehellisenä oikeassa sovelluksessa:

  • Pane TPdf.Create ja Free lukon sisälle, ei vain ilmeiset kutsut. Lataaminen, sulkeminen, ominaisuusluvut kuten PageCount, sivunvaihdot, tekstin poiminta, renderöinti ja tallentaminen ylettävät kaikki moduuliin
  • Tarkista Active sijoittamisen jälkeen. Epäonnistunut lataus jättää kohteen Active arvoon False, ja LastLoadReport.ErrorMessage kertoo miksi
  • Pidä lukko asiakirjaa kohden eikä kutsua kohden. Hienojakoisempi lukitus on mahdollinen periaatteessa, mutta vain, jos yksikään TPdf-jäsen ei koskaan aja sen ulkopuolella, ja karkea versio on se, johon komponentti itse nojaa
  • Pidä hidas ei-PDFium-työ, kuten tietokantakirjoitukset, indeksointi ja verkkokutsut, lukon ulkopuolella, tai yksi hidas kuluttaja sarjoittaa kaiken
  • Älä kohtele yksityistä instanssikohtaista renderöintilukkoa korvikkeena. Se vartioi yhtä TPdfia itseään vastaan eikä mitään muuta

Sama varovaisuus koskee koodia, jota et kirjoittanut raakoina säikeinä. Taustafutuurit ovat hyvä tapa pitää pitkät renderöinnit poissa UI-säikeeltä, kuten artikkelissa taustalla tapahtuva PDF-renderöinti peruttavilla futuureilla kuvataan, mutta futuurien suorittaja ei lisää globaalia PDFium-lukkoa omasta puolestaan. Jos useat futuurit voivat ajaa eri TPdf-instansseja samaan aikaan, ota sama prosessinlaajuinen lukko kunkin työntekijän sisällä ja kohtele pääsäikeen katselinta yhtenä lisäasiakkaana jaetulle moduulille. Instanssien välistä käyttöä asynkronisten API:jen kautta ei ole auditoitu erikseen, joten konservatiivinen oletus on, että se tarvitsee saman sarjoituksen kuin käsin kirjoitetut säikeet. Kun tarvitset aitoa PDFium-rinnakkaisuutta johonkin muuhun kuin sivujen renderöintiin, erilliset työntekijäprosessit antavat jokaiselle työlle oman moduulinsa rakenteen kautta

Pikaopas: PDFiumin säikeityssäännöt Delphille

  • PDFiumin turvaton tila on moduulinlaajuinen: fonttivälimuisti, sivumoduuli ja muut globaalit ovat jokaisen prosessin asiakirjan jaettuja
  • Yksi TPdf säiettä kohden ei eristä mitään; kaksi instanssia kahdella säikeellä voivat silti turmella toisiaan
  • Tyypillisiä oireita ovat latausviat, pääsyvirheet myöhemmässä koodissa, External exception C000001D ja poistumiset muodossa 0xC0000409 tai 0xC0000374
  • Turmeltuminen pysyy prosessissa, joten epäonnistuva kutsu on usein ei se, joka aiheutti sen
  • ValidatePdfFilesParallel on turvallinen versiosta v3.125.1 alkaen; vanhemmilla versioilla käytä arvoa WorkerCount := 1
  • TPdf.RenderPagesParallel on aidosti rinnakkainen, koska jokainen työntekijä lataa eristetyn kopion PDFium-moduulista
  • Omat säikeesi, tehtäväsi ja futuurisi tarvitsevat yhden prosessinlaajuisen lukon, joka kattaa jokaisen TPdfin kohteesta Create kohteeseen Free

PDFium Component käärii PDFium-moottorin Delphille erä-preflightilla ja validoinnilla, eristetyllä rinnakkaisella renderöinnillä, peruttavalla taustatyöllä ja yksityiskohtaisilla latausdiagnostiikoilla. Yksityiskohdat ja versiot ovat PDFium Component -tuotesivulla