Technical Article

Hintergrund-PDF-Rendering in Delphi mit abbrechbaren Futures

Das Rendern einer Seite in PDFium ist synchron. Sie rufen die Bibliothek auf, sie rastert in ein von Ihnen übergebenes Bitmap, und die Kontrolle kehrt zurück, wenn die Pixel geschrieben sind. Bei einer einzelnen Seite in Bildschirmgröße und auf einer Zoomstufe dauert das nur wenige Millisekunden und fällt niemandem auf. Bei einem 300-dpi-Export eines 200-seitigen Dokuments oder einer Thumbnail-Leiste, die jede Seite auf einmal rastern muss, kostet derselbe Aufruf jedoch Sekunden. Wenn Sie diesen Aufruf aus dem Hauptthread (Main Thread) tätigen, stoppt die Nachrichtenschleife, das Fenster wird nicht mehr neu gezeichnet, und Windows malt das gefürchtete „Keine Rückmeldung“ („Not Responding“) über Ihre Titelleiste. Die Arbeit an sich ist korrekt. Nur der Ort, an dem Sie sie ausgeführt haben, ist falsch

Die Lösung besteht darin, das langwierige Rendern in einen Hintergrund-Thread zu verlagern und das Ergebnis in den Hauptthread zurückzubringen, wo das Bitmap an ein Steuerelement übergeben werden kann. PDFium selbst hindert Sie nicht daran, dies zu tun, aber die Anbindung (Binding) muss die Übergabe sicher machen, da die Fehleroberfläche rund um „auf einem Worker ausführen, auf der UI antworten“ breit ist und die Ausfälle oft nur sporadisch auftreten. Die FPdfAsync-Unit in PDFiumPas existiert, um für dieses Muster genau eine korrekte Implementierung bereitzustellen, gepaart mit einem Abbruchmodell (Cancellation Model), das exakt 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. Ein mehrseitiger Export tut dasselbe, fügt die Ausgabe jedoch zu einer einzigen Datei zusammen. Das Hintergrund-Seitenrendern ist das, was ein Viewer tut, wenn der Benutzer zu einer Seite springt, die noch nicht im Cache ist; das Bitmap wird also abseits des Threads produziert und angezeigt, wenn es fertig ist. Alle drei teilen dieselben Einschränkungen: Sie laufen lange genug, sodass der UI-Thread sie nicht beherbergen kann, sie produzieren ein Ergebnis, das der UI-Thread letztendlich benötigt, und der Benutzer kann sie abbrechen. Das Schließen des Dokuments, das Wegscrollen von der Seite oder das Drücken auf „Abbrechen“ sollte die Arbeit stoppen, anstatt den Benutzer zu zwingen, auf eine Ausgabe zu warten, die er gar nicht mehr haben möchte

Diese letzte Einschränkung ist es, die das Design prägt. Ein Rendern, das nicht abgebrochen werden kann, ist ein Rendern, das das Dokument offen hält und CPU-Zyklen verbrennt, auch wenn die Antwort längst keine Rolle mehr spielt. Die Unit ist also um zwei Primitive herum aufgebaut, die sich zusammensetzen: ein Future, das das Ergebnis zurückbringt, und ein Token, das die Abbruchanforderung (Cancellation Request) nach vorne trägt

Ein Fire-and-Forget-Future

TPdfFuture<T>.Run nimmt einen Worker, eine Antwort (Reply) und optional ein Cancellation-Token entgegen. Es startet den Worker in einem Hintergrund-Thread, und wenn der Worker fertig ist, liefert es die Antwort im Hauptthread aus. Der generische Parameter T ist das, was das Rendern produziert, oft ein Bitmap-Handle oder ein Status-Record. Der Worker läuft abseits des Threads; die Antwort läuft dort, wo es sicher ist, auf die VCL zuzugreifen

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 aus dem Hauptthread aufgerufenes Wait ist der klassische Weg, um eine UI in einen Deadlock zu treiben: Der Worker benötigt 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 genau das Muster aus, an dem Entwickler am häufigsten scheitern, wenn sie versuchen, so etwas 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, was Hintergrund-Rendering in der Realität auch ist

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 Rendern enthält. IsCancelled bedeutet, dass das Token ausgelöst wurde und der Worker an einem Abbruchpunkt (Cancellation Point) ausgestiegen ist. IsFailure bedeutet, dass der Worker eine Ausnahme ausgelöst hat, und ErrorMessage trägt den entsprechenden Text. Die Antwort prüft den Status einmal und verzweigt sich, anstatt aus einem Sentinel-Wert erraten zu müssen, ob ein zurückgegebenes Bitmap echt ist

Der Wettlauf in v1.61.0, der die Antwortauslieferung verä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 stellt die Antwort in die Warteschlange des Hauptthreads und kehrt sofort zurück, was sich genau danach liest, was ein Fire-and-Forget-Future eigentlich will. Es war jedoch falsch, und der Grund dafür ist es wert, genau dargelegt zu werden, denn es ist die Art von Fehler, die jeden Test besteht, den Sie zu schreiben gedenken

Der Worker-Thread wird mit FreeOnTerminate := True erstellt. Das bedeutet, in dem Moment, in dem Execute zurückkehrt, reißt sich der Thread selbst ab, und TThread.Destroy ruft als Teil der Bereinigung RemoveQueuedEvents(Self) auf. RemoveQueuedEvents bereinigt jede in der Warteschlange befindliche Methode, deren Ziel der sterbende Thread ist. Die Sequenz war also: Der Worker ist fertig, er reiht die Antwort gegen sich selbst ein, Execute kehrt zurück, der Thread zerstört sich selbst, und RemoveQueuedEvents löscht die Antwort, die der Hauptthread noch gar nicht ausgeführt hatte. Das Ergebnis verschwand einfach. Schlimmer noch: In dem engen Zeitfenster, in dem der Hauptthread die eingereihte Antwort herauszog und anfing, sie genau in dem Moment auszuführen, in dem der Thread freigegeben wurde, berührte die Antwort Felder eines halb zerstörten Objekts, was zu einem Use-After-Free führte

Die Lösung in v1.61.0 bestand darin, die Antwort mit Synchronize anstelle von 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 ihm nichts unter den Füßen weggezogen und freigegeben werden kann. Der Thread kehrt nicht von Execute zurück (und beginnt daher auch nicht, sich selbst zu zerstören), bis die Antwort sicher ausgeliefert wurde. Die Zustellung 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 Lektion überdauert den spezifischen Fix. Asynchrone Fire-and-Forget-Callbacks sind das Nebenläufigkeitsmuster, das am einfachsten auf subtile Weise falsch gemacht wird, da der glückliche Pfad (Happy Path) auf Anhieb funktioniert und der Fehler lediglich in der Interaktion zwischen der Abbau-Reihenfolge des Threads und der Warteschlange schlummert. Er lässt sich nicht auf Abruf reproduzieren. Er hängt davon ab, ob der Hauptthread zufällig die Warteschlange geleert hat, bevor der Worker zufällig damit fertig war, sich selbst zu zerstören – ein Timing, das der Scheduler bei jedem Durchlauf anders entscheidet. Ein Primitiv, das einmal in der Anbindung korrekt umgesetzt wurde, ist weitaus mehr wert als derselbe Code, der in jeder Anwendung, die ein Hintergrund-Rendering benötigt, mühsam neu abgeleitet werden muss

Warum die Callbacks Methoden-Zeiger sind

Der Worker und die Antwort sind keine anonymen Methoden. Sie sind Typen vom Schlage procedure of object, namentlich TPdfFutureWorker<T> und TPdfFutureReply<T>, und diese Wahl wird durch die Compiler-Matrix erzwungen. PDFiumPas kompiliert auf Delphi XE5 und neuer 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, das lokale Variablen erfasst, würde zwar unter Delphi kompilieren, unter FPC jedoch scheitern. Daher nutzt die Unit den kleinsten gemeinsamen Nenner, den beide Compiler akzeptieren

Die praktische Konsequenz daraus ist, wo der Zustand (State) lebt. Eine anonyme Methode bildet einen Closure über lokale Variablen; ein Methoden-Zeiger tut dies nicht. Daher muss jeglicher Zustand, den der Worker benötigt (der Seitenindex, der Zoom, der Ausgabepfad), sowie jeglicher Zustand, den die Antwort aktualisieren muss (das Ziel-Bildsteuerelement oder das Fortschritts-Label), an dem Objekt hängen, dessen Methode übergeben wird. In einem Viewer ist dieses Objekt normalerweise das Formular oder ein Render-Controller, den es besitzt. Dies ist kein widerwillig aufgezwungener Workaround; es hält die Besitzverhältnisse (Ownership) dieses Zustands explizit und auf dem empfangenden Objekt sichtbar, anstatt sie tief im Inneren eines Closures zu verbergen

Kooperativer Abbruch, kein harter Kill

Der Abbruch erfolgt hier kooperativ. Es gibt keine API, die in den Worker-Thread hineingreift und ihn beendet, denn wenn man einen Thread mitten im Rendern beendet, hinterlässt das PDFium mit gehaltenen Sperren (Locks) und teilweise geschriebenen Bitmaps, und der Prozesszustand nach einem erzwungenen Abbruch (Forced Kill) ist nichts, worüber man noch verlässlich urteilen könnte. Stattdessen erhält der Worker ein Read-Only-Token, das er selbst prüfen soll, und die Render-Schleife wird so geschrieben, dass sie es zwischen den Seiten oder zwischen den Kacheln (Tiles) prüft, wo ein Stopp sauber möglich ist

Das Token bietet drei Möglichkeiten, einen Abbruch zu beobachten. IsCancelled ist eine billige boolesche Abfrage (Poll) für eine Schleife, die testen und selbst entscheiden möchte. ThrowIfCancelled ist der häufigste Fall: Rufen Sie es an einem natürlichen Abbruchpunkt auf, und wenn ein Abbruch angefordert wurde, löst es EPdfOperationCancelled aus, wodurch der Worker geradewegs zurück zum Future abgewickelt (Unwind) wird. RegisterCallback fügt eine einmalige Benachrichtigung (One-Shot Notification) an, die genau dann feuert, wenn die Quelle abgebrochen wird – nützlich, wenn ein Worker in etwas blockiert ist, das er unterbrechen kann, anstatt in einer engen Schleife festzusitzen

Die Ausnahme ist der Punkt, an dem die Thread-Grenze wichtig wird. Wenn der Worker EPdfOperationCancelled auslöst, fängt das Future dies ab und wandelt es in einen abgebrochenen Status um, sodass die Antwort IsCancelled und keinen Fehler sieht. Das Ausnahmeobjekt selbst wird niemals in den Hauptthread überführt (Marshaling). Es lebt und stirbt auf dem Worker-Thread; lediglich der Nachrichtenstring wird in ErrorMessage kopiert. Ein lebendes Ausnahmeobjekt über Threads hinweg zu überführen, würde bedeuten, in den Speicher eines Threads einzugreifen, der gerade beendet wird – was exakt dieselbe Klasse von Fehler ist, die der Synchronize-Fix eigentlich verhindern soll. Ein Statuscode und ein String überqueren die Grenze sauber; ein Objekt würde das nicht tun

Zwei Schnittstellen, damit ein Worker sich nicht selbst abbrechen kann

Der Abbruch ist absichtlich auf zwei Schnittstellen (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 sich schließt. IPdfCancellationToken ist die Leseseite: Sie hat IsCancelled, ThrowIfCancelled und RegisterCallback, und das ist alles, was der Worker jemals erhält. Ein konkretes Objekt implementiert zwar beides, aber dem Worker wird immer nur das Token übergeben, sodass er keine Möglichkeit hat, die von ihm ausgeführte Operation selbst abzubrechen. Die Aufteilung ist eine Leitplanke auf API-Ebene. Ein Worker, der über sein Token auf Cancel zugreifen könnte, würde verwirrten Code nur dazu einladen, sich selbst abzubrechen, und das Typsystem schließt diese Möglichkeit von vornherein aus

Es gibt ein passendes Detail für den Fall, dass ein Aufrufer ein Rendern wünscht, aber niemals beabsichtigt, es abzubrechen. Anstatt für jeden Aufruf eine frische Source zu erzwingen, bietet die Unit PdfNoCancellationToken an, ein Singleton-Token, das sich dauerhaft im Nicht-Abgebrochen-Zustand befindet. Run substituiert es, wenn das Token-Argument auf nil belassen wird. Dieses Singleton wird eifrig (Eagerly) während der Unit-Initialisierung konstruiert anstatt träge (Lazily) beim ersten Gebrauch, und der Grund dafür ist erneut die Nebenläufigkeit. Würden mehrere Run-Aufrufe in verschiedenen Worker-Threads alle gleichzeitig nach einem träge erstellten Singleton greifen, könnten sie bei seiner Konstruktion einen Wettlauf (Race) veranstalten, ein Duplikat verlieren oder kurzzeitig eine halb initialisierte Instanz beobachten. Es zu erstellen, bevor überhaupt ein Worker laufen kann, beseitigt diesen Wettlauf vollständig

Ein abbrechbares Rendern ausführen

In der Praxis erstellen Sie eine Source, behalten sie auf dem Formular, übergeben deren Token zusammen mit einer Worker-Methode und einer Antwort-Methode in Run und verdrahten die Abbrechen-Schaltfläche mit der Source. Der Worker prüft das Token während er rendert; die Antwort aktualisiert die Benutzeroberfläche, sobald das Ergebnis zurück ist. Da die Callbacks Methoden-Zeiger sind, lesen Worker und Antwort alles, was sie benötigen, einfach 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, denn alle drei sind erreichbar. Ein abgeschlossenes Rendern meldet Erfolg, ein Benutzer, der auf „Abbrechen“ gedrückt hat, sieht den abgebrochenen Zweig, und eine Datei, die nicht geschrieben werden konnte, oder eine Seite, die nicht geparst werden konnte, kommt als Fehlschlag mit einer Nachricht an. Keiner dieser Zweige blockiert, keiner von ihnen berührt den Worker-Thread, und das Bitmap oder der Status, den der Worker produziert hat, wird erst gelesen, nachdem das Future es in dem Thread ausgeliefert hat, dem die UI gehört

Dieselbe Threading-Disziplin zahlt sich auch an anderer Stelle in einem Viewer aus. Die Art und Weise, wie gerenderte Bitmaps über Zoomänderungen hinweg aufbewahrt und wiederverwendet werden, wird in unserer Notiz über den Render-Cache und die Zoom-Performance behandelt, und die weiter gefasste Frage, wie die PDFium-Grenze unter Delphi sicher gehalten wird, findet sich im Artikel Härtung der PDFium-VCL-ABI für Speichersicherheit. Die hier beschriebene asynchrone Infrastruktur wird als Teil der PDFium-Komponente für Delphi und C++Builder ausgeliefert, zusammen mit den Rendering-, Text- und Formular-APIs, die an anderer Stelle in diesem Blog behandelt werden