Das Rendern einer Seite in PDFium ist synchron. Sie rufen die Bibliothek auf, sie rastert in eine Bitmap, die Sie ihr übergeben haben, und die Kontrolle kehrt zurück, wenn die Pixel geschrieben sind. Für eine einzelne bildschirmgroße Seite bei einer Zoomstufe dauert das ein paar Millisekunden, und niemand bemerkt es. Für einen 300-dpi-Export eines 200-seitigen Dokuments oder eine Miniaturansichtsleiste, die jede Seite auf einmal rastern muss, kostet derselbe Aufruf Sekunden. Wenn Sie diesen Aufruf aus dem Hauptthread heraus tätigen, stoppt die Nachrichtenschleife, das Fenster hört auf, sich neu zu zeichnen, und Windows malt das gefürchtete „Keine Rückmeldung“ über Ihre Titelleiste. Die Arbeit ist korrekt. Der Ort, an dem Sie sie ausgeführt haben, ist falsch
Die Lösung besteht darin, das lange Rendern auf einen Hintergrund-Thread zu verlagern und das Ergebnis zurück in den Hauptthread zu bringen, wo die Bitmap an ein Steuerelement übergeben werden kann. PDFium selbst hindert Sie nicht daran, aber das Binding muss die Übergabe sicher gestalten, denn die Fehlerfläche rund um „auf einem Worker ausführen, auf der UI antworten“ ist groß und die Fehler treten sporadisch auf. Die FPdfAsync-Unit in PDFiumPas existiert, um diesem Muster eine korrekte Implementierung zu geben, mit einem Abbruchmodell, das dazu passt, wie sich ein langes Rendern tatsächlich verhält
Die Form der Arbeit
Drei Operationen dominieren die Fälle, in denen ein Rendern länger als ein Frame dauert. Das Batch-Rendering durchläuft einen Seitenbereich und rastert jede Seite, meist auf die Festplatte. Der Multi-Page-Export tut dasselbe, fügt die Ausgabe jedoch in einer einzigen Datei zusammen. Das Hintergrund-Seitenrendering ist das, was ein Viewer tut, wenn der Benutzer zu einer Seite springt, die noch nicht im Cache ist, sodass die Bitmap außerhalb des Threads erzeugt und angezeigt wird, sobald sie bereit ist. Alle drei teilen dieselben Einschränkungen. Sie laufen lange genug, dass der UI-Thread sie nicht beherbergen kann, sie erzeugen ein Ergebnis, das der UI-Thread irgendwann benötigt, und der Benutzer kann sie abbrechen. Das Schließen des Dokuments, das Vorbeiscrollen an der Seite oder das Drücken von Abbrechen sollte die Arbeit stoppen, anstatt den Benutzer zu zwingen, auf eine Ausgabe zu warten, die er nicht mehr will
Diese letzte Einschränkung ist diejenige, die das Design prägt. Ein Rendern, das nicht abgebrochen werden kann, ist ein Rendern, das das Dokument geöffnet hält und CPU verbrennt, nachdem die Antwort keine Rolle mehr spielt. Daher ist die Unit um zwei Primitive herum aufgebaut, die sich zusammensetzen lassen: ein Future, das das Ergebnis zurückträgt, und ein Token, das die Abbruchanfrage vorwärtsträgt
Ein Fire-and-Forget-Future
TPdfFuture<T>.Run nimmt einen Worker, eine Antwort (Reply) und ein optionales Abbruch-Token entgegen. Es startet den Worker auf einem Hintergrund-Thread, und wenn der Worker fertig ist, liefert es die Antwort auf dem Hauptthread aus. Der generische Parameter T ist das, was das Rendern erzeugt, oft ein Bitmap-Handle oder ein Status-Record. Der Worker läuft außerhalb des Threads; die Antwort läuft dort, wo es sicher ist, die VCL zu berühren
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Die bewusste Auslassung ist jegliche Art von Wait. Es gibt keine Methode, um den Aufrufer zu blockieren, bis das Future abgeschlossen ist, und das ist kein Versehen. Ein vom Hauptthread aufgerufenes Wait ist der klassische Weg, eine UI zum Deadlock zu bringen: Der Worker braucht den Hauptthread, um seine Antwort über Synchronize auszuführen, der Hauptthread ist innerhalb von Wait geparkt, und keine der beiden Seiten kann fortfahren. Indem es sich weigert, dieses Primitiv anzubieten, schließt das Future das Muster aus, an dem die meisten scheitern, die versuchen, dies selbst zu schreiben. Code, der wirklich blockieren muss, sollte einen einfachen TThread verwenden und die Konsequenzen selbst tragen. Das Future ist für den Fire-and-Forget-Fall gedacht, und genau das ist Hintergrund-Rendering tatsächlich
Das Ergebnis wird in TPdfFutureResult<T> verpackt, einem Record, der der Antwort mitteilt, welches von drei Dingen passiert ist. IsSuccess bedeutet, dass der Worker normal zurückgekehrt ist und Value das Rendering hält. IsCancelled bedeutet, dass das Token ausgelöst wurde und der Worker an einem Abbruchpunkt ausgestiegen ist. IsFailure bedeutet, dass der Worker eine Ausnahme ausgelöst hat, und ErrorMessage trägt den Text. Die Antwort prüft den Status einmal und verzweigt, anstatt anhand eines Sentinel-Werts zu raten, ob eine zurückgegebene Bitmap echt ist
Die v1.61.0-Race-Condition, die die Auslieferung der Antwort änderte
Der lehrreichste Teil dieser Unit ist eine einzeilige Änderung, deren Verständnis eine Weile gedauert hat. In frühen Versionen lieferte der Worker-Thread seine Antwort mit TThread.Queue aus. Queue postet die Antwort in die Warteschlange des Hauptthreads und kehrt sofort zurück, was sich genau so liest, wie es ein Fire-and-Forget-Future will. Es war falsch, und der Grund ist es wert, ausgeführt zu werden, denn es ist die Art von Fehler, die jeden Test besteht, an den Sie denken
Der Worker-Thread wird mit FreeOnTerminate := True erstellt. Das bedeutet, dass sich der Thread in dem Moment, in dem Execute zurückkehrt, selbst abbaut, und TThread.Destroy ruft im Rahmen der Bereinigung RemoveQueuedEvents(Self) auf. RemoveQueuedEvents löscht jede in der Warteschlange befindliche Methode, deren Ziel der sterbende Thread ist. Die Abfolge war also: Der Worker beendet sich, er stellt die Antwort gegen sich selbst in die Warteschlange, Execute kehrt zurück, der Thread zerstört sich selbst, und RemoveQueuedEvents löscht die Antwort, die der Hauptthread noch nicht ausgeführt hatte. Das Ergebnis verschwand einfach. Schlimmer noch: In dem schmalen Fenster, in dem der Hauptthread die in der Warteschlange befindliche Antwort herauszog und sie in genau dem Moment auszuführen begann, in dem der Thread freigegeben wurde, berührte die Antwort Felder eines halb zerstörten Objekts, was ein Use-after-free ist
Die Behebung in v1.61.0 bestand darin, die Antwort mit Synchronize statt mit Queue auszuliefern. Synchronize blockiert den Worker-Thread, bis der Hauptthread die Antwort vollständig ausgeführt hat. Der Worker ist noch am Leben, während seine Antwort ausgeführt wird, sodass nichts unter ihm weggegeben werden kann, und der Thread kehrt nicht aus Execute zurück (und beginnt daher nicht, sich selbst zu zerstören), bis die Antwort ausgeliefert wurde. Die Auslieferung ist garantiert, und das Use-after-free-Fenster ist geschlossen
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // already cancelled? skip the worker
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
// could be dropped by RemoveQueuedEvents before the main thread ran it.
Synchronize(DispatchReply);
end;
Die allgemeine Lehre überdauert die spezifische Behebung. Fire-and-Forget-asynchrone Callbacks sind das Nebenläufigkeitsmuster, das am leichtesten auf subtile Weise falsch zu machen ist, denn der glückliche Pfad funktioniert beim ersten Versuch und der Fehler lebt in der Interaktion zwischen der Reihenfolge des Thread-Abbaus und der Warteschlange. Er reproduziert sich nicht auf Anforderung. Er hängt davon ab, ob der Hauptthread zufällig die Warteschlange geleert hat, bevor der Worker zufällig damit fertig wurde, sich selbst zu zerstören, ein Timing, das der Scheduler bei jedem Lauf anders entscheidet. Ein Primitiv, das einmal korrekt ist, im Binding, ist weit mehr wert als derselbe Code, der in jeder Anwendung, die ein Hintergrund-Rendering braucht, neu hergeleitet wird
Warum die Callbacks Methodenzeiger sind
Der Worker und die Antwort sind keine anonymen Methoden. Es sind procedure of object-Typen, TPdfFutureWorker<T> und TPdfFutureReply<T>, und diese Wahl wird durch die Compiler-Matrix erzwungen. PDFiumPas kompiliert auf Delphi XE5 und später sowie auf Free Pascal 3.2 im Delphi-Modus, und FPC 3.2 unterstützt in diesem Modus keine anonymen Methoden. Ein Reference-to-procedure-Callback, der lokale Variablen erfasst, würde auf Delphi kompilieren und auf FPC fehlschlagen, also verwendet die Unit den kleinsten gemeinsamen Nenner, den beide Compiler akzeptieren
Die praktische Konsequenz betrifft den Ort, an dem der Zustand lebt. Eine anonyme Methode schließt über lokale Variablen; ein Methodenzeiger tut das nicht. Jeder Zustand, den der Worker benötigt, der Seitenindex, der Zoom, der Ausgabepfad, und jeder Zustand, den die Antwort aktualisieren muss, das Ziel-Bildsteuerelement oder das Fortschrittslabel, muss an dem Objekt hängen, dessen Methode übergeben wird. In einem Viewer ist dieses Objekt meist das Formular oder ein Render-Controller, der ihm gehört. Dies ist kein widerwillig auferlegter Workaround; es hält den Besitz dieses Zustands explizit und sichtbar auf dem empfangenden Objekt, anstatt ihn in einer Closure zu verstecken
Kooperativer Abbruch, kein hartes Beenden
Der Abbruch ist hier kooperativ. Es gibt keine API, die in den Worker-Thread greift und ihn beendet, denn das Beenden eines Threads mitten im Rendern lässt PDFium mit Sperren und teilweise geschriebenen Bitmaps zurück, und der Prozesszustand nach einem erzwungenen Beenden ist nichts, worüber man Schlüsse ziehen kann. Stattdessen wird dem Worker ein schreibgeschütztes Token übergeben, von dem erwartet wird, dass er es prüft, und die Render-Schleife ist so geschrieben, dass sie es zwischen Seiten oder zwischen Kacheln prüft, wo das Anhalten sauber ist
Das Token bietet drei Möglichkeiten, den Abbruch zu beobachten. IsCancelled ist eine günstige boolesche Abfrage für eine Schleife, die selbst testen und entscheiden möchte. ThrowIfCancelled ist der häufige Fall: Rufen Sie es an einem natürlichen Abbruchpunkt auf, und wenn ein Abbruch angefordert wurde, löst es EPdfOperationCancelled aus, was den Worker direkt zum Future zurück abwickelt. RegisterCallback hängt eine einmalige Benachrichtigung an, die einmal ausgelöst wird, wenn die Quelle abgebrochen wird, nützlich, wenn ein Worker in etwas blockiert ist, das er unterbrechen kann, anstatt in einer engen Schleife zu sitzen
Die Ausnahme ist der Ort, an dem die Thread-Grenze eine Rolle spielt. Wenn der Worker EPdfOperationCancelled auslöst, fängt das Future sie ab und wandelt sie in einen Abbruchstatus um, sodass die Antwort IsCancelled sieht und keinen Fehler. Das Ausnahmeobjekt selbst wird niemals zum Hauptthread gemarshallt. Es lebt und stirbt auf dem Worker-Thread; nur seine Nachrichtenzeichenfolge wird in ErrorMessage kopiert. Ein lebendes Ausnahmeobjekt über Threads hinweg zu marshallen würde bedeuten, in Speicher zu greifen, der einem Thread gehört, der gerade endet, was dieselbe Fehlerklasse ist, die die Synchronize-Behebung verhindern soll. Ein Statuscode und eine Zeichenfolge überschreiten die Grenze sauber; ein Objekt würde es nicht
Zwei Interfaces, damit ein Worker sich nicht selbst abbrechen kann
Der Abbruch ist absichtlich auf zwei Interfaces aufgeteilt. IPdfCancellationTokenSource ist die Schreibseite: Sie hat Cancel, und der Besitzer, der sie erstellt, meist das Formular, behält sie und ruft Cancel auf, wenn der Benutzer auf die Schaltfläche klickt oder das Formular geschlossen wird. IPdfCancellationToken ist die Leseseite: Sie hat IsCancelled, ThrowIfCancelled und RegisterCallback, und das ist alles, was der Worker jemals erhält. Ein konkretes Objekt implementiert beide, aber dem Worker wird nur jemals das Token übergeben, sodass er keine Möglichkeit hat, die Operation abzubrechen, die er ausführt. Die Aufteilung ist eine Leitplanke auf API-Ebene. Ein Worker, der über sein Token Cancel erreichen könnte, würde ein verwirrtes Stück Code dazu einladen, sich selbst abzubrechen, und das Typsystem entfernt diese Möglichkeit
Es gibt ein passendes Detail für den Fall, dass ein Aufrufer ein Rendering möchte, aber nie beabsichtigt, es abzubrechen. Anstatt eine frische Source pro Aufruf zu erzwingen, stellt die Unit PdfNoCancellationToken bereit, ein Singleton-Token, das sich dauerhaft im nicht abgebrochenen Zustand befindet. Run setzt es ein, wenn das Token-Argument nil gelassen wird. Dieses Singleton wird eifrig (eagerly) während der Unit-Initialisierung konstruiert und nicht träge (lazily) bei der ersten Verwendung, und der Grund ist wieder die Nebenläufigkeit. Wenn mehrere Run-Aufrufe auf verschiedenen Worker-Threads alle gleichzeitig nach einem träge erstellten Singleton griffen, könnten sie bei seiner Konstruktion in eine Race-Condition geraten, ein Duplikat lecken oder kurzzeitig eine halb initialisierte Instanz beobachten. Es vor jedem möglichen Worker-Lauf zu erstellen, beseitigt die Race-Condition vollständig
Ein abbrechbares Rendering ausführen
In der Praxis erstellen Sie eine Source, behalten sie auf dem Formular, übergeben ihr Token zusammen mit einer Worker-Methode und einer Antwort-Methode an Run und verdrahten die Abbrechen-Schaltfläche mit der Source. Der Worker prüft das Token während des Renderns; die Antwort aktualisiert die UI, sobald das Ergebnis zurück ist. Da die Callbacks Methodenzeiger sind, lesen der Worker und die Antwort alles, was sie benötigen, aus den Feldern des Formulars
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // field, lives on the form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // worker observes this at its next cancel point
end;
// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // clean stop between pages
RenderOnePage(PageIndex); // synchronous PDFium rasterisation
end;
Result := True;
end;
// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
Die Antwort behandelt alle drei Ausgänge, weil alle drei erreichbar sind. Ein abgeschlossenes Rendering meldet Erfolg, ein Benutzer, der Abbrechen gedrückt hat, sieht den Abbruchzweig, und eine Datei, die nicht geschrieben werden konnte, oder eine Seite, die nicht geparst werden konnte, kommt als Fehler mit einer Nachricht an. Keiner dieser Zweige blockiert, keiner von ihnen berührt den Worker-Thread, und die Bitmap oder der Status, den der Worker erzeugt hat, wird erst gelesen, nachdem das Future es auf dem Thread ausgeliefert hat, dem die UI gehört
Dieselbe Threading-Disziplin zahlt sich anderswo in einem Viewer aus. Die Art und Weise, wie gerenderte Bitmaps über Zoomänderungen hinweg aufbewahrt und wiederverwendet werden, wird in unserer Notiz zum Render-Cache und zur Zoom-Leistung behandelt, und die umfassendere Frage, wie die PDFium-Grenze unter Delphi sicher gehalten wird, steht in Härtung der PDFium-Component-ABI für Speichersicherheit. Die hier beschriebene Async-Infrastruktur wird als Teil der PDFium Component für Delphi und C++Builder ausgeliefert, zusammen mit den Rendering-, Text- und Formular-APIs, die an anderer Stelle auf diesem Blog behandelt werden