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

WaitForIdle Deadlock в Delphi PDFium асинхронно рендиране

Batch рендиране замръзва наполовина, защото executor-ът в PDFium Component не смята задача за завършена, докато нейният отговор не бъде диспечиран. Под padSynchronize този отговор се изпълнява на главната нишка. Ако главната нишка блокира, без да pump-ва CheckSynchronize, worker-ът чака главната нишка, докато главната нишка чака idle

Картината в debugger-а е недвусмислена, щом сте я виждали веднъж. Спрете замръзналия процес и главната нишка седи вътре в чакане на idle event-а, няколко frame-а под вашия собствен batch цикъл. Превключете на която и да е worker нишка и тя седи вътре в TThread.Synchronize, държейки завършен резултат, който не може да предаде. Нищо не се върти, никакво CPU не гори, процесът е просто паркиран. Тази статия е за това защо това състояние изобщо съществува, и за три съседни правила, решаващи дали Delphi worker pool над PDFium се държи добре или хапе: какво всъщност ограничава QueueCapacity, какъв ред трябва да отменя shutdown-ът, и какво паралелизмът не ви купува там, където PDFium object ownership е замесен

Защо WaitForIdle виси главната нишка?

Виси, защото idle в TPdfAsyncExecutor е дефиниран да включва диспечирането на отговора, не само завършването на worker-а. Броячът на изпълняващите се задачи се увеличава в DequeueTask, когато worker вземе задача, и се намалява в TaskFinished, което worker-ът извиква само след като TPdfAsyncTaskOperation.Execute се е върнал. Този метод изпълнява тялото на worker-а, записва резултата, и после диспечира отговора според TPdfAsyncDispatchMode. С padSynchronize диспечирането е извикване на TThread.Synchronize, така че Execute не се връща, докато главната нишка не го е изпълнила

Delphi поставя втората половина на този договор върху вас. TThread.Synchronize добавя метода към глобална опашка и блокира извикващата нишка на event; нещо на главната нишка трябва да извика CheckSynchronize, преди този event изобщо да бъде сигнализиран. VCL message loop-ът прави това вместо вас между съобщенията, поради което бъгът е невидим при интерактивна употреба и се появява в момента, в който напишете блокиращ batch цикъл. Блокираща главна нишка е главна нишка, напуснала message loop-а, а главна нишка извън message loop-а не отводнява никого

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.

Завършването на задача и idle-то на executor-а са два различни етапа

Разделени са умишлено, а знанието кой от двата чакате е цялата поправка. IPdfAsyncTask.WaitFor се удовлетворява в мига, в който резултатът на worker-а е решен: Complete записва финалния TPdfAsyncTaskState и задава done event-а, преди какъвто и да е отговор да е разгледан. TPdfAsyncExecutor.WaitForIdle се удовлетворява по-късно, щом опашката и броячите на изпълняващи се са и двата нула, а изпълняващият се не пада, докато отговорът не е кацнал. Така задача може да е patsSucceeded и наблюдаема чрез Snapshot, докато executor-ът е все още легитимно зает

// 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 точно такива, каквито worker-ът ги е определил. UI callback, който гръмва, докато рисува миниатюра, затова никога не превръща успешно рендиране в провалено, а телеметрията ви продължава да докладва какво действително е направил render engine-ът. Ако искате API-то с форма на callback около единична операция вместо pool, фоновото рендиране с отменяеми futures покрива този път

Ограничава ли QueueCapacity и изпълняващите се worker-и?

Не. QueueCapacity в PDFium Component брои само поставените в опашка задачи, никога вече изпълняващите се на worker. Това е умишлено: капацитетът има за цел да изрази реален backpressure на чакащата линия, а сгъването на фиксираните слотове за паралелизъм в същото число би ги преброило двойно. С четирима worker-и и капацитет осем можете да имате дванадесет задачи в движение, а 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 ред вътре във всяка. Това дава на интерактивна заявка чист начин да прескочи batch, който още не е започнал, но никога не прекъсва вече изпълняваща се работа, а извикващ, продължаващ да храни papCritical, може безкрайно да гладува papLow. Запазете горните две ленти за неща, за които човек видимо чака, и оставете bulk export на papNormal или по-долу. Използвайте Submit, когато пълна опашка е програмна грешка, заслужаваща EPdfAsyncQueueFull, и TrySubmit, когато е нормално условие, което възнамерявате да обработите

Защо Shutdown отменя извън executor lock-а?

Защото отменянето вътре в него би обърнало реда на заключванията и би окачило shutdown-а, който се опитвате да извършите. Shutdown(True) взима executor lock-а, обръща shutdown флага, и добавя всяка чакаща задача към локален снимков масив чрез AppendSnapshot. После освобождава lock-а и едва след това обхожда снимката, извиквайки Cancel на всеки запис. Отмяната на задача задейства потребителски callback-ове, регистрирани на нейния token source, а тези callback-ове са обикновен код на приложението: те могат да заявят GetStats, да подадат компенсираща работа, или да чакат idle. Всеки от тях повторно влиза в executor lock-а, а callback, извикан докато този lock е държан, би довел до deadlock срещу самия себе си

Token source-ът се подчинява на същата дисциплина едно ниво по-надолу. CancelWithReason взима lock-а на source-а, решава единствения печелещ отменящ, записва Reason, CancellationMessage и CancelledAtTick, и едва тогава обръща cancelled флага атомарно. Публикуване преди обръщане е това, което прави метаданните безопасни за четене: всяка нишка, наблюдаваща IsCancelled като True, е гарантирано да намери пълна причина зад него, а по-късни извикващи губят надпреварата, връщат False, и не могат да презапишат първата причина. Регистрираните callback-ове се снимат и изчистват вътре в lock-а, но се извикват извън него, всеки увит така, че един провалящ се handler да не потиска останалите. Задачи, вече изпълняващи се, никога не се убиват; те приключват съвместно, когато тялото им на worker следващия път извика ThrowIfCancelled, поради което Shutdown завършва с pump-ващ WaitForIdle преди join-ване на нишките

Отпускат ли паралелните worker-и PDFium object ownership?

Не отпускат, и това е границата, най-вероятно грешно разчетена. TPdfAsyncExecutor планира работа; той не прави никакво твърдение за thread affinity на каквото и да докосвате вътре в тази работа. Жива инстанция на TPdf не става едновременно достъпна, защото двама worker-и се случва да я извикат, а вътрешният render lock е защита срещу застъпващи се render извиквания, не лиценз за споделяне на документ между нишки. Паралелно рендиране или export означава по един TPdf на worker, създаден и унищожен вътре в job-а

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;

Цената е реална и си струва да се назове: всеки worker плаща собствен parse и собствен page cache, така че паметта расте с броя worker-и, а не с броя документи. Това е цената на модел, при който worker може да бъде отменен или срине, без да поврежда никой друг. Ако вашите worker-и споделят viewer-side документ вместо това, правилата за заключване около това са разгледани в render lock-а и извикванията, които го пропускат, а пътят за отменяемо single-document рендиране е в отменяемо прогресивно рендиране

Разширяване на публикуван interface без счупване на vtable

IPdfCancellationToken и IPdfCancellationTokenSource са COM-стил интерфейси, които външни binary-та вече може да консумират, така че добавянето на метод към който и да е от двата би изместило всеки по-късен слот в vtable-а и тихо би разкоординирало извиквания, компилирани срещу стария layout. Диагностичните, изчакващи, премахваеми-callback и атомарно-отменящи възможности затова живеят в IPdfCancellationTokenEx и IPdfCancellationTokenSourceEx, които наследяват, вместо да модифицират. New и Run пазят оригиналната си семантика за съществуващи извикващи; новият код посяга към NewEx, NewTimeout и RunEx, когато иска CancelWithReason, WaitForCancellation, или управляван IPdfCancellationRegistration. Наследяването е единственият безопасен начин да се разшири публикуван interface, и струва по един допълнителен тип на поколение

NewTimeout заслужава честна бележка. Всеки timeout source притежава лека нишка, чакаща на cancellation event-а или крайния срок, което дойде първо. За шепа или няколко десетки крайни срока това е просто, с ниска латентност и идентично между Delphi, Lazarus и C++Builder. За хиляди кратки крайни срока това е грешната форма, и трябва да карате отмяната от един application-level таймер вместо да държите хиляди чакащи нишки

Нито едно от тези правила не е екзотично, щом бъде записано, но всяко от тях е production инцидент, когато не е. Чакайте на правилния етап и оставете нещо да pump-ва synchronize опашката, четете QueueCapacity само като граница на чакащата линия, отменяйте извън вашите lock-ове, и давайте на всеки worker собствен документ. Асинхронният слой, описан тук, се доставя като част от Delphi PDFium Component, заедно с rendering, text и form API-тата, които планира