Wsadowe renderowanie zamraża się w połowie, ponieważ executor w PDFium Component nie uznaje zadania za zakończone, dopóki jego odpowiedź nie zostanie zdyspozycjonowana. Pod padSynchronize ta odpowiedź działa na głównym wątku. Jeśli główny wątek blokuje się bez pompowania CheckSynchronize, worker czeka na główny wątek, podczas gdy główny wątek czeka na bezczynność
Obraz w debugerze jest jednoznaczny, gdy raz go zobaczysz. Zatrzymaj zamrożony proces, a główny wątek siedzi wewnątrz oczekiwania na zdarzenie bezczynności, kilka ramek poniżej twojej własnej pętli wsadowej. Przełącz się na dowolny wątek roboczy, a on siedzi wewnątrz TThread.Synchronize, trzymając ukończony wynik, którego nie może przekazać dalej. Nic nie wiruje, żaden procesor się nie grzeje, proces jest po prostu zaparkowany. Ten artykuł tłumaczy, dlaczego ten stan w ogóle istnieje, oraz trzy sąsiadujące reguły decydujące, czy pula workerów Delphi nad PDFium zachowuje się poprawnie, czy gryzie: co faktycznie ogranicza QueueCapacity, w jakiej kolejności zamykanie musi anulować zadania oraz czego równoległość nie daje ci w kwestii własności obiektów PDFium
Dlaczego WaitForIdle zawiesza główny wątek?
Zawiesza go, bo bezczynność w TPdfAsyncExecutor jest zdefiniowana tak, by obejmować dyspozycję odpowiedzi, nie tylko ukończenie pracy workera. Licznik działających zadań jest zwiększany w DequeueTask, gdy worker podejmuje zadanie, a zmniejszany w TaskFinished, którą worker wywołuje dopiero po zwróceniu przez TPdfAsyncTaskOperation.Execute. Ta metoda uruchamia ciało workera, zapisuje wynik, a następnie dysponuje odpowiedzią zgodnie z TPdfAsyncDispatchMode. Przy padSynchronize dyspozycja to wywołanie TThread.Synchronize, więc Execute nie zwraca, dopóki główny wątek go nie wykona
Delphi kładzie drugą połowę tego kontraktu na tobie. TThread.Synchronize dopisuje metodę do globalnej kolejki i blokuje wywołujący wątek na zdarzeniu; coś na głównym wątku musi wywołać CheckSynchronize, zanim to zdarzenie zostanie kiedykolwiek zasygnalizowane. Pętla komunikatów VCL robi to za ciebie między komunikatami, co dokładnie tłumaczy, dlaczego ten błąd jest niewidoczny podczas użycia interaktywnego i pojawia się w chwili, gdy piszesz blokującą pętlę wsadową. Blokujący się główny wątek to główny wątek, który opuścił pętlę komunikatów, a główny wątek poza pętlą komunikatów nikogo nie odblokowuje
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.
Ukończenie zadania i bezczynność executora to dwa różne kamienie milowe
Są rozdzielone celowo, a wiedza, na który czekasz, to cała naprawa. IPdfAsyncTask.WaitFor jest spełnione w chwili, gdy wynik workera zostaje rozstrzygnięty: Complete zapisuje ostateczny TPdfAsyncTaskState i ustawia zdarzenie ukończenia, zanim jakakolwiek odpowiedź zostanie w ogóle rozważona. TPdfAsyncExecutor.WaitForIdle jest spełnione później, gdy zarówno liczniki kolejkowania, jak i działania spadną do zera, a licznik działania nie spada, dopóki odpowiedź nie dotrze. Zadanie może więc być patsSucceeded i obserwowalne przez Snapshot, podczas gdy executor jest wciąż zasadnie zajęty
// 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;
Jedna konsekwencja warta zapamiętania: odpowiedź, która rzuca wyjątek, nie przepisuje historii. DispatchReply przechwytuje wyjątek i zapisuje go w ReplyErrorMessage, pozostawiając State, CancellationReason i ErrorMessage dokładnie takimi, jakie ustalił worker. Callback interfejsu użytkownika, który wybucha podczas malowania miniatury, nigdy więc nie zamienia udanego renderowania w nieudane, a twoja telemetria wciąż zgłasza to, co silnik renderujący faktycznie zrobił. Jeśli chcesz API w kształcie callbacku wokół pojedynczej operacji zamiast puli, renderowanie w tle z anulowalnymi futures omawia tę ścieżkę
Czy QueueCapacity ogranicza też działające workery?
Nie. QueueCapacity w PDFium Component liczy tylko zadania w kolejce, nigdy te już wykonywane przez workera. To celowe: pojemność ma wyrażać rzeczywisty przeciwciśnienie na linii oczekujących, a wliczenie stałych slotów współbieżności do tej samej liczby liczyłoby je podwójnie. Przy czterech workerach i pojemności ośmiu możesz mieć dwanaście zadań w locie, a GetStats zgłasza ten podział uczciwie przez QueuedCount i 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;
Cztery pasy TPdfAsyncPriority są ścisłe, nie ważone. DequeueTask przechodzi od papCritical w dół do papLow i bierze pierwszy niepusty pas, zachowując kolejność FIFO w obrębie każdego pasa. To daje żądaniu interaktywnemu czysty sposób na wyprzedzenie partii wsadowej, która jeszcze się nie zaczęła, ale nigdy nie przerywa pracy już wykonywanej, a wywołujący, który stale karmi papCritical, może zagłodzić papLow w nieskończoność. Zarezerwuj dwa górne pasy dla rzeczy, na które człowiek widocznie czeka, a masowy eksport zostaw na papNormal lub niżej. Użyj Submit, gdy pełna kolejka jest błędem programistycznym wartym EPdfAsyncQueueFull, a TrySubmit, gdy to normalny warunek, który zamierzasz obsłużyć
Dlaczego Shutdown anuluje poza blokadą executora?
Ponieważ anulowanie wewnątrz niej odwróciłoby kolejność blokad i zawiesiłoby samo zamykanie, które próbujesz przeprowadzić. Shutdown(True) zajmuje blokadę executora, przestawia flagę zamykania i dopisuje każde oczekujące zadanie do lokalnej tablicy migawki przez AppendSnapshot. Następnie zwalnia blokadę i dopiero potem przechodzi po migawce, wywołując Cancel na każdym wpisie. Anulowanie zadania uruchamia callbacki użytkownika zarejestrowane na jego źródle tokenu, a te callbacki to zwykły kod aplikacji: mogą odpytywać GetStats, zgłaszać pracę kompensacyjną lub czekać na bezczynność. Każdy z nich ponownie wchodzi do blokady executora, a callback wywołany, gdy ta blokada jest trzymana, zakleszczyłby się sam ze sobą
Źródło tokenu przestrzega tej samej dyscypliny poziom niżej. CancelWithReason zajmuje blokadę źródła, decyduje o jedynym zwycięskim anulującym, zapisuje Reason, CancellationMessage i CancelledAtTick, a dopiero potem atomowo przestawia flagę anulowania. Publikacja przed przestawieniem to właśnie to, co czyni metadane bezpiecznymi do odczytu: każdy wątek, który obserwuje IsCancelled jako True, ma gwarancję znalezienia za nim kompletnego powodu, a późniejsi wywołujący przegrywają wyścig, zwracają False i nie mogą nadpisać pierwszego powodu. Zarejestrowane callbacki są migawkowane i czyszczone wewnątrz blokady, ale wywoływane poza nią, każdy owinięty tak, by jeden zawodzący handler nie mógł stłumić pozostałych. Zadania już działające nigdy nie są zabijane; kończą się kooperacyjnie, gdy ich ciało workera następnym razem wywoła ThrowIfCancelled, dlatego Shutdown kończy się pompującym WaitForIdle, zanim dołączy wątki
Czy równoległe workery rozluźniają własność obiektów PDFium?
Nie rozluźniają, a to granica najczęściej odczytywana błędnie. TPdfAsyncExecutor planuje pracę; nie wypowiada się na temat powinowactwa wątkowego czegokolwiek, czego dotykasz wewnątrz tej pracy. Żywa instancja TPdf nie staje się współbieżnie dostępna tylko dlatego, że dwóch workerów akurat ją wywołuje, a wewnętrzna blokada renderowania to zabezpieczenie przed nakładającymi się wywołaniami renderowania, nie licencja na współdzielenie dokumentu między wątkami. Równoległe renderowanie lub eksport oznacza jeden TPdf na workera, tworzony i niszczony wewnątrz zadania
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;
Koszt jest realny i warto go nazwać: każdy worker płaci za własne parsowanie i własną pamięć podręczną stron, więc zużycie pamięci skaluje się z liczbą workerów, nie z liczbą dokumentów. To cena modelu, w którym worker może zostać anulowany lub ulec awarii bez uszkodzenia kogokolwiek innego. Jeśli twoje workery zamiast tego współdzielą instancję dokumentu po stronie widoku, reguły blokowania wokół tego są omówione w blokadzie renderowania i wywołaniach, które ją pomijają, a ścieżka anulowalna z jednym dokumentem jest w anulowalnym renderowaniu progresywnym
Rozszerzanie opublikowanego interfejsu bez łamania vtable
IPdfCancellationToken i IPdfCancellationTokenSource to interfejsy w stylu COM, które zewnętrzne binaria mogą już konsumować, więc dopisanie metody do któregokolwiek przesunęłoby każdy późniejszy slot w vtable i po cichu przekierowałoby wywołania skompilowane wobec starego układu. Możliwości diagnostyczne, oczekiwania, usuwalnych callbacków i atomowego anulowania żyją więc w IPdfCancellationTokenEx i IPdfCancellationTokenSourceEx, które dziedziczą zamiast modyfikować. New i Run zachowują swoją oryginalną semantykę dla istniejących wywołujących; nowy kod sięga po NewEx, NewTimeout i RunEx, gdy chce CancelWithReason, WaitForCancellation lub zarządzaną IPdfCancellationRegistration. Dziedziczenie to jedyny bezpieczny sposób rozwijania opublikowanego interfejsu, i kosztuje jeden dodatkowy typ na generację
NewTimeout zasługuje na uczciwą uwagę. Każde źródło timeoutu posiada lekki wątek, który czeka na zdarzenie anulowania lub deadline, cokolwiek nadejdzie pierwsze. Dla garści lub kilkudziesięciu deadline'ów to proste, niskoopóźnieniowe i identyczne w Delphi, Lazarusie i C++Builderze. Dla tysięcy krótkich deadline'ów to zły kształt, i powinieneś napędzać anulowanie jednym timerem na poziomie aplikacji, zamiast trzymać tysiące oczekujących wątków
Żadna z tych reguł nie jest egzotyczna, gdy się ją zapisze, ale każda z nich to incydent produkcyjny, gdy się jej nie zapisze. Czekaj na właściwy kamień milowy i pozwól czemuś pompować kolejkę synchronize, czytaj QueueCapacity wyłącznie jako granicę linii oczekujących, anuluj poza swoimi blokadami i daj każdemu workerowi własny dokument. Opisana tu warstwa asynchroniczna jest dostarczana w ramach komponentu PDFium dla Delphi, obok API renderowania, tekstu i formularzy, które planuje