Articol tehnic

Thread safety PDFium: de ce pică lacătele per document

PDFium nu e thread-safe la nivel de modul, deci două instanțe TPdf care lucrează pe două fișiere diferite în două thread-uri se pot totuși corupe una pe alta. PDFium Component pentru Delphi gestionează asta în două feluri: din v3.125.1, ValidatePdfFilesParallel serializează fiecare apel nativ PDFium în spatele unui singur lacăt la nivel de proces, în timp ce TPdf.RenderPagesParallel dă fiecărui worker propria copie izolată a modulului PDFium. Bug-ul care a forțat repararea a fost de cel mai rău fel, intermitent. Un test de validare de batch trecea de cele mai multe ori, apoi raporta unul dintre două fișiere bune ca picat, apoi prăbușea testul următor din același proces cu un access violation, și câteodată trăgea jos întregul runner cu un cod de ieșire în loc de stack trace. Nu era nimic în neregulă cu testul, și nici cu vreun document în parte. Presupunerea era greșită: un TPdf per thread nu e izolare

De ce nu ajunge un TPdf per thread?

Un TPdf per thread nu ajunge pentru că PDFium își ține starea nesigură în modul, nu în document. Fiecare TPdf deține propriul lui handle FPDF_DOCUMENT, dar fiecare handle din proces e deservit de același DLL încărcat, iar DLL-ul acela deține singletons la nivel de proces: cache-ul de fonturi, page module-ul și alte structuri globale pe care încărcarea documentelor, parsarea și randarea le ating pe toate. Două thread-uri care încarcă două fișiere fără legătură sunt două thread-uri scriind în același cache de fonturi în același timp. Nimeni nu deține datele acelea pe partea Delphi, deci nimic pe partea Delphi nu le poate încuia per document

Componenta are un lacăt, și e ușor să tragi concluzia greșită din el. TPdf își învelește căile proprii de randare într-o secțiune critică internă (EnterRenderLock / LeaveRenderLock, metode private ale lui TPdf). Lacătul acela e per instanță. Împiedică două thread-uri să conducă același TPdf în același timp, ceea ce e un pericol real, dar nu vede o a doua instanță pe alt thread, deci concurența între instanțe trece drept pe lângă el. Regula generală e destul de simplă de spus într-o linie: într-un singur modul PDFium încărcat, cel mult un thread poate fi în interiorul PDFium în orice moment, indiferent câte documente sunt deschise

Diagramă PDFium Component a două thread-uri care rulează instanțe TPdf separate pe documente diferite, în timp ce fiecare apel converge spre un singur modul pdfium.dll încărcat, al cărui cache de fonturi, page module și alte globale la nivel de proces sunt partajate, producând eșecuri de încărcare, access violations și ieșiri fail-fast
PDFium își ține starea nesigură în modul, nu în document, deci două instanțe TPdf pe două thread-uri scriu în același cache de fonturi indiferent cât de fără legătură sunt fișierele

Cum arată corupția între documente într-un proces Delphi?

Corupția între documente arată ca un amestec aleator de eșecuri fără legătură, iar paguba supraviețuiește codului care a cauzat-o. Înainte de v3.125.1, ValidatePdfFilesParallel crea un TPdf per thread de worker și rula Active := True plus construirea raportului de preflight concurent pe modulul partajat. Simptomele văzute pe build-urile atât Delphi, cât și Free Pascal, au acoperit toată gama:

  • Un fișier valid pice la încărcare, sau se întoarce din batch drept picat când ar fi trebuit să treacă
  • Un access violation iese la suprafață într-un apel ulterior, fără legătură, adesea într-un alt test sau alt document
  • External exception C000001D apare în Delphi. Codul acela e STATUS_ILLEGAL_INSTRUCTION, ridicat de instrucțiunea ud2 pe care o execută macro-urile CHECK și IMMEDIATE_CRASH interne ale PDFium când o invariantă se rupe
  • Procesul iese cu 0xC0000409 (fail-fast, raportat drept stack buffer overrun) sau 0xC0000374 (heap corruption), fără nicio excepție Delphi

Ultimele două puncte sunt motivul pentru care bug-ul a fost greu de prins. Validarea paralelă se termina, starea globală coruptă rămânea în urmă, iar următorul fixture din același proces se împiedica de ea. Într-o rulare de regresie Delphi Win64, un val de eșecuri C000001D a lovit teste care nu atingeau deloc validarea de batch; erau pur și simplu primele coduri care foloseau PDFium după pagubă. Numerele măsurate spun limpede mărimea. O sondă Delphi care rula același eșantion prin doi worker-i a picat 122 din 160 de documente într-o rulare și 138 din 160 în alta, iar una dintre rulări a ridicat direct External exception C000001D. Un caz de stres cu 8 documente, 4 worker-i și 5 runde a picat sau s-a prăbușit în 5 din 5 rulări pe Free Pascal Win64. După reparare, aceeași sondă a picat 0 din 1.200 de documente

Cum rămâne ValidatePdfFilesParallel în siguranță din v3.125.1

ValidatePdfFilesParallel serializează acum partea nativă a fiecărui job și ține partea managed paralelă. Fiecare worker ia o secțiune critică la nivel de unit înainte să-și creeze TPdf-ul și o ține de-a lungul lui FileName, Active := True, construirii raportului de preflight și Free. Crearea și distrugerea sunt în interiorul lacătului în mod deliberat: închiderea unui document apelează înapoi în modul exact cum o face încărcarea. Odată ce worker-ul are o înregistrare TPdfPreflightReport capturată, eliberează lacătul și evaluează regulile de validare contra acelei înregistrări, ceea ce nu atinge nicio stare PDFium, deci evaluarea regulilor pentru un fișier se suprapune cu lucrul PDFium pentru următorul

Diagramă ValidatePdfFilesParallel PDFium Component arătând fiecare worker ținând o secțiune critică la nivel de proces de-a lungul creării, încărcării, preflight-ului și eliberării TPdf, în timp ce evaluarea regulilor raportului capturat rulează în afara lacătului în paralel, deci jumătatea PDFium a batch-ului e serială prin construcție
Crearea și distrugerea rămân în interiorul lacătului fiindcă închiderea unui document apelează înapoi în modul, în timp ce evaluarea raportului nu atinge nicio stare PDFium și se suprapune cu fișierul următor

Două schimbări mai mici au venit odată cu repararea. Un eșec de încărcare ridică acum EPdfError cu LastLoadReport.ErrorMessage, deci ErrorMessage-ul articolului numește problema reală de parsare în loc de o eroare secundară „no active document”. Iar costul e spus onest: partea PDFium a batch-ului e acum serială, deci pe un batch dominat de parsare și preflight, worker-i în plus cumpără puțin. Dacă sunteți pe o versiune de dinainte de v3.125.1, setați WorkerCount pe 1; asta elimină concurența și odată cu ea corupția

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 = număr de procesoare, plafonat la 8
    Options.Standards := [ppsPdfA];
    // Cu un registry explicit, alegeți voi profilul potrivit.
    // O listă Profiles goală rulează fiecare regulă înregistrată, iar regulile
    // pentru standarde nepreflight-uite raportează „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;

Pasarea lui nil drept registry e ruta mai scurtă: ValidatePdfFilesParallel creează apoi singur registry-ul implicit, derivă lista de profiluri din Options.Standards și eliberează registry-ul când se întoarce. Rezultatele se întorc întotdeauna în ordinea de intrare, orice ordine au terminat worker-ii. Pentru formatele de raport și wrapper-ul de linie de comandă din jurul aceluiași motor, vezi rapoarte de preflight PDF în batch cu CLI-ul PDFium Component, iar pentru ce acoperă verificările PDF/A în sine, validarea de preflight PDF/A în Delphi

Cum rulează RenderPagesParallel paginile cu adevărat în paralel?

TPdf.RenderPagesParallel rulează în paralel pentru că worker-ii lui nu împart niciodată un modul PDFium. Metoda salvează întâi documentul activ într-un magazin sursă pe thread-ul apelant. Fiecare worker copiază apoi DLL-ul PDFium încărcat într-un fișier cu nume unic în directorul temp, încarcă copia aceea cu LoadLibrary și o inițializează. Windows tratează un DLL încărcat de la o altă cale drept un alt modul, deci fiecare copie își capătă globalele proprii: propriul cache de fonturi, propriul page module, propriul ei tot. Worker-ul deschide documentul salvat în modulul lui privat, rendează paginile lui progresiv cu verificări de anulare între pași, apoi distruge biblioteca, descarcă copia și șterge fișierul

Diagramă RenderPagesParallel PDFium Component în care thread-ul apelant salvează un snapshot al documentului, apoi fiecare worker copiază DLL-ul PDFium într-un fișier temp unic, îl încarcă drept modul separat cu globalele lui proprii, rendează paginile lui cu verificări de anulare și descarcă copia
Paralelismul real vine din izolarea modulului: Windows tratează fiecare copie de DLL drept un alt modul, deci worker-ii nu împart nimic în afară de snapshot-ul salvat de thread-ul apelant sub lacăt

Izolarea nu e gratis, iar valorile implicite reflectă asta. Fiecare worker plătește o copie de DLL pe disc, un al doilea set de globale PDFium în memorie și o parsare proaspătă a documentului. MaxWorkers = 0 înseamnă cel mult 4 worker-i, MaxPixelsPerPage și MaxTotalOutputBytes plafonează output-ul brut, iar opțiunile de randare inverted și night-duotone sunt respinse fiindcă bufferele se întorc brute. Rezultatul e un TPdfParallelRenderReport al cărui array Results deține un buffer top-down de 32 de biți per pagină cerută, în ordinea cererii

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;                 // numerele de pagini au baza unu

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

  // Snapshot-ul sursă se face pe modulul partajat, deci țineți lacătul
  // PDFium la nivel de proces dacă alte thread-uri folosesc și ele 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;

Observați lacătul din jurul apelului. Modulele worker sunt private, dar pasul de snapshot de la început rulează SaveAs pe modulul partajat din thread-ul apelant. Dacă nimic altceva din procesul vostru atinge TPdf concurent, puteți renunța la lacăt; dacă ceva o face, snapshot-ul are nevoie de aceeași protecție ca orice alt apel pe modul partajat

TiparSigur între documenteLucrul PDFium rulează în paralelCost
Un TPdf per thread, fără lacăt partajatNuDa, până corupeCrash-uri intermitente, stare de proces avariată
Un lacăt la nivel de proces în jurul tuturor apelurilor PDFiumDaNuPartea PDFium e serială
ValidatePdfFilesParallel din v3.125.1DaNu; evaluarea regulilor e paralelăParsarea și preflight-ul sunt seriale
TPdf.RenderPagesParallelDaDaCopie de DLL, memorie și o parsare proaspătă per worker

Cum ar trebui să structurați propriul cod PDFium multithread?

Thread-urile voastre proprii ar trebui să împartă un singur lacăt la nivel de proces și să-l țină pe toată durata de viață a fiecărui TPdf pe care îl folosesc, sau să folosească un API de componentă care izolează modulul pentru voi. Lacătul trebuie să fie un singur obiect pentru tot procesul, nu unul per thread, per formă sau per document; un lacăt pe care două thread-uri nu-l împart nu protejează nimic. Tiparul de mai jos oglindește ce face componenta intern din v3.125.1: creați, încărcați, citiți și eliberați în interiorul lacătului, apoi faceți tot ce nu atinge PDFium în afara lui

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

var
  PdfiumLock: TCriticalSection;        // un singur lacăt pentru tot procesul

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;                      // și închiderea documentului e lucru PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Nicio apelare PDFium sub linia asta, deci partea asta rulează în paralel
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Câteva reguli țin tiparul onest într-o aplicație reală:

  • Puneți TPdf.Create și Free în interiorul lacătului, nu doar apelurile evidente. Încărcarea, închiderea, citirile de proprietăți precum PageCount, schimbările de pagină, extragerea de text, randarea și salvarea ajung toate în modul
  • Verificați Active după atribuire. O încărcare eșuată lasă Active pe False, iar LastLoadReport.ErrorMessage spune de ce
  • Țineți lacătul per document, nu per apel. O încuiere mai fină e posibilă în principiu, dar doar dacă niciun membru TPdf nu rulează vreodată în afara ei, iar versiunea grosieră e cea de care se bazează însăși componenta
  • Țineți lucrul lent non-PDFium, precum scrieri în baza de date, indexarea și apelurile de rețea, în afara lacătului, altfel un singur consumator lent serializează totul
  • Nu tratați lacătul intern de randare per instanță drept un substitut. El păzește un TPdf de el însuși și nimic mai mult

Aceeași prudență se aplică codului pe care nu l-ați scris drept thread-uri brute. Future-urile de fundal sunt o cale bună de a ține randările lungi departe de thread-ul UI, cum e descris în randarea PDF în fundal cu future-uri anulabile, dar executorul de future-uri nu adaugă un lacăt PDFium global propriu. Dacă mai multe future-uri pot conduce instanțe TPdf diferite în același timp, luați același lacăt la nivel de proces în interiorul fiecărui worker, și tratați un viewer pe thread-ul principal ca încă un client al modulului partajat. Utilizarea între instanțe prin API-urile asincrone nu a fost auditată separat, deci presupunerea conservatoare e că are nevoie de aceeași serializare ca thread-urile scrise de mână. Când aveți nevoie de paralelism PDFium real pentru altceva decât randare de pagini, procesele worker separate dau fiecărui job propriul modul prin construcție

Referință rapidă: regulile de threading PDFium pentru Delphi

  • Starea nesigură a PDFium-ului e la nivel de modul: cache-ul de fonturi, page module-ul și alte globale sunt partajate de fiecare document din proces
  • Un TPdf per thread nu izolează nimic; două instanțe pe două thread-uri se pot totuși corupe una pe alta
  • Simptomele tipice sunt eșecuri de încărcare, access violations în cod ulterior, External exception C000001D, și ieșiri cu 0xC0000409 sau 0xC0000374
  • Corupția persistă în proces, deci apelul care pice nu e adesea cel care a cauzat-o
  • ValidatePdfFilesParallel e sigur din v3.125.1; pe versiunile mai vechi folosiți WorkerCount := 1
  • TPdf.RenderPagesParallel e cu adevărat paralel pentru că fiecare worker încarcă o copie izolată a modulului PDFium
  • Thread-urile, task-urile și future-urile voastre au nevoie de un lacăt la nivel de proces care acoperă fiecare TPdf de la Create la Free

PDFium Component învelește motorul PDFium pentru Delphi cu preflight și validare de batch, randare paralelă izolată, lucru în fundal anubil și diagnostice detaliate de încărcare. Detalii și ediții sunt pe pagina de produs PDFium Component