Ein Batch-Rendering friert auf halbem Weg ein, weil der Executor in PDFium Component eine Aufgabe erst als beendet betrachtet, wenn ihre Antwort ausgeliefert wurde. Unter padSynchronize läuft diese Antwort auf dem Hauptthread. Blockiert der Hauptthread, ohne CheckSynchronize zu pumpen, wartet der Worker auf den Hauptthread, während der Hauptthread auf Idle wartet
Das Debugger-Bild ist unverkennbar, sobald man es einmal gesehen hat. Den eingefrorenen Prozess pausieren, und der Hauptthread sitzt in einem Warten auf das Idle-Ereignis, mehrere Frames unterhalb der eigenen Batch-Schleife. Zu irgendeinem Worker-Thread wechseln, und der sitzt in TThread.Synchronize, ein fertiges Ergebnis haltend, das er nicht übergeben kann. Nichts dreht sich, keine CPU brennt, der Prozess ist schlicht geparkt. Dieser Artikel handelt davon, warum dieser Zustand überhaupt existiert, und von drei benachbarten Regeln, die entscheiden, ob sich ein Delphi-Worker-Pool über PDFium brav verhält oder beißt: was QueueCapacity tatsächlich begrenzt, in welcher Reihenfolge der Shutdown abbrechen muss, und was Parallelität einem hinsichtlich PDFium-Objekt-Ownership nicht abnimmt
Warum hängt WaitForIdle den Hauptthread auf?
Es hängt, weil Idle in TPdfAsyncExecutor so definiert ist, dass es Antwort-Dispatch einschließt, nicht nur Worker-Abschluss. Der laufende Zähler wird in DequeueTask erhöht, wenn ein Worker eine Aufgabe aufnimmt, und in TaskFinished verringert, was der Worker erst aufruft, nachdem TPdfAsyncTaskOperation.Execute zurückgekehrt ist. Diese Methode führt den Worker-Körper aus, zeichnet das Ergebnis auf und dispatched dann die Antwort gemäß TPdfAsyncDispatchMode. Bei padSynchronize ist der Dispatch ein TThread.Synchronize-Aufruf, sodass Execute erst zurückkehrt, wenn der Hauptthread ihn ausgeführt hat
Delphi legt die zweite Hälfte dieses Vertrags in die eigene Verantwortung. TThread.Synchronize hängt die Methode an eine globale Queue an und blockiert den aufrufenden Thread an einem Ereignis; etwas auf dem Hauptthread muss CheckSynchronize aufrufen, bevor dieses Ereignis je signalisiert wird. Die VCL-Nachrichtenschleife erledigt das zwischen Nachrichten automatisch, weshalb der Bug bei interaktiver Nutzung unsichtbar bleibt und genau in dem Moment auftaucht, in dem man eine blockierende Batch-Schleife schreibt. Ein blockierender Hauptthread ist ein Hauptthread, der die Nachrichtenschleife verlassen hat, und ein Hauptthread außerhalb der Nachrichtenschleife entleert niemanden
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.
Aufgabenabschluss und Executor-Idle sind zwei verschiedene Meilensteine
Sie sind absichtlich getrennt, und zu wissen, auf welchen man wartet, ist der ganze Fix. IPdfAsyncTask.WaitFor ist erfüllt, sobald das Worker-Ergebnis feststeht: Complete schreibt den finalen TPdfAsyncTaskState und setzt das Done-Ereignis, bevor irgendeine Antwort in Betracht gezogen wird. TPdfAsyncExecutor.WaitForIdle ist später erfüllt, sobald sowohl der Warteschlangen- als auch der Laufend-Zähler null sind, und laufend fällt erst, wenn die Antwort gelandet ist. Eine Aufgabe kann daher patsSucceeded sein und über Snapshot beobachtbar, während der Executor noch legitim beschäftigt ist
// 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;
Eine Konsequenz, die man verinnerlichen sollte: Eine Antwort, die eine Exception auslöst, schreibt die Geschichte nicht um. DispatchReply fängt die Exception ab und speichert sie in ReplyErrorMessage, wobei State, CancellationReason und ErrorMessage genau so bleiben, wie der Worker sie bestimmt hat. Ein UI-Callback, der beim Zeichnen einer Miniaturansicht abstürzt, verwandelt daher nie ein erfolgreiches Rendering in ein fehlgeschlagenes, und die eigene Telemetrie meldet weiterhin, was die Rendering-Engine tatsächlich getan hat. Wer die callback-förmige API um eine einzelne Operation herum will statt um einen Pool, dem gilt Hintergrund-Rendering mit abbrechbaren Futures
Begrenzt QueueCapacity auch laufende Worker?
Nein. QueueCapacity in PDFium Component zählt nur wartende Aufgaben, nie die bereits auf einem Worker laufenden. Das ist beabsichtigt: Kapazität soll echten Gegendruck auf der Warteschlange ausdrücken, und die festen Nebenläufigkeits-Slots in dieselbe Zahl einzufalten würde sie doppelt zählen. Mit vier Workern und einer Kapazität von acht kann man zwölf Aufgaben in Flug haben, und GetStats meldet die Aufteilung ehrlich über QueuedCount und 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;
Die vier Bahnen von TPdfAsyncPriority sind strikt, nicht gewichtet. DequeueTask durchläuft von papCritical abwärts bis papLow und nimmt die erste nicht-leere Bahn, wobei die FIFO-Reihenfolge innerhalb jeder Bahn erhalten bleibt. Das gibt einer interaktiven Anfrage einen sauberen Weg, an einem noch nicht begonnenen Batch vorbeizuziehen, unterbricht aber nie bereits laufende Arbeit, und ein Aufrufer, der ständig papCritical füttert, kann papLow unbegrenzt aushungern lassen. Die oberen zwei Bahnen für Dinge reservieren, auf die ein Mensch sichtbar wartet, und Massenexport auf papNormal oder darunter belassen. Submit verwenden, wenn eine volle Queue ein Programmierfehler ist, der ein EPdfAsyncQueueFull wert ist, und TrySubmit, wenn es ein normaler Zustand ist, den man behandeln will
Warum bricht Shutdown außerhalb der Executor-Sperre ab?
Weil das Abbrechen innerhalb davon die Sperrreihenfolge umkehren und genau den Shutdown aufhängen würde, den man durchführen will. Shutdown(True) nimmt die Executor-Sperre, dreht das Shutdown-Flag um und hängt jede wartende Aufgabe über AppendSnapshot an ein lokales Snapshot-Array an. Dann gibt es die Sperre frei und durchläuft erst danach das Snapshot, wobei es auf jedem Eintrag Cancel aufruft. Eine Aufgabe abzubrechen feuert benutzerregistrierte Callbacks auf ihrer Token-Quelle, und diese Callbacks sind gewöhnlicher Anwendungscode: Sie können GetStats abfragen, kompensierende Arbeit einreichen oder auf Idle warten. Jeder davon betritt die Executor-Sperre erneut, und ein Callback, der aufgerufen wird, während diese Sperre gehalten wird, würde gegen sich selbst deadlocken
Die Token-Quelle folgt eine Ebene tiefer derselben Disziplin. CancelWithReason nimmt die Quellsperre, entscheidet den einen gewinnenden Canceller, schreibt Reason, CancellationMessage und CancelledAtTick, und dreht erst danach das Cancelled-Flag atomar um. Veröffentlichen vor Umdrehen ist es, was die Metadaten sicher lesbar macht: Jeder Thread, der IsCancelled als True beobachtet, findet garantiert einen vollständigen Grund dahinter, und spätere Aufrufer verlieren das Rennen, geben False zurück und können den ersten Grund nicht überschreiben. Die registrierten Callbacks werden innerhalb der Sperre snapshotted und geleert, aber außerhalb davon aufgerufen, jeder so umhüllt, dass ein fehlschlagender Handler nicht die übrigen unterdrücken kann. Bereits laufende Aufgaben werden nie getötet; sie enden kooperativ, sobald ihr Worker-Körper als Nächstes ThrowIfCancelled aufruft, weshalb Shutdown mit einem pumpenden WaitForIdle endet, bevor Threads gejoint werden
Lockert paralleles Arbeiten die PDFium-Objekt-Ownership?
Nein, und das ist die Grenze, die am ehesten missverstanden wird. TPdfAsyncExecutor plant Arbeit; er trifft keine Aussage über die Thread-Affinität von irgendetwas, das man innerhalb dieser Arbeit anfasst. Eine lebende TPdf-Instanz wird nicht dadurch nebenläufig zugreifbar, dass zwei Worker zufällig in sie hineinrufen, und die interne Render-Sperre ist eine Absicherung gegen überlappende Render-Aufrufe, keine Lizenz, ein Dokument über Threads hinweg zu teilen. Paralleles Rendering oder Export bedeutet ein TPdf pro Worker, innerhalb des Jobs erzeugt und zerstört
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;
Die Kosten sind real und es lohnt sich, sie zu benennen: Jeder Worker zahlt sein eigenes Parsen und seinen eigenen Seiten-Cache, sodass der Speicher mit der Worker-Zahl skaliert, nicht mit der Dokumentzahl. Das ist der Preis eines Modells, in dem ein Worker abgebrochen werden oder abstürzen kann, ohne irgendjemand anderen zu korrumpieren. Teilen sich die eigenen Worker stattdessen ein Viewer-seitiges Dokument, sind die Sperrregeln dazu im Artikel zur Render-Sperre und den Aufrufen, die sie verpassen behandelt, und der abbrechbare Einzeldokument-Pfad ist in abbrechbarem progressivem Rendering
Eine veröffentlichte Schnittstelle erweitern, ohne die Vtable zu brechen
IPdfCancellationToken und IPdfCancellationTokenSource sind COM-artige Schnittstellen, die externe Binärdateien bereits konsumieren könnten, sodass das Anhängen einer Methode an eine von beiden jeden späteren Slot in der Vtable verschieben und Aufrufe, die gegen das alte Layout kompiliert wurden, still fehlleiten würde. Die Diagnose-, Warte-, entfernbare-Callback- und atomaren Cancel-Fähigkeiten leben daher in IPdfCancellationTokenEx und IPdfCancellationTokenSourceEx, die erben statt zu modifizieren. New und Run behalten ihre ursprüngliche Semantik für bestehende Aufrufer; neuer Code greift zu NewEx, NewTimeout und RunEx, wenn er CancelWithReason, WaitForCancellation oder eine verwaltete IPdfCancellationRegistration will. Vererbung ist der einzig sichere Weg, eine veröffentlichte Schnittstelle wachsen zu lassen, und es kostet einen zusätzlichen Typ pro Generation
NewTimeout verdient einen ehrlichen Hinweis. Jede Timeout-Quelle besitzt einen leichtgewichtigen Thread, der auf das Cancellation-Ereignis oder die Deadline wartet, je nachdem, was zuerst eintritt. Für eine Handvoll oder ein paar Dutzend Deadlines ist das einfach, latenzarm und identisch über Delphi, Lazarus und C++Builder hinweg. Für Tausende kurzer Deadlines ist das die falsche Form, und man sollte Cancellation stattdessen von einem einzigen anwendungsweiten Timer aus steuern, statt Tausende wartender Threads zu halten
Keine dieser Regeln ist exotisch, einmal aufgeschrieben, aber jede davon ist ein Produktionsvorfall, wenn sie es nicht ist. Auf den richtigen Meilenstein warten und etwas die Synchronize-Queue pumpen lassen, QueueCapacity nur als Grenze der Warteschlange lesen, außerhalb der eigenen Sperren abbrechen und jedem Worker sein eigenes Dokument geben. Die hier beschriebene Async-Schicht ist Teil der Delphi PDFium Component, zusammen mit den Rendering-, Text- und Formular-APIs, die sie plant