مقاله فنی

قفل‌مرگی WaitForIdle در رندرینگ ناهمگام PDFium Delphi

یک رندر دسته‌ای در نیمه‌راه یخ می‌زند چون اجراکننده در 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 یا پایین‌تر بگذارید. وقتی یک صف پر یک خطای برنامه‌نویسی است که ارزش یک EPdfAsyncQueueFull دارد از Submit استفاده کنید، و وقتی یک شرط عادی است که می‌خواهید مدیریتش کنید از 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 یکسان است. برای هزاران مهلت کوتاه، این شکل اشتباه است، و باید لغو را از یک تایمر سطح-اپلیکیشن هدایت کنید به‌جای نگه‌داشتن هزاران رشته منتظر

هیچ‌کدام از این قواعد وقتی نوشته شوند عجیب نیستند، اما هرکدام یک حادثه تولید است وقتی نوشته نشده باشند. روی نقطه عطف درست منتظر بمانید و بگذارید چیزی صف synchronize را پمپاژ کند، QueueCapacity را فقط به‌عنوان یک کران روی خط انتظار بخوانید، بیرون از قفل‌هایتان لغو کنید، و به هر کارگر سند خودش را بدهید. لایه ناهمگام که در اینجا شرح داده شد به‌عنوان بخشی از مؤلفه Delphi PDFium ارائه می‌شود، در کنار APIهای رندرینگ، متن و فرم که زمان‌بندی می‌کند