مقال تقني

تقديم PDF في الخلفية في Delphi باستخدام العقود الآجلة القابلة للإلغاء

يعد تقديم صفحة في PDFium عملية متزامنة. تقوم بالاستدعاء إلى المكتبة، وتُرسَم نقطياً (rasterise) في صورة نقطية سلمتها إياها، ويعود التحكم عند كتابة وحدات البكسل. بالنسبة لصفحة واحدة بحجم الشاشة عند مستوى تكبير واحد، يستغرق الأمر بضع أجزاء من الألف من الثانية ولا يلاحظ أحد ذلك. بالنسبة لتصدير بدقة 300 نقطة في البوصة لمستند مكون من 200 صفحة، أو شريط صور مصغرة يجب أن يُرسم نقطياً لكل صفحة في وقت واحد، فإن نفس الاستدعاء يكلف ثواني. إذا قمت بهذا الاستدعاء من الخيط الرئيسي (main thread)، تتوقف حلقة الرسائل، وتتوقف النافذة عن إعادة الرسم، ويرسم Windows عبارة "لا يستجيب" المخيفة فوق شريط العنوان الخاص بك. العمل صحيح. المكان الذي قمت بتشغيله فيه خاطئ

الحل هو نقل العرض الطويل إلى خيط خلفية (background thread) وإعادة النتيجة إلى الخيط الرئيسي، حيث يمكن تسليم الصورة النقطية إلى عنصر تحكم (control). لا يمنعك PDFium نفسه من القيام بذلك، ولكن يجب أن يجعل الارتباط (binding) عملية التسليم آمنة، لأن مساحة الخطأ حول "التشغيل على عامل (worker)، والرد على واجهة المستخدم (UI)" واسعة والفشل متقطع. توجد وحدة FPdfAsync في PDFiumPas لمنح هذا النمط تنفيذاً صحيحاً واحداً، مع نموذج إلغاء (cancellation model) يناسب كيف يتصرف العرض الطويل في الواقع

شكل العمل

تسيطر ثلاث عمليات على الحالات التي يتجاوز فيها العرض إطاراً واحداً. يمر العرض الدفعي (batch rendering) عبر نطاق من الصفحات ويرسم كل صفحة نقطياً، وعادةً ما يكون ذلك إلى القرص. يقوم التصدير متعدد الصفحات بنفس الشيء ولكنه يجمع المخرجات في ملف واحد. تقديم الصفحة في الخلفية هو ما يفعله عارض (viewer) عندما يقفز المستخدم إلى صفحة ليست في ذاكرة التخزين المؤقت (cache) بعد، لذلك يتم إنتاج الصورة النقطية خارج الخيط (off-thread) وتُعرض عندما تكون جاهزة. تشترك الثلاثة في نفس القيود. إنها تعمل لفترة كافية بحيث لا يمكن لخيط واجهة المستخدم استضافتها، وتنتج نتيجة يحتاجها خيط واجهة المستخدم في النهاية، وقد يتخلى المستخدم عنها. يجب أن يؤدي إغلاق المستند، أو التمرير (scrolling) متجاوزاً الصفحة، أو الضغط على إلغاء (Cancel) إلى إيقاف العمل بدلاً من إجبار المستخدم على انتظار المخرجات التي لم يعد يريدها

هذا القيد الأخير هو الذي يشكل التصميم. إن العرض الذي لا يمكن إلغاؤه هو عرض يبقي المستند مفتوحاً ويحرق وحدة المعالجة المركزية (CPU) بعد أن تتوقف الإجابة عن أن تكون مهمة. لذلك يتم بناء الوحدة حول بدائيتين (primitives) تتألفان: عقد آجل (future) يحمل النتيجة إلى الخلف، ورمز (token) يحمل طلب الإلغاء إلى الأمام

عقد آجل أطلق وانسَ (fire-and-forget future)

يأخذ TPdfFuture<T>.Run عاملاً، ورداً، ورمز إلغاء (cancellation token) اختيارياً. يبدأ العامل على خيط خلفية، وعندما ينتهي العامل، فإنه يسلم الرد على الخيط الرئيسي. المعلمة العامة (generic parameter) T هي أياً كان ما ينتجه العرض، غالباً ما يكون مقبض صورة نقطية (bitmap handle) أو سجل حالة (status record). يعمل العامل خارج الخيط؛ يعمل الرد حيث يكون آمناً لمس VCL

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

الإغفال المتعمد هو أي نوع من Wait (انتظار). لا توجد طريقة لصد المتصل (block caller) حتى يكتمل العقد الآجل، وهذا ليس سهواً. إن Wait الذي يُستدعى من الخيط الرئيسي هو الطريقة الكلاسيكية للوصول إلى طريق مسدود (deadlock) في واجهة المستخدم: يحتاج العامل إلى الخيط الرئيسي لتشغيل رده من خلال Synchronize، ويتم ركن الخيط الرئيسي داخل Wait، ولا يمكن لأي من الجانبين المضي قدماً. من خلال رفض تقديم هذه البدائية، يستبعد العقد الآجل النمط الذي يهزم في أغلب الأحيان الأشخاص الذين يحاولون كتابة هذا بأنفسهم. الكود الذي يحتاج حقاً إلى الصد (block) يجب أن يستخدم TThread بسيط ويمتلك العواقب. العقد الآجل مخصص لحالة أطلق وانسَ (fire-and-forget)، وهو ما يمثله تقديم الخلفية في الواقع

يتم تغليف النتيجة في TPdfFutureResult<T>، وهو سجل يخبر الرد بأي من الأشياء الثلاثة حدثت. IsSuccess تعني أن العامل عاد بشكل طبيعي وأن Value تحمل العرض. IsCancelled تعني أن الرمز قد أُطلق وأن العامل قد انسحب في نقطة إلغاء (cancellation point). IsFailure تعني أن العامل أثار (raised) استثناءً، وأن ErrorMessage تحمل النص. يفحص الرد الحالة مرة واحدة ويتفرع (branches)، بدلاً من التخمين من قيمة حارس (sentinel value) ما إذا كانت الصورة النقطية المرتجعة حقيقية

السباق (race) في الإصدار v1.61.0 الذي غيّر توصيل الرد

الجزء الأكثر إفادة في هذه الوحدة هو تغيير من سطر واحد استغرق وقتاً لفهمه. من خلال الإصدارات المبكرة، قام خيط العامل بتوصيل رده باستخدام TThread.Queue. تقوم Queue بوضع الرد على قائمة انتظار (queue) الخيط الرئيسي وتعود على الفور، وهو ما يُقرأ تماماً على أنه ما يريده العقد الآجل من نوع أطلق وانسَ. لقد كان هذا خاطئاً، والسبب يستحق التوضيح لأنه نوع الخلل الذي يمر عبر كل اختبار يخطر ببالك كتابته

يتم إنشاء خيط العامل باستخدام FreeOnTerminate := True. هذا يعني أنه في اللحظة التي يعود فيها Execute، يقوم الخيط بهدم نفسه، ويستدعي TThread.Destroy الدالة RemoveQueuedEvents(Self) كجزء من التنظيف. تزيل RemoveQueuedEvents أي طريقة موضوعة في قائمة الانتظار (queued method) يكون هدفها الخيط المحتضر. لذلك كان التسلسل هو: ينتهي العامل، ويضع الرد في قائمة الانتظار ضده، ويعود Execute، ويدمر الخيط نفسه، وتحذف RemoveQueuedEvents الرد الذي لم يقم الخيط الرئيسي بتشغيله بعد. لقد تلاشت النتيجة ببساطة. والأسوأ من ذلك، في النافذة الضيقة حيث سحب الخيط الرئيسي الرد الموضوع في قائمة الانتظار وبدأ في تشغيله في نفس اللحظة التي كان يتم فيها تحرير الخيط (freed)، لامس الرد حقول كائن نصف مُدمر، وهو استخدام بعد التحرير (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) غير المتزامنة من نوع أطلق وانسَ هي أسهل نمط التزامن يمكن أن نخطئ فيه بمهارة، لأن المسار السعيد (happy path) يعمل من المحاولة الأولى ويعيش الخلل في التفاعل بين ترتيب هدم الخيط وقائمة الانتظار. لا يمكن إعادة إنتاجه عند الطلب. يعتمد الأمر على ما إذا كان الخيط الرئيسي قد استنزف قائمة الانتظار قبل أن ينتهي العامل من تدمير نفسه، وهو توقيت يقرره المجدول (scheduler) بشكل مختلف في كل عملية تشغيل. البدائية الصحيحة مرة واحدة، في الارتباط (binding)، تستحق أكثر بكثير من نفس الكود المعاد اشتقاقه في كل تطبيق يحتاج إلى عرض في الخلفية

لماذا تكون معاودات الاتصال مؤشرات طرق (method pointers)

العامل والرد ليسا طرقاً مجهولة (anonymous methods). إنها أنواع procedure of object، و TPdfFutureWorker<T> و TPdfFutureReply<T>، وهذا الخيار مفروض بمصفوفة المترجم (compiler matrix). يتم تجميع PDFiumPas في Delphi XE5 وما بعده وفي Free Pascal 3.2 في وضع Delphi، ولا يدعم FPC 3.2 في هذا الوضع الطرق المجهولة. إن معاودة الاتصال من مرجع إلى إجراء (reference-to-procedure) التي تلتقط المتغيرات المحلية سيتم تجميعها في Delphi وتفشل في FPC، لذلك تستخدم الوحدة القاسم المشترك الأصغر الذي يقبله كلا المترجمين

النتيجة العملية هي أين تعيش الحالة (state). تُغلق طريقة مجهولة على المتغيرات المحلية (locals)؛ لا يفعل مؤشر الطريقة ذلك. لذا فإن أية حالة يحتاجها العامل، مثل فهرس الصفحة (page index)، والتكبير (zoom)، ومسار الإخراج (output path)، وأي حالة يحتاج الرد إلى تحديثها، مثل عنصر التحكم في الصورة الهدف أو تسمية التقدم (progress label)، يجب أن تتدلى من الكائن الذي يتم تمرير طريقته. في العارض (viewer)، يكون هذا الكائن عادةً النموذج (form) أو وحدة تحكم عرض (render controller) يمتلكها. هذا ليس حلاً بديلاً مفروضاً على مضض؛ إنه يبقي ملكية تلك الحالة صريحة ومرئية على الكائن المتلقي بدلاً من إخفائها داخل إغلاق (closure)

إلغاء تعاوني، وليس قتلاً صعباً (hard kill)

الإلغاء هنا تعاوني (cooperative). لا توجد واجهة برمجة تطبيقات (API) تمتد إلى خيط العامل وتنهيه، لأن إنهاء خيط في منتصف العرض يترك PDFium يمسك بأقفال (locks) وصور نقطية مكتوبة جزئياً، ولا يمكنك التفكير في حالة العملية بعد القتل القسري. بدلاً من ذلك، يُسلّم العامل رمزاً للقراءة فقط (read-only token) ويُتوقع منه التحقق منه، وكُتبت حلقة العرض (render loop) للتحقق منه بين الصفحات أو بين المربعات (tiles)، حيث يكون التوقف نظيفاً

يقدم الرمز ثلاث طرق لمراقبة الإلغاء. IsCancelled هي عملية استطلاع منطقية رخيصة (cheap boolean poll) لحلقة تريد الاختبار والتقرير بنفسها. ThrowIfCancelled هي الحالة الشائعة: استدعها في نقطة إلغاء طبيعية، وإذا تم طلب الإلغاء، فإنها تثير EPdfOperationCancelled، والذي يفك (unwinds) العامل مباشرة إلى العقد الآجل. يرفق RegisterCallback إشعاراً لمرة واحدة (one-shot notification) يتم تشغيله مرة واحدة عندما يتم إلغاء المصدر، وهو أمر مفيد عندما يُسد (blocked) العامل في شيء يمكنه مقاطعته بدلاً من الجلوس في حلقة ضيقة

الاستثناء هو حيث يهم حد الخيط (thread boundary). عندما يثير العامل EPdfOperationCancelled، يلتقطه العقد الآجل ويحوله إلى حالة مُلغاة (cancelled status)، لذلك يرى الرد IsCancelled وليس فشلاً. لا يتم أبداً توجيه (marshaled) كائن الاستثناء نفسه إلى الخيط الرئيسي. إنه يعيش ويموت على خيط العامل؛ يُنسخ فقط سلسلة رسالته (message string) إلى ErrorMessage. توجيه كائن استثناء حي عبر الخيوط يعني الوصول إلى ذاكرة مملوكة لخيط ينتهي، وهي نفس فئة الأخطاء التي وُجد إصلاح Synchronize لمنعها. يعبر رمز الحالة وسلسلة النص الحدود بشكل نظيف؛ الكائن لن يفعل ذلك

واجهتان (Interfaces)، بحيث لا يمكن للعامل إلغاء نفسه

يتم تقسيم الإلغاء عبر واجهتين عن قصد. IPdfCancellationTokenSource هو جانب الكتابة (write side): لديه Cancel، والمالك الذي ينشئه، وهو عادةً النموذج (form)، يحتفظ به ويستدعي Cancel عندما ينقر المستخدم فوق الزر أو يُغلق النموذج. IPdfCancellationToken هو جانب القراءة (read side): لديه IsCancelled و ThrowIfCancelled و RegisterCallback، وهذا كل ما يستلمه العامل على الإطلاق. ينفذ كائن ملموس (concrete object) واحد كليهما، ولكن العامل لا يُسلَّم أبداً إلا الرمز، لذلك ليس لديه طريقة لإلغاء العملية التي يقوم بتشغيلها. التقسيم هو حاجز حماية على مستوى واجهة برمجة التطبيقات (API). العامل الذي يمكنه الوصول إلى Cancel من خلال رمزه من شأنه أن يدعو جزءاً مشوشاً من الكود لإلغاء نفسه، ويزيل نظام النوع (type system) هذا الاحتمال

هناك تفصيل مطابق للحالة التي يريد فيها المتصل عرضاً ولكنه لا ينوي أبداً إلغاءه. بدلاً من فرض مصدر جديد لكل استدعاء، تعرض الوحدة PdfNoCancellationToken، وهو رمز مفرد (singleton token) يكون دائماً في الحالة غير الملغاة. يستبدله Run عندما تُترك وسيطة الرمز (token argument) خالية (nil). يتم إنشاء هذا المفرد بفارغ الصبر (eagerly) أثناء تهيئة الوحدة بدلاً من إنشائه بكسل (lazily) عند الاستخدام الأول، والسبب هو التزامن (concurrency) مرة أخرى. إذا وصلت عدة استدعاءات Run على خيوط عاملة مختلفة جميعها للحصول على مفرد مُنشأ بكسل في وقت واحد، فيمكنها التسابق على بنائه، أو تسريب نسخة مكررة، أو مراقبة مثيل (instance) مهيأ جزئياً لفترة وجيزة. إن بنائه قبل أن يتمكن أي عامل من العمل يزيل السباق تماماً

تشغيل عرض قابل للإلغاء

من الناحية العملية، تقوم بإنشاء مصدر (source)، والاحتفاظ به في النموذج، وتمرير Token الخاص به إلى Run جنباً إلى جنب مع طريقة العامل وطريقة الرد، وربط زر الإلغاء بالمصدر. يتحقق العامل من الرمز أثناء العرض؛ يقوم الرد بتحديث واجهة المستخدم بمجرد عودة النتيجة. نظراً لأن معاودات الاتصال هي مؤشرات طرق، فإن العامل والرد يقرآن ما يحتاجان إليه من حقول النموذج

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;

يعالج الرد جميع النتائج الثلاث لأنها جميعها قابلة للوصول (reachable). يبلغ العرض المنتهي عن النجاح، ويرى المستخدم الذي ضغط على إلغاء الفرع المُلغى، ويصل ملف لم يمكن كتابته أو صفحة فشل تحليلها كفشل مع رسالة. لا تصد أي من هذه الفروع، ولا تلمس أي منها خيط العامل، ولا تُقرأ الصورة النقطية أو الحالة التي أنتجها العامل إلا بعد أن يسلمها العقد الآجل على الخيط الذي يمتلك واجهة المستخدم

يؤتي نفس الانضباط في الخيوط بثماره في أماكن أخرى في العارض. تتم تغطية طريقة الاحتفاظ بالصور النقطية المعروضة وإعادة استخدامها عبر تغييرات التكبير في ملاحظاتنا حول ذاكرة التخزين المؤقت للعرض وأداء التكبير، والسؤال الأوسع حول الحفاظ على أمان حدود PDFium في Delphi موجود في تقوية PDFium VCL ABI لسلامة الذاكرة. يتم شحن البنية التحتية غير المتزامنة الموضحة هنا كجزء من مكون PDFium Component لـ Delphi و C++Builder، إلى جانب واجهات برمجة تطبيقات العرض، والنص، والنموذج التي تمت تغطيتها في مكان آخر في هذه المدونة