Техническая статья

Взаимоблокировка WaitForIdle в асинхронном рендеринге Delphi PDFium

Пакетный рендеринг замирает на середине, потому что исполнитель в 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 в точности такими, какими их определил воркер. UI-колбэк, взрывающийся при отрисовке миниатюры, поэтому никогда не превращает успешный рендер в неудавшийся, а ваша телеметрия продолжает сообщать, что движок рендеринга реально сделал. Если вам нужен API в форме колбэка вокруг одной операции, а не пула, фоновый рендеринг с отменяемыми 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 на каждой записи. Отмена задачи запускает пользовательские колбэки, зарегистрированные на её источнике токена, а эти колбэки — обычный код приложения: они могут запрашивать GetStats, отправлять компенсирующую работу или ждать простоя. Каждый из них повторно входит в блокировку исполнителя, а колбэк, вызванный, пока эта блокировка удерживается, привёл бы к взаимоблокировке против самого себя

Источник токена подчиняется той же дисциплине на один уровень ниже. CancelWithReason берёт блокировку источника, решает единственного побеждающего отменяющего, записывает Reason, CancellationMessage и CancelledAtTick, и только затем атомарно переключает флаг отменённости. Публикация перед переключением — это то, что делает метаданные безопасными для чтения: любой поток, наблюдающий IsCancelled как True, гарантированно найдёт за этим полную причину, а более поздние вызывающие стороны проигрывают гонку, возвращают False и не могут перезаписать первую причину. Зарегистрированные колбэки снимаются и очищаются внутри блокировки, но вызываются вне неё, каждый обёрнут так, что один отказавший обработчик не может подавить остальные. Уже выполняющиеся задачи никогда не убиваются; они завершаются кооперативно, когда их тело воркера в следующий раз вызывает 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 и молча направило бы вызовы, скомпилированные против старой разметки, не туда. Диагностические, ожидающие, отзываемые колбэки и атомарная отмена поэтому живут в IPdfCancellationTokenEx и IPdfCancellationTokenSourceEx, которые наследуют, а не модифицируют. New и Run сохраняют свою исходную семантику для существующих вызывающих сторон; новый код тянется к NewEx, NewTimeout и RunEx, когда хочет CancelWithReason, WaitForCancellation или управляемую IPdfCancellationRegistration. Наследование — единственный безопасный способ расширить опубликованный интерфейс, и это стоит один дополнительный тип на поколение

NewTimeout заслуживает честного примечания. Каждый источник таймаута владеет лёгким потоком, ждущим на событии отмены или дедлайне, смотря что наступит раньше. Для горстки или нескольких десятков дедлайнов это просто, с низкой задержкой и идентично на Delphi, Lazarus и C++Builder. Для тысяч коротких дедлайнов это неверная форма, и вам следует управлять отменой от одного таймера на уровне приложения вместо удержания тысяч ожидающих потоков

Ни одно из этих правил не экзотично, будучи записанным, но каждое из них — производственный инцидент, когда не записано. Ждите правильную веху и пусть что-то прокачивает очередь синхронизации, читайте QueueCapacity как границу только линии ожидания, отменяйте вне своих блокировок и давайте каждому воркеру собственный документ. Описанный здесь асинхронный слой поставляется как часть Delphi-компонента PDFium, наряду с API рендеринга, текста и форм, которые он планирует