Teknisk artikel

Parallel PDF-siderendering i Delphi: Trådsikkerhed

Rendering af PDF-sider i parallel fra Delphi bunder i én regel: Giv hver arbejder-tråd sin egen renderer. losLab PDF Library eksponerer RenderPagesToFilesParallel til netop det job, hvor et sideområde fordeles over en TTask-pulje med én TPDFlib-forekomst pr. arbejder, så en multi-core-maskine forvandler et batch-rasterjob til en gennemstrømning, der skalerer tæt på kerneantallet. Hvis du i stedet deler en enkelt forekomst på tværs af tråde, bremses kørslen ikke yndefuldt, den korrumperer hukommelsen og crasher

Dette er artiklen, du griber ud efter, når et natligt job skal forvandle en 500-siders PDF til 500 PNG'er, maskinen har 16 ledige kerner, og dit første ærlige forsøg på at tråde det væltede inde i GDI+. Den korte version er, at trådsikkerhed her er en strukturel egenskab, ikke et flag du indstiller, og resten af dette gennemgår, hvorfor den sikre form ser ud, som den gør, og hvor det reelle loft for hastighedsforøgelse faktisk befinder sig

Er TPDFlib trådsikker til parallel rendering?

Nej, og årsagen er værd at forstå, før du designer udenom den. En enkelt TPDFlib-forekomst er erklæret til enkelttrådet brug, og den skarpe kant er TPDFPageTree.GetPage: Den skriver et delt FPagePointer-felt på forekomsten som en bivirkning af at vælge en side. To tråde, der kalder ind i den samme forekomst, dyster om det felt, så arbejder A kan være halvvejs gennem side 3, når arbejder B genpeger sidetræet på side 40. Intet ved API'en forhindrer dig i at skrive koden nedenfor, og den vil endda køre i nogle få sider, før den fejler, hvilket er den værste måde for en fejl som denne at opføre 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;

Fejlen er ikke deterministisk, hvilket er præcis hvorfor den overlever en hurtig smoketest og derefter dukker op på en kundes maskine med et andet kerneantal og et tungere dokument. Der er heller ikke nogen lås, du kan pakke om RenderPageToFile for at løse dette billigt, fordi det at holde en mutex på tværs af hele renderkaldet serialiserer arbejdet og smider den parallelisme væk, du kom efter

Hvorfor har hver render-arbejder brug for sin egen TPDFlib-forekomst?

Fordi forekomsten er isolationsenheden. Når først hver arbejder ejer en privat TPDFlib, der indlæste filen uafhængigt, har hver sit eget sidetræ, sit eget FPagePointer og sin egen rendertilstand, så der er intet delt at dyste om. Den sikkerhed har en pris, du bør gøre op på forhånd: Hver arbejder fortolker hele dokumentet i hukommelsen, så det maksimale fodaftryk er omtrent N gange omkostningen ved en enkelt forekomst. Otte arbejdere på en 300 MB PDF is otte fulde fortolkninger indlæst på én gang, og på meget store input er det den begrænsning, der afgør dit arbejderantal, ikke CPU'en. Når dokumentet er enormt, og du er hukommelsesbegrænset snarere end CPU-begrænset, is den direkte adgangsvej i behandling af store PDF'er uden fuld dokumentfortolkning ofte et bedre håndtag end flere rendertråde

API'en med et enkelt kald: RenderPagesToFilesParallel

losLab PDF Library pakker hele det sikre mønster bag en enkelt metode, så i det almindelige tilfælde behøver du ikke selv at skrive noget af det. RenderPagesToFilesParallel tager filnavnet og adgangskoden, en DPI, en inklusiv start- og slutside, en Options-værdi sendt direkte videre til rasterstien pr. side, et outputmønster, hvor %p erstattes af sidenummeret, og et arbejderloft, hvor enhver værdi på eller under nul betyder auto. Den returnerer antallet af sider, der er renderet med succes, og det er en sti, der kun virker på Windows, fordi den læner sig op ad CoInitialize og 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;

Hvorfor CoInitialize på hver arbejder-tråd?

GDI+ er rasteringsmotoren bag siderendering, og GDI+ is apartment-threaded: Den forventer, at COM er initialiseret på den tråd, der kalder ind i den. Hovedtråden i en VCL-app har normalt allerede dette sat op, men en nyligt oprettet TTask-arbejder har ikke, og at kalde renderstien fra en uinitialiseret tråd er en pålidelig måde at crashe på. Så hver arbejder parrer en CoInitialize(nil) ved indgang med en CoUninitialize ved udgang, hvilket omslutter hele dens levetid. Dette er den samme disciplin, som alt GDI+- eller COM-arbejde har brug for uden for hovedtråden, og det er den anden halvdel af, hvad der får isolation pr. arbejder til rent faktisk at holde, hvor den første halvdel er den private forekomst. Den samme GDI+-rastersti driver de enkelttrådede motorer dækket i valg af en renderingsmotor til PDF-output

Statisk opdeling versus dynamisk sidekrav

Den indlysende måde at opdele 500 sider på 8 arbejdere er at give hver en fast del på ca. 62 sider. losLab PDF Library gør ikke det, og årsagen er belastningsfordeling. Sideomkostningerne varierer enormt: En side med brødtekst renderer på millisekunder, en side med tætte vektorkort eller et scannet billede uden margener kan tage halvtreds gange længere. Skær arbejdet i faste dele, og den arbejder, der tilfældigvis tegner den tunge del, kører længe efter, at de andre er gået i tomgang, så din tid målt på væguret bestemmes af den mest uheldige del, ikke gennemsnittet. I stedet kræver hver arbejder den næste side fra en fælles tæller under en kort kritisk sektion, renderer den og vender tilbage efter en anden, hvilket holder hver kerne travlt beskæftiget, indtil hele området er drænet

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

Struktureret logning på tværs af arbejder-tråde

Fejlfinding i en batch, der dør på side 213 af 500, er forfærdelig uden en log, og en simpel log er sin egen samtidighedsfejl. losLab PDF Library leverer TPDFlibLogger, tilknyttet via TPDFlib.Logger-egenskaben og nil som standard, så stien uden logning forbliver gratis. Den er callback-first: Du indstiller OnLog og dirigerer poster derhen, hvor værten ønsker det, filtreret efter et llDebug / llInfo / llWarn / llError-niveau, og PDFlibErrorMessage forvandler de rå numeriske koder til menneskelig tekst, så en Error-post læses som mere end et råt heltal. Den valgfrie fil-sink er den ene delte ressource, og den er beskyttet af en TCriticalSection netop for, at flere arbejdere kan tilføje til én logfil på en sikker måde. Bemærk den ærlige grænse: Kun den fil-sink er synkroniseret, så hvis du deler én logger på tværs af en selvbygget pulje, og din OnLog berører brugergrænsefladen, skal du stadig selv dirigere det tilbage til hovedtrå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;

Hvor stor en hastighedsforøgelse skal du egentlig forvente?

Vær ærlig over for dig selv om, hvor tiden går hen, for parallel rendering betaler sig kun, hvor arbejdet reelt er CPU-begrænset. Output med høj DPI og komplekse vektorsider eller skyggesider er beregningstunge, og disse skalerer tæt på lineært med kerneantallet, indtil du mætter CPU'en. Helt enkle sider er en anden historie: Der kan omkostningen ved LoadFromFile pr. arbejder, plus diskomkostningerne ved at skrive outputfilerne, oversvømme selve renderingen, og otte arbejdere, der kæmper om én langsom disk, kan afslutte langsommere end en ren seriel løkke. Indstil MaxWorkers to dit fysiske kerneantal frem for noget ambitiøst, hold øje med hukommelsen, når kilde-PDF'en er stor, og hvis en batch viser sig at være IO-bound, er løsningen hurtigere lagring eller færre arbejdere, ikke flere tråde. Brugt til de job, den er bygget til, er den batch-renderingssti, der vises her, en del af standarden losLab PDF Library til Delphi og C++Builder, og den forvandler ledige kerner til færdige sider uden nogen af de trådsikkerhedsfælder, du ellers selv skulle løse