Un render batch si blocca a metà perché l'executor in PDFium Component non considera un task finito finché la sua risposta non è stata dispatchata. Sotto padSynchronize quella risposta gira sul thread principale. Se il thread principale si blocca senza pompare CheckSynchronize, il worker aspetta il thread principale mentre il thread principale aspetta idle
Il quadro nel debugger è inconfondibile una volta che lo hai visto. Metti in pausa il processo bloccato e il thread principale si trova dentro un'attesa sull'evento idle, diversi frame sotto il tuo stesso loop batch. Passa a qualunque worker thread e lo trovi dentro TThread.Synchronize, in possesso di un risultato finito che non può consegnare. Nulla gira a vuoto, nessuna CPU brucia, il processo è semplicemente parcheggiato. Questo articolo tratta il perché quello stato esista affatto, e tre regole vicine che decidono se un pool di worker Delphi sopra PDFium si comporta bene o morde: cosa limita realmente QueueCapacity, in che ordine lo shutdown deve cancellare, e cosa il parallelismo non ti compra per quanto riguarda la proprietà degli oggetti PDFium
Perché WaitForIdle blocca il thread principale?
Si blocca perché idle in TPdfAsyncExecutor è definito per includere il dispatch della risposta, non solo il completamento del worker. Il conteggio running viene incrementato in DequeueTask quando un worker preleva un task, e viene decrementato in TaskFinished, che il worker chiama solo dopo che TPdfAsyncTaskOperation.Execute è ritornato. Quel metodo esegue il corpo del worker, registra l'esito, e poi dispatcha la risposta secondo TPdfAsyncDispatchMode. Con padSynchronize il dispatch è una chiamata TThread.Synchronize, quindi Execute non ritorna finché il thread principale non l'ha eseguita
Delphi mette la seconda metà di quel contratto su di te. TThread.Synchronize accoda il metodo a una coda globale e blocca il thread chiamante su un evento; qualcosa sul thread principale deve chiamare CheckSynchronize prima che quell'evento venga mai segnalato. Il message loop VCL fa questo per te tra un messaggio e l'altro, ed è esattamente per questo che il bug è invisibile durante l'uso interattivo e appare nel momento in cui scrivi un loop batch bloccante. Un thread principale bloccante è un thread principale che ha lasciato il message loop, e un thread principale fuori dal message loop non sta drenando nessuno
uses
System.Classes, FPdfAsync, PDFium;
// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
padSynchronize);
Task.WaitFor(High(Cardinal)); // the main thread now parks in a kernel wait
// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
// TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.
Completamento task ed executor idle sono due traguardi diversi
Sono separati di proposito, e sapere su quale dei due stai aspettando è l'intera correzione. IPdfAsyncTask.WaitFor è soddisfatto nell'istante in cui il risultato del worker è deciso: Complete scrive lo TPdfAsyncTaskState finale e imposta l'evento done prima che qualunque risposta venga considerata. TPdfAsyncExecutor.WaitForIdle è soddisfatto più tardi, una volta che i conteggi queued e running sono entrambi zero, e running non scende finché la risposta non è atterrata. Quindi un task può essere patsSucceeded e osservabile tramite Snapshot mentre l'executor è ancora legittimamente occupato
// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
ReportBatchTimeout;
// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
ATimeoutMs: Cardinal): Boolean;
var
StartedAt: UInt64;
begin
StartedAt := PdfAsyncTick;
repeat
if ATask.WaitFor(10) then
Exit(True);
CheckSynchronize(0); // release any pending padSynchronize reply
Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
until not Result;
end;
Una conseguenza che vale la pena interiorizzare: una risposta che solleva un'eccezione non riscrive la storia. DispatchReply cattura l'eccezione e la memorizza in ReplyErrorMessage, lasciando State, CancellationReason ed ErrorMessage esattamente come il worker li ha determinati. Una callback UI che esplode mentre disegna una miniatura quindi non trasforma mai un render riuscito in uno fallito, e la tua telemetria continua a riportare cosa ha effettivamente fatto il motore di rendering. Se vuoi l'API a forma di callback attorno a una singola operazione anziché un pool, il rendering in background con future cancellabili tratta quel percorso
QueueCapacity limita anche i worker in esecuzione?
No. QueueCapacity in PDFium Component conta solo i task in coda, mai quelli già in esecuzione su un worker. Questo è deliberato: la capacità è pensata per esprimere la vera backpressure sulla linea di attesa, e piegare gli slot fissi di concorrenza nello stesso numero li conterebbe due volte. Con quattro worker e una capacità di otto puoi avere dodici task in volo, e GetStats riporta onestamente la divisione tramite QueuedCount e RunningCount
var
Stats: TPdfAsyncExecutorStats;
Task: IPdfAsyncTask;
begin
// TrySubmit never raises: it returns False when the waiting line is full or
// the executor is already shutting down, and bumps RejectedCount.
if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
padSynchronize) then
begin
Stats := Executor.GetStats;
// QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
// WorkerCount and is never charged against the capacity.
LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
Stats.RejectedCount);
Exit;
end;
Le quattro corsie di TPdfAsyncPriority sono rigide, non pesate. DequeueTask percorre da papCritical giù fino a papLow e prende la prima corsia non vuota, preservando l'ordine FIFO all'interno di ciascuna. Questo dà a una richiesta interattiva un modo pulito per saltare avanti a un batch non ancora iniziato, ma non interrompe mai un lavoro già in esecuzione, e un chiamante che continua ad alimentare papCritical può affamare papLow indefinitamente. Riserva le due corsie più alte per cose su cui un essere umano sta visibilmente aspettando, e lascia l'export in massa su papNormal o inferiore. Usa Submit quando una coda piena è un errore di programmazione che merita un EPdfAsyncQueueFull, e TrySubmit quando è una condizione normale che intendi gestire
Perché Shutdown cancella fuori dal lock dell'executor?
Perché cancellare al suo interno invertirebbe l'ordine dei lock e bloccherebbe lo shutdown stesso che si sta cercando di eseguire. Shutdown(True) prende il lock dell'executor, inverte il flag di shutdown, e accoda ogni task in sospeso a un array di snapshot locale tramite AppendSnapshot. Poi rilascia il lock e solo dopo percorre lo snapshot chiamando Cancel su ogni voce. Cancellare un task attiva callback utente registrate sulla sua token source, e quelle callback sono codice applicativo ordinario: possono interrogare GetStats, sottomettere lavoro compensativo, o aspettare idle. Ognuna di esse rientra nel lock dell'executor, e una callback invocata mentre quel lock è tenuto andrebbe in deadlock contro se stessa
La token source obbedisce alla stessa disciplina un livello più sotto. CancelWithReason prende il lock della source, decide l'unico canceller vincente, scrive Reason, CancellationMessage e CancelledAtTick, e solo dopo inverte il flag cancelled atomicamente. Pubblicare prima di invertire è ciò che rende i metadati sicuri da leggere: qualunque thread che osserva IsCancelled come True è garantito trovare una ragione completa dietro di esso, e i chiamanti successivi perdono la gara, restituiscono False, e non possono sovrascrivere la prima ragione. Le callback registrate vengono fotografate e azzerate dentro il lock ma invocate fuori da esso, ciascuna avvolta così un handler fallito non può sopprimere gli altri. I task già in esecuzione non vengono mai uccisi; terminano cooperativamente quando il corpo del loro worker chiama successivamente ThrowIfCancelled, motivo per cui Shutdown termina con un WaitForIdle pompante prima di unire i thread
I worker paralleli allentano la proprietà degli oggetti PDFium?
Non lo fanno, e questo è il confine più probabile da fraintendere. TPdfAsyncExecutor pianifica lavoro; non fa alcuna affermazione sull'affinità di thread di qualunque cosa tu tocchi dentro quel lavoro. Un'istanza TPdf viva non diventa accessibile concorrentemente perché due worker capitano di chiamarla, e il lock di render interno è una guardia contro chiamate di render sovrapposte, non una licenza per condividere un documento tra thread. Rendering o export parallelo significa un TPdf per worker, creato e distrutto dentro il job
type
TPageRenderJob = class
private
FFileName: string;
FPageIndex: Integer;
public
procedure Run(const AToken: IPdfCancellationToken);
end;
procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
LocalPdf: TPdf; // one document instance per worker, never shared
Bmp: TBitmap;
begin
LocalPdf := TPdf.Create(nil);
try
LocalPdf.FileName := FFileName;
LocalPdf.Active := True;
LocalPdf.PageNumber := FPageIndex;
AToken.ThrowIfCancelled;
Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
try
HandOffBitmap(FPageIndex, Bmp); // ownership moves to the reply stage
finally
Bmp.Free;
end;
finally
LocalPdf.Free;
end;
end;
Il costo è reale e vale la pena nominarlo: ogni worker paga il proprio parsing e la propria cache di pagine, quindi la memoria scala con il numero di worker anziché con il numero di documenti. Questo è il prezzo di un modello in cui un worker può essere cancellato o crashare senza corrompere nessun altro. Se i tuoi worker invece condividono un documento lato viewer, le regole di locking attorno a ciò sono trattate in il render lock e le chiamate che lo saltano, e il percorso cancellabile a singolo documento è in rendering progressivo cancellabile
Estendere un'interfaccia pubblicata senza rompere la vtable
IPdfCancellationToken e IPdfCancellationTokenSource sono interfacce in stile COM che binari esterni potrebbero già consumare, quindi aggiungere un metodo a una delle due sposterebbe ogni slot successivo nella vtable e disorienterebbe silenziosamente le chiamate compilate contro il vecchio layout. Le capacità di diagnostica, attesa, callback rimovibile e cancellazione atomica vivono quindi in IPdfCancellationTokenEx e IPdfCancellationTokenSourceEx, che ereditano anziché modificare. New e Run mantengono la propria semantica originale per i chiamanti esistenti; il codice nuovo si rivolge a NewEx, NewTimeout e RunEx quando vuole CancelWithReason, WaitForCancellation o un IPdfCancellationRegistration gestito. L'ereditarietà è l'unico modo sicuro per far crescere un'interfaccia pubblicata, e costa un tipo extra per generazione
NewTimeout merita una nota onesta. Ogni timeout source possiede un thread leggero che aspetta sull'evento di cancellazione o sulla scadenza, qualunque arrivi prima. Per una manciata o alcune dozzine di scadenze questo è semplice, a bassa latenza e identico tra Delphi, Lazarus e C++Builder. Per migliaia di scadenze brevi è la forma sbagliata, e dovresti guidare la cancellazione da un unico timer a livello applicazione invece di mantenere migliaia di thread in attesa
Nessuna di queste regole è esotica una volta scritta, ma ognuna di esse è un incidente di produzione quando non lo è. Aspetta sul traguardo giusto e lascia che qualcosa pompi la coda di synchronize, leggi QueueCapacity come un limite solo sulla linea di attesa, cancella fuori dai tuoi lock, e dai a ogni worker il proprio documento. Lo strato asincrono descritto qui viene fornito come parte del Delphi PDFium Component, insieme alle API di rendering, testo e form che pianifica