Műszaki cikk

PDFium Thread Safety: miért nem véd a dokumentumonkénti lock

A PDFium modul szinten nem szálbiztos, így két TPdf példány is egymás adatait rongálhatja, ha két különböző fájlon dolgozik két szálban. A Delphihez készült PDFium Component ezt kétféleképpen kezeli: v3.125.1 óta a ValidatePdfFilesParallel minden natív PDFium hívást egy, az egész folyamatra érvényes lock mögé szerializál, a TPdf.RenderPagesParallel pedig minden workernek a PDFium modul saját, izolált másolatát adja. A hiba, ami kikényszerítette a javítást, a legrosszabb fajta intermittens volt. Egy kötegvalidálási teszt a futások többségében rendben ment, aztán a két jó fájl egyikét hibásnak jelentette, aztán access violationnel lelőtte ugyanabban a folyamatban a következő tesztet, és néha az egész futtatót is elvitte egy exit kóddal, stack trace helyett. A teszttel nem volt baj, és egyetlen dokumentummal sem. A feltevés volt rossz: egy TPdf szálonként nem jelent izolációt

Miért nem elég egy TPdf szálonként?

Egy TPdf szálonként azért nem elég, mert a PDFium a nem szálbiztos állapotát a modulban tartja, nem a dokumentumban. Minden TPdf a saját FPDF_DOCUMENT handlejét birtokolja, de a folyamatban minden handlet ugyanaz a betöltött DLL szolgál ki, és az olyan folyamat szintű singletonokat tart: a font cacheet, a page modulet és egyéb globális struktúrákat, amibe a dokumentumbetöltés, a parse és a renderelés is beletalál. Két szál, ami két egymással kapcsolatban nem álló fájlt tölt, két szál, ami ugyanabba a font cachebe ír egyszerre. Delphi oldalon ez az adat nem senkié, ezért Delphi oldalon semmi nem tudja dokumentumonként lockolni

A komponensben van lock, és abból könnyű rossz következtetést levonni. A TPdf a saját render útjait egy belső critical sectionbe csomagolja (EnterRenderLock / LeaveRenderLock, a TPdf privát metódusai). Az a lock példányonkénti. Megakadályozza, hogy két szál egyszerre ugyanazt a TPdf-et hajtsa – ez valódi veszély –, de egy másik szálon futó második példányt nem lát, így a példányok közti konkurencia egyenesen átsétál rajta. Az általános szabály egy sorban is elmondható: egyetlen betöltött PDFium modulban bármely pillanatban legfeljebb egy szál lehet a PDFiumon belül, akárhány dokumentum van nyitva

PDFium Component ábra: két szál külön TPdf példányokat hajt különböző dokumentumokon, miközben minden hívás ugyanabba a betöltött pdfium.dll modulba fut össze, aminek a font cachee, page moduleje és egyéb folyamat szintű globálisai közösek, így keletkeznek betöltési hibák, access violationök és fail-fast kilépések
A PDFium a nem szálbiztos állapotát a modulban tartja, nem a dokumentumban, így két szál két TPdf példánya ugyanabba a font cachebe ír, akármilyen egymástól távol álló fájlokról van szó

Hogyan néz ki a dokumentumok közti rongálás egy Delphi folyamatban?

A dokumentumok közti rongálás egymással össze nem függő hibák véletlenszerű keverékének tűnik, és a kár túléli az őt okozó kódot. v3.125.1 előtt a ValidatePdfFilesParallel worker szálonként egy TPdf-et készített, és az Active := True-t meg a preflight riport felépítését egyszerre futtatta a megosztott modulon. A Delphi és a Free Pascal buildeken is látott tünetek a teljes skálát lefedték:

  • Egy valid fájl nem tölt be, vagy a kötegből hibásként jön vissza, amikor át kellett volna mennie
  • Egy access violation egy későbbi, egymással össze nem függő hívásban tör fel, gyakran másik tesztben vagy másik dokumentumban
  • External exception C000001D jelenik meg Delphiben. Ez a kód a STATUS_ILLEGAL_INSTRUCTION: akkor tör fel, amikor a PDFium belső CHECK és IMMEDIATE_CRASH makrói lefuttatják a ud2 utasítást egy sérült invariánsra
  • A folyamat 0xC0000409-cel lép ki (fail-fast, stack buffer overrunként jelentve) vagy 0xC0000374-cel (heap corruption), Delphi kivétel nélkül

Az utolsó két pont miatt volt olyan nehéz rajtakapni a hibát. A párhuzamos validálás befejeződött, a megrongált globális állapot ott maradt, és ugyanabban a folyamatban a következő fixture belebotlott. Egy Delphi Win64 regressziós futásban C000001D hibák hulláma sújtotta azokat a teszteket, amik a kötegvalidáláshoz soha nem nyúltak; egyszerűen ők voltak az első kód, ami a kár után PDFiumot használt. A mért számok kézzelfoghatóvá teszik a nagyságrendet. Egy Delphi próbaprogram, ami ugyanazt a mintát két workeren futtatta, az egyik futásban 160 dokumentumból 122-nél hibázott, a másikban 160-ból 138-nál, és az egyik futás External exception C000001D-t dobott ki nyíltan. Egy 8 dokumentumos, 4 workeres, 5 körös stresszeset a Free Pascal Win64-en 5 futásból 5-ben hibázott vagy crashtelt. A javítás után ugyanez a próbaprogram 1200 dokumentumból 0-nál hibázott

Hogyan marad biztonságos a ValidatePdfFilesParallel v3.125.1 óta

A ValidatePdfFilesParallel mostantól minden munka natív felét szerializálja, a menedzselt felét párhuzamosan tartja. Minden worker egység szintű critical sectiont foglal, mielőtt létrehozza a TPdf-jét, és az egész FileName, Active := True, preflight riport felépítés és Free alatt tartja. A létrehozás és a megsemmisítés szándékosan a lockon belül van: egy dokumentum bezárása ugyanúgy visszahív a modulba, mint a betöltés. Amint a workernek megvan a rögzített TPdfPreflightReport rekord, elengedi a lockot, és ahhoz a rekordhoz értékeli a validálási szabályokat, ami PDFium állapotot nem érint, így az egyik fájl szabályértékelése fedésben van a következő PDFium munkájával

PDFium Component ValidatePdfFilesParallel ábra: minden worker egy folyamat szintű critical sectiont tart a TPdf create, load, preflight és free alatt, miközben a rögzített riport szabályértékelése a lockon kívül fut párhuzamosan, így a köteg PDFium fele tervezésből szerialis
A létrehozás és a megsemmisítés a lockon belül marad, mert a dokumentum bezárása visszahív a modulba, a riport értékelése pedig PDFium állapotot nem érint, és fedésben van a következő fájllal

A javítással két kisebb változás is jött. Egy betöltési hiba mostantól EPdfError-t dob LastLoadReport.ErrorMessage-mal, így a tétel ErrorMessage-e a tényleges parse problémát nevezi meg egy másodlagos „nincs aktív dokumentum” hiba helyett. És az ár őszintén le van írva: a köteg PDFium fele mostantól szerialis, így parse-ban és preflightban gazdag kötegen a plusz workerek keveset érnek. Ha v3.125.1 előtti verzión vagy, állítsd a WorkerCount-ot 1-re; ezzel a konkurencia is eltűnik, és a rongálás vele

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 = processzorszám, maximum 8
    Options.Standards := [ppsPdfA];
    // Explicit registry esetén a megfelelő profilt magad válaszd ki.
    // Üres Profiles lista minden regisztrált szabályt futtat, és azok a
    // standardok, amiket nem preflightoltál, "did not pass" eredményt adnak
    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-t átadni registryként a rövidebb út: a ValidatePdfFilesParallel ekkor maga készíti el a default registryt, az Options.Standards-ból származtatja a profillistát, és visszatéréskor felszabadítja. Az eredmények mindig bemeneti sorrendben jönnek vissza, akármilyen sorrendben fejezték be a workerek. A riport formátumokról és ugyanennek a motornak a parancssori burkáról a kötegelt PDF preflight riportok a PDFium Component CLI-vel szól, arról pedig, mit fednek a PDF/A ellenőrzések maguk, a PDF/A preflight validáció Delphiben

Hogyan futtat valóban párhuzamosan oldalakat a RenderPagesParallel?

A TPdf.RenderPagesParallel azért fut párhuzamosan, mert a workerei soha nem osztoznak PDFium modulon. A metódus előbb a hívó szálon elmenti az aktív dokumentumot egy forrástárba. Aztán minden worker a betöltött PDFium DLL-t egy egyedi nevű fájlba másolja a temp könyvtárban, azt a másolatot LoadLibrary-vel tölti, és inicializálja. A Windows egy másik útvonalról betöltött DLL-t másik modulként kezel, így minden másolat megkapja a saját globálisait: a saját font cacheét, a saját page modulejét, a saját mindenét. A worker a saját privát moduljában nyitja meg az elmentett dokumentumot, fokozatosan rendereli az oldalait lépések közti megszakítás-ellenőrzésekkel, aztán megsemmisíti a libraryt, unloadolja a másolatot, és törli a fájlt

PDFium Component RenderPagesParallel ábra: a hívó szál elment egy dokumentum snapshotot, aztán minden worker a PDFium DLL-t egyedi temp fájlba másolja, külön modulként tölti saját globálisokkal, rendereli az oldalait megszakítás-ellenőrzésekkel, és unloadolja a másolatot
A valódi párhuzamosítás modulizolációból jön: a Windows minden DLL másolatot másik modulként kezel, így a workerek semmiben nem osztoznak, csak abban a snapshotban, amit a hívó szál a lock alatt elmentett

Az izoláció nem ingyen van, és a defaultok ezt tükrözik. Minden worker fizet egy DLL másolatért a lemezen, egy második PDFium globális szettért a memóriában, és a dokumentum friss parse-jáért. A MaxWorkers = 0 azt jelenti, hogy legfeljebb 4 worker, a MaxPixelsPerPage és a MaxTotalOutputBytes a nyers kimenetet korlátozza, az invertált és éjszakai duotónia render opciók pedig elutasításra kerülnek, mert a bufferek nyersen jönnek vissza. Az eredmény egy TPdfParallelRenderReport, aminek a Results tömbje kért oldalonként egy fentről lefelé olvasott 32 bites buffert tartalmaz, kérés sorrendben

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;                 // az oldalszámok 1-alapúak

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

  // A forrás snapshot a megosztott modulon készül, ezért fogd
  // a folyamat szintű PDFium lockot, ha más szálak is használnak TPdf-et
  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;

Vedd észre a lockot a hívás körül. A worker modulok privátak, de az elején lévő snapshot lépés a hívó szálról SaveAs-t futtat a megosztott modulon. Ha a folyamatodban semmi más nem érint konkurensen TPdf-et, elhagyhatod a lockot; ha bármi érinti, a snapshotnak ugyanaz a védelem jár, mint minden más megosztott modulos hívásnak

MintaDokumentumok közt biztonságosPDFium munka párhuzamosan futÁr
TPdf szálonként, megosztott lock nélkülNemIgen, amíg meg nem rongálIntermittens crashek, megrongált folyamatállapot
Egy folyamat szintű lock minden PDFium hívás körülIgenNemA PDFium rész szerialis
ValidatePdfFilesParallel v3.125.1 ótaIgenNem; a szabályértékelés párhuzamosA parse és a preflight szerialis
TPdf.RenderPagesParallelIgenIgenDLL másolat, memória és friss parse workerenként

Hogyan építsd fel a saját többszálú PDFium kódodat?

A saját szálaid egyetlen folyamat szintű lockon osszák meg, és tartsák azt minden általuk használt TPdf teljes élete alatt, vagy használjanak olyan komponens API-t, ami helyetted izolálja a modult. A locknak egyetlen objektumnak kell lennie az egész folyamatra, nem szálonkénti, formonkénti vagy dokumentumonkéntinek; egy lock, amit két szál nem oszt meg, semmit nem véd. Az alábbi minta azt tükrözi, amit a komponens v3.125.1 óta belül csinál: create, load, olvasás és free a lockon belül, minden, ami PDFiumot nem érint, azon kívül

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

var
  PdfiumLock: TCriticalSection;        // egy lock az egész folyamatra

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;                      // a dokumentum bezárása is PDFium munka
      end;
    finally
      PdfiumLock.Release;
    end;
    // E sor alatt nincs több PDFium, ezért ez a rész párhuzamosan fut
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Néhány szabály tartja őszintén a mintát egy valódi alkalmazásban:

  • A TPdf.Create-et és a Free-t tedd a lockba, ne csak a kézenfekvő hívásokat. Betöltés, bezárás, olyan property olvasások, mint a PageCount, oldalváltás, szövegkinyerés, renderelés és mentés mind beleér a modulba
  • A hozzárendelés után nézd meg az Active-ot. Egy elbukott betöltés False-on hagyja az Active-ot, és a LastLoadReport.ErrorMessage megmondja, miért
  • A lockot dokumentumonként tartsd, nem hívásonként. Finomabb lockolás elvileg lehetséges, de csak akkor, ha semelyik TPdf tag soha nem fut azon kívül, és a durva változat az, amire maga a komponens is támaszkodik
  • A lassú, nem PDFium munkát, például adatbázisírásokat, indexelést és hálózati hívásokat tartsd a lockon kívül, különben egy lassú fogyasztó mindent szerializál
  • A privát, példányonkénti render lockot ne kezeld helyettesítőnek. Az egy TPdf-et védi önmagától, és semmi mást

Ugyanez az óvatosság érvényes arra a kódra is, amit nem írtál nyers szálakként. A háttér futurek jó módszerek arra, hogy a hosszú renderelések ne az UI szálon fussanak, ahogy a PDF renderelés a háttérben megszakítható futurekkel írja, de a future executor nem ad hozzá saját globális PDFium lockot. Ha több future is hajthat egyszerre különböző TPdf példányokat, fogd ugyanazt a folyamat szintű lockot minden workeren belül, és a fő szálon futó viewert kezeld a megosztott modul még egy klienseként. A példányok közti használat az aszinkron API-kon át nincs külön auditálva, ezért a konzervatív feltevés az, hogy ugyanazt a szerializációt igényli, mint a kézzel írt szálak. Ha valami más miatt kell valódi PDFium párhuzamosítás, mint az oldalrenderelés, a külön worker folyamatok építésből adják minden munkának a saját modulját

Gyorsreferencia: PDFium szálkezelési szabályok Delphihez

  • A PDFium nem szálbiztos állapota modul szintű: a font cache, a page module és egyéb globálisokat a folyamat minden dokumentuma közösen használja
  • Egy TPdf szálonként semmit nem izolál; két példány két szálon még mindig rongálhatja egymást
  • Tipikus tünetek: betöltési hibák, access violationök későbbi kódban, External exception C000001D, és kilépések 0xC0000409-cel vagy 0xC0000374-cel
  • A rongálás a folyamatban megmarad, így a hibázó hívás gyakran nem az, ami okozta
  • A ValidatePdfFilesParallel v3.125.1 óta biztonságos; régebbi verziókon WorkerCount := 1-t használj
  • A TPdf.RenderPagesParallel valóban párhuzamos, mert minden worker a PDFium modul egy izolált másolatát tölti
  • A saját szálaid, taszkjaid és futureid egyetlen folyamat szintű lockot igényelnek, ami minden TPdf-et fed a Create-től a Free-ig

A PDFium Component a PDFium engineet csomagolja Delphihez kötegelt preflighttal és validációval, izolált párhuzamos rendereléssel, megszakítható háttérmunkával és részletes betöltési diagnosztikával. Részletek és kiadások a PDFium Component terméklapon