مقاله فنی

رندر پس‌زمینه PDF در دلفی با آینده‌های قابل لغو (Cancellable Futures)

رندر یک صفحه در PDFium همگام (synchronous) است. شما کتابخانه را فراخوانی می‌کنید، آن را در بیت‌مپی که به آن داده‌اید شطرنجی (rasterise) می‌کند، و پس از نوشتن پیکسل‌ها، کنترل به شما باز می‌گردد. برای یک صفحه تکی به اندازه مانیتور در یک سطح زوم، این کار چند میلی‌ثانیه زمان می‌برد و کسی متوجه آن نمی‌شود. برای یک خروجی 300 dpi از یک سند 200 صفحه‌ای، یا نوار تصاویر بندانگشتی که باید تمام صفحات را یک‌باره شطرنجی کند، همین فراخوانی ثانیه‌ها هزینه دارد. اگر این فراخوانی را از رشته اصلی (main thread) انجام دهید، حلقه پیام (message loop) متوقف می‌شود، پنجره دوباره رنگ نمی‌شود، و ویندوز پیغام ترسناک "Not Responding" (پاسخ نمی‌دهد) را روی نوار عنوان شما رسم می‌کند. کار درست است. اما مکانی که آن را اجرا کرده‌اید اشتباه است

راه‌حل این است که رندر طولانی را به یک رشته پس‌زمینه منتقل کنید و نتیجه را به رشته اصلی بازگردانید، جایی که بیت‌مپ می‌تواند به یک کنترل تحویل داده شود. خود PDFium مانع از این کار نمی‌شود، اما بایندینگ (binding) باید این تحویل را ایمن کند، زیرا سطح باگ در اطراف "اجرا در یک ورکر (worker)، پاسخ در رابط کاربری (UI)" گسترده است و شکست‌ها متناوب هستند. یونیت FPdfAsync در PDFiumPas برای ارائه یک پیاده‌سازی صحیح از این الگو، با مدل لغوی متناسب با رفتار واقعی یک رندر طولانی وجود دارد

شکل کار

سه عملیات در مواردی که یک رندر بیشتر از یک فریم طول می‌کشد غالب هستند. رندر دسته‌ای (Batch rendering) یک محدوده صفحه را طی می‌کند و هر صفحه را، معمولاً در دیسک، شطرنجی می‌کند. خروجی چند صفحه‌ای نیز همین کار را انجام می‌دهد اما خروجی را در یک فایل مونتاژ می‌کند. رندر صفحه پس‌زمینه کاری است که یک نمایشگر زمانی که کاربر به صفحه‌ای می‌پرد که هنوز در کش نیست انجام می‌دهد، بنابراین بیت‌مپ در خارج از رشته تولید می‌شود و پس از آماده شدن نمایش داده می‌شود. هر سه این موارد محدودیت‌های یکسانی دارند. آن‌ها به اندازه کافی طولانی اجرا می‌شوند که رشته رابط کاربری نمی‌تواند میزبان آن‌ها باشد، نتیجه‌ای تولید می‌کنند که در نهایت رشته رابط کاربری به آن نیاز دارد، و ممکن است کاربر آن‌ها را رها کند. بستن سند، اسکرول کردن به پایین صفحه یا فشار دادن لغو (Cancel) باید کار را متوقف کند به جای اینکه کاربر را مجبور کند منتظر خروجی‌ای بماند که دیگر نمی‌خواهد

محدودیت آخر همان چیزی است که طراحی را شکل می‌دهد. یک رندر غیرقابل لغو رندری است که سند را باز نگه می‌دارد و پس از اینکه پاسخ دیگر اهمیتی ندارد پردازنده (CPU) را می‌سوزاند. بنابراین این یونیت حول دو متغیر اولیه ساخته شده است که عبارتند از: آینده‌ای (future) که نتیجه را بازمی‌گرداند، و توکنی که درخواست لغو را به جلو می‌برد

یک آینده "شلیک کن و فراموش کن" (fire-and-forget)

متد TPdfFuture<T>.Run یک ورکر، یک پاسخ، و یک توکن لغو اختیاری می‌گیرد. این متد ورکر را در یک رشته پس‌زمینه شروع می‌کند، و پس از اتمام ورکر پاسخ را در رشته اصلی تحویل می‌دهد. پارامتر ژنریک T هر چیزی است که رندر تولید می‌کند، اغلب یک هندل بیت‌مپ یا یک رکورد وضعیت. ورکر در خارج از رشته اجرا می‌شود؛ پاسخ در جایی اجرا می‌شود که لمس VCL ایمن است

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

حذف عمدی شامل هر نوع متد Wait (انتظار) است. هیچ متدی برای مسدود کردن (block) فراخوانی‌کننده تا تکمیل شدنِ آینده وجود ندارد، و این یک سهل‌انگاری نیست. فراخوانی یک Wait از رشته اصلی، روش کلاسیک برای ایجاد بن‌بست (deadlock) در یک رابط کاربری است: ورکر برای اجرای پاسخ خود از طریق Synchronize به رشته اصلی نیاز دارد، رشته اصلی در داخل Wait متوقف (parked) شده است، و هیچ‌کدام از طرفین نمی‌توانند پیش بروند. با امتناع از ارائه این قابلیت، آینده (future) الگویی را که اغلب باعث شکست افرادی می‌شود که سعی در نوشتن آن دارند، کنار می‌گذارد. کدی که واقعاً به مسدود شدن نیاز دارد باید از یک TThread ساده استفاده کرده و عواقب آن را بپذیرد. آینده برای حالت‌های شلیک-و-فراموش (fire-and-forget) است، که دقیقاً همان چیزی است که رندر پس‌زمینه به آن نیاز دارد

نتیجه در داخل TPdfFutureResult<T> قرار داده شده است، رکوردی که به پاسخ می‌گوید کدام یک از سه اتفاق رخ داده است. IsSuccess به این معنی است که ورکر به طور عادی بازگشته است و Value دربردارنده رندر است. IsCancelled به این معنی است که توکن اجرا شده و ورکر در نقطه لغو (cancellation point) متوقف شده است. IsFailure به این معنی است که ورکر خطا داده و ErrorMessage متن خطا را در خود دارد. پاسخ به جای اینکه از روی یک مقدار نگهبان (sentinel value) حدس بزند که آیا بیت‌مپ بازگردانده شده واقعی است یا خیر، وضعیت را بررسی و یک‌بار منشعب (branch) می‌کند

شرایط رقابتی (race condition) نسخه 1.61.0 که نحوه تحویل پاسخ را تغییر داد

آموزنده‌ترین بخش این یونیت، تغییری یک‌خطی است که درک آن کمی زمان برد. در نسخه‌های اولیه، رشته ورکر (worker thread) پاسخ خود را با TThread.Queue تحویل می‌داد. متد Queue پاسخ را به صف رشته اصلی ارسال می‌کند و فوراً باز می‌گردد، که دقیقاً همان چیزی به نظر می‌رسد که یک آینده شلیک-و-فراموش می‌خواهد. اما این کار اشتباه بود، و بیان دلیل آن ارزشش را دارد، زیرا این از آن دسته باگ‌هایی است که از تمام تست‌هایی که فکر می‌کنید باید بنویسید با موفقیت عبور می‌کند

رشته ورکر با مشخصه FreeOnTerminate := True ایجاد می‌شود. این بدان معناست که در همان لحظه‌ای که متد Execute بازمی‌گردد، رشته خود را نابود می‌کند و متد TThread.Destroy به عنوان بخشی از عملیات پاک‌سازی، RemoveQueuedEvents(Self) را فراخوانی می‌کند. متد RemoveQueuedEvents هر متد در صف مانده‌ای را که هدفش رشته در حال مرگ است پاک می‌کند. بنابراین توالی عملیات به این شکل بود: ورکر به پایان می‌رسد، پاسخ را برای خودش در صف قرار می‌دهد، متد Execute برمی‌گردد، رشته خود را از بین می‌برد، و RemoveQueuedEvents پاسخی را که رشته اصلی هنوز اجرا نکرده است حذف می‌کند. بنابراین نتیجه به سادگی ناپدید می‌شود. بدتر از آن، در یک پنجره باریک، زمانی که رشته اصلی پاسخ در صف مانده را بیرون کشیده و شروع به اجرای آن در همان لحظه‌ای می‌کند که رشته در حال آزاد شدن بود، پاسخ به فیلدهای یک شیء نیمه‌ویران‌شده دسترسی پیدا می‌کند، که در واقع یک باگ دسترسی پس از آزادسازی (use-after-free) است

راه‌حل در نسخه 1.61.0، تحویل پاسخ با متد Synchronize به جای Queue بود. متد Synchronize رشته ورکر را تا زمانی که رشته اصلی، پاسخ را به طور کامل اجرا کند، مسدود (block) می‌کند. در حین اجرای پاسخ ورکر همچنان زنده است، بنابراین چیزی برای آزاد شدن (free) از زیر آن وجود ندارد، و رشته تا زمانی که پاسخ تحویل داده نشده است از Execute بازنمی‌گردد (و در نتیجه نابودسازی خود را آغاز نمی‌کند). تحویل پاسخ تضمین شده است و پنجره زمانی رخ دادن خطای use-after-free نیز بسته می‌شود

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // already cancelled? skip the worker
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
    // could be dropped by RemoveQueuedEvents before the main thread ran it.
    Synchronize(DispatchReply);
end;

درس کلی، ماندگارتر از اصلاح خاص یک باگ است. فراخوان‌های بازگشتی ناهمگام شلیک-و-فراموش (fire-and-forget asynchronous callbacks) ساده‌ترین الگوی هم‌روندی هستند که ممکن است به طور ظریفی اشتباه شوند، زیرا مسیر خوش‌بینانه در همان تلاش اول کار می‌کند اما باگ در تعامل بین ترتیب نابودسازی رشته و صف نهفته است. چنین باگی به طور دلخواه و در زمان تقاضا بازتولید نمی‌شود. بستگی به این دارد که آیا رشته اصلی به طور اتفاقی قبل از آنکه ورکر نابودسازی خود را تمام کند، صف را تخلیه کرده است یا خیر، که یک زمان‌بندی (timing) است که برنامه‌ریز سیستم (scheduler) در هر اجرا به طور متفاوتی در مورد آن تصمیم‌گیری می‌کند. یک متغیر اولیه که در بایندینگ یک‌بار به درستی نوشته شده است، بسیار ارزشمندتر از این است که همان کد در هر برنامه‌ای که به رندر پس‌زمینه نیاز دارد، دوباره بازسازی (re-derive) شود

چرا بازگشت‌ها (callbacks) اشاره‌گرهای متد هستند

ورکر و پاسخ، متدهای ناشناس (anonymous) نیستند. آن‌ها انواعی از نوع procedure of object هستند، TPdfFutureWorker<T> و TPdfFutureReply<T>، و این انتخاب توسط ماتریس کامپایلر تحمیل شده است. کتابخانه PDFiumPas در محیط دلفی XE5 و بالاتر و همچنین در کامپایلر Free Pascal 3.2 (در حالت سازگاری با دلفی) کامپایل می‌شود، در حالی که FPC 3.2 در این حالت از متدهای ناشناس پشتیبانی نمی‌کند. یک بازگشت (callback) مرجع‌-به-رویه که متغیرهای محلی را ضبط (capture) می‌کند، در دلفی کامپایل شده اما در FPC با شکست مواجه می‌شود، به همین دلیل این یونیت از کمترین مخرج مشترکی که هر دو کامپایلر می‌پذیرند استفاده می‌کند

پیامد عملی، محل نگهداری وضعیت (state) است. یک متد ناشناس بر روی متغیرهای محلی بسته (close) می‌شود؛ در حالی که یک اشاره‌گر متد اینگونه نیست. بنابراین هر وضعیتی که ورکر نیاز دارد، مانند ایندکس صفحه، سطح زوم، مسیر خروجی، و هر وضعیتی که پاسخ نیاز به بروزرسانی آن دارد، مانند کنترل تصویر هدف یا برچسب پیشرفت کار، باید به شیئی متصل شود که متد آن در حال ارسال است. در یک نمایشگر، این شیء معمولاً فرم (form) یا کنترل‌کننده رندری است که متعلق به آن است. این یک راه‌حل موقتی که با بی‌میلی تحمیل شده باشد نیست؛ بلکه مالکیت آن وضعیت را به جای پنهان شدن در داخل یک کلوژر (closure)، بر روی شیء دریافت‌کننده، صریح و قابل مشاهده نگه می‌دارد

لغو مشارکتی، نه نابودی اجباری

لغو در اینجا مشارکتی (cooperative) است. هیچ APIای وجود ندارد که به رشته ورکر دسترسی پیدا کرده و آن را خاتمه دهد، زیرا خاتمه دادن به یک رشته در اواسط رندر، باعث می‌شود PDFium قفل‌ها را نگه داشته و بیت‌مپ‌های نیمه‌نوشته‌ای را باقی بگذارد، و وضعیت فرایند پس از یک نابودی اجباری (forced kill) چیزی نیست که بتوانید درباره آن استدلال کنید. به جای آن یک توکن فقط خواندنی (read-only) به ورکر داده می‌شود و انتظار می‌رود که آن را بررسی کند، و حلقه رندر برای بررسی آن بین صفحات یا کاشی‌ها (tiles)، جایی که توقف تمیز و ایمن است، نوشته می‌شود

این توکن سه راه برای مشاهده لغو ارائه می‌دهد. مشخصه IsCancelled یک بررسی بولین (boolean) کم‌هزینه برای حلقه‌ای است که می‌خواهد بررسی کرده و خودش تصمیم بگیرد. متد ThrowIfCancelled حالت معمول است: آن را در یک نقطه لغوِ طبیعی فراخوانی کنید و در صورت درخواست لغو، متد خطای EPdfOperationCancelled را مطرح می‌کند که ورکر را مستقیماً به نقطه مبدأ یعنی آینده (future) برمی‌گرداند (unwind). متد RegisterCallback یک اعلان یک‌باره را متصل می‌کند که زمانی که منبع لغو می‌شود یک‌بار اجرا (fire) می‌شود، و زمانی مفید است که ورکر به جای اینکه در یک حلقه بسته قرار بگیرد، در نقطه‌ای مسدود شده باشد که بتواند در آن وقفه (interrupt) ایجاد کند

استثنا (exception) جایی است که مرز رشته‌ها اهمیت پیدا می‌کند. زمانی که ورکر خطای EPdfOperationCancelled را صادر می‌کند، آینده آن را گرفته (catch کرده) و به یک وضعیتِ لغوشده (cancelled) تبدیل می‌کند، بنابراین پاسخ IsCancelled را مشاهده می‌کند و نه یک شکست را. خود شیء استثنا هرگز به رشته اصلی مارشال (marshal) نمی‌شود. شیء استثنا روی رشته ورکر زندگی کرده و همان‌جا از بین می‌رود؛ و فقط پیام متنی آن در مشخصه ErrorMessage کپی می‌شود. مارشال کردن یک شیء استثنای زنده در بین رشته‌ها به معنای دسترسی به حافظه‌ای است که متعلق به رشته‌ای در حال پایان یافتن است، که این اشتباه دقیقاً از همان جنس است که رفعِ مربوط به Synchronize برای جلوگیری از آن ایجاد شده است. یک کد وضعیت و یک رشته (string) به شکلی تمیز از مرزها عبور می‌کنند؛ اما یک شیء چنین نیست

دو اینترفیس (رابط)، تا یک ورکر نتواند خودش را لغو کند

عملیات لغو به عمد به دو اینترفیس (رابط) تقسیم شده است. IPdfCancellationTokenSource بخش نوشتن است: این رابط متد Cancel را دارد، و مالکی که آن را ایجاد می‌کند، که معمولاً فرم است، آن را نگه می‌دارد و هنگامی که کاربر دکمه‌ای را کلیک می‌کند یا فرم بسته می‌شود، متد Cancel را فراخوانی می‌کند. رابط IPdfCancellationToken بخش خواندن است: متدهای IsCancelled، ThrowIfCancelled، و RegisterCallback را دارد، و این‌ها تمام چیزی است که ورکر دریافت می‌کند. یک شیء واحد هر دو رابط را پیاده‌سازی می‌کند، اما به ورکر فقط توکن داده می‌شود، بنابراین هیچ راهی برای لغو عملیاتی که در حال اجرای آن است ندارد. این تقسیم‌بندی یک حصار محافظتی (guard rail) در سطح API است. ورکری که بتواند از طریق توکن خود به Cancel دسترسی پیدا کند باعث می‌شود یک قطعه کد سردرگم اقدام به لغو خودش نماید، و سیستم نوع‌داده (type system) این احتمال را از بین می‌برد

یک جزئیات مشابه برای موردی وجود دارد که در آن یک فراخوانی‌کننده رندری را می‌خواهد اما هرگز قصد لغو آن را ندارد. به جای اینکه برای هر فراخوانی یک منبع جدید را تحمیل کند، یونیت PdfNoCancellationToken را ارائه می‌دهد که یک توکن سینگلتون است و به طور دائم در وضعیت لغونشده قرار دارد. در صورت خالی بودن آرگومان توکن (nil)، متد Run آن را جایگزین می‌کند. این شیءِ سینگلتون به جای اینکه در اولین استفاده و به روش تنبل (lazily) ساخته شود، با روش حریصانه (eagerly) و در طول زمان مقداردهی اولیه یونیت ساخته می‌شود و دلیل آن باز هم مسئله هم‌روندی (concurrency) است. اگر چندین فراخوانی Run در رشته‌های ورکر متفاوت همگی به طور همزمان برای دسترسی به یک شیءِ سینگلتونِ تنبل اقدام کنند، ممکن است در ایجاد آن با هم رقابت (race condition) کنند، یک نسخه تکراری نشت (leak) دهند، یا اینکه برای لحظاتی کوتاه یک نمونه (instance) که تا نیمه مقداردهی اولیه شده را مشاهده نمایند. ساختن آن پیش از آنکه هر ورکری بتواند اجرا شود، احتمال بروز چنین رقابتی را به طور کامل از بین می‌برد

اجرای یک رندر قابل لغو

در عمل شما یک منبع (source) ایجاد می‌کنید، آن را روی فرم نگه می‌دارید، متغیر Token آن را در کنار یک متدِ ورکر و یک متدِ پاسخ به Run پاس می‌دهید، و دکمه لغو (Cancel) را به منبع متصل (wire) می‌کنید. ورکر در حین رندر توکن را بررسی می‌کند؛ و پاسخ نیز هنگامی که نتیجه بازگشت، رابط کاربری را بروزرسانی می‌کند. از آنجایی که بازگشت‌ها (callbacks) اشاره‌گرهای متد هستند، ورکر و پاسخ هر آنچه را که نیاز دارند از فیلدهای فرم می‌خوانند

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // field, lives on the form
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // worker observes this at its next cancel point
end;

// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // clean stop between pages
    RenderOnePage(PageIndex);       // synchronous PDFium rasterisation
  end;
  Result := True;
end;

// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

متدِ پاسخ هر سه پیامد (outcomes) را مدیریت می‌کند زیرا هر سه قابل دستیابی هستند. یک رندر پایان‌یافته موفقیت را گزارش می‌دهد، کاربری که گزینه لغو را فشار داده است شاخه لغوشده را می‌بیند، و فایلی که امکان نوشتن نداشته یا صفحه‌ای که در پردازش (parse) شکست خورده، به عنوان یک شکست به همراه یک پیغام (message) می‌رسند. هیچ‌کدام از این شاخه‌ها باعث مسدود شدن (block) نمی‌شوند، هیچ‌کدام رشته ورکر را لمس نمی‌کنند، و بیت‌مپ یا وضعیتی که ورکر تولید کرده است، تنها پس از آن خوانده می‌شود که آینده (future) آن را در رشته‌ای که مالک رابط کاربری است، تحویل داده باشد

همین نظم در مورد رشته‌ها، در بخش‌های دیگر یک نمایشگر نیز نتیجه‌بخش خواهد بود. نحوه نگهداری بیت‌مپ‌های رندرشده و استفاده مجدد آن‌ها در سراسر تغییرات سطح زوم، در یادداشت ما در مورد کشِ رندر و عملکرد زوم پوشش داده شده است، و بحث گسترده‌تر در مورد ایمن نگه داشتن مرز PDFium در محیط دلفی در مقاوم‌سازی ABI کامپوننت PDFium برای ایمنی حافظه ارائه شده است. زیرساخت ناهمگامِ شرح داده شده در اینجا، به عنوان بخشی از کامپوننت PDFium برای دلفی و C++Builder در کنار رابط‌های برنامه‌نویسی کاربردیِ رندر، متن و فرم که در جای دیگر این وبلاگ پوشش داده شده است، عرضه می‌شود