مقاله فنی

رندر موازی صفحات PDF در دلفی: ایمنی نخ (Thread Safety)

رندر موازی صفحات PDF در دلفی به یک قانون خلاصه می‌شود: به هر نخ کارگر (worker thread) رندرکننده خودش را بدهید. کتابخانه PDF losLab متد RenderPagesToFilesParallel را دقیقاً برای همین کار ارائه می‌دهد، به طوری که محدوده صفحات را در یک استخر TTask با یک نمونه TPDFlib برای هر کارگر توزیع می‌کند؛ بنابراین یک سیستم چند هسته‌ای یک کار شطرنجی‌سازی (rasterization) دسته‌ای را به خروجی‌ای تبدیل می‌کند که مقیاس آن به تعداد هسته‌ها نزدیک است. اگر به جای این کار، یک نمونه واحد را بین نخ‌ها به اشتراک بگذارید، برنامه به آرامی کند نمی‌شود، بلکه حافظه را خراب کرده و کرش می‌کند

این همان مقاله‌ای است که وقتی یک کار شبانه باید یک PDF ۵۰۰ صفحه‌ای را به ۵۰۰ تصویر PNG تبدیل کند، سیستم دارای ۱۶ هسته بلااستفاده است، و اولین تلاش جدی شما برای چندنخی کردن آن در داخل GDI+ با شکست مواجه شده، به آن مراجعه می‌کنید. خلاصه داستان این است که ایمنی نخ در اینجا یک ویژگی ساختاری است، نه پرچمی که تنظیم کنید، و ادامه این مطلب به این موضوع می‌پردازد که چرا شکل ایمن کار به این صورت است و سقف واقعی افزایش سرعت کجاست

آیا TPDFlib برای رندر موازی ایمن از نظر نخ (thread-safe) است؟

خیر، و دلیل آن ارزش فهمیدن دارد قبل از اینکه ساختار خود را بر اساس آن طراحی کنید. یک نمونه واحد TPDFlib برای استفاده تک‌نخی اعلام شده است و بخش حساس آن TPDFPageTree.GetPage است: این متد به عنوان یک اثر جانبی انتخاب یک صفحه، در فیلد مشترک FPagePointer در آن نمونه می‌نویسد. دو نخ که یک نمونه را فراخوانی می‌کنند روی آن فیلد دچار رقابت داده‌ای (data race) می‌شوند، بنابراین کارگر A می‌تواند در اواسط صفحه ۳ باشد که کارگر B درخت صفحه را به صفحه ۴۰ تغییر مسیر می‌دهد. هیچ چیز در API مانع از نوشتن کد زیر نمی‌شود و حتی برای چند صفحه قبل از بروز خطا اجرا خواهد شد، که این بدترین روش برای رفتار چنین باگی است

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

این شکست قطعی و قابل پیش‌بینی نیست، که دقیقاً به همین دلیل از یک تست اولیه سریع جان سالم به در می‌برد و سپس در سیستم مشتری با تعداد هسته‌های متفاوت و سند سنگین‌تر ظاهر می‌شود. هیچ قفلی هم وجود ندارد که بتوانید دور RenderPageToFile بپیچید تا این مشکل را به طور ارزان حل کند، زیرا نگه داشتن یک mutex در طول کل فراخوانی رندر، کار را متوالی می‌کند و موازی‌سازی که برای آن آمده بودید را از بین می‌برد

چرا هر کارگر رندر به نمونه TPDFlib خود نیاز دارد؟

زیرا نمونه کاربری همان واحد جداسازی است. هنگامی که هر کارگر مالک یک TPDFlib خصوصی باشد که فایل را به طور مستقل بارگذاری کرده است، هر کدام درخت صفحه خود، FPagePointer خود و وضعیت رندر خود را دارند، بنابراین هیچ چیز مشترکی برای رقابت وجود ندارد. این ایمنی هزینه‌ای دارد که باید از قبل ارزیابی کنید: هر کارگر کل سند را در حافظه تجزیه می‌کند، بنابراین اوج مصرف حافظه تقریباً N برابر هزینه تک نمونه است. هشت کارگر روی یک PDF ۳۰۰ مگابایتی به معنای هشت تجزیه کامل به طور همزمان در حافظه است، و در ورودی‌های بسیار بزرگ، این محدودیت است که تعداد کارگران شما را تعیین می‌کند، نه CPU. وقتی سند بسیار بزرگ است و شما با محدودیت حافظه مواجه هستید تا محدودیت CPU، روش دسترسی مستقیم در پردازش PDFهای بزرگ بدون تجزیه کامل سند اغلب اهرم بهتری نسبت به نخ‌های رندر بیشتر است

دستور تک‌فراخوانی: RenderPagesToFilesParallel

کتابخانه PDF losLab کل الگوی ایمن را در پشت یک متد واحد بسته‌بندی می‌کند، بنابراین برای موارد معمول نیازی به نوشتن دستی هیچ بخشی از آن ندارید. متد RenderPagesToFilesParallel نام فایل و رمز عبور، DPI، صفحه شروع و پایان (شامل خود آن‌ها)، یک مقدار Options که مستقیماً به مسیر شطرنجی‌سازی هر صفحه منتقل می‌شود، یک الگوی خروجی که در آن %p با شماره صفحه جایگزین می‌شود، و سقف کارگران را دریافت می‌کند که هر مقدار مساوی یا کمتر از صفر به معنای خودکار (auto) است. این متد تعداد صفحاتی را که با موفقیت رندر شده‌اند برمی‌گرداند و این مسیر فقط مخصوص ویندوز است زیرا به CoInitialize و GDI+ وابسته است

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

چرا CoInitialize در هر نخ کارگر؟

ابزار GDI+ شطرنجی‌ساز زیرین رندر صفحه است و GDI+ از نوع apartment-threaded است: انتظار دارد COM روی هر نخی که آن را فراخوانی می‌کند، مقداردهی اولیه شده باشد. نخ اصلی یک برنامه VCL معمولاً این تنظیمات را از قبل دارد، اما یک کارگر TTask که تازه ایجاد شده است فاقد آن است، و فراخوانی مسیر رندر از یک نخ بدون مقداردهی اولیه روشی مطمئن برای کرش کردن است. بنابراین هر کارگر یک CoInitialize(nil) در بدو ورود را با یک CoUninitialize در زمان خروج همراه می‌کند و کل طول عمر خود را در میان می‌گیرد. این همان نظمی است که هر کار GDI+ یا COM در خارج از نخ اصلی به آن نیاز دارد، و این نیمه دوم چیزی است که باعث می‌شود جداسازی برای هر کارگر واقعاً برقرار باشد، در حالی که نیمه اول آن نمونه خصوصی است. همان مسیر شطرنجی‌ساز GDI+ موتورهای تک‌نخی پوشش داده‌شده در انتخاب موتور رندر برای خروجی PDF را هدایت می‌کند

بخش‌بندی استاتیک در مقابل تخصیص پویای صفحات

روش بدیهی برای تقسیم ۵۰۰ صفحه بین ۸ کارگر این است که به هر کدام بخش ثابتی در حدود ۶۲ صفحه بدهیم. کتابخانه PDF losLab این کار را انجام نمی‌دهد و دلیل آن تعادل بار (load balance) است. هزینه رندر صفحات بسیار متفاوت است: یک صفحه از متن بدنه در چند میلی‌ثانیه رندر می‌شود، اما یک صفحه از نقشه‌های برداری متراکم یا یک تصویر اسکن‌شده تمام‌صفحه می‌تواند پنجاه برابر بیشتر طول بکشد. کار را به بخش‌های ثابت تقسیم کنید و کارگری که تصادفاً بخش سنگین به او افتاده، تا مدت‌ها پس از بیکار شدن بقیه کار خواهد کرد، بنابراین زمان کل شما توسط بدشانس‌ترین بخش تعیین می‌شود، نه میانگین. در عوض، هر کارگر صفحه بعدی را از یک شمارنده مشترک در زیر یک بخش بحرانی (critical section) کوتاه درخواست می‌کند، آن را رندر می‌نماید و برای صفحه دیگری برمی‌گردد، که این کار همه هسته‌ها را تا تخلیه کامل محدوده مشغول نگه می‌دارد

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          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;

گزارش‌گیری ساختاریافته در میان نخ‌های کارگر

دیباگ کردن دسته‌ای که در صفحه ۲۱۳ از ۵۰۰ متوقف می‌شود بدون فایل لاگ عذاب‌آور است، و یک لاگ ساده خود یک باگ همزمانی (concurrency bug) است. کتابخانه PDF losLab کلاس TPDFlibLogger را ارائه می‌دهد که از طریق ویژگی TPDFlib.Logger متصل می‌شود و به طور پیش‌فرض nil است تا مسیر بدون لاگ بدون هزینه بماند. این ابزار مبتنی بر رویداد (callback-first) است: شما OnLog را تنظیم می‌کنید و رکوردها را به هر کجا که میزبان می‌خواهد هدایت می‌نمایید، که توسط سطوح llDebug / llInfo / llWarn / llError فیلتر می‌شوند، و PDFlibErrorMessage کدهای عددی خام را به متن انسانی تبدیل می‌کند تا یک رکورد Error چیزی بیش از یک عدد خام خوانده شود. فایل خروجی اختیاری تنها منبع مشترک است و دقیقاً توسط یک TCriticalSection محافظت می‌شود تا چندین کارگر بتوانند با خیال راحت به یک فایل لاگ بنویسند. به مرز واقعی توجه کنید: فقط آن فایل خروجی همگام‌سازی شده است، بنابراین اگر یک لاگر را در یک استخر دست‌نویس به اشتراک بگذارید و OnLog شما با رابط کاربری (UI) در تماس باشد، همچنان باید خودتان آن را به نخ اصلی منتقل کنید

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

واقعاً چه مقدار افزایش سرعت را باید انتظار داشته باشید؟

خروجی با DPI بالا و صفحات برداری پیچیده یا سایه‌دار از نظر محاسباتی سنگین هستند و مقیاس آنها تا زمانی که CPU را اشباع کنید، به طور خطی با تعداد هسته‌ها افزایش می‌یابد. صفحات ساده داستان متفاوتی دارند: در آنجا بار اضافی LoadFromFile برای هر کارگر، به اضافه هزینه دیسک برای نوشتن فایل‌های خروجی، می‌تواند خود فرآیند رندر را تحت‌الشعاع قرار دهد، و هشت کارگر که روی یک دیسک کند کار می‌کنند می‌توانند دیرتر از یک حلقه سریال تمیز کار را تمام کنند. مقدار MaxWorkers را به جای یک عدد بلندپروازانه، روی تعداد هسته‌های فیزیکی خود تنظیم کنید، زمانی که فایل PDF منبع بزرگ است حافظه را زیر نظر داشته باشید، و اگر مشخص شد که یک دسته کار با محدودیت IO مواجه است، راه حل آن ذخیره‌سازی سریع‌تر یا کارگران کمتر است، نه نخ‌های بیشتر. مسیر رندر دسته‌ای نشان‌داده‌شده در اینجا که در کارهای مناسب خود استفاده می‌شود، بخشی از losLab PDF Library استاندارد برای دلفی و سی‌پلاس‌پلاس‌بیلدر است و بدون هیچ یک از تله‌های ایمنی نخ که در غیر این صورت باید خودتان حل می‌کردید، هسته‌های بیکار را به صفحات تمام‌شده تبدیل می‌کند