Teknisk artikel

Parallell PDF-sidrendering i Delphi: Trådsäkerhet

Att rendera PDF-sidor parallellt från Delphi kokar ner till en regel: ge varje arbetstråd dess egen renderare. losLab PDF Library tillhandahåller RenderPagesToFilesParallel för just det jobbet, vilket sprider ett sidintervall över en pool av TTask med en TPDFlib-instans per arbetstråd. Detta gör att en flerkärnig maskin förvandlar ett batch-rasteriseringsjobb till en genomströmning som skalar nära antalet kärnor. Om du istället delar en enda instans mellan trådar blir körningen inte bara långsammare, den korrumperar minnet och kraschar

Detta är artikeln du söker när ett nattligt jobb måste förvandla en 500-sidors PDF till 500 PNG-bilder, maskinen har 16 lediga kärnor och ditt första ärliga försök att tråda det föll platt inuti GDI+. Den korta versionen är att trådsäkerhet här är en strukturell egenskap, inte en flagga du ställer in, och resten av denna text går igenom varför den säkra strukturen ser ut som den gör och var gränsen för den faktiska hastighetsökningen egentligen går

Är TPDFlib trådsäkert för parallell rendering?

Nej, och anledningen är värd att förstå innan du designar runt den. En enskild TPDFlib-instans är deklarerad för entrådig användning, och den skarpa kanten är TPDFPageTree.GetPage: den skriver till ett delat FPagePointer-fält på instansen som en bieffekt av att wybrać en sida. Två trådar som anropar samma instans tävlar om det fältet, så arbetstråd A kan vara halvvägs genom sida 3 när arbetstråd B pekar om sidträdet till sida 40. Ingenting i API:et hindrar dig från att skriva koden nedan, och den kommer till och med att köras under några sidor innan den kraschar, vilket är det sämsta sättet för en sådan här bugg att bete sig på

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

Felet är inte deterministiskt, vilket är precis varför det överlever ett snabbt test och sedan dyker upp på en kunds maskin med ett annat antal kärnor och ett tyngre dokument. Det finns inte heller något lås du kan lägga runt RenderPageToFile för att lösa detta billigt, eftersom att hålla ett mutex över hela renderanropet sekventialiserar arbetet och kastar bort parallellismen du ville ha

Varför behöver varje renderarbetare en egen TPDFlib-instans?

Eftersom instansen är isoleringsenheten. När varje arbetare äger en privat TPDFlib som läste in filen självständigt, har varje arbetare sitt eget sidträd, sitt eget FPagePointer och sitt eget renderingstillstånd, så det finns inget delat att tävla om. Den säkerheten har ett pris som du bör beräkna i förväg: varje arbetare läser in hela dokumentet i minnet, så den maximala minnesanvändningen är ungefär N gånger kostnaden för en enskild instans. Åtta arbetare på en PDF på 300 MB innebär åtta fullständiga analyser i minnet samtidigt, och på mycket stora filer är det denna begränsning som avgör ditt antal arbetare, inte processorn. När dokumentet är enormt och du är minnesbegränsad snarare än processorbegränsad är den direkta åtkomstvägen i bearbetning av stora PDF-filer utan fullständig dokumentanalys ofta ett bättre alternativ än fler renderingstrådar

API:et med ett anrop: RenderPagesToFilesParallel

losLab PDF Library paketerar hela det säkra mönstret bakom en enda metod, så för det vanliga fallet behöver du inte bygga något av det själv. RenderPagesToFilesParallel tar filnamnet och lösenordet, en DPI, en inkluderande start- och slutsida, ett Options-värde som skickas direkt till rasterbanan per sida, ett utdatamönster där %p ersätts med sidnumret, och en gräns för arbetare där alla värden vid eller under noll innebär automatisk inställning. Den returnerar antalet sidor som renderats framgångsrikt, och det är en ren Windows-bana eftersom den lutar sig mot CoInitialize och 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;

Varför CoInitialize på varje arbetstråd?

GDI+ är rasteriseraren under sidrendering, och GDI+ använder lägenhetsmodellering för trådar (apartment-threaded): den förväntar sig att COM är initierat på den tråd som anropar den. Huvudtråden i en VCL-applikation har vanligtvis redan detta inställt, men en nyskapad TTask-arbetare har det inte, och att anropa renderingsbanan från en oinitierad tråd är ett säkert sätt att krascha på. Så varje arbetare parar en CoInitialize(nil) vid start med en CoUninitialize vid avslut, vilket ramar in hela dess livstid. Detta är samma disciplin som allt GDI+- eller COM-arbete behöver utanför huvudtråden, och det är den andra halvan av vad som gör att isoleringen per arbetare faktiskt håller, där den första halvan är den privata instansen. Samma GDI+-rasteriseringsbana driver de entrådiga motorerna som beskrivs i att välja en renderingsmotor för PDF-utdata

Statisk uppdelning kontra dynamisk sidadressering

Det uppenbara sättet att dela upp 500 sidor på 8 arbetare är att ge var och en en fast del på cirka 62 sidor. losLab PDF Library gör inte så, och anledningen är lastbalansering. Sidkostnaden varierar enormt: en sida med löpande text renderas på några millisekunder, medan en sida med täta vektorkartor eller en skannad bild kan ta femtio gånger längre tid. Dela upp arbetet i fasta delar och arbetaren som råkar rita den tunga delen kommer att köra långt efter att de andra har blivit klara, så din totala tid bestäms av den tyngsta delen, inte genomsnittet. Istället gör varje arbetare anspråk på nästa sida från en delad räknare under en kort kritisk sektion, renderar den och kommer tillbaka för en ny, vilket håller varje kärna upptagen tills hela intervallet är avklarat

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

Strukturerad loggning över arbetstrådar

Att felsöka en batch som dör på sida 213 av 500 är eländigt utan en logg, och en naiv logg är ett eget trådproblem. losLab PDF Library levereras med TPDFlibLogger, kopplad via egenskapen TPDFlib.Logger och satt till nil som standard så att banan utan logg förblir kostnadsfri. Den är callback-baserad: du ställer in OnLog och skickar poster dit värden önskar, filtrerade efter nivån llDebug / llInfo / llWarn / llError, och PDFlibErrorMessage förvandlar de råa numeriska koderna till läsbar text så att en Error-post innehåller mer än bara ett heltal. Den valfria filskrivningen är den enda delade resursen, och den skyddas av en TCriticalSection just för att flera arbetare ska kunna skriva till samma loggfil på ett säkert sätt. Observera den ärliga gränsen: endast denna filskrivning är synkroniserad, så om du delar en loggare över en manuellt skapad pool och din OnLog rör gränssnittet, måste du fortfarande själv skicka tillbaka detta till huvudtråden

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;

Hur mycket hastighetsökning bör du egentligen förvänta dig?

Var ärlig mot dig själv om vart tiden tar vägen, eftersom parallell rendering endast lönar sig där arbetet verkligen är processorbegränsat. Högupplöst utdata och komplexa vektor- eller tonade sidor är beräkningsintensiva, och dessa skalar nästan linjärt med antalet kärnor tills du mättar processorn. Enkla sidor är en annan historia: där kan den extra belastningen av LoadFromFile per arbetare, plus diskkostnaden för att skriva utdatafilerna, överskugga själva renderingen, och åtta arbetare som slåss om en långsam hårddisk kan bli klara långsammare än en ren sekventiell loop. Ställ in MaxWorkers till dina fysiska kärnor snarare än något önsketänkande, håll ett öga på minnet när käll-PDF-filen är stor, och om en batch visar sig vara begränsad av in- och utmatning (IO-bound) är lösningen snabbare lagring eller färre arbetare, inte fler trådar. Använd på de jobb den byggdes för, är den batchrenderingsbana som visas här en del av standardbiblioteket losLab PDF Library för Delphi och C++Builder, och den förvandlar lediga kärnor till färdiga sidor helt utan de trådsäkerhetsfällor som du annars skulle behöva lösa själv