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
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
Wyścig v1.61.0, który zmienił dostarczanie odpowiedzi
Najbardziej pouczającą częścią tego modułu jest jednostronicowa zmiana, której zrozumienie wymagało czasu. Przez wczesne wersje wątek roboczy dostarczał odpowiedź za pomocą TThread.Queue. Queue wysyła odpowiedź do kolejki głównego wątku i wraca natychmiast, co wygląda dokładnie tak, jak to, czego chce future fire-and-forget. To było błędne, a powód warto wyjaśnić, bo to rodzaj błędu, który przechodzi każdy test, który pomyślisz, że napisać
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 była więc taka: 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, gdy wątek był zwalniany, odpowiedź dotykała pól na wpół zniszczonego obiektu, co jest użyciem po zwolnieniu
Poprawka w v1.61.0 polegała na dostarczaniu odpowiedzi 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 okno użycia po zwolnieniu jest zamknięte
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;
Ogólna lekcja przeżywa konkretną poprawkę. 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 istnieje poprawka Synchronize. Kod statusu i ciąg znaków przekraczają granicę czysto; obiekt by nie przekroczył
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; // 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;
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 VCL 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