مقال تقني

سلامة الخيوط في PDFium: قفل كل مستند يفشل في Delphi

‏PDFium ليست آمنة الخيوط على مستوى الوحدة، فمثيلتا TPdf تعملان على ملفين مختلفين في خيطين قد تفسد إحداهما الأخرى مع ذلك. يتولى PDFium Component for Delphi ذلك بطريقتين: منذ v3.125.1 يجمع ValidatePdfFilesParallel كل استدعاء PDFium أصلي خلف قفلٍ واحد على مستوى العملية، بينما تعطي TPdf.RenderPagesParallel كل عاملٍ نسخة معزولة خاصة به من وحدة PDFium. وكان العيب الذي فرض الإصلاح أسوأ أنواع الانقطاع: نجح اختبار تحقق دفعي أغلب الوقت، ثم بلّغ أحد ملفين صحيحين فاشلاً، ثم أسقط الاختبار التالي في العملية نفسها انتهاك وصول، وأحياناً أسقط العدّاء كله برمز خروج بدل تتبع مكدس. لم يكن خطأ في الاختبار، ولا خطأ في أي مستند بعينه. الافتراض كان الخطأ: ‏TPdf واحد لكل خيط ليس عزلاً

لماذا لا يكفي TPdf واحد لكل خيط؟

‏TPdf واحد لكل خيط لا يكفي لأن PDFium تحفظ حالتها غير الآمنة في الوحدة لا في المستند. تملك كل TPdf معالج FPDF_DOCUMENT خاصاً بها، لكن كل معالج في العملية تخدمه الـ DLL المحملة نفسها، وتحمل تلك الـ DLL مفردات على مستوى العملية: ذاكرة الخطوط المؤقتة، ووحدة الصفحات، وبنى عامة أخرى تلمسها التحليل والتحميل والتصيير كلها. خيطان يحملان ملفين غير متصلين هما خيطان يكتبان في ذاكرة الخطوط المؤقتة نفسها في الوقت نفسه. لا أحد يملك تلك البيانات على جانب Delphi، فلا شيء على جانب Delphi يستطيع قفلها لكل مستند

للمكوّن قفلٌ فعلاً، ومن السهل استخلاص نتيجة خاطئة منه. يغلّف TPdf مسارات التصيير الخاصة بها في قسم حرج داخلي ‏(EnterRenderLock / LeaveRenderLock، تابعتان خاصتان لـ TPdf). ذلك القفل لكل مثيل. يمنع خيطين من قيادة TPdf نفسها في وقت واحد، وهو خطر حقيقي، لكنه لا يرى مثيلاً ثانياً على خيط آخر، فيمر التزامن عبر المثيلات من جانبه مباشرة. والقاعدة العامة بسيطة تتسع في سطر: في وحدة PDFium محملة واحدة يُجاز لمؤخلٍ واحد على الأكثر أن يكون داخل PDFium في أي لحظة، أياً كان عدد المستندات المفتوحة

مخطط PDFium Component لخيطين يشغلان مثيلي TPdf منفصلين على مستندات مختلفة بينما تتقارب كل الاستدعاءات على وحدة pdfium.dll محملة واحدة تتشارك ذاكرة الخطوط المؤقتة ووحدة الصفحات وبقية المفردات على مستوى العملية، منتجةً إخفاقات تحميل وانتهاكات وصول وخروج fail-fast
تحفظ PDFium حالتها غير الآمنة في الوحدة لا في المستند، فمثيلا TPdf على خيطين يكتبان في ذاكرة الخطوط المؤقتة نفسها أياً كان مدى عدم اتصال الملفين

ماذا تبدو الإفساد عبر المستندات في عملية Delphi؟

يبدو الإفساد عبر المستندات مزيجاً عشوائياً من إخفاقات غير متصلة، ويعيش الضرر أطول من الكود الذي سببه. قبل v3.125.1 أنشأ ValidatePdfFilesParallel ‏TPdf لكل خيط عامل وشغّل Active := True وبناء تقرير الفحص المسبق تزامناً على الوحدة المشتركة. والأعراض المرصودة على بناءات Delphi و Free Pascal غطت المدى كله:

  • ملف صحيح يفشل تحميله، أو يعود من الدفعة فاشلاً حين كان يجب أن يجتاز
  • انتهاك وصول يظهر في استدعاء لاحق غير متصل، غالباً في اختبار مختلف أو مستند مختلف
  • يظهر External exception C000001D في Delphi. ذلك الرمز هو STATUS_ILLEGAL_INSTRUCTION، يرفعه تعليمة ud2 التي تنفذها ماكرو CHECK و IMMEDIATE_CRASH الداخليان في PDFium حين تنكسر ثابتة
  • تخرج العملية بـ 0xC0000409 ‏(fail-fast، تُبلَّغ فيضان مخزن مكدس) أو 0xC0000374 ‏(إفساد كومة)، من دون أي استثناء Delphi

النقطتان الأخيرتان هما سبب صعوبة تثبيت العيب. اكتمل التحقق المتوازي، وبقيت الحالة العامة المفسدة خلفه، وعثرت التجهيزة التالية في العملية نفسها عليها. في تشغيل انحدار Delphi Win64 صابت موجة من إخفاقات C000001D اختباراتٍ لم تلمس التحقق الدفعي قط؛ كانت ببساطة أول كود يستخدم PDFium بعد الضرر. والأرقام المقيسة تجعل الحجم جلياً: فحص Delphi شغّل العينة نفسها عبر عاملين فشل في 122 من 160 مستنداً في تشغيل واحد و 138 من 160 في آخر، ورفع أحد ذلك التشغيلين External exception C000001D صريحة. وحالة إجهاد من 8 مستندات و 4 عمال و 5 جولات فشلت أو انهارت في 5 من 5 تشغيلات على Free Pascal Win64. بعد الإصلاح فشل الفحص نفسه في 0 من 1,200 مستند

كيف يبقى ValidatePdfFilesParallel آمناً منذ v3.125.1

يجمع ValidatePdfFilesParallel الآن النصف الأصلي من كل مهمة ويبقي النصف المُدار متوازياً. يأخذ كل عامل قسماً حرجاً على مستوى الوحدة قبل أن ينشئ TPdf خاصته، ويحمله عبر FileName و Active := True وبناء تقرير الفحص المسبق و Free. الإنشاء والإتلاف داخل القفل عن قصد: إغلاق مستند يعود استدعاءً إلى الوحدة كما يفعل التحميل. وبمجرد امتلاك العامل سجل TPdfPreflightReport ملتقطاً يحرر القفل ويقيّم قواعد التحقق مقابل ذلك السجل، الذي لا يلمس حالة PDFium، فيتداخل تقييم القواعد لملفٍ مع عمل PDFium للملف التالي

مخطط ValidatePdfFilesParallel في PDFium Component يعرض كل عامل ممسكاً قسماً حرجاً واحداً على مستوى العملية عبر إنشاء TPdf وتحميله وفحصه المسبق وتحريره بينما يجري تقييم قواعد السجل الملتقط خارج القفل بالتوازي، فالنصف PDFium من الدفعة تسلسلي بالتصميم
يبقى الإنشاء والإتلاف داخل القفل لأن إغلاق مستند يعود استدعاءً إلى الوحدة، بينما لا يلمس تقييم التقرير حالة PDFium ويتداخل مع الملف التالي

جاء مع الإصلاح تغييران أصغر. يرفع إخفاقُ تحميل الآن EPdfError مع LastLoadReport.ErrorMessage، فيسمي ErrorMessage الخاص بالبند مشكلة التحليل الفعلية بدل خطأ ثانوي «لا مستند نشط». والكلفة مصرَّح بها بأمانة: صار جزء PDFium من الدفعة تسلسلياً، فعلى دفعة يغلب عليها التحليل والفحص المسبق لا تشتري العمالُ الإضافيون شيئاً يذكر. وإن كنت على إصدار قبل v3.125.1 اضبط WorkerCount على 1؛ ذلك يزيل التزامن والإفساد معه

uses
  System.SysUtils, PDFium, FPdfPreflightReport;

procedure ValidateBatch(const Files: array of string);
var
  Registry: TPdfValidationRuleRegistry;
  Options: TPdfBatchValidationOptions;
  Report: TPdfBatchValidationReport;
  I: Integer;
begin
  Registry := CreateDefaultPdfValidationRuleRegistry;
  try
    Options := TPdfBatchValidationOptions.Default;
    Options.WorkerCount := 4;          // ‏0 = عدد المعالجات، مسقوف عند 8
    Options.Standards := [ppsPdfA];
    // مع سجل صريح انتقِ الملف الجانبي المطابق بنفسك.
    // قائمة Profiles الفارغة تشغل كل قاعدة مسجلة، وقواعد
    // المعايير التي لم تفحصها تبلّغ «لم تجتز»
    SetLength(Options.ValidationOptions.Profiles, 1);
    Options.ValidationOptions.Profiles[0] := 'PDF/A';
    Report := ValidatePdfFilesParallel(Files, Registry, Options);
  finally
    Registry.Free;
  end;

  for I := 0 to High(Report.Results) do
    case Report.Results[I].Status of
      pbvisPass:  Writeln('PASS  ', Report.Results[I].FileName);
      pbvisFail:  Writeln('FAIL  ', Report.Results[I].FileName);
      pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
                    Report.Results[I].ErrorMessage);
    else
      Writeln('SKIP  ', Report.Results[I].FileName);   // pbvisCancelled
    end;
  Writeln(Report.PassedDocumentCount, ' passed, ',
    Report.FailedDocumentCount, ' failed, ',
    Report.ErrorDocumentCount, ' errors');
end;

تمرير nil بوصفه سجلاً هو الطريق الأقصر: ينشئ ValidatePdfFilesParallel السجل الافتراضي بنفسه حينئذٍ، ويشتق قائمة الملفات الجانبية من Options.Standards، ويحرر السجل عند عودته. تعود النتائج دوماً بترتيب الإدخال أياً كان ترتيب انتهاء العمال. ولصيغ التقارير والغلاف سطر الأوامر حول المحرك نفسه راجع تقارير الفحص المسبق الدفعية لملفات PDF مع واجهة PDFium Component الطرفية، ولما تغطيه فحوص PDF/A ذاتها التحقق المسبق من PDF/A في Delphi

كيف تشغّل RenderPagesParallel الصفحات بالتوازي فعلاً؟

تعمل TPdf.RenderPagesParallel بالتوازي لأن عمالها لا يتشاركون وحدة PDFium قط. تحفظ الطريقة أولاً المستند النشط في مخزن مصدر على الخيط المستدعي. ثم تنسخ كل عامل الـ DLL لـ PDFium المحملة إلى ملف مسمى فريداً في مجلد temp، وتحمل تلك النسخة بـ LoadLibrary، وتهيئها. يعامل Windows الـ DLL المحملة من مسار مختلف وحدةً مختلفة، فتكسب كل نسخة مفرداتها الخاصة: ذاكرة خطوطها المؤقتة الخاصة، ووحدة صفحاتها الخاصة، وكل شيء خاصاً بها. تفتح العاملة المستند المحفوظ في وحدتها الخاصة، وتصيّر صفحاتها تدريجياً بفحوص إلغاء بين الخطوات، ثم تدمر المكتبة، وتفرغ النسخة، وتحذف الملف

مخطط RenderPagesParallel في PDFium Component حيث يخزن الخيط المستدعي لقطة مستند، ثم تنسخ كل عاملة DLL لـ PDFium إلى ملف temp فريد وتحملها وحدةً منفصلة بمفرداتها الخاصة وتصيّر صفحاتها بفحوص إلغاء وتفرغ النسخة
التوازي الحقيقي يأتي من عزل الوحدة: يعامل Windows كل نسخة DLL وحدةً مختلفة، فلا يتشارك العمال شيئاً سوى اللقطة التي خزنها الخيط المستدعي تحت القفل

العزل ليس مجانياً، والافتراضات تعكس ذلك. تدفع كل عاملة كلفة نسخة DLL على القرص، ومجموعة ثانية من مفردات PDFium في الذاكرة، وتحليلاً جديداً للمستند. ‏MaxWorkers = 0 يعني 4 عمال على الأكثر، ويسقف MaxPixelsPerPage و MaxTotalOutputBytes الناتجَ الخام، ويُرفض خيارا التصيير المعكوس وثنائي اللون الليلي لأن المخازن تعود خاماً. والناتج TPdfParallelRenderReport تحمل مصفوفة Results فيه مخزناً واحداً 32-بت من الأعلى للأسفل لكل صفحة مطلوبة، بترتيب الطلب

procedure RenderAllPages(Pdf: TPdf);
var
  Options: TPdfParallelRenderOptions;
  Report: TPdfParallelRenderReport;
  Pages: array of Integer;
  I: Integer;
begin
  SetLength(Pages, Pdf.PageCount);
  for I := 0 to High(Pages) do
    Pages[I] := I + 1;                 // أرقام الصفحات تبدأ من 1

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // تؤخذ لقطة المصدر على الوحدة المشتركة، فامسك
  // قفل PDFium على مستوى العملية إن استخدمت خيوط أخرى TPdf أيضاً
  PdfiumLock.Acquire;
  try
    Report := Pdf.RenderPagesParallel(Pages, Options);
  finally
    PdfiumLock.Release;
  end;

  for I := 0 to High(Report.Results) do
    if Report.Results[I].Status = pprsSucceeded then
      SavePageBuffer(Report.Results[I])   // Width, Height, Stride, PixelFormat, Pixels
    else
      Writeln('Page ', Report.Results[I].PageNumber, ': ',
        Report.Results[I].ErrorMessage);
end;

لاحظ القفل حول الاستدعاء. وحدات العمال خاصة، لكن خطوة اللقطة في البداية تشغّل SaveAs على الوحدة المشتركة من الخيط المستدعي. فإن لم يلمس أي شيء آخر في عمليتك TPdf تزامناً أسقطت القفل؛ وإن لمس شيء ذلك احتاجت اللقطة الحماية نفسها التي يحتاجها كل استدعاء آخر للوحدة المشتركة

النمطآمن عبر المستنداتعمل PDFium يجري بالتوازيالكلفة
‏TPdf واحد لكل خيط، بلا قفل مشتركلانعم، حتى تفسدانهيارات متقطعة، حالة عملية تالفة
قفل واحد على مستوى العملية حول كل استدعاءات PDFiumنعملاجزء PDFium تسلسلي
‏ValidatePdfFilesParallel منذ v3.125.1نعملا؛ تقييم القواعد متوازٍالتحليل والفحص المسبق تسلسليان
‏TPdf.RenderPagesParallelنعمنعمنسخة DLL وذاكرة وتحليل جديد لكل عامل

كيف تبني كود PDFium متعدد الخيوط خاصتك؟

ينبغي لخيوطك أن تتشارك قفلاً واحداً على مستوى العملية وتمدده على الحياة الكاملة لكل TPdf تستخدمه، أو تستخدم واجهة مكوّن تعزلك الوحدة عنك. القفل لا بد أن يكون كائناً واحداً للعملية كلها، لا واحداً لكل خيط أو نموذج أو مستند؛ قفل لا يتشاركه خيطان لا يحمي شيئاً. يحاكي النمط أدناه ما يفعله المكوّن داخلياً منذ v3.125.1: إنشاء وتحميل وقراءة وتحرير داخل القفل، ثم كل ما لا يلمس PDFium خارجه

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // قفل واحد للعملية كلها

type
  TTextExtractThread = class(TThread)
  private
    FFileName: string;
    FText: string;
  protected
    procedure Execute; override;
  public
    constructor Create(const AFileName: string);
    property ExtractedText: string read FText;
  end;

constructor TTextExtractThread.Create(const AFileName: string);
begin
  inherited Create(True);
  FFileName := AFileName;
end;

procedure TTextExtractThread.Execute;
var
  Pdf: TPdf;
  Page: Integer;
  Raw: TStringBuilder;
begin
  Raw := TStringBuilder.Create;
  try
    PdfiumLock.Acquire;
    try
      Pdf := TPdf.Create(nil);
      try
        Pdf.FileName := FFileName;
        Pdf.Active := True;
        if not Pdf.Active then
          raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
        for Page := 1 to Pdf.PageCount do
        begin
          Pdf.PageNumber := Page;
          Raw.AppendLine(Pdf.Text);
        end;
      finally
        Pdf.Free;                      // إغلاق المستند عمل PDFium أيضاً
      end;
    finally
      PdfiumLock.Release;
    end;
    // لا PDFium تحت هذا السطر، فهذا الجزء يعمل بالتوازي
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

بضعة قواعد تبقي النمط صادقاً في تطبيق حقيقي:

  • ضع TPdf.Create و Free داخل القفل، لا الاستدعاءات الواضحة وحدها. التحميل والإغلاق وقراءات الخصائص مثل PageCount وتغييرات الصفحات واستخلاص النص والتصيير والحفظ كلها تمد يدها إلى الوحدة
  • افحص Active بعد إسناده. يبقي التحميل الفاشل Active على False، ويقول LastLoadReport.ErrorMessage السبب
  • امسك القفل لكل مستند لا لكل استدعاء. القفل الأدق ممكن مبدئياً، لكن فقط إذا لم يجري أي عضو من أعضاء TPdf خارجه قط، والنسخة الخشنة هي التي يعتمد عليها المكوّن نفسه
  • أبقِ العمل البطيء غير PDFium، ككتابات قواعد البيانات والفهرسة واستدعاءات الشبكة، خارج القفل، وإلا جمع مستهلك بطيء واحد كل شيء
  • لا تعامل قفل التصيير الداخلي لكل مثيل بديلاً. يحرس TPdf واحداً ضد نفسه ولا أكثر

يسري الحذر نفسه على كود لم تكتبه خيوطاً خاماً. العقود المستقبلية الخلفية طريقة جيدة لإبقاء التصييرات الطويلة بعيداً عن خيط الواجهة، كما في تصيير PDF في الخلفية بعقود مستقبلية قابلة للإلغاء، لكن منفذ العقد لا يضيف قفل PDFium عاماً من عنده. فإن استطاعت عدة عقود قيادة مثيلات TPdf مختلفة في الوقت نفسه، خذ القفل نفسه على مستوى العملية داخل كل عامل، وعامل العارض على الخيط الرئيس عميلاً إضافياً للوحدة المشتركة. الاستخدام عبر المثيلات عبر الواجهات غير المتزامنة لم يُدقق على حدة، فالافتراض المتحفظ أنه يحتاج التجميع نفسه الذي تحتاجه الخيوط المكتوبة يدوياً. وحين تحتاج توازي PDFium حقيقياً لشيء سوى تصيير الصفحات، تعطي عمليات العامل المنفصلة كل مهمة وحدتها الخاصة بالبناء

مرجع سريع: قواعد خيوط PDFium لـ Delphi

  • حالة PDFium غير الآمنة على مستوى الوحدة كلها: ذاكرة الخطوط المؤقتة ووحدة الصفحات والمفردات الأخرى تتشاركها كل مستندات العملية
  • ‏TPdf واحد لكل خيط لا يعزل شيئاً؛ مثيلان على خيطين قد يفسد إحداهما الأخرى مع ذلك
  • الأعراض النموذجية إخفاقات تحميل وانتهاكات وصول في كود لاحق و External exception C000001D وخروج بـ 0xC0000409 أو 0xC0000374
  • الإفساد يبقى في العملية، فالاستدعاء الفاشل غالباً ليس الذي سببه
  • ‏ValidatePdfFilesParallel آمنة منذ v3.125.1؛ وعلى الإصدارات الأقدم استخدم WorkerCount := 1
  • ‏TPdf.RenderPagesParallel متوازية فعلاً لأن كل عامل تحمّل نسخة معزولة من وحدة PDFium
  • تحتاج خيوطك ومهامك وعقودك قفلاً واحداً على مستوى العملية يغطي كل TPdf من Create إلى Free

يغلّف PDFium Component محرك PDFium لـ Delphi بفحص مسبق وتحقق دفعي وتصيير متوازٍ معزول وعمل خلفي قابل للإلغاء وتشخيصات تحميل مفصلة. التفاصيل والإصدارات في صفحة منتج PDFium Component