Technikai cikk

Párhuzamos PDF oldalrenderelés Delphiben: Szálbiztonság

A PDF oldalak Delphiből történő párhuzamos renderelése egyetlen szabályra vezethető vissza: adjon minden végrehajtó szálnak (worker thread) saját renderelőt. A losLab PDF Library pontosan erre a feladatra teszi elérhetővé a RenderPagesToFilesParallel metódust, amely egy oldaltartományt oszt szét egy TTask készletben (pool), szálanként egy-egy TPDFlib példánnyal, így egy többmagos gép a kötegelt raszterezési feladatot a magok számával közel lineárisan skálázódó teljesítménnyé alakítja. Ha viszont egyetlen példányt oszt meg a szálak között, a futás nem egyszerűen lelassul, hanem memóriasérülést és összeomlást okoz

Ez az a cikk, amelyhez akkor nyúl, ha egy éjszakai feladatnak egy 500 oldalas PDF-et kell 500 PNG-vé alakítania, miközben a gép 16 magja tétlenül áll, és az első komoly szálkezelési kísérlete elbukott a GDI+-on belül. Röviden: a szálbiztonság itt strukturális tulajdonság, nem pedig egy beállítható jelző (flag), és a cikk további része végigvezeti Önt azon, hogy a biztonságos felépítés miért pont így néz ki, és hol van a valódi gyorsulás elméleti határa

Szálbiztos a TPDFlib a párhuzamos rendereléshez?

Nem, és az okot érdemes megérteni, mielőtt tervezne köré. Egyetlen TPDFlib példány egyszálú használatra van deklarálva, és a kényes pont a TPDFPageTree.GetPage: egy oldal kiválasztásának mellékhatásaként írja a közös FPagePointer mezőt a példányon. Két szál, amely ugyanazt a példányt hívja meg, versenyhelyzetbe (race condition) kerül ezen a mezőn, így az A szál félig kész lehet a 3. oldallal, amikor a B szál átirányítja az oldalfát a 40. oldalra. Az API-ban semmi sem akadályozza meg, hogy leírja az alábbi kódot, és az akár le is fut néhány oldal erejéig a hiba jelentkezése előtt, ami a legrosszabb módja egy ilyen hiba viselkedésének

// 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;

A hiba nem determinisztikus, ami pontosan az oka annak, hogy túlél egy gyors füsttesztet, majd megjelenik egy ügyfél gépén, amely eltérő magszámmal és nehezebb dokumentummal rendelkezik. Nincs olyan zár (lock) sem, amellyel a RenderPageToFile hívást olcsón körül lehetne venni, mert a mutex megtartása a teljes renderelési hívás alatt sorosítja a munkát, és eldobja a párhuzamosságot, amiért jött

Miért van szüksége minden renderelő szálnak saját TPDFlib példányra?

Mert maga a példány az izoláció egysége. Miután minden szál egy saját, a fájlt függetlenül betöltő TPDFlib példánnyal rendelkezik, mindegyiknek saját oldalfája, saját FPagePointer-e és saját renderelési állapota van, így nincs semmi megosztott, amin versenyhelyzet alakulhatna ki. Ennek a biztonságnak megvan az ára, amelyet előre fel kell mérnie: minden szál a teljes dokumentumot elemzi a memóriában, így a maximális memóriaigény nagyjából N-szerese az egypéldányos költségnek. Nyolc végrehajtó szál egy 300 MB-os PDF-en nyolc teljes, egyszerre rezidens elemzést jelent, és igen nagy fájlok esetén ez az a korlát, amely eldönti a szálak számát, nem pedig a CPU. Ha a dokumentum hatalmas, és Ön inkább memóriakorlátos, mint CPU-korlátos, a nagy PDF-ek teljes dokumentumelemzés nélküli feldolgozásáról szóló cikkben leírt közvetlen hozzáférésű útvonal gyakran jobb eszköz, mint a renderelő szálak számának növelése

Az egyhívásos API: RenderPagesToFilesParallel

A losLab PDF Library a teljes biztonságos mintát egyetlen metódus mögé csomagolja, így a gyakori esetekben semmit sem kell kézzel megírnia. A RenderPagesToFilesParallel átveszi a fájlnevet és a jelszót, a DPI-t, a kezdő és befejező oldalt (beleértve a határokat), a per-oldal raszterezési útvonalnak közvetlenül átadott Options értéket, egy kimeneti mintát, ahol a %p helyére az oldalszám kerül, és egy szálkorlátot, ahol minden nulla vagy annál kisebb érték automatikus beállítást jelent. Visszaadja a sikeresen renderelt oldalak számát, és ez egy csak Windows rendszeren futó útvonal, mivel a CoInitialize és a GDI+ elemekre támaszkodik

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;

Miért kell a CoInitialize minden szálon?

A GDI+ a raszterező az oldalrenderelés alatt, és a GDI+ apartment-szálkezelésű: elvárja, hogy a COM inicializálva legyen azon a szálon, amely meghívja. Egy VCL-alkalmazás főszála általában már be van állítva ehhez, de egy frissen létrehozott TTask szál nem, és a renderelési útvonal meghívása egy inicializálatlan szálról biztos módja az összeomlásnak. Ezért minden szál belépéskor egy CoInitialize(nil) hívást végez, kilépéskor pedig egy CoUninitialize hívást, lefedve a teljes élettartamát. Ez ugyanaz a fegyelem, amelyet bármely GDI+ vagy COM munka megkövetel a főszálon kívül, és ez a második fele annak, amitől a szálankénti izoláció valóban működik, míg az első fele a privát példány. Ugyanez a GDI+ raszterezési útvonal hajtja azokat a különálló renderelő motorokat, amelyeket a renderelő motor kiválasztása PDF kimenethez című cikk tárgyal

Statikus szeletelés kontra dinamikus oldallekérés

Az 500 oldal 8 szálra történő felosztásának kézenfekvő módja az lenne, ha mindegyiknek egy fix, körülbelül 62 oldalas szeletet adnánk. A losLab PDF Library nem így tesz, ennek oka pedig a terheléselosztás. Az oldalak feldolgozási költsége vadul változik: egy törzsszöveges oldal ezredmásodpercek alatt renderelődik, míg egy sűrű vektoros térképeket vagy teljes méretű beszkennelt képet tartalmazó oldal ötvenszer tovább tarthat. Ha a munkát fix szeletekre vágná, az a szál, amelyik a nehéz szeletet kapta, még sokáig futna azután is, hogy a többiek már tétlenné váltak, így a teljes futásidőt a legszerencsétlenebb szelet határozná meg, nem az átlag. Ehelyett minden szál egy megosztott számlálóból kéri le a következő oldalt egy rövid kritikus szakaszon (critical section) belül, lerendereli, majd visszatér a következőért, ami minden magot foglalkoztat a teljes tartomány kiürüléséig

// 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;

Strukturált naplózás a végrehajtó szálakon keresztül

Egy 500 oldalból a 213. oldalon elhaló köteg hibakeresése napló nélkül gyötrelmes, egy naiv naplózás pedig saját maga is egy párhuzamossági hiba. A losLab PDF Library a TPDFlib.Logger tulajdonságon keresztül csatolt TPDFlibLogger-t szállítja, amely alapértelmezetten nil, így a naplózás nélküli útvonal nulla költségű marad. Ez egy callback-központú megoldás: beállítja az OnLog-ot, és oda irányítja a bejegyzéseket, ahova a gazdaalkalmazás szeretné, szűrve az llDebug / llInfo / llWarn / llError szintek szerint, a PDFlibErrorMessage pedig a nyers számkódokat emberi szöveggé alakítja, így egy Error bejegyzés többként olvasható, mint egy puszta egész szám. Az opcionális fájl-nyelő (file sink) az egyetlen megosztott erőforrás, és ezt egy TCriticalSection védi pontosan azért, hogy több szál is biztonságosan hozzáfűzhessen egyetlen logfájlhoz. Vegye figyelembe az őszinte határt: csak a fájl-nyelő van szinkronizálva, így ha egyetlen logger-t oszt meg egy kézzel készített készleten keresztül, és az OnLog érinti a felhasználói felületet, akkor azt továbbra is magának kell visszaterelnie a főszálra

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;

Milyen mértékű gyorsulásra számíthat valójában?

Legyen őszinte magához azzal kapcsolatban, hogy hova megy el az idő, mert a párhuzamos renderelés csak ott kifizetődő, ahol a munka valóban CPU-igényes. A nagy DPI-s kimenet és a bonyolult vektoros vagy árnyékolt oldalak számításigényesek, és ezek a magok számával közel lineárisan skálázódnak, amíg el nem éri a CPU telítettségét. A triviális oldalak egészen más lapra tartoznak: ott a szálankénti LoadFromFile rezsiköltség, valamint a kimeneti fájlok lemezre írásának költsége elnyomhatja magát a renderelést, és egy lassú lemezt gyötrő nyolc szál lassabban végezhet, mint egy tiszta soros ciklus. Állítsa a MaxWorkers értékét a fizikai magok számára ahelyett, hogy valami vágyott értéket adna meg, figyelje a memóriát, ha a forrás PDF nagy, és ha egy kötegről kiderül, hogy IO-korlátos, a megoldás a gyorsabb tárhely vagy a kevesebb szál, nem pedig a több szál. Azokon a feladatokon használva, amelyekre készült, az itt bemutatott kötegelt renderelési útvonal a Delphihez és C++Builderhez készült standard losLab PDF Library része, és a tétlen magokat kész oldalakká alakítja anélkül a szálbiztonsági csapdák nélkül, amelyeket egyébként magának kellene megoldania