مقاله فنی

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

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

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

شکل کار

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

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

یک Future از نوع شلیک‌کن-و-فراموش‌کن (fire-and-forget)

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

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

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

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

رقابت (Race) در نسخه v1.61.0 که نحوه تحویل پاسخ را تغییر داد

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

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

راه‌حل در نسخه v1.61.0 این بود که پاسخ را با Synchronize به جای Queue تحویل دهیم. Synchronize ترد کارگر را مسدود می‌کند تا زمانی که ترد اصلی پاسخ را به طور کامل اجرا کند. کارگر در حالی که پاسخش در حال اجراست همچنان زنده است، بنابراین چیزی برای آزاد شدن از زیر دست آن وجود ندارد، و ترد از Execute بازنمی‌گردد (و بنابراین شروع به نابودی خود نمی‌کند) تا زمانی که پاسخ تحویل داده شود. تحویل تضمین می‌شود، و پنجره استفاده-پس-از-آزادشدن بسته می‌گردد

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;

درس کلی این موضوع فراتر از یک رفع باگِ خاص باقی می‌ماند. توابع بازگشتیِ (callbacks) ناهمگامِ (asynchronous) شلیک‌کن-و-فراموش‌کن، ساده‌ترین الگوی هم‌روندی (concurrency) هستند که به طرز نامحسوسی اشتباه درمی‌آیند، زیرا مسیر موفقیت در همان تلاش اول کار می‌کند و باگ در تعامل بین ترتیب نابودی ترد و صف مخفی می‌شود. این مشکل همیشه روی نمی‌دهد. به این بستگی دارد که آیا ترد اصلی تصادفاً صف را تخلیه می‌کند قبل از آنکه کارگر تصادفاً کارِ نابودی خود را به پایان برساند، که این یک زمان‌بندی (timing) است که برنامه‌ریز (scheduler) در هر اجرا متفاوت تصمیم می‌گیرد. یک متغیر اولیه که در بایندینگ یک بار به درستی نوشته شود، بسیار ارزشمندتر از بازنویسیِ همان کد در هر برنامه‌ای است که به رندر پس‌زمینه نیاز دارد

چرا توابع بازگشتی به شکل اشاره‌گرهای متد (method pointers) هستند

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

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

لغو مشارکتی (Cooperative cancellation)، نه یک کشتار سخت

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

توکن سه راه برای مشاهده لغو شدن ارائه می‌دهد. IsCancelled یک نظرسنجی (poll) بولی ارزان است برای حلقه‌ای که می‌خواهد خودش آزمایش کرده و تصمیم بگیرد. ThrowIfCancelled رایج‌ترین حالت است: آن را در یک نقطه لغوِ طبیعی فراخوانی کنید و اگر درخواست لغو شده باشد، یک استثنا از نوع EPdfOperationCancelled صادر می‌کند (raises)، که کارگر را مستقیماً به future بازمی‌گرداند. متد RegisterCallback یک اعلان تک‌بار (one-shot notification) را متصل می‌کند که به محض لغو منبع، یک بار اجرا می‌شود، که زمانی مفید است که یک کارگر در چیزی مسدود شده است که می‌تواند به جای نشستن در یک حلقه بسته (tight loop)، آن را قطع کند

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

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

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

یک جزئیات تطبیق‌یافته نیز برای موردی وجود دارد که در آن فراخوان‌دهنده یک رندر را می‌خواهد اما هرگز قصد لغو آن را ندارد. این یونیت به جای اجبار به ایجاد یک منبع جدید برای هر فراخوانی، PdfNoCancellationToken را در معرض دید قرار می‌دهد؛ یک توکنِ سینگلتون (singleton) که دائماً در حالتِ "لغونشده" قرار دارد. اگر آرگومان توکن مقدار nil داشته باشد، متد Run آن را جایگزین می‌کند. این سینگلتون در زمانِ مقداردهی اولیه یونیت (unit initialization) به صورت مشتاقانه (eagerly) ساخته می‌شود به جای اینکه با تنبلی (lazily) در اولین استفاده ساخته شود، و دلیل آن باز هم هم‌روندی (concurrency) است. اگر چندین فراخوانیِ Run در تردهای کارگرِ مختلف همه به طور همزمان برای یک سینگلتون که با تنبلی ساخته شده است دست دراز کنند، ممکن است در ساخت آن رقابت کنند (race)، یک نسخه تکراری نشت (leak) کنند، یا به طور خلاصه یک نمونه نیمه‌آماده را مشاهده نمایند. ساختن آن قبل از اینکه هر کارگری بتواند اجرا شود، به طور کامل این رقابت را از بین می‌برد

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

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

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // فیلدی که روی فرم زندگی می‌کند
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // کارگر در نقطه لغو بعدیِ خود این را مشاهده می‌کند
end;

// روی یک ترد پس‌زمینه اجرا می‌شود. مقادیر FPageRange / FOutputDir را از فرم می‌خواند.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // توقف ایمن در بین صفحات
    RenderOnePage(PageIndex);       // شطرنجی‌سازی همگام PDFium
  end;
  Result := True;
end;

// روی ترد اصلی اجرا می‌شود. در اینجا تماس با VCL ایمن است.
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;

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

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