مقاله فنی

thread safety در PDFium: چرا قفل هر سند جواب نمی‌دهد

PDFium در سطح module ای امن نیست، پس دو instance از TPdf که روی دو فایل متفاوت در دو thread کار می‌کنند همچنان می‌توانند همدیگر را خراب کنند. PDFium Component برای Delphi این را دو جور هندل می‌کند: از v3.125.1 به بعد ValidatePdfFilesParallel هر فراخوانی بومی PDFium را پشت یک قفل در سطح کل پروسه serialize می‌کند، در حالی که TPdf.RenderPagesParallel به هر worker یک کپی ایزوله از module یعنی PDFium می‌دهد. باگی که فیکس را مجبور کرد بدترین جور متناوب بود. یک تست اعتبارسنجی دسته‌ای بیشتر وقت‌ها پاس می‌شد، بعد یکی از دو فایل سالم را ناموفق گزارش می‌کرد، بعد تست بعدی را در همان پروسه با یک access violation کرش می‌کرد، و گاهی کل runner را با یک exit code به پایین می‌کشید به‌جای یک stack trace. تست هیچ عیبی نداشت و هیچ سندی تکی هم نداشت. اشتباه، فرض بود: یک TPdf به‌ازای هر thread ایزوله نیست

چرا یک TPdf به‌ازای هر thread کافی نیست؟

یک TPdf به‌ازای هر thread کافی نیست چون PDFium state ناامنش را در module نگه می‌دارد نه در سند. هر TPdf مالک handle یعنی FPDF_DOCUMENT خودش است، اما هر handle در پروسه توسط همان DLL بارگذاری‌شده سرو می‌شود، و آن DLL singletonهای در-سطح-پروسه دارد: کش فونت، page module، و ساختارهای سراسری دیگر که load و parse و رندر سند همگی به آن‌ها دست می‌زنند. دو thread که دو فایل بی‌ربط load می‌کنند دو thread هستند که هم‌زمان داخل همان کش فونت می‌نویسند. هیچ‌کس در سمت دلفی مالک آن داده نیست، پس هیچ چیز در سمت دلفی نمی‌تواند به‌ازای هر سند رویش قفل بگذارد

کامپوننت یک قفل دارد، و از رویش راحت می‌شود نتیجه اشتباه کشید. ‏TPdf مسیرهای رندر خودش را در یک critical section داخلی می‌پیچد (‏EnterRenderLock / LeaveRenderLock، متدهای private مربوط به TPdf). آن قفل به‌ازای هر instance است. جلوی اینکه دو thread هم‌زمان همان TPdf را برانند را می‌گیرد، که یک خطر واقعی است، اما نمی‌تواند یک instance دوم روی thread دیگر را ببیند، پس هم‌زمانی بین-instance‌ای مستقیم از کنارش رد می‌شود. قاعده کلی به اندازه یک خط ساده است: در یک module ی PDFium بارگذاری‌شده، حداکثر یک thread در هر لحظه می‌تواند داخل PDFium باشد، فارغ از اینکه چند سند باز است

نمودار PDFium Component از دو thread که instanceهای TPdf جدا روی اسناد متفاوت اجرا می‌کنند در حالی که هر فراخوانی به یک module یعنی pdfium.dll بارگذاری‌شده همگرا می‌شود که کش فونت و page module و بقیه سراسری‌های در-سطح-پروسه اش مشترک است، و شکست‌های load و access violation و خروج‌های fail-fast تولید می‌کند
PDFium state ناامنش را در module نگه می‌دارد نه در سند، پس دو instance از TPdf روی دو thread بدون توجه به بی‌ربط بودن فایل‌ها داخل همان کش فونت می‌نویسند

خرابی بین-اسنادی در یک پروسه دلفی چه شکلی است؟

خرابی بین-اسنادی شکلی مثل یک ترکیب تصادفی از شکست‌های بی‌ربط است، و آسیب از کدی که باعثش شد عمر بیشتر دارد. قبل از v3.125.1، ‏ValidatePdfFilesParallel به‌ازای هر worker thread یک TPdf می‌ساخت و مقدار Active := True به‌علاوه ساخت گزارش preflight را روی module مشترک به‌طور هم‌زمان اجرا می‌کرد. symptomهایی که روی buildهای Delphi و Free Pascal هر دو دیده شد کل طیف را پوشش می‌دادند:

  • یک فایل سالم load نمی‌شود، یا از دسته به‌عنوان ناموفق برمی‌گردد وقتی باید پاس می‌شد
  • یک access violation در یک فراخوانی بعدی و بی‌ربط بیرون می‌زند، اغلب در یک تست دیگر یا یک سند دیگر
  • مقدار External exception C000001D در Delphi ظاهر می‌شود. آن کد STATUS_ILLEGAL_INSTRUCTION است، که توسط دستورالعمل ud2 raise می‌شود که ماکروهای CHECK و IMMEDIATE_CRASH داخلی PDFium وقتی یک invariant می‌شکند اجرا می‌کنند
  • پروسه با 0xC0000409 (fail-fast، گزارش‌شده به‌عنوان stack buffer overrun) یا 0xC0000374 (خرابی heap) خارج می‌شود، بدون هیچ exception دلفی ای

دو بند آخر دلیل سخت بودن پیدا کردن این باگ است. اعتبارسنجی موازی تمام می‌شد، state سراسری خراب‌شده جا می‌ماند، و fixture بعدی در همان پروسه رویش سکندری می‌خورد. در یک ران regression مربوط به Delphi روی Win64، موجی از شکست‌های C000001D به تست‌هایی می‌خورد که هرگز اعتبارسنجی دسته‌ای را لمس نمی‌کردند؛ آن‌ها صرفاً اولین کدهایی بودند که بعد از آسیب از PDFium استفاده می‌کردند. اعداد اندازه‌گیری‌شده مقیاس را بی‌پرده می‌کنند. یک probe دلفی که همان نمونه را با دو worker اجرا می‌کرد در یک ران 122 از 160 سند را ناموفق کرد و در ران دیگر 138 از 160، و یکی از آن ران‌ها مستقیم External exception C000001D داد. یک حالت فشار با 8 سند و 4 worker و 5 دور، در 5 از 5 ران روی Free Pascal Win64 ناموفق یا کرش می‌کرد. بعد از فیکس، همان probe تعداد 0 از 1,200 سند را ناموفق کرد

‏ValidatePdfFilesParallel از v3.125.1 چطور امن می‌ماند

مقدار ValidatePdfFilesParallel حالا نیمه بومی هر کار را serialize می‌کند و نیمه مدیریت‌شده را موازی نگه می‌دارد. هر worker قبل از ساختن TPdf یک critical section در-سطح-یونیت می‌گیرد و آن را در طول FileName و Active := True و ساخت گزارش preflight و Free نگه می‌دارد. ساخت و نابود کردن عمداً داخل قفل است: بستن یک سند هم مثل load کردن به داخل module فراخوانی برمی‌گردد. وقتی worker یک رکورد TPdfPreflightReport گرفته‌شده داشته باشد، قفل را آزاد می‌کند و قواعد اعتبارسنجی را مقابل همان رکورد ارزیابی می‌کند، که به هیچ state ی PDFium دست نمی‌زند، پس ارزیابی قواعد یک فایل با کار PDFium فایل بعد هم‌پوشانی دارد

نمودار ValidatePdfFilesParallel در PDFium Component که نشان می‌دهد هر worker یک critical section در-سطح-پروسه را در طول ساخت و load و preflight و آزاد کردن TPdf نگه می‌دارد در حالی که ارزیابی قواعد روی گزارش گرفته‌شده بیرون قفل به‌طور موازی اجرا می‌شود، پس نیمه PDFium دسته طبق طراحی سریال است
ساخت و نابود کردن داخل قفل می‌مانند چون بستن یک سند به داخل module فراخوانی برمی‌گردد، در حالی که ارزیابی گزارش به هیچ state ی PDFium دست نمی‌زند و با فایل بعد هم‌پوشانی دارد

دو تغییر کوچک‌تر با فیکس آمد. یک شکست load حالا EPdfError با LastLoadReport.ErrorMessage می‌دهد، پس مقدار ErrorMessage آن آیتم مشکل واقعی parse را نام می‌برد نه یک خطای ثانویه یعنی «سند فعال نیست». و هزینه صادقانه گفته شده: نیمه PDFium دسته حالا سریال است، پس روی دسته‌ای که parse و preflight غالب‌اند، workerهای اضافی خریدار کمی‌اند. اگر روی نسخه‌ای قبل از 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];
    // با یک registry صریح، پروفایل هم‌خوان را خودتان انتخاب کنید.
    // یک لیست Profiles خالی هر قاعده ثبت‌شده را اجرا می‌کند، و قواعد
    // استانداردهایی که preflight نکرده‌اید گزارش می‌دهند «پاس نشد»
    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 به‌عنوان registry مسیر کوتاه‌تر است: ‏ValidatePdfFilesParallel بعد خودش registry پیش‌فرض را می‌سازد، لیست پروفایل‌ها را از Options.Standards استخراج می‌کند، و موقع برگشتن registry را آزاد می‌کند. نتایج همیشه به ترتیب ورودی برمی‌گردند، هر ترتیبی که workerها تمام کرده باشند. برای فرمت‌های گزارش و wrapper خط-فرمان دور همان موتور، گزارش‌های preflight دسته‌ای PDF با CLI مربوط به PDFium Component را ببینید، و برای اینکه چک‌های PDF/A خودشان چه چیزی را پوشش می‌دهند اعتبارسنجی preflight یعنی PDF/A در دلفی

‏RenderPagesParallel چطور صفحه‌ها را واقعاً موازی اجرا می‌کند؟

‏TPdf.RenderPagesParallel موازی اجرا می‌شود چون workerهایش هرگز یک module ی PDFium را شریک نمی‌شوند. متد اول سند فعال را روی thread فراخواننده در یک مخزن منبع ذخیره می‌کند. بعد هر worker مقدار DLL ی PDFium بارگذاری‌شده را در فایلی با نام یکتا در پوشه temp کپی می‌کند، همان کپی را با LoadLibrary load می‌کند، و مقداردهی اولیه‌اش می‌کند. Windows یک DLL که از مسیر متفاوتی load شود را یک module متفاوت می‌گیرد، پس هر کپی سراسری‌های خودش را دارد: کش فونت خودش، page module خودش، همه‌چیز خودش. worker سند ذخیره‌شده را در module خصوصی‌اش باز می‌کند، صفحه‌هایش را به‌تدریج با چک‌های لغو بین قدم‌ها رندر می‌کند، بعد کتابخانه را نابود می‌کند، کپی را unload می‌کند و فایل را حذف می‌کند

نمودار RenderPagesParallel در PDFium Component که در آن thread فراخواننده یک snapshot از سند ذخیره می‌کند، بعد هر worker مقدار DLL یعنی PDFium را در یک فایل temp یکتا کپی می‌کند، آن را به‌عنوان یک module جدا با سراسری‌های خودش load می‌کند، صفحه‌هایش را با چک‌های لغو رندر می‌کند و کپی را unload می‌کند
موازی‌سازی واقعی از ایزوله شدن module می‌آید: Windows هر کپی DLL را یک module متفاوت می‌گیرد، پس workerها هیچ چیزی را شریک نمی‌شوند جز snapshot ای که thread فراخواننده زیر قفل ذخیره کرده

ایزوله شدن رایگان نیست، و پیش‌فرض‌ها همین را منعکس می‌کنند. هر worker هزینه یک کپی DLL روی دیسک، یک مجموعه دوم از سراسری‌های PDFium در حافظه، و یک parse تازه از سند را می‌دهد. مقدار MaxWorkers = 0 یعنی حداکثر 4 worker، و 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;                 // شماره صفحه‌ها یک-مبنا هستند

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

  // snapshot منبع روی module مشترک گرفته می‌شود، پس اگر threadهای
  // دیگر هم از TPdf استفاده می‌کنند قفل PDFium در-سطح-پروسه را نگه دارید
  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;

به قفل دور فراخوانی توجه کنید. moduleهای worker خصوصی‌اند، اما قدم snapshot در شروع مقدار SaveAs را روی module مشترک از thread فراخواننده اجرا می‌کند. اگر هیچ چیز دیگری در پروسه‌تان هم‌زمان به TPdf دست نمی‌زند می‌توانید قفل را بردارید؛ اگر چیزی دست می‌زند، snapshot به همان حفاظتی نیاز دارد که هر فراخوانی دیگری از module مشترک

الگوامن بین اسنادکار PDFium موازی اجرا می‌شودهزینه
یک TPdf به‌ازای هر thread، بدون قفل مشترکنهبله، تا وقتی خراب نکندکرش‌های متناوب، state پروسه آسیب‌دیده
یک قفل در سطح کل پروسه دور همه فراخوانی‌های PDFiumبلهنهنیمه PDFium سریال است
ValidatePdfFilesParallel از v3.125.1بلهنه؛ ارزیابی قواعد موازی استparse و preflight سریال‌اند
TPdf.RenderPagesParallelبلهبلهکپی DLL و حافظه و یک parse تازه به‌ازای هر worker

کد چندthreadه خودتان را چطور ساختار بدهید؟

threadهای خودتان باید یک قفل در سطح کل پروسه را شریک شوند و آن را در تمام عمر هر TPdf ای که استفاده می‌کنند نگه دارند، یا وگرنه از یک API کامپوننت استفاده کنند که module را برایتان ایزوله می‌کند. قفل باید یک شیء واحد برای کل پروسه باشد، نه یکی به‌ازای هر thread و هر فرم و هر سند؛ قفلی که دو thread شریکش نیستند هیچ چیزی را حفاظت نمی‌کند. الگوی پایین آینه کاری است که کامپوننت از v3.125.1 در درون می‌کند: ساخت و load و خواندن و آزاد کردن داخل قفل، بعد همه چیزهایی که به 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 را داخل قفل بگذارید، نه فقط فراخوانی‌های آشکار را. load کردن، بستن، خواندن ویژگی‌هایی مثل PageCount، تغییر صفحه، استخراج متن، رندر و ذخیره همه به داخل module دست دراز می‌کنند
  • مقدار Active را بعد از assign کردنش چک کنید. یک load ناموفق مقدار Active را روی False می‌گذارد، و LastLoadReport.ErrorMessage می‌گوید چرا
  • قفل را به‌ازای هر سند نگه دارید نه به‌ازای هر فراخوانی. قفل‌بندی ریزتر در اصل ممکن است، اما فقط اگر هیچ عضوی از TPdf هرگز بیرونش اجرا نشود، و نسخه درشت همان چیزی است که خود کامپوننت به آن تکیه دارد
  • کار کند غیر-PDFium مثل نوشتن در دیتابیس و ایندکس‌گذاری و فراخوانی‌های شبکه را بیرون قفل نگه دارید، وگرنه یک مصرف‌کننده کند همه چیز را serialize می‌کند
  • قفل رندر خصوصی به‌ازای هر instance را جانشین نگیرید. از یک TPdf در مقابل خودش محافظت می‌کند و همین

همان احتیاط برای کدی اعمال می‌شود که ننوشته‌اید به‌شکل threadهای خام. futureهای پس‌زمینه راه خوبی برای دور نگه داشتن رندرهای طولانی از thread یعنی UI هستند، همان‌طور که در رندر پس‌زمینه PDF با futureهای قابل-لغو توضیح داده شده، اما executor مربوط به future خودش یک قفل سراسری PDFium اضافه نمی‌کند. اگر چند future بتوانند هم‌زمان instanceهای متفاوتی از TPdf را برانند، همان قفل در-سطح-پروسه را داخل هر worker بگیرید، و با یک viewer روی thread اصلی مثل یک کلاینت دیگر از module مشترک رفتار کنید. استفاده بین-instance‌ای از طریق APIهای ناهمزمان جداگانه ممیزی نشده، پس فرض محافظه‌کارانه این است که به همان serialize شدن دست می‌خواهد مثل threadهای دست‌نویس. وقتی به موازی‌سازی واقعی PDFium برای چیزی غیر از رندر صفحه نیاز دارید، پروسه‌های worker جدا به هر کار module خودش را به‌طور ساختاری می‌دهند

مرجع سریع: قواعد threading یعنی PDFium برای دلفی

  • state ناامن PDFium در کل module است: کش فونت و page module و بقیه سراسری‌ها بین هر سند در پروسه مشترک‌اند
  • یک TPdf به‌ازای هر thread هیچ چیزی را ایزوله نمی‌کند؛ دو instance روی دو thread همچنان می‌توانند همدیگر را خراب کنند
  • symptomهای معمول شکست‌های load و access violation در کدهای بعدی و External exception C000001D و خروج‌ها با 0xC0000409 یا 0xC0000374 هستند
  • خرابی در پروسه می‌ماند، پس فراخوانی شکست‌خورده اغلب همان نیست که باعثش شده
  • ‏ValidatePdfFilesParallel از v3.125.1 امن است؛ روی نسخه‌های قدیمی‌تر مقدار WorkerCount := 1 را بردارید
  • ‏TPdf.RenderPagesParallel واقعاً موازی است چون هر worker یک کپی ایزوله از module یعنی PDFium load می‌کند
  • threadها و taskها و futureهای خودتان به یک قفل در سطح کل پروسه نیاز دارند که هر TPdf را از Create تا Free بپوشاند

‏PDFium Component موتور PDFium را برای Delphi با preflight و اعتبارسنجی دسته‌ای و رندر موازی ایزوله و کار پس‌زمینه قابل-لغو و عیب‌یابی دقیق load wrap می‌کند. جزئیات و نسخه‌ها در صفحه محصول PDFium Component است