Articolo tecnico

Thread safety PDFium: i lucchetti per documento non bastano

PDFium non è thread-safe a livello di modulo, quindi due istanze di TPdf che lavorano su due file diversi in due thread possono comunque corrompersi a vicenda. PDFium Component for Delphi lo gestisce in due modi: dalla v3.125.1, ValidatePdfFilesParallel serializza ogni chiamata PDFium nativa dietro a un unico lucchetto a livello di processo, mentre TPdf.RenderPagesParallel dà a ogni worker la sua copia isolata del modulo PDFium. Il bug che ha imposto la correzione era del peggior tipo, quello intermittente. Un test di validazione batch passava quasi sempre, poi riportava uno di due file buoni come fallito, poi faceva crashare il test successivo nello stesso processo con un access violation, e a volte portava giù l'intero runner con un exit code anziché uno stack trace. Il test non aveva niente di sbagliato, e nessun singolo documento aveva niente di sbagliato. L'assunzione era sbagliata: un TPdf per thread non è isolamento

Perché un TPdf per thread non basta?

Un TPdf per thread non basta perché PDFium tiene il suo stato insicuro nel modulo, non nel documento. Ogni TPdf possiede il proprio handle FPDF_DOCUMENT, ma ogni handle del processo è servito dalla stessa DLL caricata, e quella DLL contiene singleton a livello di processo: la cache dei font, il page module, e altre strutture globali che caricamento, parsing e rendering dei documenti toccano tutti. Due thread che caricano due file non imparentati sono due thread che scrivono nella stessa cache dei font nello stesso momento. Nessuno possiede quei dati sul lato Delphi, quindi niente sul lato Delphi può metterci un lucchetto per documento

Il componente ha un lucchetto, ed è facile tirarne la conclusione sbagliata. TPdf avvolge i propri percorsi di render in una critical section interna (EnterRenderLock / LeaveRenderLock, metodi private di TPdf). Quel lucchetto è per istanza. Impedisce a due thread di pilotare lo stesso TPdf in contemporanea, che è un pericolo reale, ma non vede una seconda istanza su un altro thread, quindi la concorrenza cross-istanza gli passa dritta accanto. La regola generale è semplice da dire in una riga: in un singolo modulo PDFium caricato, al massimo un thread può trovarsi dentro PDFium in un dato momento, a prescindere da quanti documenti siano aperti

Diagramma PDFium Component di due thread che fanno girare istanze TPdf separate su documenti diversi mentre ogni chiamata converge su un unico modulo pdfium.dll caricato la cui cache dei font, page module e altre globali a livello di processo sono condivise, producendo fallimenti di caricamento, access violation e uscite fail-fast
PDFium tiene il suo stato insicuro nel modulo, non nel documento, così due istanze TPdf su due thread scrivono nella stessa cache dei font non importa quanto siano imparentati i file

Che aspetto ha la corruzione cross-document in un processo Delphi?

La corruzione cross-document ha l'aspetto di un mix casuale di fallimenti non imparentati, e il danno sopravvive al codice che l'ha causata. Prima della v3.125.1, ValidatePdfFilesParallel creava un TPdf per thread worker e faceva girare Active := True più la costruzione del report preflight in concorrenza sul modulo condiviso. I sintomi visti sia sulle build Delphi sia su quelle Free Pascal coprivano tutta la gamma:

  • Un file valido non si carica, o torna dal batch come fallito quando avrebbe dovuto passare
  • Un access violation emerge in una chiamata successiva e non imparentata, spesso in un test diverso o su un documento diverso
  • In Delphi appare External exception C000001D. Quel codice è STATUS_ILLEGAL_INSTRUCTION, sollevato dall'istruzione ud2 che le macro interne CHECK e IMMEDIATE_CRASH di PDFium eseguono quando un invariante si rompe
  • Il processo esce con 0xC0000409 (fail-fast, riportato come stack buffer overrun) o 0xC0000374 (heap corruption), senza alcuna eccezione Delphi

Gli ultimi due punti sono il motivo per cui il bug era così difficile da inchiodare. La validazione parallela finiva, lo stato globale corrotto restava lì, e il prossimo fixture nello stesso processo ci inciampava. In un'esecuzione di regressione Delphi Win64, un'ondata di fallimenti C000001D colpiva test che non toccavano mai la validazione batch; erano semplicemente il primo codice a usare PDFium dopo il danno. I numeri misurati rendono la scala evidente. Una sonda Delphi che faceva girare lo stesso campione attraverso due worker falliva su 122 di 160 documenti in un'esecuzione e 138 di 160 in un'altra, e una di quelle esecuzioni sollevava direttamente External exception C000001D. Un caso di stress con 8 documenti, 4 worker e 5 round falliva o crashava in 5 esecuzioni su 5 su Free Pascal Win64. Dopo la correzione, la stessa sonda falliva su 0 di 1.200 documenti

Come resta sicuro ValidatePdfFilesParallel dalla v3.125.1

ValidatePdfFilesParallel ora serializza la metà nativa di ogni job e tiene la metà gestita in parallelo. Ogni worker prende una critical section a livello di unità prima di creare il suo TPdf, e la tiene attraverso FileName, Active := True, la costruzione del report preflight, e Free. Creazione e distruzione stanno dentro il lucchetto di proposito: chiudere un documento richiama nel modulo proprio come il caricamento. Una volta che il worker ha un record TPdfPreflightReport catturato, rilascia il lucchetto e valuta le regole di validazione contro quel record, che non tocca alcuno stato PDFium, così la valutazione delle regole di un file si sovrappone al lavoro PDFium del successivo

Diagramma ValidatePdfFilesParallel di PDFium Component che mostra ogni worker che tiene una critical section a livello di processo attraverso create, load, preflight e free di TPdf mentre la valutazione delle regole del report catturato gira fuori dal lucchetto in parallelo, così la metà PDFium del batch è seriale per costruzione
Creazione e distruzione restano dentro il lucchetto perché chiudere un documento richiama nel modulo, mentre la valutazione del report non tocca alcuno stato PDFium e si sovrappone al file successivo

Due cambiamenti minori sono arrivati con la correzione. Un fallimento di caricamento ora solleva EPdfError con LastLoadReport.ErrorMessage, così l'ErrorMessage della voce nomina il vero problema di parse anziché un errore secondario "no active document". E il costo viene dichiarato onestamente: la parte PDFium del batch ora è seriale, quindi su un batch dominato da parsing e preflight, worker extra comprano poco. Se sei su una versione precedente alla v3.125.1, imposta WorkerCount a 1; questo toglie la concorrenza e con essa la corruzione

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 = numero dei processori, limitato a 8
    Options.Standards := [ppsPdfA];
    // Con un registry esplicito, seleziona tu il profilo corrispondente.
    // Una lista Profiles vuota fa girare ogni regola registrata, e le regole per
    // standard di cui non hai fatto preflight riportano "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;

Passare nil come registry è la via più breve: ValidatePdfFilesParallel allora crea da solo il registry predefinito, ricava la lista dei profili da Options.Standards, e libera il registry quando ritorna. I risultati tornano sempre nell'ordine di input, qualsiasi sia l'ordine in cui i worker hanno finito. Per i formati di report e il wrapper a riga di comando attorno allo stesso motore, vedi report preflight PDF in batch con la CLI di PDFium Component, e per ciò che i controlli PDF/A coprono davvero, validazione preflight PDF/A in Delphi

Come fa RenderPagesParallel a far girare le pagine davvero in parallelo?

TPdf.RenderPagesParallel gira in parallelo perché i suoi worker non condividono mai un modulo PDFium. Il metodo prima salva il documento attivo in uno store sorgente sul thread chiamante. Ogni worker poi copia la DLL PDFium caricata in un file con nome univoco nella directory temporanea, carica quella copia con LoadLibrary, e la inizializza. Windows tratta una DLL caricata da un percorso diverso come un modulo diverso, quindi ogni copia prende le sue globali: la sua cache dei font, il suo page module, il suo tutto. Il worker apre il documento salvato nel suo modulo privato, renderizza le sue pagine progressivamente con controlli di cancellazione tra i passi, poi distrugge la libreria, scarica la copia e cancella il file

Diagramma RenderPagesParallel di PDFium Component in cui il thread chiamante salva uno snapshot del documento, poi ogni worker copia la DLL PDFium in un file temporaneo univoco, la carica come modulo separato con le sue globali, renderizza le sue pagine con controlli di cancellazione e scarica la copia
Il parallelismo vero viene dall'isolamento dei moduli: Windows tratta ogni copia della DLL come un modulo diverso, quindi i worker non condividono niente tranne lo snapshot che il thread chiamante ha salvato sotto lucchetto

L'isolamento non è gratis, e i default lo riflettono. Ogni worker paga una copia della DLL su disco, un secondo insieme di globali PDFium in memoria, e un parse fresco del documento. MaxWorkers = 0 significa al massimo 4 worker, MaxPixelsPerPage e MaxTotalOutputBytes limitano l'output grezzo, e le opzioni di render invertite e in duotone notturno vengono rifiutate perché i buffer vengono restituiti grezzi. Il risultato è un TPdfParallelRenderReport il cui array Results contiene un buffer top-down a 32 bit per pagina richiesta, nell'ordine di richiesta

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;                 // i numeri di pagina sono a base uno

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

  // Lo snapshot sorgente è preso sul modulo condiviso, quindi tieni il
  // lucchetto PDFium a livello di processo se anche altri thread usano 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;

Nota il lucchetto attorno alla chiamata. I moduli dei worker sono privati, ma il passo dello snapshot all'inizio esegue SaveAs sul modulo condiviso dal thread chiamante. Se nient'altro nel tuo processo tocca TPdf in concorrenza puoi togliere il lucchetto; se qualcosa lo fa, lo snapshot ha bisogno della stessa protezione di ogni altra chiamata al modulo condiviso

PatternSicuro tra documentiLavoro PDFium in paralleloCosto
Un TPdf per thread, nessun lucchetto condivisoNoSì, finché non corrompeCrash intermittenti, stato del processo danneggiato
Un lucchetto a livello di processo attorno a tutte le chiamate PDFiumSìNoLa parte PDFium è seriale
ValidatePdfFilesParallel dalla v3.125.1SìNo; la valutazione delle regole è parallelaParsing e preflight sono seriali
TPdf.RenderPagesParallelSìSìCopia DLL, memoria e un parse fresco per worker

Come strutturare il tuo codice PDFium multithread?

I tuoi thread dovrebbero condividere un unico lucchetto a livello di processo e tenerlo per l'intera vita di ogni TPdf che usano, oppure usare un'API del componente che isola il modulo per te. Il lucchetto deve essere un oggetto unico per l'intero processo, non uno per thread, per form o per documento; un lucchetto che due thread non condividono non protegge niente. Il pattern qui sotto ricalca ciò che il componente fa internamente dalla v3.125.1: create, load, read e free dentro il lucchetto, poi tutto ciò che non tocca PDFium fuori da esso

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

var
  PdfiumLock: TCriticalSection;        // un lucchetto per l'intero processo

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;                      // chiudere il documento è lavoro PDFium anche quello
      end;
    finally
      PdfiumLock.Release;
    end;
    // Niente PDFium sotto questa riga, quindi questa parte gira in parallelo
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Poche regole tengono il pattern onesto in un'applicazione reale:

  • Metti TPdf.Create e Free dentro il lucchetto, non solo le chiamate ovvie. Caricare, chiudere, le letture di proprietà come PageCount, i cambi di pagina, l'estrazione testo, il rendering e il salvataggio arrivano tutti fino al modulo
  • Controlla Active dopo averlo assegnato. Un caricamento fallito lascia Active a False, e LastLoadReport.ErrorMessage dice perché
  • Tieni il lucchetto per documento anziché per chiamata. Lucchettature più fini sono possibili in linea di principio, ma solo se nessun membro di TPdf gira mai fuori da esso, e la versione grossolana è quella su cui il componente stesso conta
  • Tieni il lavoro lento non-PDFium, come scritture su database, indicizzazione e chiamate di rete, fuori dal lucchetto, altrimenti un solo consumatore lento serializza tutto
  • Non trattare il lucchetto di render privato per istanza come un sostituto. Protegge un TPdf da sé stesso e niente di più

La stessa cautela vale per il codice che non hai scritto come thread grezzi. I future in background sono un buon modo di tenere i render lunghi fuori dal thread UI, come descritto in rendering PDF in background con future cancellabili, ma l'executor dei future non aggiunge di suo un lucchetto PDFium globale. Se più future possono pilotare istanze TPdf diverse allo stesso tempo, prendi lo stesso lucchetto a livello di processo dentro ogni worker, e tratta un viewer sul thread principale come un altro cliente del modulo condiviso. L'uso cross-istanza attraverso le API asincrone non è stato auditato separatamente, quindi l'assunzione conservativa è che richieda la stessa serializzazione dei thread scritti a mano. Quando ti serve vero parallelismo PDFium per qualcosa di diverso dal rendering di pagine, processi worker separati danno a ogni job il suo modulo per costruzione

Riferimento rapido: regole di threading PDFium per Delphi

  • Lo stato insicuro di PDFium è a livello di modulo: cache dei font, page module e altre globali sono condivise da ogni documento del processo
  • Un TPdf per thread non isola niente; due istanze su due thread possono comunque corrompersi a vicenda
  • I sintomi tipici sono fallimenti di caricamento, access violation in codice successivo, External exception C000001D, e uscite con 0xC0000409 o 0xC0000374
  • La corruzione persiste nel processo, quindi la chiamata che fallisce spesso non è quella che l'ha causata
  • ValidatePdfFilesParallel è sicuro dalla v3.125.1; sulle versioni più vecchie usa WorkerCount := 1
  • TPdf.RenderPagesParallel è davvero parallelo perché ogni worker carica una copia isolata del modulo PDFium
  • I tuoi thread, task e future hanno bisogno di un lucchetto a livello di processo che copra ogni TPdf da Create a Free

PDFium Component incapsula il motore PDFium per Delphi con preflight e validazione batch, rendering parallelo isolato, lavoro in background cancellabile e diagnostica di caricamento dettagliata. Dettagli ed edizioni sono sulla pagina prodotto di PDFium Component