Teknisk artikkel

Parallell PDF-siderendring i Delphi: Trådsikkerhet

Rendring av PDF-sider i parallell fra Delphi koker ned til én regel: gi hver arbeidstråd (worker thread) sin egen rendrer. losLab PDF Library tilbyr RenderPagesToFilesParallel for akkurat den jobben, som fordeler et sideområde over en TTask-pool med én TPDFlib-instans per arbeider (worker), slik at en maskin med flere kjerner gjør en bunke-rasterjobb om til en ytelse som skalerer nær antall kjerner. Hvis du i stedet deler en enkelt instans på tvers av tråder, vil ikke kjøringen bare gå tregere, den vil korrumpere minnet og krasje

Dette er artikkelen du tyr til når en nattlig jobb må gjøre en 500-siders PDF om til 500 PNG-er, maskinen har 16 ledige kjerner, og ditt første ærlige forsøk på å bruke tråder feilet inni GDI+. Den korte versjonen er at trådsikkerhet her er en strukturell egenskap, ikke et flagg du setter, og resten av denne artikkelen går gjennom hvorfor den sikre formen ser ut som den gjør, og hvor det virkelige taket for hastighetsøkning faktisk ligger

Er TPDFlib trådsikker for parallell rendring?

Nei, og grunnen er verdt å forstå før du designer løsningen din rundt den. En enkelt TPDFlib-instans er deklarert for entrådet bruk, og den kritiske delen er TPDFPageTree.GetPage: den skriver til et delt FPagePointer-felt på instansen som en bivirkning av å velge en side. To tråder som gjør kall til samme instans vil konkurrere (race) om dette feltet, slik at arbeider A kan være halvveis gjennom side 3 når arbeider B peker sidetreet til side 40 på nytt. Ingenting i API-et hindrer deg i å skrive koden nedenfor, og den vil til og med kjøre i noen sider før den feiler, noe som er den verste måten en slik feil kan oppføre seg 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;

Feilen er ikke deterministisk, noe som er akkurat hvorfor den overlever en kjapp test og deretter dukker opp på en kundemaskin med et annet antall kjerner og et tyngre dokument. Det er heller ikke mulig å legge til en lås rundt RenderPageToFile for å løse dette enkelt, fordi å holde en mutex over hele rendringskallet vil gjøre arbeidet sekvensielt og fjerne all parallellismen du var ute etter

Hvorfor trenger hver rendringsarbeider sin egen TPDFlib-instans?

Fordi instansen er isolasjonsenheten. Når hver arbeider eier en privat TPDFlib som lastet inn filen uavhengig av hverandre, har hver sitt eget sidetre, sin egen FPagePointer og sin egen rendringstilstand, slik at det ikke er noe delt å konkurrere om. Denne sikkerheten har en pris du bør vurdere på forhånd: hver arbeider tolker hele dokumentet inn i minnet, slik at det maksimale minneavtrykket er omtrent N ganger kostnaden for en enkelt instans. Åtte arbeidere på en 300 MB PDF betyr åtte fulle tolkninger i minnet samtidig, og ved veldig store inndata er det denne begrensningen som bestemmer antall arbeidere, ikke CPU-en. Når dokumentet er enormt og du er minnebegrenset fremfor CPU-begrenset, er metoden for direkte tilgang i behandling av store PDF-er uten full tolkning av dokumentet ofte en bedre løsning enn flere rendringstråder

Ettkalls-API-et: RenderPagesToFilesParallel

losLab PDF Library pakker hele det sikre mønsteret inn i en enkelt metode, slik at du for det vanlige tilfellet slipper å skrive noe av det selv. RenderPagesToFilesParallel tar filnavnet og passordet, en DPI, en inkluderende start- og sluttside, en Options-verdi som sendes rett gjennom til rasterbanen for hver side, et utdatamønster der %p erstattes av sidenummeret, og en arbeidergrense der alle verdier på eller under null betyr auto. Den returnerer antallet sider som ble rendret vellykket, og det er en ren Windows-bane fordi den støtter seg på 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 arbeidstråd?

GDI+ er rasterisereren under siderendringen, og GDI+ er apartment-threaded: den forventer at COM er initialisert på den tråden som kaller den. Hovedtråden i en VCL-app har vanligvis dette satt opp allerede, men en nyopprettet TTask-arbeider har det ikke, og å kalle rendringsbanen fra en uinitialisert tråd er en sikker måte å krasje på. Så hver arbeider parer en CoInitialize(nil) ved inngang med en CoUninitialize ved utgang, noe som omslutter hele levetiden. Dette er den samme disiplinen som alt GDI+- eller COM-arbeid trenger utenfor hovedtråden, og det er den andre halvdelen av det som gjør at isolasjonen per arbeider faktisk holder, der den første halvdelen er den private instansen. Den samme GDI+-rasterbanen driver de entrådede motorene som dekkes i valg av rendringsmotor for PDF-utdata

Statisk oppdeling kontra dynamisk sidetildeling

Den åpenbare måten å dele 500 sider på 8 arbeidere er å gi hver en fast del på rundt 62 sider. losLab PDF Library gjør ikke det, og grunnen er lastbalansering. Siders ressurskostnad varierer voldsomt: en side med brødtekst rendres på millisekunder, mens en side med tette vektorkart eller et skannet bilde uten marger kan ta femti ganger lengre tid. Hvis du deler arbeidet inn i faste deler, vil arbeideren som tilfeldigvis tegner den tunge delen kjøre lenge etter at de andre er ferdige, slik at den totale tiden bestemmes av den mest uheldige delen, ikke gjennomsnittet. I stedet henter hver arbeider neste side fra en delt teller under en kort kritisk seksjon (critical section), rendrer den, og kommer tilbake for en ny, noe som holder hver kjerne opptatt til hele området er tømt

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

Strukturert logging på tvers av arbeidstråder

Å feilsøke en bunke som dør på side 213 av 500 is elendig uten en logg, og en enkel logg er sitt eget samtidighetsproblem. losLab PDF Library leveres med TPDFlibLogger, koblet til via TPDFlib.Logger-egenskapen og nil som standard, slik at banen uten logg forblir uten ekstra kostnad. Det er callback-fokusert: du setter OnLog og ruter oppføringer dit verten ønsker, filtrert av et llDebug / llInfo / llWarn / llError-nivå, og PDFlibErrorMessage gjør de rå tallkodene om til menneskelig tekst, slik at en Error-oppføring leses som mer enn bare et enkelt heltall. Det valgfrie fil-mottaket (file sink) er den ene delte ressursen, og den er beskyttet av en TCriticalSection nettopp for at flere arbeidere skal kunne legge til i én loggfil på en sikker måte. Merk den ærlige grensen: kun det filmottaket er synkronisert, så hvis du deler én logger på tvers av en trådpool og din OnLog berører brukergrensesnittet, må du fortsatt overføre (marshal) det tilbake til hovedtråden selv

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 hastighetsøkning bør du faktisk forvente?

Vær ærlig med deg selv om hvor tiden går, fordi parallell rendring kun lønner seg der arbeidet er reelt CPU-bundet. Høy-DPI-utdata og komplekse vektor- eller skyggelagte sider er beregningstunge, og disse skalerer nesten lineært med antall kjerner til du metter CPU-en. Enkle sider er en annen historie: der kan overheaden for LoadFromFile per arbeider, pluss diskostnaden ved å skrive utfilene, overvelde selve rendringen, og åtte arbeidere som belaster én treg disk kan fullføre tregere enn en ren seriell løkke. Sett MaxWorkers til ditt fysiske antall kjerner fremfor noe optimistisk, overvåk minnet når kilde-PDF-en er stor, og hvis en bunke viser seg å være IO-bundet, er løsningen raskere lagring eller færre arbeidere, ikke flere tråder. Brukt på de jobbene den ble bygget for, er batch-rendringsbanen som vises her en del av standard losLab PDF Library for Delphi og C++Builder, og den gjør ledige kjerner om til ferdige sider uten noen av de trådsikkerhetsfellene du ellers måtte ha løst selv