Artykuł techniczny

Renderowanie PDF w tle z anulowalnymi futures w Delphi

Renderowanie strony w PDFium jest synchroniczne. Wywołujesz bibliotekę, ona rasteryzuje do przekazanej przez ciebie bitmapy, a sterowanie wraca po zapisaniu pikseli. Dla pojedynczej strony w rozmiarze ekranu przy jednym poziomie powiększenia zajmuje to kilka milisekund i nikt tego nie zauważa. Dla eksportu 300 dpi dokumentu o 200 stronach, lub paska miniatur, który musi rasteryzować każdą stronę naraz, to samo wywołanie kosztuje sekundy. Jeśli wykonasz je z głównego wątku, pętla komunikatów zatrzymuje się, okno przestaje się odświeżać, a Windows maluje przerażające "Nie odpowiada" na twoim pasku tytułu. Praca jest poprawna. Miejsce, gdzie ją uruchomiłeś, jest błędne

Rozwiązaniem jest przeniesienie długiego renderowania na wątek w tle i przywrócenie wyniku do głównego wątku, gdzie bitmapę można przekazać do kontrolki. PDFium samo w sobie nie przeszkadza ci w tym, ale binding musi zapewnić bezpieczne przekazanie, ponieważ przestrzeń błędów wokół "uruchom na robotniku, odpowiedz na UI" jest szeroka, a awarie są sporadyczne. Moduł FPdfAsync w PDFiumPas istnieje właśnie po to, by dać temu wzorcowi jedną poprawną implementację, z modelem anulowania dopasowanym do tego, jak długie renderowanie faktycznie się zachowuje

Kształt pracy

Trzy operacje dominują w przypadkach, gdy renderowanie przekracza czas jednej klatki. Renderowanie wsadowe przechodzi przez zakres stron i rasteryzuje każdą, zazwyczaj na dysk. Wielostronicowy eksport robi to samo, ale składa wynik w jeden plik. Renderowanie strony w tle to to, co przeglądarka robi, gdy użytkownik przeskakuje do strony, której nie ma jeszcze w pamięci podręcznej, więc bitmapa jest wytwarzana poza wątkiem i pokazywana gdy jest gotowa. Wszystkie trzy dzielą te same ograniczenia. Trwają na tyle długo, że wątek UI nie może ich obsłużyć, produkują wynik, który wątek UI w końcu potrzebuje, a użytkownik może je porzucić. Zamknięcie dokumentu, przewinięcie do innej strony lub naciśnięcie Anuluj powinno zatrzymać pracę zamiast zmuszać użytkownika do oczekiwania na wynik, którego już nie chce

To ostatnie ograniczenie kształtuje projekt. Renderowanie, które nie może być anulowane, trzyma dokument otwarty i spala CPU, gdy odpowiedź przestała mieć znaczenie. Dlatego moduł jest zbudowany wokół dwóch prymitywów, które się komponują: future niosące wynik z powrotem i token przenoszący żądanie anulowania do przodu

Future fire-and-forget

TPdfFuture<T>.Run przyjmuje robotnika, odpowiedź i opcjonalny token anulowania. Uruchamia robotnika na wątku w tle, a gdy robotnik skończy, dostarcza odpowiedź na głównym wątku. Parametr generyczny T to cokolwiek renderowanie produkuje, często uchwyt bitmapy lub rekord statusu. Robotnik działa poza wątkiem; odpowiedź działa tam, gdzie można bezpiecznie dotykać VCL

Diagram anulowalnego renderowania PDF w tle w Delphi: wątek główny uruchamia future z workerem i odpowiedzią, wątek tła odpytuje token anulowania między stronami, a odpowiedź wykonuje się na wątku głównym z wynikami sukces, anulowano i niepowodzenie
Future wręcza workerowi token tylko do odczytu, a odpowiedź ląduje na wątku głównym, który rozgałęzia się raz na trzech osiągalnych statusach
class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

Celowym pominięciem jest jakikolwiek Wait. Nie ma metody blokującej wywołującego do czasu ukończenia future, i to nie jest przeoczenie. Wait wywołany z głównego wątku to klasyczny sposób na zakleszczenie UI: robotnik potrzebuje głównego wątku do uruchomienia swojej odpowiedzi przez Synchronize, główny wątek stoi zaparkowany wewnątrz Wait, i żadna strona nie może ruszyć. Odmawiając oferowania tego prymitywu, future eliminuje wzorzec, który najczęściej pokonuje ludzi próbujących napisać to samodzielnie. Kod, który naprawdę musi blokować, powinien używać zwykłego TThread i ponosić konsekwencje. Future jest dla przypadku fire-and-forget, którym renderowanie w tle faktycznie jest

Wynik jest zawijany w TPdfFutureResult<T>, rekord, który informuje odpowiedź, które z trzech rzeczy się stało. IsSuccess oznacza, że robotnik zwrócił normalnie i Value zawiera render. IsCancelled oznacza, że token zadziałał i robotnik wycofał się w punkcie anulowania. IsFailure oznacza, że robotnik rzucił wyjątek, a ErrorMessage przenosi tekst. Odpowiedź sprawdza status raz i rozgałęzia, zamiast zgadywać ze zwróconej bitmapy wartości sentinel, czy wynik jest prawdziwy

Dlaczego odpowiedź idzie przez Synchronize, a nie Queue

Najbardziej pouczającą częścią tego modułu jest jedna linijka: jak odpowiedź wraca do wątku głównego. Oczywisty wybór to TThread.Queue. Queue wysyła odpowiedź do kolejki głównego wątku i wraca natychmiast, co wygląda dokładnie tak, jak tego chce future fire-and-forget. Tutaj to błędny wybór, a powód warto wyjaśnić, bo to rodzaj błędu, który przechodzi przez każdy test, o którym pomyślisz, że go napiszesz

Wątek roboczy jest tworzony z FreeOnTerminate := True. Oznacza to, że w momencie powrotu z Execute wątek się rozbiera, a TThread.Destroy wywołuje RemoveQueuedEvents(Self) jako część sprzątania. RemoveQueuedEvents czyści wszelkie metody w kolejce, których celem jest umierający wątek. Sekwencja wygląda więc tak: robotnik kończy, kolejkuje odpowiedź przeciwko sobie, Execute wraca, wątek niszczy się, a RemoveQueuedEvents usuwa odpowiedź, której główny wątek jeszcze nie uruchomił. Wynik po prostu znika. Co gorsza, w wąskim oknie, gdy główny wątek ściąga zakolejkowaną odpowiedź i zaczyna ją uruchamiać w tym samym momencie, w którym wątek jest zwalniany, odpowiedź dotyka pól na wpół zniszczonego obiektu, co jest użyciem po zwolnieniu

FPdfAsync dostarcza więc odpowiedź przez Synchronize zamiast Queue. Synchronize blokuje wątek roboczy do czasu uruchomienia odpowiedzi przez główny wątek do końca. Robotnik jest nadal żywy, gdy jego odpowiedź jest wykonywana, więc nic nie jest zwalniane spod niego, a wątek nie wraca z Execute (i dlatego nie zaczyna się niszczyć), dopóki odpowiedź nie zostanie dostarczona. Dostarczenie jest zagwarantowane, a okna użycia po zwolnieniu nie ma

Obok siebie ułożone osie czasu wyjaśniające, dlaczego TThread.Queue mógł po cichu zgubić odpowiedź future, gdy FreeOnTerminate niszczył wątek roboczy Delphi, i jak Synchronize gwarantuje doręczenie przy renderowaniu PDFium w tle
Queue pozwalało wątkowi FreeOnTerminate wypurgować własną niedoręczoną odpowiedź; Synchronize trzyma worker przy życiu, dopóki wątek główny nie skończy odpowiedzi
procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // już anulowane? pomiń roboczy
    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, nie Queue: ten wątek jest FreeOnTerminate, więc odpowiedź w kolejce
    // mogłaby zostać usunięta przez RemoveQueuedEvents, zanim główny wątek ją wykona.
    Synchronize(DispatchReply);
end;

Ogólna lekcja sięga znacznie dalej niż ten jeden moduł. Asynchroniczne wywołania zwrotne fire-and-forget są najłatwiejszym wzorcem współbieżności, który można subtelnie popsuć, ponieważ szczęśliwa ścieżka działa za pierwszym razem, a błąd leży w interakcji między kolejnością teardownu wątku a kolejką. Nie da się go odtworzyć na żądanie. Zależy od tego, czy główny wątek zdarzył się opróżnić kolejkę przed tym, jak robotnik zdarzył się skończyć się niszczyć, co jest czasowaniem, o którym planista decyduje inaczej przy każdym uruchomieniu. Prymityw, który jest poprawny raz, w bindingu, jest wart znacznie więcej niż ten sam kod wyprowadzany na nowo w każdej aplikacji, która potrzebuje renderowania w tle

Dlaczego callbacki są wskaźnikami metod

Robotnik i odpowiedź nie są metodami anonimowymi. Są typami procedure of object, TPdfFutureWorker<T> i TPdfFutureReply<T>, a ten wybór jest wymuszony przez macierz kompilatorów. PDFiumPas kompiluje się na Delphi XE5 i nowszych oraz na Free Pascal 3.2 w trybie Delphi, a FPC 3.2 w tym trybie nie obsługuje metod anonimowych. Callback z referencją do procedury, który przechwytuje zmienne lokalne, kompilowałby się na Delphi i zawodziłby na FPC, więc moduł używa najniższego wspólnego mianownika, który oba kompilatory akceptują

Praktyczną konsekwencją jest miejsce, gdzie żyje stan. Metoda anonimowa zamyka się nad zmiennymi lokalnymi; wskaźnik metody nie. Więc każdy stan potrzebny robotnikowi - indeks strony, powiększenie, ścieżka wyjściowa - i każdy stan potrzebny odpowiedzi do aktualizacji - docelowa kontrolka obrazu lub etykieta postępu - musi wisieć na obiekcie, którego metoda jest przekazywana. W przeglądarce tym obiektem jest zazwyczaj formularz lub kontroler renderowania, który posiada. To nie jest obejście narzucone niechętnie; utrzymuje własność tego stanu wyraźną i widoczną na odbierającym obiekcie zamiast ukrytą wewnątrz zamknięcia

Anulowanie kooperacyjne, nie twarde zakończenie

Anulowanie jest tutaj kooperacyjne. Nie ma API, które sięga do wątku roboczego i kończy go, ponieważ zakończenie wątku w środku renderowania zostawia PDFium trzymające blokady i częściowo zapisane bitmapy, a stan procesu po wymuszonym zabójstwie nie jest czymś, o czym można rozsądnie wnioskować. Zamiast tego robotnik otrzymuje token tylko do odczytu i oczekuje się, że go sprawdzi, a pętla renderowania jest napisana tak, by sprawdzać go między stronami lub kafelkami, gdzie zatrzymanie jest czyste

Token oferuje trzy sposoby obserwowania anulowania. IsCancelled to tanie odpytywanie boolean dla pętli, która chce testować i samodzielnie decydować. ThrowIfCancelled to typowy przypadek: wywołaj go w naturalnym punkcie anulowania i, jeśli zażądano anulowania, rzuca EPdfOperationCancelled, co odwija robotnika prosto z powrotem do future. RegisterCallback dołącza jednorazowe powiadomienie, które odpala raz, gdy źródło jest anulowane, przydatne gdy robotnik jest zablokowany w czymś, co może przerwać, zamiast siedzieć w ciasnej pętli

Wyjątek jest tam, gdzie granica wątku ma znaczenie. Gdy robotnik rzuca EPdfOperationCancelled, future łapie go i zamienia w status anulowania, więc odpowiedź widzi IsCancelled, a nie awarię. Sam obiekt wyjątku nigdy nie jest marshalowany do głównego wątku. Żyje i umiera na wątku roboczym; tylko jego ciąg znaków z komunikatem jest kopiowany do ErrorMessage. Marshalowanie żywego obiektu wyjątku przez wątki oznaczałoby sięganie do pamięci będącej własnością wątku, który kończy pracę, co jest tą samą klasą błędu, której ma zapobiegać dostarczanie przez Synchronize. Kod statusu i ciąg znaków przekraczają granicę czysto; obiekt by nie przekroczył

Diagram kooperacyjnego modelu anulowania w PDFium Component dla Delphi: formularz jest właścicielem zapisywalnego źródła tokenu anulowania, worker w tle dostaje tylko token tylko-do-odczytu odpytywany w czystych punktach, a wyniki to sukces, anulowano lub niepowodzenie bez zakańczania wątków
Odwołanie jest kooperacyjne: formularz trzyma źródło zapisywalne, worker widzi tylko członki odpytywalne, a wyjątek nigdy nie przekracza granicy wątku

Dwa interfejsy, by robotnik nie mógł anulować siebie

Anulowanie jest celowo podzielone między dwa interfejsy. IPdfCancellationTokenSource to strona zapisu: ma Cancel, a właściciel, który go tworzy - zazwyczaj formularz - przechowuje go i wywołuje Cancel, gdy użytkownik kliknie przycisk lub formularz się zamknie. IPdfCancellationToken to strona odczytu: ma IsCancelled, ThrowIfCancelled i RegisterCallback, i to wszystko, co robotnik kiedykolwiek otrzymuje. Jeden konkretny obiekt implementuje oba, ale robotnik jest zawsze przekazywany tylko tokenem, więc nie ma możliwości anulowania operacji, którą uruchamia. Podział jest barierą ochronną na poziomie API. Robotnik, który mógłby sięgnąć do Cancel przez swój token, zaprosiłby zdezorientowany fragment kodu do anulowania siebie, a system typów usuwa tę możliwość

Jest pasujący szczegół dla przypadku, gdy wywołujący chce renderowania, ale nigdy nie zamierza go anulować. Zamiast wymuszać świeże źródło na każde wywołanie, moduł eksponuje PdfNoCancellationToken, singleton token, który jest trwale w stanie nie-anulowanym. Run podstawia go, gdy argument tokena jest pozostawiony jako nil. Ten singleton jest tworzony zachłannie podczas inicjalizacji modułu zamiast leniwie przy pierwszym użyciu, a powód jest znowu współbieżność. Gdyby kilka wywołań Run na różnych wątkach roboczych wszystkie sięgały po leniwie tworzony singleton naraz, mogłyby wyścigować przy jego konstruowaniu, wyciekać duplikat lub przez chwilę obserwować na wpół zainicjowaną instancję. Budowanie go przed uruchomieniem jakiegokolwiek robotnika całkowicie usuwa wyścig

Uruchamianie anulowanego renderowania

W praktyce tworzysz źródło, przechowujesz je na formularzu, przekazujesz jego Token do Run obok metody robotnika i metody odpowiedzi, i podpinasz przycisk Anuluj do źródła. Robotnik sprawdza token podczas renderowania; odpowiedź aktualizuje UI po powrocie wyniku. Ponieważ callbacki są wskaźnikami metod, robotnik i odpowiedź czytają to, czego potrzebują, z pól formularza

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // pole, żyje na formularzu
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // robotnik obserwuje to w swoim następnym punkcie anulowania
end;

// Działa w wątku tła. Czyta FPageRange / FOutputDir z formularza.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // czyste zatrzymanie między stronami
    RenderOnePage(PageIndex);       // synchroniczna rasteryzacja PDFium
  end;
  Result := True;
end;

// Działa w głównym wątku. Bezpiecznie dotknąć VCL tutaj.
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;

Odpowiedź obsługuje wszystkie trzy wyniki, bo wszystkie trzy są osiągalne. Zakończone renderowanie zgłasza sukces, użytkownik, który nacisnął Anuluj, widzi gałąź anulowania, a plik, który nie mógł zostać zapisany lub strona, która nie mogła być sparsowana, trafia jako awaria z komunikatem. Żadna z tych gałęzi nie blokuje, żadna nie dotyka wątku roboczego, a bitmapa lub status, które robotnik wyprodukował, są czytane dopiero po tym, jak future dostarczyło je na wątku, który jest właścicielem UI

Ta sama dyscyplina wątkowania opłaca się gdzie indziej w przeglądarce. Sposób przechowywania i ponownego używania wyrenderowanych bitmap przy zmianach powiększenia jest omówiony w naszej notatce o pamięci podręcznej renderowania i wydajności powiększenia, a szersze pytanie o bezpieczne utrzymanie granicy PDFium w Delphi jest opisane w hartowaniu PDFium Component ABI dla bezpieczeństwa pamięci. Infrastruktura asynchroniczna opisana tutaj jest dostarczana jako część PDFium Component dla Delphi i C++Builder, razem z API renderowania, tekstu i formularzy opisanymi gdzie indziej na tym blogu