Пакетний рендеринг застигає на півдорозі, бо виконавець у PDFium Component не вважає завдання завершеним, доки його відповідь не диспетчеризована. Під padSynchronize ця відповідь виконується в головному потоці. Якщо головний потік блокується, не прокачуючи CheckSynchronize, робочий потік чекає на головний потік, тоді як головний потік чекає на простій
Картина в дебагері безпомилкова, щойно її бачив. Постав на паузу застряглий процес, і головний потік сидить усередині очікування на подію простою, кількома кадрами нижче твого власного пакетного циклу. Перемкнись на будь-який робочий потік, і він сидить усередині TThread.Synchronize, тримаючи завершений результат, який не може передати. Ніщо не крутиться, жоден CPU не горить, процес просто припаркований. Ця стаття про те, чому цей стан взагалі існує, і про три сусідні правила, що вирішують, чи поводиться пул робочих потоків Delphi над PDFium добре чи кусається: що насправді обмежує QueueCapacity, у якому порядку завершення роботи мусить скасовувати, і чого паралелізм не купує там, де йдеться про володіння об'єктами PDFium
Чому WaitForIdle зависає головний потік?
Він зависає, бо простій у TPdfAsyncExecutor визначений так, що включає диспетчеризацію відповіді, а не лише завершення робочого потоку. Лічильник запущених інкрементується в DequeueTask, коли робочий потік бере завдання, і декрементується в TaskFinished, який робочий потік викликає лише після того, як TPdfAsyncTaskOperation.Execute повернувся. Цей метод запускає тіло робочого потоку, записує результат і потім диспетчеризує відповідь відповідно до TPdfAsyncDispatchMode. З padSynchronize диспетчеризація — виклик TThread.Synchronize, тож Execute не повертається, доки головний потік його не виконав
Delphi покладає другу половину цього контракту на тебе. TThread.Synchronize дописує метод до глобальної черги і блокує потік, що викликає, на події; щось у головному потоці мусить викликати CheckSynchronize, перш ніж цю подію взагалі сигналізують. Цикл повідомлень VCL робить це за тебе між повідомленнями, що і чому баг невидимий при інтерактивному використанні і з'являється в мить, коли ти пишеш блокуючий пакетний цикл. Головний потік, що блокується, — це головний потік, що покинув цикл повідомлень, а головний потік поза циклом повідомлень нікого не зливає
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.
Завершення завдання і простій виконавця — два різних етапи
Вони розділені навмисно, і знання, на який з них ти чекаєш, — і є все виправлення. IPdfAsyncTask.WaitFor задовольняється в мить, коли вирішений результат робочого потоку: Complete записує фінальний TPdfAsyncTaskState і встановлює подію завершення до того, як хоч якась відповідь розглядається. TPdfAsyncExecutor.WaitForIdle задовольняється пізніше, щойно лічильники поставлених у чергу і запущених обидва нульові, а запущений не спадає, доки відповідь не приземлилася. Тож завдання може бути patsSucceeded і спостережуваним через Snapshot, тоді як виконавець все ще законно зайнятий
// 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;
Один наслідок, який варто засвоїти: відповідь, що піднімає виняток, не переписує історію. DispatchReply ловить виняток і зберігає його в ReplyErrorMessage, лишаючи State, CancellationReason та ErrorMessage рівно такими, якими їх визначив робочий потік. Callback інтерфейсу користувача, що вибухає при малюванні мініатюри, тому ніколи не перетворює успішний рендеринг на провалений, і твоя телеметрія продовжує звітувати те, що рушій рендерингу насправді зробив. Якщо хочеш API у формі callback навколо однієї операції, а не пула, фоновий рендеринг зі скасовуваними futures охоплює цей шлях
Чи обмежує QueueCapacity ще й запущені робочі потоки?
Ні. QueueCapacity у PDFium Component рахує лише завдання в черзі, ніколи ті, що вже виконуються на робочому потоці. Це навмисно: місткість покликана виражати реальний зворотний тиск на лінії очікування, а згортання фіксованих слотів паралелізму в те саме число порахувало б їх двічі. З чотирма робочими потоками й місткістю вісім у тебе може бути дванадцять завдань у польоті, і GetStats чесно звітує поділ через QueuedCount та 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;
Чотири смуги TPdfAsyncPriority строгі, не зважені. DequeueTask обходить від papCritical вниз до papLow і бере першу непорожню смугу, зберігаючи порядок FIFO всередині кожної. Це дає інтерактивному запиту чистий спосіб перескочити пакет, що ще не почався, але воно ніколи не перериває вже запущену роботу, а викликач, що продовжує подавати papCritical, може заморити papLow голодом безкінечно. Резервуй дві верхні смуги для того, на що людина видимо чекає, а масовий експорт лиши на papNormal чи нижче. Використовуй Submit, коли повна черга — помилка програмування, варта EPdfAsyncQueueFull, і TrySubmit, коли це нормальна умова, яку маєш намір обробити
Чому Shutdown скасовує поза блокуванням виконавця?
Тому що скасування всередині нього перевернуло б порядок блокувань і зависло б завершення роботи, яке ти намагаєшся виконати. Shutdown(True) бере блокування виконавця, перемикає прапорець завершення й дописує кожне завдання в очікуванні в локальний масив-знімок через AppendSnapshot. Потім звільняє блокування і лише потім обходить знімок, викликаючи Cancel на кожному записі. Скасування завдання спрацьовує зареєстровані користувачем callback-и на його джерелі токена, і ці callback-и — звичайний код застосунку: вони можуть запитати GetStats, подати компенсуючу роботу чи чекати на простій. Кожен з них повторно входить у блокування виконавця, а callback, викликаний, доки те блокування утримується, зациклився б у дедлок сам з собою
Джерело токена підпорядковується тій самій дисципліні на рівень нижче. CancelWithReason бере блокування джерела, вирішує єдиного переможного скасовувача, записує Reason, CancellationMessage та CancelledAtTick, і лише потім перемикає прапорець скасування атомарно. Публікація перед перемиканням — те, що робить метадані безпечними для читання: будь-який потік, що спостерігає IsCancelled як True, гарантовано знаходить повну причину за ним, а пізніші викликачі програють гонку, повертають False і не можуть переписати першу причину. Зареєстровані callback-и знімаються знімком і очищаються всередині блокування, але викликаються поза ним, кожен обгорнутий так, щоб один обробник, що провалюється, не міг придушити решту. Завдання, що вже виконуються, ніколи не вбиваються; вони завершуються кооперативно, коли тіло їхнього робочого потоку наступного разу викликає ThrowIfCancelled, що і чому Shutdown завершується прокачуючим WaitForIdle перед приєднанням потоків
Чи послаблюють паралельні робочі потоки володіння об'єктами PDFium?
Ні, і це та межа, яку найімовірніше неправильно зрозуміють. TPdfAsyncExecutor планує роботу; він не робить жодної заяви про приналежність потоку чогось, до чого ти торкаєшся всередині цієї роботи. Живий екземпляр TPdf не стає одночасно доступним лише тому, що два робочі потоки випадково викликають його, а внутрішнє блокування рендерингу — захист від накладання викликів рендерингу, не ліцензія ділити документ між потоками. Паралельний рендеринг чи експорт означає один TPdf на робочий потік, створений і знищений усередині завдання
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;
Ціна реальна й варта названя: кожен робочий потік платить власним парсингом і власним кешем сторінок, тож пам'ять масштабується з кількістю робочих потоків, а не з кількістю документів. Це ціна моделі, де робочий потік можна скасувати чи він може впасти без пошкодження когось іншого. Якщо твої робочі потоки натомість справді ділять екземпляр документа на боці переглядача, правила блокування навколо цього розглянуті в блокуванні рендерингу та викликах, що його пропускають, а шлях скасовуваного одиночного документа — в скасовуваному прогресивному рендерингу
Розширення опублікованого інтерфейсу без ламання vtable
IPdfCancellationToken та IPdfCancellationTokenSource — інтерфейси у стилі COM, які зовнішні бінарники можуть уже споживати, тож дописування методу до будь-якого з них зсунуло б кожен пізніший слот у vtable й тихо неправильно маршрутизувало б виклики, скомпільовані проти старої розкладки. Діагностичні, очікувальні, знімні-callback та атомарно-скасовувані можливості тому живуть у IPdfCancellationTokenEx та IPdfCancellationTokenSourceEx, що успадковують, а не модифікують. New та Run тримають свою оригінальну семантику для наявних викликачів; новий код тягнеться до NewEx, NewTimeout та RunEx, коли хоче CancelWithReason, WaitForCancellation чи керовану IPdfCancellationRegistration. Успадкування — єдиний безпечний спосіб розширити опублікований інтерфейс, і воно коштує один додатковий тип на покоління
NewTimeout заслуговує на чесну примітку. Кожне джерело таймауту володіє легким потоком, що чекає на подію скасування чи дедлайн, залежно від того, що настане першим. Для жменьки чи кількох дюжин дедлайнів це просто, з низькою затримкою й ідентично на Delphi, Lazarus та C++Builder. Для тисяч коротких дедлайнів це неправильна форма, і тобі варто керувати скасуванням з одного таймера на рівні застосунку замість тримання тисяч потоків очікування
Жодне з цих правил не екзотичне, щойно записане, але кожне з них — виробничий інцидент, коли не записане. Чекай на правильний етап і дозволь чомусь прокачувати чергу синхронізації, читай QueueCapacity як межу лише лінії очікування, скасовуй поза своїми блокуваннями і давай кожному робочому потоку власний документ. Асинхронний шар, описаний тут, постачається як частина Delphi PDFium Component, поряд з API рендерингу, тексту й форм, які він планує