Articolo tecnico

Rendering parallelo di pagine PDF in Delphi: thread safety

Il rendering parallelo di pagine PDF da Delphi si riduce a un'unica regola: fornire a ogni thread di lavoro il proprio renderer. losLab PDF Library espone RenderPagesToFilesParallel esattamente per questo compito, distribuendo un intervallo di pagine su un pool di TTask con un'istanza di TPDFlib per ogni worker, in modo che una macchina multi-core trasformi un lavoro raster batch in un rendimento che scala quasi proporzionalmente al numero di core. Se invece condividi un'unica istanza tra più thread, l'esecuzione non rallenterà in modo controllato, ma corromperà la memoria e andrà in crash

Questo è l'articolo a cui fare riferimento quando un processo notturno deve convertire un PDF di 500 pagine in 500 PNG, la macchina ha 16 core inutilizzati e il tuo primo onesto tentativo di thread si è interrotto all'interno di GDI+. In breve, la sicurezza dei thread qui è una proprietà strutturale, non un flag da impostare, e il resto dell'articolo spiega perché la struttura sicura ha questa forma e dove si colloca l'effettivo limite di accelerazione

TPDFlib è thread-safe per il rendering parallelo?

No, e vale la pena capirne il motivo prima di progettarvi attorno. Una singola istanza di TPDFlib è dichiarata per l'uso a thread singolo, e il punto critico è TPDFPageTree.GetPage: scrive un campo FPagePointer condiviso sull'istanza come effetto collaterale della selezione di una pagina. Due thread che chiamano la stessa istanza entrano in conflitto su quel campo, quindi il worker A può essere a metà della pagina 3 quando il worker B reindirizza l'albero delle pagine alla pagina 40. Nulla nelle API impedisce di scrivere il codice seguente, e funzionerà persino per alcune pagine prima di fallire, che è il modo peggiore in cui un bug del genere possa comportarsi

// NON farlo: un'istanza condivisa, molti thread
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // ogni thread rientra nella stessa istanza -> data race su FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

Il fallimento non è deterministico, che è esattamente il motivo per cui sopravvive a un rapido test iniziale e poi si manifesta su una macchina cliente con un diverso numero di core e un documento più pesante. Non c'è alcun blocco da poter applicare a RenderPageToFile per risolvere il problema a basso costo, poiché mantenere un mutex per l'intera chiamata di rendering serializza il lavoro e vanifica il parallelismo desiderato

Perché ogni worker del rendering richiede un'istanza TPDFlib propria?

Perché l'istanza è l'unità di isolamento. Quando ogni worker possiede un'istanza privata di TPDFlib che ha caricato il file in modo indipendente, ognuno ha il proprio albero delle pagine, il proprio FPagePointer e il proprio stato di rendering, quindi non c'è nulla di condiviso su cui competere. Questa sicurezza ha un prezzo che è bene valutare in anticipo: ogni worker analizza l'intero documento in memoria, quindi il picco di occupazione è di circa N times il costo della singola istanza. Otto worker su un PDF da 300 MB significano otto analisi complete residenti contemporaneamente, e su input molto grandi questo è il vincolo che determina il numero di worker, non la CPU. Quando il documento è enorme e i limiti sono di memoria piuttosto che di CPU, l'approccio ad accesso diretto trattato in elaborare PDF di grandi dimensioni senza l'analisi completa del documento è spesso una scelta migliore rispetto all'aggiunta di altri thread di rendering

L'API a singola chiamata: RenderPagesToFilesParallel

losLab PDF Library racchiude l'intero pattern sicuro dietro un unico metodo, così che per i casi comuni non sia necessario scrivere codice personalizzato. RenderPagesToFilesParallel accetta il nome del file e la password, un DPI, una pagina iniziale e finale inclusive, un valore di Options passato direttamente al percorso raster per singola pagina, un pattern di output in cui %p viene sostituito dal numero di pagina e un limite di worker per cui qualsiasi valore pari o inferiore a zero indica l'impostazione automatica. Restituisce il numero di pagine renderizzate con successo, ed è un percorso solo per Windows poiché fa affidamento su CoInitialize e GDI+

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // Nome file, password, DPI, pagina iniziale, pagina finale, opzioni, pattern, max worker
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(conteggio pagine, core CPU)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Perché chiamare CoInitialize su ogni thread di lavoro?

GDI+ è il rasterizzatore alla base del rendering delle pagine, ed è basato sul modello apartment-threaded: si aspetta che COM sia inizializzato su qualsiasi thread che effettua chiamate ad esso. Il thread principale di un'applicazione VCL di solito ha già questa impostazione configurata, ma un worker TTask appena creato no, e chiamare il percorso di rendering da un thread non inizializzato è un modo sicuro per causare un crash. Di conseguenza, ogni worker associa una chiamata a CoInitialize(nil) all'ingresso con una a CoUninitialize all'uscita, racchiudendo l'intera sua durata di vita. Questa è la stessa disciplina richiesta da qualsiasi lavoro GDI+ o COM al di fuori del thread principale, ed è la seconda metà di ciò che rende effettivo l'isolamento per singolo worker, laddove la prima metà è rappresentata dall'istanza privata. Lo stesso percorso raster GDI+ guida i motori a thread singolo trattati in scegliere un motore di rendering per l'output PDF

Sharding statico contro recupero dinamico delle pagine

Il modo ovvio per dividere 500 pagine tra 8 worker è assegnare a ciascuno una porzione fissa di circa 62 pagine. losLab PDF Library non fa questo, e la ragione è il bilanciamento del carico. Il costo di rendering di una pagina varia enormemente: una pagina di testo semplice viene renderizzata in millisecondi, mentre una pagina con mappe vettoriali dense o un'immagine scansionata a tutta pagina può richiedere un tempo cinquanta volte superiore. Dividendo il lavoro in porzioni fisse, il worker che si trova a elaborare la porzione più pesante continuerà a lavorare molto tempo dopo che gli altri sono diventati inattivi, cosicché il tempo complessivo di esecuzione sarà stabilito dalla porzione più sfortunata, non dalla media. Invece, ogni worker richiede la pagina successiva da un contatore condiviso all'interno di una breve sezione critica, esegue il rendering e torna per richiederne un'altra, mantenendo ogni core occupato finché l'intero intervallo non è completato

// Cosa fa ogni worker all'interno del pool (semplificato)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ è apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // un'istanza privata per ogni worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // acquisisce la pagina successiva in modo atomico
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

Logging strutturato tra thread di lavoro

Il debug di un batch che si interrompe alla pagina 213 di 500 è quasi impossibile senza un log, e un log ingenuo è di per sé un bug di concorrenza. losLab PDF Library fornisce TPDFlibLogger, collegato tramite la proprietà TPDFlib.Logger e impostato a nil per impostazione predefinita, in modo che il percorso senza logger rimanga a costo zero. Funziona principalmente tramite callback: configuri OnLog e indirizzi i record ovunque l'applicazione ospite desideri, filtrati per livello llDebug / llInfo / llWarn / llError, e PDFlibErrorMessage converte i codici numerici grezzi in testo leggibile, in modo che un record Error sia interpretabile ed esprima più di un semplice intero. Il sink di file opzionale è l'unica risorsa condivisa, ed è protetto da una TCriticalSection proprio per consentire a diversi worker di accodare messaggi allo stesso file di log in modo sicuro. Tieni presente il limite effettivo: solo quel sink di file è sincronizzato, quindi se condividi un unico logger in un pool creato manualmente e il tuo OnLog interagisce con l'interfaccia utente, devi comunque gestire autonomamente il marshalling verso il thread principale

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // sink condiviso opzionale (protetto da lock)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // esegui autonomamente il marshalling verso il thread UI; OnLog si attiva sui thread di lavoro
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil per impostazione predefinita; costo zero quando non impostato
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // un errore ora trasporta testo, ad esempio 401 -> "Password errata o autorizzazione negata"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Quale accelerazione dovresti effettivamente aspettarti?

Sii onesto con te stesso su dove viene speso il tempo, perché il rendering parallelo ripaga solo quando il lavoro è realmente limitato dalla CPU. L'output ad alti DPI e le pagine con vettori complessi o sfumature richiedono molto calcolo, e scalano in modo quasi lineare con il numero di core fino a saturare la CPU. Le pagine semplici sono una storia diversa: qui l'overhead di LoadFromFile per ciascun worker, sommato al costo del disco per la scrittura dei file di output, può superare il rendering stesso, e otto worker che sollecitano un unico disco lento possono finire per essere più lenti rispetto a un pulito ciclo seriale. Imposta MaxWorkers sul numero effettivo di core fisici anziché su valori teorici, tieni d'occhio la memoria quando il PDF di origine è di grandi dimensioni e, se un batch si rivela limitato dall'I/O, la soluzione è uno storage più veloce o un minor numero di worker, non un aumento dei thread. Utilizzato per i compiti per cui è stato progettato, il percorso di rendering batch mostrato qui fa parte di losLab PDF Library standard per Delphi e C++Builder, e trasforma i core inattivi in pagine completate senza alcuna delle insidie sulla thread safety che dovresti altrimenti risolvere da solo