مقال تقني

عرض صفحات PDF المتوازية في Delphi: أمان الخيوط

عرض صفحات PDF بالتوازي في Delphi يرجع إلى قاعدة واحدة: إعطاء كل خيط عمل (worker thread) عارضًا خاصًا به؛ وتوفر مكتبة losLab PDF Library الدالة RenderPagesToFilesParallel لهذه المهمة تحديدًا، حيث تنشر نطاق الصفحات عبر تجمع TTask مع مثيل TPDFlib واحد لكل عامل، بحيث يحول الجهاز متعدد النواة مهمة التنقيط الدفعتية (batch raster job) إلى إنتاجية تقترب من عدد الأنوية؛ وبدلاً من ذلك، فإن مشاركة مثيل واحد عبر الخيوط لا يبطئ التشغيل بلطف، بل يؤدي إلى إتلاف الذاكرة والانهيار

هذا هو المقال الذي تلجأ إليه عندما يتعين على مهمة ليلية تحويل ملف PDF مكون من ٥٠٠ صفحة إلى ٥٠٠ صورة PNG، ويحتوي الجهاز على ١٦ نواة معطلة، وتفشل محاولتك الأولى الجادة لتقسيم الخيوط داخل GDI+؛ والنسخة المختصرة هي أن أمان الخيوط هنا هو خاصية هيكلية، وليس علامة (flag) تقوم بتعيينها، ويستعرض بقية هذا المقال سبب ظهور الشكل الآمن بالطريقة التي يظهر بها وأين يقع سقف تسريع العمل الحقيقي بالفعل

هل TPDFlib آمن للخيوط (thread-safe) للعرض المتوازي؟

لا، والسبب يستحق الفهم قبل التصميم حوله؛ حيث يتم الإعلان عن مثيل TPDFlib واحد للاستخدام في خيط واحد، والحد الحرج هنا هو TPDFPageTree.GetPage: فهو يكتب حقلاً مشتركًا FPagePointer على المثيل كأثر جانبي لتحديد صفحة؛ ويسابق خيطان يستدعيان نفس المثيل على هذا الحقل، لذا يمكن للعامل A أن يكون في منتصف الصفحة ٣ عندما يعيد العامل B توجيه شجرة الصفحات إلى الصفحة ٤٠؛ ولا يمنعك أي شيء في واجهة برمجة التطبيقات من كتابة الكود أدناه، بل إنه سيعمل لبضع صفحات قبل أن يتعطل، وهو أسوأ سلوك لمثل هذا الخطأ

// لا تفعل هذا: مثيل مشترك واحد، وخيوط متعددة
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // كل خيط يدخل نفس المثيل مجددًا -> سباق بيانات على FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

الفشل ليس حتميًا، ولهذا السبب تحديدًا ينجو من اختبار سريع ثم يظهر على جهاز العميل الذي يحتوي على عدد أنوية مختلف ومستند أثقل؛ ولا يوجد قفل يمكنك لفه حول RenderPageToFile لإصلاح هذا برخص أيضًا، لأن الاحتفاظ بالـ mutex عبر استدعاء العرض بالكامل يجعل العمل متسلسلاً ويلغي التوازي الذي تسعى إليه

لماذا يحتاج كل عامل عرض إلى مثيل TPDFlib الخاص به؟

لأن المثيل *هو* وحدة العزل؛ فبمجرد أن يمتلك كل عامل مثيل TPDFlib خاصًا به قام بتحميل الملف بشكل مستقل، يصبح لكل منهم شجرة صفحاته الخاصة، وحقله FPagePointer الخاص، وحالة العرض الخاصة به، فلا يوجد شيء مشترك للتسابق عليه؛ وهذا الأمان له ثمن يجب تقديره مسبقًا: فكل عامل يحلل المستند بأكمله في الذاكرة، لذا فإن أقصى استهلاك للذاكرة يبلغ تقريبًا N من المرات تكلفة المثيل الفردي؛ وثمانية عمال على ملف PDF بحجم ٣٠٠ ميجابايت يعني ثمانية عمليات تحليل كاملة مقيمة في الذاكرة في نفس الوقت، وفي المدخلات الكبيرة جدًا، فإن هذا هو القيد الذي يحدد عدد العمال لديك، وليس وحدة المعالجة المركزية؛ وعندما يكون المستند ضخمًا وتكون مقيدًا بالذاكرة بدلاً من وحدة المعالجة المركزية، فإن مسار الوصول المباشر في معالجة ملفات PDF الكبيرة دون تحليل المستند الكامل هو الرافعة الأفضل غالبًا من خيوط العرض الإضافية

واجهة برمجة تطبيقات الاستدعاء الواحد: RenderPagesToFilesParallel

تجمع مكتبة losLab PDF Library النمط الآمن بأكمله خلف طريقة واحدة، بحيث لا تضطر إلى كتابة أي منها يدويًا في الحالات الشائعة؛ حيث تأخذ الدالة RenderPagesToFilesParallel اسم الملف وكلمة المرور، ودقة الـ DPI، وصفحة البدء والنهاية الشاملتين، وقيمة Options التي يتم تمريرها مباشرة إلى مسار التنقيط لكل صفحة، ونمط الإخراج حيث يتم استبدال %p برقم الصفحة، والحد الأقصى للعمال حيث تعني أي قيمة عند الصفر أو أقل التحديد التلقائي؛ وترجع الدالة عدد الصفحات التي تم عرضها بنجاح، وهي مسار مخصص لنظام ويندوز فقط لأنها تعتمد على CoInitialize و GDI+

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // اسم الملف، كلمة المرور، DPI، صفحة البدء، صفحة النهاية، الخيارات، النمط، الحد الأقصى للعمال
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // الحد الأقصى للعمال = 0 -> تلقائي: الأقل بين (عدد الصفحات، أنوية المعالجة)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

لماذا CoInitialize في كل خيط عمل؟

إن GDI+ هو المنقط (rasterizer) تحت عرض الصفحات، و GDI+ هو شقة الخيوط (apartment-threaded): فهو يتوقع تهيئة COM على أي خيط يستدعيه؛ وعادة ما يكون الخيط الرئيسي لتطبيق VCL مهيأً لذلك بالفعل، ولكن عامل TTask المنشأ حديثًا لا يكون كذلك، واستدعاء مسار العرض من خيط غير مهيأ هو طريقة مؤكدة للانهيار؛ لذلك يقرن كل عامل استدعاء CoInitialize(nil) عند الدخول بـ CoUninitialize عند الخروج، محددًا دورة حياته بأكملها؛ وهذا هو نفس الانضباط الذي يحتاجه أي عمل GDI+ أو COM خارج الخيط الرئيسي، وهو النصف الثاني مما يجعل عزل كل عامل ينجح فعليًا، والنصف الأول هو المثيل الخاص؛ ويقود نفس مسار تنقيط GDI+ المحركات أحادية الخيط المغطاة في اختيار محرك عرض لإخراج PDF

التقسيم الثابت مقابل حيازة الصفحات الديناميكية

الطريقة الواضحة لتقسيم ٥٠٠ صفحة على ٨ عمال هي تسليم كل منهم شريحة ثابتة تبلغ حوالي ٦٢ صفحة؛ ولكن مكتبة losLab PDF Library لا تفعل ذلك، والسبب هو توازن الحمل؛ حيث تختلف تكلفة معالجة الصفحات بشكل كبير: فتُعرض صفحة من متن النص في أجزاء من الثانية، بينما يمكن لصفحة من الخرائط المتجهية الكثيفة أو صورة ممسوحة ضوئيًا بالكامل أن تستغرق وقتًا أطول بخمسين مرة؛ وقسّم العمل إلى شرائح ثابتة وسيعمل العامل الذي يرسم الشريحة الثقيلة لفترة طويلة بعد أن يصبح الآخرون عاطلين، وبالتالي يتم تحديد وقتك الإجمالي من خلال الشريحة الأكثر سوءًا للحظ، وليس المتوسط؛ وبدلاً من ذلك، يحصل كل عامل على الصفحة التالية من عداد مشترك تحت قسم حرج قصير، ويعرضها، ويعود للحصول على صفحة أخرى، مما يبقي كل نواة مشغولة حتى يتم استنفاد النطاق بأكمله

// ما يفعله كل عامل داخل التجمع (مبسط)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ هو شقة الخيوط
    try
      LocalLib := TPDFlib.Create;   // مثيل خاص واحد لكل عامل
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // المطالبة بالصفحة التالية بشكل ذري
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

التسجيل الهيكلي عبر خيوط العمل

إن تصحيح أخطاء دفعة تموت في الصفحة ٢١٣ من أصل ٥٠٠ أمر بائس بدون سجل (log)، والسجل الساذج هو خطأ متزامن بحد ذاته؛ وتشحن مكتبة losLab PDF Library المكون TPDFlibLogger، المرتبط من خلال خاصية TPDFlib.Logger وهو nil افتراضيًا بحيث يظل مسار عدم استخدام السجل خاليًا من التكلفة؛ وهو يعتمد على الاسترجاعات أولاً: تقوم بتعيين OnLog وتوجيه السجلات أينما يريد المضيف، وتصفيتها بواسطة مستوى llDebug أو llInfo أو llWarn أو llError، وتحول الدالة PDFlibErrorMessage الرموز الرقمية الخام إلى نص بشري بحيث يقرأ سجل Error أكثر من مجرد عدد صحيح مجرد؛ ومستودع الملف الاختياري هو المورد المشترك الوحيد، وهو محمي بواسطة TCriticalSection بدقة حتى يتمكن العديد من العمال من الإضافة إلى ملف سجل واحد بأمان؛ ولاحظ الحد الصريح هنا: تتم مزامنة مستودع الملف هذا فقط، لذا إذا شاركت سجلاً واحدًا عبر تجمع مصنوع يدويًا وتطرق حدث OnLog الخاص بك إلى واجهة المستخدم، فلا يزال يتعين عليك إعادة توجيه ذلك إلى الخيط الرئيسي بنفسك

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // مستودع مشترك اختياري (محمي بقفل)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // قم بتوجيه العمل إلى خيط واجهة المستخدم بنفسك؛ ينطلق OnLog على خيوط العمل
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil افتراضيًا؛ خالي من التكلفة عند عدم تعيينه
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // يحمل الخطأ الآن نصًا، على سبيل المثال 401 -> "كلمة مرور خاطئة أو تم رفض الإذن"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

ما مقدار التسريع الذي يجب أن تتوقعه فعليًا؟

كن صادقًا مع نفسك بشأن أين يذهب الوقت، لأن العرض المتوازي لا يؤتي ثماره إلا عندما يكون العمل مقيدًا حقًا بوحدة المعالجة المركزية؛ حيث تكون مخرجات الـ DPI العالية والصفحات الموجهة المعقدة أو المظللة كثيفة الحسابات، وتتدرج هذه بشكل قريب من الخطي مع عدد الأنوية حتى تشبع وحدة المعالجة المركزية؛ بينما الصفحات البسيطة هي قصة أخرى: فهناك عبء LoadFromFile لكل عامل، بالإضافة إلى تكلفة القرص لكتابة ملفات الإخراج، مما قد يطغى على عملية العرض نفسها، ويمكن لثمانية عمال يطحنون قرصًا بطيئًا واحدًا أن ينتهوا بشكل أبطأ من حلقة تسلسلية نظيفة؛ اضبط MaxWorkers على عدد أنويتك المادية بدلاً من قيمة طموحة، وراقب الذاكرة عندما يكون ملف PDF المصدر كبيرًا، وإذا تبين أن الدفعة مقيدة بـ IO، فإن الحل هو تخزين أسرع أو عدد عمال أقل، وليس خيوطًا أكثر؛ وعند استخدامه في المهام التي بُني من أجلها، يكون مسار العرض الدفعتي الموضح هنا جزءًا من مكتبة losLab PDF Library القياسية لـ Delphi و C++Builder، ويحول الأنوية الخاملة إلى صفحات منتهية دون أي من فخاخ أمان الخيوط التي كان سيتعين عليك حلها بنفسك بخلاف ذلك